From 4a5fbb7f4abecf6de7d3585318f4ad0f30057fcb Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Sun, 12 Apr 2026 21:46:11 -0400 Subject: [PATCH] feat(auth): add dynamic OAuth provider system (#282) * feat(auth): add dynamic OAuth provider system Admin can configure OAuth/OIDC providers in the database via API. All 35 built-in better-auth providers are registered as async functions that read config from system_options at runtime. Custom OIDC providers use the genericOAuth plugin with configs loaded at auth init time. New endpoints: - GET /api/auth-providers (public, enabled only, no secrets) - GET /api/auth-providers/admin (admin, all configs, masked secrets) - PUT /api/auth-providers/admin/:providerId (admin, upsert) - DELETE /api/auth-providers/admin/:providerId (admin, remove) Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-Authored-By: Claude Opus 4.6 * fix: async createTestApp compat in email and invite test files createAuth became async in the OAuth PR, which made createTestApp async. Email and invite code test files need await + Awaited<> type wrappers. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Bob Co-authored-by: Claude Opus 4.6 --- scripts/db-reset.ts | 2 +- server/app.ts | 3 + server/auth.ts | 71 +++++- server/bootstrap.ts | 2 +- server/middleware/auth.test.ts | 16 +- server/routes/auth-providers.test.ts | 337 +++++++++++++++++++++++++++ server/routes/auth-providers.ts | 108 +++++++++ server/routes/auth-username.test.ts | 8 +- server/routes/auth.cf-test.ts | 8 +- server/routes/auth.test.ts | 24 +- server/routes/email-config.test.ts | 42 ++-- server/routes/health.cf-test.ts | 6 +- server/routes/health.test.ts | 2 +- server/routes/invite-codes.test.ts | 50 ++-- server/routes/objects-quota.test.ts | 37 +-- server/routes/objects.cf-test.ts | 18 +- server/routes/objects.test.ts | 146 ++++++------ server/routes/quotas.test.ts | 22 +- server/routes/storages.cf-test.ts | 16 +- server/routes/storages.test.ts | 48 ++-- server/routes/system.cf-test.ts | 8 +- server/routes/system.test.ts | 8 +- server/routes/users.test.ts | 18 +- server/services/email.test.ts | 26 +-- server/services/invite.test.ts | 60 ++--- server/services/matter.test.ts | 46 ++-- server/services/org.test.ts | 12 +- server/services/storage.test.ts | 50 ++-- server/test/setup.ts | 4 +- shared/oauth-providers.ts | 79 +++++++ workers/bootstrap.ts | 26 ++- 31 files changed, 954 insertions(+), 349 deletions(-) create mode 100644 server/routes/auth-providers.test.ts create mode 100644 server/routes/auth-providers.ts create mode 100644 shared/oauth-providers.ts diff --git a/scripts/db-reset.ts b/scripts/db-reset.ts index 805e1f35..66f176be 100644 --- a/scripts/db-reset.ts +++ b/scripts/db-reset.ts @@ -37,7 +37,7 @@ const platform = isD1 ? resetD1() : resetNode() // ── 2. seed ── const secret = process.env.BETTER_AUTH_SECRET || 'dev-secret-for-seed' -const auth = createAuth(platform.db, secret, 'http://localhost:8222') +const auth = await createAuth(platform.db, secret, 'http://localhost:8222') const app = createApp(platform, auth) // register admin user diff --git a/server/app.ts b/server/app.ts index d78d7b2f..e230729c 100644 --- a/server/app.ts +++ b/server/app.ts @@ -6,6 +6,7 @@ import { accessLog } from './middleware/logger' import type { Env } from './middleware/platform' import { platformMiddleware } from './middleware/platform' import type { Platform } from './platform/interface' +import authProviders from './routes/auth-providers' import emailConfig from './routes/email-config' import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes' import objects from './routes/objects' @@ -50,6 +51,7 @@ export function createApp(platform: Platform, auth: Auth) { app.route('/api/admin/quotas', adminQuotas) app.route('/api/quotas', userQuotas) app.route('/api/system', system) + app.route('/api/auth-providers', authProviders) app.get('/api/health', (c) => c.json({ status: 'ok' })) @@ -69,3 +71,4 @@ export type SystemRoute = typeof system export type EmailConfigRoute = typeof emailConfig export type AdminInviteCodesRoute = typeof adminInviteCodes export type PublicInviteCodesRoute = typeof publicInviteCodes +export type AuthProvidersRoute = typeof authProviders diff --git a/server/auth.ts b/server/auth.ts index aed216de..d440ef02 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -2,8 +2,15 @@ import crypto from 'node:crypto' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { admin, organization, username } from 'better-auth/plugins' -import { count, eq } from 'drizzle-orm' +import { genericOAuth } from 'better-auth/plugins/generic-oauth' +import { count, eq, like } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { + BUILTIN_PROVIDER_IDS, + OAUTH_PROVIDER_KEY_PATTERN, + OAUTH_PROVIDER_KEY_PREFIX, + parseProviderConfig, +} from '../shared/oauth-providers' import * as authSchema from './db/auth-schema' import { orgQuotas, systemOptions } from './db/schema' import type { Database } from './platform/interface' @@ -30,7 +37,49 @@ async function verifyPassword({ hash, password }: { hash: string; password: stri return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex')) } -export function createAuth(db: Database, secret: string, baseURL?: string, trustedOrigins?: string[]) { +async function loadProviderConfig(db: Database, providerId: string) { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(eq(systemOptions.key, `${OAUTH_PROVIDER_KEY_PREFIX}${providerId}`)) + const raw = rows[0]?.value + if (!raw) return null + return parseProviderConfig(raw) +} + +async function loadOidcConfigs(db: Database) { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN)) + const configs = [] + for (const r of rows) { + const c = parseProviderConfig(r.value) + if (c && c.type === 'oidc' && c.enabled) configs.push(c) + } + return configs +} + +// All 35 built-in providers are registered as async functions so better-auth +// can resolve them on demand. Unconfigured providers return enabled: false +// and are ignored by the framework. +function buildDynamicSocialProviders(db: Database) { + const providers: Record Promise<{ clientId: string; clientSecret: string; enabled: boolean }>> = {} + for (const id of BUILTIN_PROVIDER_IDS) { + providers[id] = async () => { + const config = await loadProviderConfig(db, id) + if (!config?.enabled || config.type !== 'builtin') { + return { clientId: '', clientSecret: '', enabled: false } + } + return { clientId: config.clientId, clientSecret: config.clientSecret, enabled: true } + } + } + return providers +} + +export async function createAuth(db: Database, secret: string, baseURL?: string, trustedOrigins?: string[]) { + const oidcConfigs = await loadOidcConfigs(db) + return betterAuth({ database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }), secret, @@ -49,7 +98,21 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust maxAge: 60 * 5, }, }, - plugins: [admin(), organization(), username()], + socialProviders: buildDynamicSocialProviders(db), + plugins: [ + admin(), + organization(), + username(), + genericOAuth({ + config: oidcConfigs.map((c) => ({ + providerId: c.providerId, + clientId: c.clientId, + clientSecret: c.clientSecret, + discoveryUrl: c.discoveryUrl, + scopes: c.scopes, + })), + }), + ], databaseHooks: { user: { create: { @@ -86,7 +149,7 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust }) } -export type Auth = ReturnType +export type Auth = Awaited> async function isFirstUser(db: Database): Promise { const [row] = await db.select({ c: count() }).from(authSchema.user) diff --git a/server/bootstrap.ts b/server/bootstrap.ts index d8023c5e..c11d51a0 100644 --- a/server/bootstrap.ts +++ b/server/bootstrap.ts @@ -11,6 +11,6 @@ const baseURL = process.env.BETTER_AUTH_URL || 'http://localhost:5173' const trustedOrigins = process.env.TRUSTED_ORIGINS?.split(',') .map((o) => o.trim()) .filter(Boolean) || ['http://localhost:5173'] -const auth = createAuth(platform.db, secret, baseURL, trustedOrigins) +const auth = await createAuth(platform.db, secret, baseURL, trustedOrigins) export default createApp(platform, auth) diff --git a/server/middleware/auth.test.ts b/server/middleware/auth.test.ts index 72f57f53..d198df01 100644 --- a/server/middleware/auth.test.ts +++ b/server/middleware/auth.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest' import { authedHeaders, createTestApp } from '../test/setup.js' import { requireAdmin } from './auth.js' -function createAdminTestApp() { - const { app, db, auth } = createTestApp() +async function createAdminTestApp() { + const { app, db, auth } = await createTestApp() // Add a test-only route protected by requireAdmin app.get('/api/admin-only', requireAdmin, (c) => c.json({ ok: true })) return { app, db, auth } @@ -11,7 +11,7 @@ function createAdminTestApp() { // Signs up then signs in to get a session cookie that reflects the post-hook role update async function authedHeadersWithFreshSession( - app: ReturnType['app'], + app: Awaited>['app'], email: string, password = 'password123456', name = 'Test User', @@ -32,13 +32,13 @@ async function authedHeadersWithFreshSession( describe('requireAdmin middleware', () => { it('returns 403 when user is not authenticated', async () => { - const { app } = createAdminTestApp() + const { app } = await createAdminTestApp() const res = await app.request('/api/admin-only') expect(res.status).toBe(401) }) it('returns 403 when authenticated user does not have admin role', async () => { - const { app } = createAdminTestApp() + const { app } = await createAdminTestApp() // First user becomes admin; second does not await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') const headers = await authedHeaders(app, 'regular@example.com', 'password123456') @@ -47,7 +47,7 @@ describe('requireAdmin middleware', () => { }) it('returns Forbidden error body when user lacks admin role', async () => { - const { app } = createAdminTestApp() + const { app } = await createAdminTestApp() await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') const headers = await authedHeaders(app, 'regular@example.com', 'password123456') const res = await app.request('/api/admin-only', { headers }) @@ -56,7 +56,7 @@ describe('requireAdmin middleware', () => { }) it('allows request when user has admin role', async () => { - const { app } = createAdminTestApp() + const { app } = await createAdminTestApp() // First signup → role updated to admin by hook; sign in to get fresh session const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') const res = await app.request('/api/admin-only', { headers }) @@ -64,7 +64,7 @@ describe('requireAdmin middleware', () => { }) it('returns expected body when admin accesses protected route', async () => { - const { app } = createAdminTestApp() + const { app } = await createAdminTestApp() const headers = await authedHeadersWithFreshSession(app, 'admin@example.com', 'password123456', 'Admin') const res = await app.request('/api/admin-only', { headers }) const body = (await res.json()) as { ok: boolean } diff --git a/server/routes/auth-providers.test.ts b/server/routes/auth-providers.test.ts new file mode 100644 index 00000000..8eee520e --- /dev/null +++ b/server/routes/auth-providers.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from 'vitest' +import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' + +const githubConfig = { + type: 'builtin' as const, + clientId: 'client-id-123', + clientSecret: 'super-secret-value', + enabled: true, +} + +const oidcConfig = { + type: 'oidc' as const, + clientId: 'oidc-client-id', + clientSecret: 'oidc-secret-value', + enabled: true, + discoveryUrl: 'https://accounts.example.com/.well-known/openid-configuration', + scopes: ['openid', 'email', 'profile'], +} + +async function putProvider( + app: Awaited>['app'], + headers: Record, + providerId: string, + body: Record, +) { + return app.request(`/api/auth-providers/admin/${providerId}`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('Auth Providers — public list', () => { + it('returns empty items when no providers are configured', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/auth-providers') + expect(res.status).toBe(200) + const body = (await res.json()) as { items: unknown[] } + expect(body.items).toEqual([]) + }) + + it('returns only enabled providers', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', { ...githubConfig, enabled: true }) + await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id', enabled: false }) + + const res = await app.request('/api/auth-providers') + expect(res.status).toBe(200) + const body = (await res.json()) as { items: Array> } + expect(body.items).toHaveLength(1) + expect(body.items[0].providerId).toBe('github') + }) + + it('does not include clientSecret in public response', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + + const res = await app.request('/api/auth-providers') + const body = (await res.json()) as { items: Array> } + expect(body.items[0]).not.toHaveProperty('clientSecret') + }) + + it('returns display name and icon from provider metadata', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + + const res = await app.request('/api/auth-providers') + const body = (await res.json()) as { items: Array> } + expect(body.items[0].name).toBe('GitHub') + expect(body.items[0].icon).toBe('github') + }) + + it('uses providerId as fallback name and icon for unknown OIDC provider', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'my-custom-oidc', oidcConfig) + + const res = await app.request('/api/auth-providers') + const body = (await res.json()) as { items: Array> } + expect(body.items).toHaveLength(1) + // No entry in OAuthProviderMeta for 'my-custom-oidc', so falls back to providerId + expect(body.items[0].name).toBe('my-custom-oidc') + expect(body.items[0].icon).toBe('my-custom-oidc') + }) + + it('disabled provider does not appear in public list', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', { ...githubConfig, enabled: false }) + + const res = await app.request('/api/auth-providers') + const body = (await res.json()) as { items: unknown[] } + expect(body.items).toHaveLength(0) + }) +}) + +describe('Auth Providers — admin list', () => { + it('returns 401 without authentication', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/auth-providers/admin') + expect(res.status).toBe(401) + }) + + it('returns 403 for non-admin user', async () => { + const { app } = await createTestApp() + // First sign-up makes admin; second is regular user + await adminHeaders(app) + await authedHeaders(app, 'regular@example.com') // registers the second user + const signInRes = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }), + }) + const freshHeaders = { Cookie: signInRes.headers.getSetCookie().join('; ') } + const res = await app.request('/api/auth-providers/admin', { headers: freshHeaders }) + expect(res.status).toBe(403) + }) + + it('returns empty items when no providers are configured', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + const res = await app.request('/api/auth-providers/admin', { headers: admin }) + expect(res.status).toBe(200) + const body = (await res.json()) as { items: unknown[] } + expect(body.items).toEqual([]) + }) + + it('returns all configs including disabled providers', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', { ...githubConfig, enabled: true }) + await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id', enabled: false }) + + const res = await app.request('/api/auth-providers/admin', { headers: admin }) + const body = (await res.json()) as { items: Array> } + expect(body.items).toHaveLength(2) + }) + + it('masks clientSecret leaving only last 4 chars visible', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + + const res = await app.request('/api/auth-providers/admin', { headers: admin }) + const body = (await res.json()) as { items: Array> } + const secret = body.items[0].clientSecret as string + expect(secret).toMatch(/^\*+alue$/) + expect(secret).not.toBe(githubConfig.clientSecret) + }) + + it('masks short secret entirely with four asterisks', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', { ...githubConfig, clientSecret: 'abc' }) + + const res = await app.request('/api/auth-providers/admin', { headers: admin }) + const body = (await res.json()) as { items: Array> } + expect(body.items[0].clientSecret).toBe('****') + }) +}) + +describe('Auth Providers — admin upsert (PUT)', () => { + it('returns 401 without authentication', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/auth-providers/admin/github', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(githubConfig), + }) + expect(res.status).toBe(401) + }) + + it('admin can create a builtin provider', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'github', githubConfig) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.providerId).toBe('github') + expect(body.type).toBe('builtin') + expect(body.clientId).toBe(githubConfig.clientId) + expect(body.enabled).toBe(true) + }) + + it('returns masked secret on create response', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'github', githubConfig) + const body = (await res.json()) as Record + expect(body.clientSecret).not.toBe(githubConfig.clientSecret) + expect((body.clientSecret as string).endsWith('alue')).toBe(true) + }) + + it('updates an existing provider on second PUT', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + const res = await putProvider(app, admin, 'github', { ...githubConfig, clientId: 'new-client-id', enabled: false }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.clientId).toBe('new-client-id') + expect(body.enabled).toBe(false) + + // Admin list should still have only one entry + const listRes = await app.request('/api/auth-providers/admin', { headers: admin }) + const listBody = (await listRes.json()) as { items: unknown[] } + expect(listBody.items).toHaveLength(1) + }) + + it('admin can create an OIDC provider with discoveryUrl', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'my-oidc', oidcConfig) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.providerId).toBe('my-oidc') + expect(body.type).toBe('oidc') + expect(body.discoveryUrl).toBe(oidcConfig.discoveryUrl) + expect(body.scopes).toEqual(oidcConfig.scopes) + }) + + it('returns 400 for unknown builtin provider ID', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'not-a-real-provider', githubConfig) + expect(res.status).toBe(400) + const body = (await res.json()) as Record + expect(body.error).toMatch(/Unknown builtin provider/) + }) + + it('returns 400 for OIDC provider missing discoveryUrl', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const { discoveryUrl: _, ...oidcWithoutDiscovery } = oidcConfig + const res = await putProvider(app, admin, 'my-oidc', oidcWithoutDiscovery) + expect(res.status).toBe(400) + const body = (await res.json()) as Record + expect(body.error).toMatch(/discoveryUrl is required/) + }) + + it('returns 400 when clientId is missing', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const { clientId: _, ...withoutClientId } = githubConfig + const res = await putProvider(app, admin, 'github', withoutClientId) + expect(res.status).toBe(400) + }) + + it('returns 400 when clientSecret is empty string', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'github', { ...githubConfig, clientSecret: '' }) + expect(res.status).toBe(400) + }) + + it('returns 400 when type is invalid', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await putProvider(app, admin, 'github', { ...githubConfig, type: 'unknown-type' }) + expect(res.status).toBe(400) + }) +}) + +describe('Auth Providers — admin delete', () => { + it('returns 401 without authentication', async () => { + const { app } = await createTestApp() + const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE' }) + expect(res.status).toBe(401) + }) + + it('admin can delete a provider', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + + const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.deleted).toBe(true) + expect(body.providerId).toBe('github') + }) + + it('deleted provider no longer appears in public list', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin }) + + const res = await app.request('/api/auth-providers') + const body = (await res.json()) as { items: unknown[] } + expect(body.items).toHaveLength(0) + }) + + it('deleted provider no longer appears in admin list', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + await putProvider(app, admin, 'github', githubConfig) + await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin }) + + const res = await app.request('/api/auth-providers/admin', { headers: admin }) + const body = (await res.json()) as { items: unknown[] } + expect(body.items).toHaveLength(0) + }) + + it('deleting a non-existent provider returns 200 with deleted flag', async () => { + const { app } = await createTestApp() + const admin = await adminHeaders(app) + + const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.deleted).toBe(true) + }) +}) diff --git a/server/routes/auth-providers.ts b/server/routes/auth-providers.ts new file mode 100644 index 00000000..aa681783 --- /dev/null +++ b/server/routes/auth-providers.ts @@ -0,0 +1,108 @@ +import { zValidator } from '@hono/zod-validator' +import { eq, like } from 'drizzle-orm' +import { Hono } from 'hono' +import { z } from 'zod' +import { + BUILTIN_PROVIDER_IDS, + isValidProviderId, + OAUTH_PROVIDER_KEY_PATTERN, + OAUTH_PROVIDER_KEY_PREFIX, + OAuthProviderMeta, + parseProviderConfig, +} from '../../shared/oauth-providers' +import { systemOptions } from '../db/schema' +import { requireAdmin } from '../middleware/auth' +import type { Env } from '../middleware/platform' + +function optionKey(providerId: string): string { + return `${OAUTH_PROVIDER_KEY_PREFIX}${providerId}` +} + +function maskSecret(secret: string): string { + if (secret.length <= 4) return '****' + return `${'*'.repeat(secret.length - 4)}${secret.slice(-4)}` +} + +const upsertSchema = z.object({ + type: z.enum(['builtin', 'oidc']), + clientId: z.string().min(1), + clientSecret: z.string().min(1), + enabled: z.boolean(), + discoveryUrl: z.string().url().optional(), + scopes: z.array(z.string()).optional(), +}) + +const app = new Hono() + // Public: enabled providers only, no secrets (for login page buttons) + .get('/', async (c) => { + const db = c.get('platform').db + const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN)) + const items = rows + .map((r) => { + const config = parseProviderConfig(r.value) + if (!config?.enabled) return null + const meta = OAuthProviderMeta[config.providerId] + return { + providerId: config.providerId, + type: config.type, + name: meta?.name ?? config.providerId, + icon: meta?.icon ?? config.providerId, + } + }) + .filter((item) => item !== null) + return c.json({ items }) + }) + // Admin: list all provider configs (secrets masked) + .get('/admin', requireAdmin, async (c) => { + const db = c.get('platform').db + const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN)) + const items = rows + .map((r) => { + const config = parseProviderConfig(r.value) + if (!config) return null + return { ...config, clientSecret: maskSecret(config.clientSecret) } + }) + .filter((item) => item !== null) + return c.json({ items }) + }) + // Admin: upsert a provider config + .put('/admin/:providerId', requireAdmin, zValidator('json', upsertSchema), async (c) => { + const db = c.get('platform').db + const providerId = c.req.param('providerId') + const body = c.req.valid('json') + + if (!isValidProviderId(providerId)) { + return c.json({ error: 'Provider ID must contain only lowercase letters, numbers, and hyphens' }, 400) + } + if (body.type === 'builtin' && !BUILTIN_PROVIDER_IDS.includes(providerId)) { + return c.json({ error: `Unknown builtin provider: ${providerId}` }, 400) + } + if (body.type === 'oidc' && !body.discoveryUrl) { + return c.json({ error: 'discoveryUrl is required for OIDC providers' }, 400) + } + + const config = { providerId, ...body } + const key = optionKey(providerId) + const value = JSON.stringify(config) + + const existing = await db.select({ key: systemOptions.key }).from(systemOptions).where(eq(systemOptions.key, key)) + if (existing.length > 0) { + await db.update(systemOptions).set({ value, public: false }).where(eq(systemOptions.key, key)) + } else { + await db.insert(systemOptions).values({ key, value, public: false }) + } + + return c.json({ ...config, clientSecret: maskSecret(config.clientSecret) }) + }) + // Admin: delete a provider config + .delete('/admin/:providerId', requireAdmin, async (c) => { + const db = c.get('platform').db + const providerId = c.req.param('providerId') + if (!isValidProviderId(providerId)) { + return c.json({ error: 'Provider ID must contain only lowercase letters, numbers, and hyphens' }, 400) + } + await db.delete(systemOptions).where(eq(systemOptions.key, optionKey(providerId))) + return c.json({ providerId, deleted: true }) + }) + +export default app diff --git a/server/routes/auth-username.test.ts b/server/routes/auth-username.test.ts index dc855fcf..728bf5f4 100644 --- a/server/routes/auth-username.test.ts +++ b/server/routes/auth-username.test.ts @@ -24,7 +24,7 @@ describe('migration 0004_username_plugin.sql', () => { describe('username plugin — sign-up with username', () => { it('sign-up with username stores the username on the user record', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -40,7 +40,7 @@ describe('username plugin — sign-up with username', () => { }) it('sign-up without username leaves the username column null', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -51,7 +51,7 @@ describe('username plugin — sign-up with username', () => { }) it('sign-up with duplicate username returns a non-200 response', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -76,7 +76,7 @@ describe('username plugin — sign-up with username', () => { }) it('two users with different usernames both register successfully', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const res1 = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server/routes/auth.cf-test.ts b/server/routes/auth.cf-test.ts index 09513e6c..280810fc 100644 --- a/server/routes/auth.cf-test.ts +++ b/server/routes/auth.cf-test.ts @@ -4,15 +4,15 @@ import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -function buildApp() { +async function buildApp() { const platform = createCloudflarePlatform(env) - const auth = createAuth(platform.db, env.BETTER_AUTH_SECRET) + const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET) return createApp(platform, auth) } describe('[CF] Auth API', () => { it('POST /api/auth/sign-up/email creates user', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -24,7 +24,7 @@ describe('[CF] Auth API', () => { }) it('POST /api/auth/sign-in/email signs in', async () => { - const app = buildApp() + const app = await buildApp() const email = `cf-signin-${Date.now()}@example.com` await app.request('/api/auth/sign-up/email', { method: 'POST', diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 90c9a683..73aae818 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -6,7 +6,7 @@ import { createTestApp } from '../test/setup.js' describe('Auth API', () => { it('POST /api/auth/sign-up/email creates user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -18,7 +18,7 @@ describe('Auth API', () => { }) it('POST /api/auth/sign-in/email signs in', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() // First sign up await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -36,7 +36,7 @@ describe('Auth API', () => { }) it('POST /api/auth/sign-in/email rejects wrong password', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -51,7 +51,7 @@ describe('Auth API', () => { }) it('first user gets admin role after signup', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -62,7 +62,7 @@ describe('Auth API', () => { }) it('first user gets a personal organization created after signup', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const signUpRes = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -80,7 +80,7 @@ describe('Auth API', () => { }) it('second user does NOT get admin role', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() // First user await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -98,7 +98,7 @@ describe('Auth API', () => { }) it('second user also gets a personal organization created', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() // First user await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -123,7 +123,7 @@ describe('Auth API', () => { }) it('second user gets a member record with owner role in their personal org', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() // First user await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -152,7 +152,7 @@ describe('Auth API', () => { }) it('signup without default_org_quota set creates an org_quotas row with quota=10485760 (built-in default)', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const signUpRes = await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -174,7 +174,7 @@ describe('Auth API', () => { }) it('signup with default_org_quota set to 1073741824 creates an org_quotas row with quota=1073741824 and used=0', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '1073741824' }) const signUpRes = await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -197,7 +197,7 @@ describe('Auth API', () => { }) it('signup with default_org_quota set to 0 does NOT create an org_quotas row', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' }) await app.request('/api/auth/sign-up/email', { method: 'POST', @@ -209,7 +209,7 @@ describe('Auth API', () => { }) it('sign-in with a malformed stored password hash returns a non-200 error response', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() await app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/server/routes/email-config.test.ts b/server/routes/email-config.test.ts index 277c0a43..a2b43966 100644 --- a/server/routes/email-config.test.ts +++ b/server/routes/email-config.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as schema from '../db/schema.js' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' -async function seedSmtpConfig(db: ReturnType['db']) { +async function seedSmtpConfig(db: Awaited>['db']) { await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -14,7 +14,7 @@ async function seedSmtpConfig(db: ReturnType['db']) { ]) } -async function seedHttpConfig(db: ReturnType['db']) { +async function seedHttpConfig(db: Awaited>['db']) { await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -25,13 +25,13 @@ async function seedHttpConfig(db: ReturnType['db']) { describe('Admin Email Config API — auth', () => { it('GET returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/email-config') expect(res.status).toBe(401) }) it('GET returns 403 for non-admin user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() await authedHeaders(app, 'admin@example.com') await authedHeaders(app, 'regular@example.com') const signInRes = await app.request('/api/auth/sign-in/email', { @@ -45,7 +45,7 @@ describe('Admin Email Config API — auth', () => { }) it('PUT returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/email-config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -55,7 +55,7 @@ describe('Admin Email Config API — auth', () => { }) it('POST /test returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/email-config/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -67,7 +67,7 @@ describe('Admin Email Config API — auth', () => { describe('Admin Email Config API — GET', () => { it('returns { provider: null } when no config exists', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { headers }) expect(res.status).toBe(200) @@ -76,7 +76,7 @@ describe('Admin Email Config API — GET', () => { }) it('returns masked SMTP config after SMTP config is saved', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedSmtpConfig(db) @@ -97,7 +97,7 @@ describe('Admin Email Config API — GET', () => { }) it('returns masked HTTP config after HTTP config is saved', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedHttpConfig(db) @@ -111,13 +111,13 @@ describe('Admin Email Config API — GET', () => { // apiKey must be masked expect(http.apiKey).not.toBe('my-secret-key') expect(String(http.apiKey).endsWith('-key')).toBe(true) - expect(String(http.apiKey)).toMatch(/^\*+\-key$/) + expect(String(http.apiKey)).toMatch(/^\*+-key$/) }) }) describe('Admin Email Config API — PUT', () => { it('saves SMTP config and returns success', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { @@ -141,7 +141,7 @@ describe('Admin Email Config API — PUT', () => { }) it('persists SMTP config so GET reflects the saved values', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/email-config', { @@ -170,7 +170,7 @@ describe('Admin Email Config API — PUT', () => { }) it('saves HTTP config and returns success', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { @@ -191,7 +191,7 @@ describe('Admin Email Config API — PUT', () => { }) it('persists HTTP config so GET reflects the saved values', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/email-config', { @@ -216,7 +216,7 @@ describe('Admin Email Config API — PUT', () => { }) it('returns 400 for invalid provider value', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { @@ -228,7 +228,7 @@ describe('Admin Email Config API — PUT', () => { }) it('returns 400 for invalid from email', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { @@ -240,7 +240,7 @@ describe('Admin Email Config API — PUT', () => { }) it('updates existing config when PUT is called a second time', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/email-config', { @@ -281,7 +281,7 @@ describe('Admin Email Config API — POST /test', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedHttpConfig(db) @@ -303,7 +303,7 @@ describe('Admin Email Config API — POST /test', () => { }) vi.stubGlobal('fetch', fetchMock) - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedHttpConfig(db) @@ -319,7 +319,7 @@ describe('Admin Email Config API — POST /test', () => { }) it('returns 400 when no email config is set', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config/test', { @@ -334,7 +334,7 @@ describe('Admin Email Config API — POST /test', () => { }) it('returns 400 for invalid to email', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedSmtpConfig(db) diff --git a/server/routes/health.cf-test.ts b/server/routes/health.cf-test.ts index 14e07785..2763073a 100644 --- a/server/routes/health.cf-test.ts +++ b/server/routes/health.cf-test.ts @@ -4,15 +4,15 @@ import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -function buildApp() { +async function buildApp() { const platform = createCloudflarePlatform(env) - const auth = createAuth(platform.db, env.BETTER_AUTH_SECRET) + const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET) return createApp(platform, auth) } describe('[CF] GET /api/health', () => { it('returns ok', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/health') expect(res.status).toBe(200) expect(await res.json()).toEqual({ status: 'ok' }) diff --git a/server/routes/health.test.ts b/server/routes/health.test.ts index 443a4182..877a5fb1 100644 --- a/server/routes/health.test.ts +++ b/server/routes/health.test.ts @@ -3,7 +3,7 @@ import { createTestApp } from '../test/setup.js' describe('GET /api/health', () => { it('returns ok', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/health') expect(res.status).toBe(200) expect(await res.json()).toEqual({ status: 'ok' }) diff --git a/server/routes/invite-codes.test.ts b/server/routes/invite-codes.test.ts index 077c086e..a72bd594 100644 --- a/server/routes/invite-codes.test.ts +++ b/server/routes/invite-codes.test.ts @@ -6,13 +6,13 @@ import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' describe('Admin Invite Codes API — auth guards', () => { it('GET / returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/invite-codes') expect(res.status).toBe(401) }) it('GET / returns 403 for a non-admin user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() await adminHeaders(app) // first user becomes admin const headers = await authedHeaders(app, 'regular@example.com') const res = await app.request('/api/admin/invite-codes', { headers }) @@ -20,7 +20,7 @@ describe('Admin Invite Codes API — auth guards', () => { }) it('POST / returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/invite-codes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -30,7 +30,7 @@ describe('Admin Invite Codes API — auth guards', () => { }) it('DELETE /:id returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/invite-codes/someid', { method: 'DELETE' }) expect(res.status).toBe(401) }) @@ -38,7 +38,7 @@ describe('Admin Invite Codes API — auth guards', () => { describe('Admin Invite Codes API — GET /', () => { it('returns an empty list when no codes exist', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { headers }) expect(res.status).toBe(200) @@ -47,7 +47,7 @@ describe('Admin Invite Codes API — GET /', () => { }) it('returns created codes with correct total', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/invite-codes', { @@ -64,7 +64,7 @@ describe('Admin Invite Codes API — GET /', () => { }) it('paginates with page and pageSize query params', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/invite-codes', { @@ -83,7 +83,7 @@ describe('Admin Invite Codes API — GET /', () => { describe('Admin Invite Codes API — POST /', () => { it('creates the requested number of codes and returns 201', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { method: 'POST', @@ -96,7 +96,7 @@ describe('Admin Invite Codes API — POST /', () => { }) it('creates codes with an expiry when expiresInDays is provided', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { method: 'POST', @@ -109,7 +109,7 @@ describe('Admin Invite Codes API — POST /', () => { }) it('returns 400 when count is missing', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { method: 'POST', @@ -120,7 +120,7 @@ describe('Admin Invite Codes API — POST /', () => { }) it('returns 400 when count exceeds maximum of 100', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { method: 'POST', @@ -131,7 +131,7 @@ describe('Admin Invite Codes API — POST /', () => { }) it('returns 400 when count is zero', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { method: 'POST', @@ -144,7 +144,7 @@ describe('Admin Invite Codes API — POST /', () => { describe('Admin Invite Codes API — DELETE /:id', () => { it('deletes an unused code and returns deleted:true', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) const [row] = await generateInviteCodes(db, 'admin-user', 1) @@ -159,7 +159,7 @@ describe('Admin Invite Codes API — DELETE /:id', () => { }) it('returns 404 for a nonexistent code id', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes/nonexistent', { method: 'DELETE', @@ -169,7 +169,7 @@ describe('Admin Invite Codes API — DELETE /:id', () => { }) it('returns 400 when trying to delete an already-used code', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) const [row] = await generateInviteCodes(db, 'admin-user', 1) await redeemInviteCode(db, row.code, 'user-123') @@ -186,7 +186,7 @@ describe('Admin Invite Codes API — DELETE /:id', () => { describe('Public Invite Codes API — POST /validate', () => { it('returns valid:true for a valid unused code', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) const res = await app.request('/api/invite-codes/validate', { @@ -200,7 +200,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns valid:false for a nonexistent code', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -213,7 +213,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns valid:false for a used code', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-99') @@ -228,7 +228,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns valid:false for an expired code', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const past = new Date(Date.now() - 1000) const [row] = await generateInviteCodes(db, 'admin-1', 1, past) @@ -243,7 +243,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns 400 when code field is missing from request body', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -253,7 +253,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns 400 when code is an empty string', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -263,7 +263,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns 400 when code contains lowercase letters', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -273,7 +273,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns 400 when code is fewer than 8 characters', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -283,7 +283,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('returns 400 when code is more than 8 characters', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/invite-codes/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -293,7 +293,7 @@ describe('Public Invite Codes API — POST /validate', () => { }) it('is accessible without authentication', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) // No auth headers — should still work diff --git a/server/routes/objects-quota.test.ts b/server/routes/objects-quota.test.ts index 006b3fd2..4862e234 100644 --- a/server/routes/objects-quota.test.ts +++ b/server/routes/objects-quota.test.ts @@ -25,7 +25,7 @@ const validStorage = { secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', } -async function insertStorage(db: ReturnType['db'], used = 0) { +async function insertStorage(db: Awaited>['db'], used = 0) { const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -36,7 +36,7 @@ async function insertStorage(db: ReturnType['db'], used = } async function insertFile( - db: ReturnType['db'], + db: Awaited>['db'], orgId: string, opts: { id: string; name: string; size?: number; status?: string }, ) { @@ -50,14 +50,19 @@ async function insertFile( `) } -async function getOrgId(db: ReturnType['db']): Promise { +async function getOrgId(db: Awaited>['db']): Promise { const rows = await db.all<{ id: string }>(sql` SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1 `) return rows[0].id } -async function setOrgQuota(db: ReturnType['db'], orgId: string, quota: number, used = 0) { +async function setOrgQuota( + db: Awaited>['db'], + orgId: string, + quota: number, + used = 0, +) { const existing = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId)) if (existing.length > 0) { await db.update(orgQuotas).set({ quota, used }).where(eq(orgQuotas.orgId, orgId)) @@ -70,7 +75,7 @@ async function setOrgQuota(db: ReturnType['db'], orgId: st describe('POST /api/objects/:id/copy — quota enforcement', () => { it('returns 422 when copying a file would exceed quota', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -89,7 +94,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 201 and increments orgQuotas.used when copy succeeds within quota', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -109,7 +114,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 201 and increments storages.used when copy succeeds', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db, 50) const orgId = await getOrgId(db) @@ -127,7 +132,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 201 without incrementing usage when copying a zero-size file', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -152,7 +157,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 201 when no quota row exists (unlimited)', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -168,7 +173,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 201 when quota is 0 (unlimited) regardless of file size', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -184,7 +189,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { }) it('returns 404 when source file does not exist', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent/copy', { @@ -200,7 +205,7 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => { describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', () => { it('returns 200 and increments usage when quota allows', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -217,7 +222,7 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', }) it('returns 200 and increments storages.used when quota allows', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db, 100) const orgId = await getOrgId(db) @@ -231,7 +236,7 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', }) it('returns 422 when confirming upload would exceed quota', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -246,7 +251,7 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', }) it('does not change usage when a file with size 0 is confirmed', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db, 50) const orgId = await getOrgId(db) @@ -262,7 +267,7 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', }) it('returns 200 when no quota row exists (unlimited)', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) diff --git a/server/routes/objects.cf-test.ts b/server/routes/objects.cf-test.ts index 734159c1..d210af6a 100644 --- a/server/routes/objects.cf-test.ts +++ b/server/routes/objects.cf-test.ts @@ -4,9 +4,9 @@ import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -function buildApp() { +async function buildApp() { const platform = createCloudflarePlatform(env) - const auth = createAuth(platform.db, env.BETTER_AUTH_SECRET) + const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET) return createApp(platform, auth) } @@ -23,13 +23,13 @@ async function authedHeaders(app: ReturnType) { describe('[CF] Objects API', () => { it('returns 401 without auth', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/objects') expect(res.status).toBe(401) }) it('GET /api/objects returns empty list', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { headers }) expect(res.status).toBe(200) @@ -38,7 +38,7 @@ describe('[CF] Objects API', () => { }) it('POST /api/objects returns 400 for invalid input', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { method: 'POST', @@ -49,7 +49,7 @@ describe('[CF] Objects API', () => { }) it('POST /api/objects returns 500 when no storage configured', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { method: 'POST', @@ -60,14 +60,14 @@ describe('[CF] Objects API', () => { }) it('GET /api/objects/:id returns 404 for missing object', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { headers }) expect(res.status).toBe(404) }) it('PATCH /api/objects/:id returns 404 for missing object', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { method: 'PATCH', @@ -78,7 +78,7 @@ describe('[CF] Objects API', () => { }) it('DELETE /api/objects/:id returns 404 for missing object', async () => { - const app = buildApp() + const app = await buildApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { method: 'DELETE', diff --git a/server/routes/objects.test.ts b/server/routes/objects.test.ts index d11f5478..de4144c7 100644 --- a/server/routes/objects.test.ts +++ b/server/routes/objects.test.ts @@ -36,7 +36,7 @@ const validStorage = { secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', } -async function insertStorage(db: ReturnType['db']) { +async function insertStorage(db: Awaited>['db']) { const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -45,7 +45,7 @@ async function insertStorage(db: ReturnType['db']) { } async function insertFolder( - db: ReturnType['db'], + db: Awaited>['db'], orgId: string, opts: { id: string; name: string; parent?: string }, ) { @@ -57,7 +57,7 @@ async function insertFolder( } async function insertFile( - db: ReturnType['db'], + db: Awaited>['db'], orgId: string, opts: { id: string; name: string; parent?: string; status?: string }, ) { @@ -69,7 +69,7 @@ async function insertFile( `) } -async function getOrgId(db: ReturnType['db']): Promise { +async function getOrgId(db: Awaited>['db']): Promise { const rows = await db.all<{ id: string }>(sql` SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1 `) @@ -78,13 +78,13 @@ async function getOrgId(db: ReturnType['db']): Promise { it('returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/objects') expect(res.status).toBe(401) }) it('GET /api/objects returns empty list', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { headers }) expect(res.status).toBe(200) @@ -93,7 +93,7 @@ describe('Objects API', () => { }) it('GET /api/objects respects pagination params', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects?page=2&pageSize=10', { headers }) expect(res.status).toBe(200) @@ -103,7 +103,7 @@ describe('Objects API', () => { }) it('POST /api/objects creates a folder', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const res = await app.request('/api/objects', { @@ -121,7 +121,7 @@ describe('Objects API', () => { }) it('POST /api/objects returns 400 for invalid input', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { method: 'POST', @@ -132,7 +132,7 @@ describe('Objects API', () => { }) it('POST /api/objects returns 500 when no storage available', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { method: 'POST', @@ -143,7 +143,7 @@ describe('Objects API', () => { }) it('GET /api/objects lists active objects in root', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -162,7 +162,7 @@ describe('Objects API', () => { }) it('GET /api/objects filters by parent', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -178,7 +178,7 @@ describe('Objects API', () => { }) it('GET /api/objects filters by status', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -193,7 +193,7 @@ describe('Objects API', () => { }) it('GET /api/objects/:id returns folder detail', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -209,14 +209,14 @@ describe('Objects API', () => { }) it('GET /api/objects/:id returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { headers }) expect(res.status).toBe(404) }) it('PATCH /api/objects/:id renames an object', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -233,7 +233,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id moves an object', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -251,7 +251,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { method: 'PATCH', @@ -262,7 +262,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/done confirms upload', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -278,7 +278,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/done returns 404 for non-draft object', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -292,7 +292,7 @@ describe('Objects API', () => { }) it('DELETE /api/objects/:id rejects active object (must trash first)', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -303,7 +303,7 @@ describe('Objects API', () => { }) it('DELETE /api/objects/:id permanently deletes a trashed folder', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -323,7 +323,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/trash trashes a file', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -341,7 +341,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/restore restores a trashed file', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -354,7 +354,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/trash cascades to folder children', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -379,7 +379,7 @@ describe('Objects API', () => { }) it('POST /api/recycle-bin/empty purges all trashed items', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -396,7 +396,7 @@ describe('Objects API', () => { }) it('DELETE /api/objects/:id returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { method: 'DELETE', @@ -406,7 +406,7 @@ describe('Objects API', () => { }) it('POST /api/objects/:id/copy copies a folder', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -425,7 +425,7 @@ describe('Objects API', () => { }) it('POST /api/objects/:id/copy returns 404 for missing source', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent/copy', { method: 'POST', @@ -436,7 +436,7 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/done returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent/done', { method: 'PATCH', @@ -446,7 +446,7 @@ describe('Objects API', () => { }) it('POST /api/objects creates a file with upload URL', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const res = await app.request('/api/objects', { @@ -462,7 +462,7 @@ describe('Objects API', () => { }) it('POST /api/objects/:id/copy copies a file with S3', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -478,7 +478,7 @@ describe('Objects API', () => { }) it('DELETE /api/objects/:id permanently deletes a trashed file with S3 cleanup', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -493,7 +493,7 @@ describe('Objects API', () => { }) it('DELETE /api/objects/:id purges folder with file children from S3', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -510,14 +510,14 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/trash returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent/trash', { method: 'PATCH', headers }) expect(res.status).toBe(404) }) it('PATCH /api/objects/:id/trash is idempotent for already-trashed item', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -529,14 +529,14 @@ describe('Objects API', () => { }) it('PATCH /api/objects/:id/restore returns 404 for missing object', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent/restore', { method: 'PATCH', headers }) expect(res.status).toBe(404) }) it('PATCH /api/objects/:id/restore is no-op for active item', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -549,7 +549,7 @@ describe('Objects API', () => { }) it('POST /api/recycle-bin/empty with files calls S3 deleteObjects', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -561,7 +561,7 @@ describe('Objects API', () => { }) it('POST /api/recycle-bin/empty handles folders (no S3 object) and files together', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -578,7 +578,7 @@ describe('Objects API', () => { }) it('POST /api/recycle-bin/empty returns 0 when trash is empty', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers }) expect(res.status).toBe(200) @@ -587,7 +587,7 @@ describe('Objects API', () => { }) it('GET /api/objects/:id returns downloadUrl for files', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -600,7 +600,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/move moves multiple items', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -618,7 +618,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/move returns 400 for invalid input', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/batch/move', { method: 'POST', @@ -629,7 +629,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/move returns 400 if any id missing from org', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -643,7 +643,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/trash trashes items and cascades', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -661,7 +661,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/trash returns 400 for invalid input', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/batch/trash', { method: 'POST', @@ -672,7 +672,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/delete permanently deletes trashed items', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -695,7 +695,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/delete returns 400 if any item is not trashed', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -711,7 +711,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/delete returns 400 for invalid input', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/batch/delete', { method: 'POST', @@ -722,7 +722,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/delete decrements usage for files with size > 0', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -750,7 +750,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/trash returns 400 when IDs do not belong to org', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -767,7 +767,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/move returns 400 when moving folder into itself', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -784,7 +784,7 @@ describe('Objects API', () => { }) it('POST /api/objects/batch/move cascades path when moving a folder', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) @@ -805,7 +805,7 @@ describe('Objects API', () => { describe('Matter service', () => { it('createMatter applies defaults for optional fields', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -828,7 +828,7 @@ describe('Matter service', () => { }) it('createMatter uses provided optional fields', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -852,7 +852,7 @@ describe('Matter service', () => { }) it('listMatters returns paginated results', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -885,25 +885,25 @@ describe('Matter service', () => { }) it('getMatter returns null for missing record', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await getMatter(db, 'nonexistent', 'org-1') expect(result).toBeNull() }) it('updateMatter returns null for missing record', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await updateMatter(db, 'nonexistent', 'org-1', { name: 'new' }) expect(result).toBeNull() }) it('confirmUpload returns null for missing record', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const { matter } = await confirmUpload(db, 'nonexistent', 'org-1') expect(matter).toBeNull() }) it('confirmUpload returns null for non-draft status', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -922,7 +922,7 @@ describe('Matter service', () => { }) it('deleteMatter removes and returns the record', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -946,13 +946,13 @@ describe('Matter service', () => { }) it('deleteMatter returns null for missing record', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await deleteMatter(db, 'nonexistent', 'org-1') expect(result).toBeNull() }) it('copyMatter creates a new record from source', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -979,13 +979,13 @@ describe('Matter service', () => { }) it('getMatters returns empty array for empty ids list', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await getMatters(db, 'org-1', []) expect(result).toEqual([]) }) it('batchMove moves multiple items to a new parent', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1017,7 +1017,7 @@ describe('Matter service', () => { }) it('batchMove throws if any ID does not belong to the org', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1038,7 +1038,7 @@ describe('Matter service', () => { }) it('batchTrash sets status to trashed for multiple items', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1068,7 +1068,7 @@ describe('Matter service', () => { }) it('batchTrash cascades into folder children', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1103,7 +1103,7 @@ describe('Matter service', () => { }) it('batchTrash throws if any ID does not belong to the org', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1124,7 +1124,7 @@ describe('Matter service', () => { }) it('batchDelete permanently deletes trashed items', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1155,7 +1155,7 @@ describe('Matter service', () => { }) it('batchDelete throws if any item is not trashed', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) @@ -1184,7 +1184,7 @@ describe('Matter service', () => { }) it('batchDelete throws if any ID does not belong to the org', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const now = Date.now() await db.run(sql` INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) diff --git a/server/routes/quotas.test.ts b/server/routes/quotas.test.ts index 0adae207..6e8c5c1c 100644 --- a/server/routes/quotas.test.ts +++ b/server/routes/quotas.test.ts @@ -16,13 +16,13 @@ async function adminHeaders(app: ReturnType { it('returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/quotas') expect(res.status).toBe(401) }) it('returns 403 for non-admin', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() await authedHeaders(app, 'admin@example.com') await authedHeaders(app, 'regular@example.com') const signInRes = await app.request('/api/auth/sign-in/email', { @@ -36,7 +36,7 @@ describe('Admin Quotas API', () => { }) it('GET /api/admin/quotas returns the default quota row created at signup', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/quotas', { headers }) expect(res.status).toBe(200) @@ -47,7 +47,7 @@ describe('Admin Quotas API', () => { }) it('PUT /api/admin/quotas/:orgId creates quota for org', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) // Find the admin's personal org @@ -72,7 +72,7 @@ describe('Admin Quotas API', () => { }) it('PUT /api/admin/quotas/:orgId updates existing quota', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) const orgs = await db.all<{ id: string }>( @@ -99,7 +99,7 @@ describe('Admin Quotas API', () => { }) it('PUT /api/admin/quotas/:orgId rejects negative quota', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/quotas/some-org', { method: 'PUT', @@ -110,7 +110,7 @@ describe('Admin Quotas API', () => { }) it('GET /api/admin/quotas lists quotas with org info', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) const orgs = await db.all<{ id: string }>( @@ -138,13 +138,13 @@ describe('Admin Quotas API', () => { describe('User Quotas API — /api/quotas', () => { it('GET /api/quotas/me returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/quotas/me') expect(res.status).toBe(401) }) it('GET /api/quotas/me returns the built-in default quota of 10MB when no system option is set', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/quotas/me', { headers }) expect(res.status).toBe(200) @@ -155,7 +155,7 @@ describe('User Quotas API — /api/quotas', () => { }) it('GET /api/quotas/me returns 404 when user has no org', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const _headers = await authedHeaders(app, 'noorg@example.com') // Delete the user's org membership and org to simulate no org const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'noorg@example.com'`) @@ -172,7 +172,7 @@ describe('User Quotas API — /api/quotas', () => { }) it('GET /api/quotas/me returns quota after admin sets it', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const adminH = await adminHeaders(app) // Find admin's org diff --git a/server/routes/storages.cf-test.ts b/server/routes/storages.cf-test.ts index 6b7f9cd7..b9b7f7e0 100644 --- a/server/routes/storages.cf-test.ts +++ b/server/routes/storages.cf-test.ts @@ -6,9 +6,9 @@ import { createAuth } from '../auth' import { user } from '../db/auth-schema' import { createCloudflarePlatform } from '../platform/cloudflare' -function buildApp() { +async function buildApp() { const platform = createCloudflarePlatform(env) - const auth = createAuth(platform.db, env.BETTER_AUTH_SECRET) + const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET) return createApp(platform, auth) } @@ -43,13 +43,13 @@ const validStorage = { describe('[CF] Admin Storages API', () => { it('returns 401 without auth', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/admin/storages') expect(res.status).toBe(401) }) it('GET /api/admin/storages returns empty list', async () => { - const app = buildApp() + const app = await buildApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages', { headers }) expect(res.status).toBe(200) @@ -58,7 +58,7 @@ describe('[CF] Admin Storages API', () => { }) it('POST /api/admin/storages creates a storage', async () => { - const app = buildApp() + const app = await buildApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages', { method: 'POST', @@ -73,7 +73,7 @@ describe('[CF] Admin Storages API', () => { }) it('GET /api/admin/storages/:id returns storage detail', async () => { - const app = buildApp() + const app = await buildApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -90,7 +90,7 @@ describe('[CF] Admin Storages API', () => { }) it('PUT /api/admin/storages/:id updates a storage', async () => { - const app = buildApp() + const app = await buildApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -111,7 +111,7 @@ describe('[CF] Admin Storages API', () => { }) it('DELETE /api/admin/storages/:id deletes a storage', async () => { - const app = buildApp() + const app = await buildApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { diff --git a/server/routes/storages.test.ts b/server/routes/storages.test.ts index d8eda7f0..05df17ea 100644 --- a/server/routes/storages.test.ts +++ b/server/routes/storages.test.ts @@ -15,13 +15,13 @@ const validStorage = { describe('Admin Storages API', () => { it('returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/storages') expect(res.status).toBe(401) }) it('returns 403 for non-admin user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() // First user becomes admin await authedHeaders(app, 'admin@example.com') // Second user is non-admin @@ -37,7 +37,7 @@ describe('Admin Storages API', () => { }) it('GET / returns empty list', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages', { headers }) expect(res.status).toBe(200) @@ -46,7 +46,7 @@ describe('Admin Storages API', () => { }) it('POST / creates a storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages', { method: 'POST', @@ -65,7 +65,7 @@ describe('Admin Storages API', () => { }) it('GET / lists created storages', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) await app.request('/api/admin/storages', { @@ -83,7 +83,7 @@ describe('Admin Storages API', () => { }) it('GET /:id returns storage detail', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -101,14 +101,14 @@ describe('Admin Storages API', () => { }) it('GET /:id returns 404 for missing storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages/nonexistent', { headers }) expect(res.status).toBe(404) }) it('PUT /:id updates a storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -130,7 +130,7 @@ describe('Admin Storages API', () => { }) it('PUT /:id returns 404 for missing storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages/nonexistent', { method: 'PUT', @@ -141,7 +141,7 @@ describe('Admin Storages API', () => { }) it('DELETE /:id deletes a storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -161,7 +161,7 @@ describe('Admin Storages API', () => { }) it('DELETE /:id returns 404 for missing storage', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages/nonexistent', { method: 'DELETE', @@ -171,7 +171,7 @@ describe('Admin Storages API', () => { }) it('DELETE /:id returns 409 when matters reference the storage', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) const createRes = await app.request('/api/admin/storages', { @@ -197,7 +197,7 @@ describe('Admin Storages API', () => { // Helper to insert a storage row directly into the DB for service-level tests async function insertStorage( - db: ReturnType['db'], + db: Awaited>['db'], opts: { id: string mode: 'private' | 'public' @@ -220,7 +220,7 @@ async function insertStorage( describe('selectStorage service', () => { it('returns the single active storage when capacity is unlimited (0)', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 }) const storage = await selectStorage(db, 'private') @@ -228,7 +228,7 @@ describe('selectStorage service', () => { }) it('returns storage when used is below capacity', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 }) const storage = await selectStorage(db, 'private') @@ -236,7 +236,7 @@ describe('selectStorage service', () => { }) it('skips storage where used equals capacity', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 }) @@ -245,7 +245,7 @@ describe('selectStorage service', () => { }) it('skips storage where used exceeds capacity', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 }) @@ -254,7 +254,7 @@ describe('selectStorage service', () => { }) it('picks the oldest active storage first (sequential fill order)', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 }) @@ -263,7 +263,7 @@ describe('selectStorage service', () => { }) it('ignores disabled storages', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 }) @@ -272,20 +272,20 @@ describe('selectStorage service', () => { }) it('ignores storages of a different mode', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 }) await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') }) it('throws when no active storage exists for the requested mode', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') }) it('throws when all storages of the mode are at full capacity', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 }) @@ -293,7 +293,7 @@ describe('selectStorage service', () => { }) it('returns a public storage when mode is public', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 }) const storage = await selectStorage(db, 'public') @@ -301,7 +301,7 @@ describe('selectStorage service', () => { }) it('does not return a public storage when private mode is requested', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 }) diff --git a/server/routes/system.cf-test.ts b/server/routes/system.cf-test.ts index 35669fe0..3a01cd4b 100644 --- a/server/routes/system.cf-test.ts +++ b/server/routes/system.cf-test.ts @@ -4,15 +4,15 @@ import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -function buildApp() { +async function buildApp() { const platform = createCloudflarePlatform(env) - const auth = createAuth(platform.db, env.BETTER_AUTH_SECRET) + const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET) return createApp(platform, auth) } describe('[CF] System API', () => { it('GET /api/system/options returns empty list without auth', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/system/options') expect(res.status).toBe(200) const body = (await res.json()) as { items: unknown[]; total: number } @@ -20,7 +20,7 @@ describe('[CF] System API', () => { }) it('GET unknown option returns 404', async () => { - const app = buildApp() + const app = await buildApp() const res = await app.request('/api/system/options/nonexistent_key') expect(res.status).toBe(404) }) diff --git a/server/routes/system.test.ts b/server/routes/system.test.ts index 6357026c..d76324e5 100644 --- a/server/routes/system.test.ts +++ b/server/routes/system.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { adminHeaders, createTestApp } from '../test/setup.js' async function putOption( - app: ReturnType['app'], + app: Awaited>['app'], headers: Record, key: string, body: Record, @@ -16,13 +16,13 @@ async function putOption( describe('System API — options CRUD', () => { it('GET unknown key returns 404', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/system/options/site_name') expect(res.status).toBe(404) }) it('full admin CRUD lifecycle with public/private visibility', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const admin = await adminHeaders(app) // Create public option @@ -72,7 +72,7 @@ describe('System API — options CRUD', () => { }) it('unauthenticated mutations are rejected', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/system/options/site_name', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/server/routes/users.test.ts b/server/routes/users.test.ts index e8bfa6a8..6c3b33a7 100644 --- a/server/routes/users.test.ts +++ b/server/routes/users.test.ts @@ -25,13 +25,13 @@ async function signUpUser(app: ReturnType, describe('Admin Users API', () => { it('returns 401 without auth', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const res = await app.request('/api/admin/users') expect(res.status).toBe(401) }) it('returns 403 for non-admin user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() // Create first user (auto-admin), then second user (non-admin) await authedHeaders(app, 'admin@example.com') const _headers = await authedHeaders(app, 'regular@example.com') @@ -47,7 +47,7 @@ describe('Admin Users API', () => { }) it('GET /api/admin/users lists users with pagination', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users', { headers }) @@ -60,7 +60,7 @@ describe('Admin Users API', () => { }) it('PUT /api/admin/users/:id/status disables a user', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) // Create a second user @@ -85,7 +85,7 @@ describe('Admin Users API', () => { }) it('PUT /api/admin/users/:id/status rejects invalid status', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users/someid/status', { method: 'PUT', @@ -96,7 +96,7 @@ describe('Admin Users API', () => { }) it('PUT /api/admin/users/:id/status returns 404 for missing user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users/nonexistent/status', { method: 'PUT', @@ -107,7 +107,7 @@ describe('Admin Users API', () => { }) it('DELETE /api/admin/users/:id deletes a user', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'todelete@example.com') @@ -128,7 +128,7 @@ describe('Admin Users API', () => { }) it('disabled user is rejected by auth middleware on existing session', async () => { - const { app, db } = createTestApp() + const { app, db } = await createTestApp() const headers = await adminHeaders(app) // Create a second user and get their session before banning @@ -152,7 +152,7 @@ describe('Admin Users API', () => { }) it('DELETE /api/admin/users/:id returns 404 for missing user', async () => { - const { app } = createTestApp() + const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users/nonexistent', { method: 'DELETE', diff --git a/server/services/email.test.ts b/server/services/email.test.ts index 11797322..0c01e089 100644 --- a/server/services/email.test.ts +++ b/server/services/email.test.ts @@ -11,18 +11,18 @@ vi.mock('nodemailer', () => ({ describe('getEmailConfig', () => { it('throws when email_provider is not set', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await expect(getEmailConfig(db)).rejects.toThrow('Email provider not configured') }) it('throws when email_from is not set', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values({ key: 'email_provider', value: 'smtp' }) await expect(getEmailConfig(db)).rejects.toThrow('Email sender not configured') }) it('throws when SMTP host is missing', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -31,7 +31,7 @@ describe('getEmailConfig', () => { }) it('throws when SMTP port is missing', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -41,7 +41,7 @@ describe('getEmailConfig', () => { }) it('returns SMTP config when provider is smtp and all required options are set', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -63,7 +63,7 @@ describe('getEmailConfig', () => { }) it('returns SMTP config with secure=false when email_smtp_secure is not "true"', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -78,7 +78,7 @@ describe('getEmailConfig', () => { }) it('throws when HTTP url is missing', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -87,7 +87,7 @@ describe('getEmailConfig', () => { }) it('throws when HTTP apiKey is missing', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -97,7 +97,7 @@ describe('getEmailConfig', () => { }) it('returns HTTP config when provider is http and all required options are set', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -113,7 +113,7 @@ describe('getEmailConfig', () => { }) it('throws when provider is an unknown value', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'unknown' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -129,7 +129,7 @@ describe('sendEmail — SMTP provider', () => { it('calls nodemailer sendMail with correct parameters', async () => { sendMailMock.mockResolvedValue({}) - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -156,7 +156,7 @@ describe('sendEmail — HTTP provider', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, @@ -189,7 +189,7 @@ describe('sendEmail — HTTP provider', () => { }) vi.stubGlobal('fetch', fetchMock) - const { db } = createTestApp() + const { db } = await createTestApp() await db.insert(schema.systemOptions).values([ { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, diff --git a/server/services/invite.test.ts b/server/services/invite.test.ts index 7af4c0fe..ff65a6fb 100644 --- a/server/services/invite.test.ts +++ b/server/services/invite.test.ts @@ -10,26 +10,26 @@ import { describe('generateInviteCodes', () => { it('returns the requested number of codes', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 5) expect(codes).toHaveLength(5) }) it('returns one code when count is 1', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 1) expect(codes).toHaveLength(1) }) it('generates unique codes for each entry', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 10) const uniqueCodes = new Set(codes.map((c) => c.code)) expect(uniqueCodes.size).toBe(10) }) it('each code has an 8-character uppercase alphanumeric code field', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 3) for (const code of codes) { expect(code.code).toMatch(/^[0-9A-Z]{8}$/) @@ -37,7 +37,7 @@ describe('generateInviteCodes', () => { }) it('sets createdBy to the provided admin user id on all codes', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-42', 3) for (const code of codes) { expect(code.createdBy).toBe('admin-42') @@ -45,7 +45,7 @@ describe('generateInviteCodes', () => { }) it('sets usedBy and usedAt to null on fresh codes', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 2) for (const code of codes) { expect(code.usedBy).toBeNull() @@ -54,7 +54,7 @@ describe('generateInviteCodes', () => { }) it('sets expiresAt to null when not provided', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 2) for (const code of codes) { expect(code.expiresAt).toBeNull() @@ -62,7 +62,7 @@ describe('generateInviteCodes', () => { }) it('propagates expiresAt to all generated codes', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const expiry = new Date(Date.now() + 86400000) const codes = await generateInviteCodes(db, 'admin-1', 3, expiry) for (const code of codes) { @@ -71,7 +71,7 @@ describe('generateInviteCodes', () => { }) it('persists codes to the database', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const codes = await generateInviteCodes(db, 'admin-1', 2) for (const code of codes) { const result = await validateInviteCode(db, code.code) @@ -82,21 +82,21 @@ describe('generateInviteCodes', () => { describe('validateInviteCode', () => { it('returns valid:true for an unused, unexpired code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) const result = await validateInviteCode(db, row.code) expect(result).toEqual({ valid: true }) }) it('returns valid:false with an error for a nonexistent code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await validateInviteCode(db, 'NOSUCHCD') expect(result.valid).toBe(false) expect(result.error).toBeTruthy() }) it('returns valid:false with an error for a used code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-99') const result = await validateInviteCode(db, row.code) @@ -105,7 +105,7 @@ describe('validateInviteCode', () => { }) it('returns valid:false with an error for an expired code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const pastDate = new Date(Date.now() - 1000) const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate) const result = await validateInviteCode(db, row.code) @@ -114,7 +114,7 @@ describe('validateInviteCode', () => { }) it('returns valid:true for a code that has not yet expired', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const futureDate = new Date(Date.now() + 86400000) const [row] = await generateInviteCodes(db, 'admin-1', 1, futureDate) const result = await validateInviteCode(db, row.code) @@ -124,14 +124,14 @@ describe('validateInviteCode', () => { describe('redeemInviteCode', () => { it('returns ok when redeeming a valid unused code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) const result = await redeemInviteCode(db, row.code, 'user-55') expect(result).toBe('ok') }) it('marks the code as used so it cannot be validated again', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-55') const check = await validateInviteCode(db, row.code) @@ -139,13 +139,13 @@ describe('redeemInviteCode', () => { }) it('returns not_found for a nonexistent code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await redeemInviteCode(db, 'NOSUCHCD', 'user-55') expect(result).toBe('not_found') }) it('returns already_used when redeeming a previously redeemed code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-55') const result = await redeemInviteCode(db, row.code, 'user-99') @@ -153,7 +153,7 @@ describe('redeemInviteCode', () => { }) it('returns expired for an expired code', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const pastDate = new Date(Date.now() - 1000) const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate) const result = await redeemInviteCode(db, row.code, 'user-55') @@ -161,7 +161,7 @@ describe('redeemInviteCode', () => { }) it('sets usedAt to a non-null timestamp after redemption', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-55') const check = await validateInviteCode(db, row.code) @@ -171,13 +171,13 @@ describe('redeemInviteCode', () => { describe('listInviteCodes', () => { it('returns empty items and total 0 when no codes exist', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await listInviteCodes(db, 1, 20) expect(result).toEqual({ items: [], total: 0 }) }) it('returns all codes when fewer than pageSize', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await generateInviteCodes(db, 'admin-1', 3) const result = await listInviteCodes(db, 1, 20) expect(result.total).toBe(3) @@ -185,7 +185,7 @@ describe('listInviteCodes', () => { }) it('paginates correctly — page 1 returns first pageSize items', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await generateInviteCodes(db, 'admin-1', 5) const result = await listInviteCodes(db, 1, 3) expect(result.total).toBe(5) @@ -193,7 +193,7 @@ describe('listInviteCodes', () => { }) it('paginates correctly — page 2 returns remaining items', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await generateInviteCodes(db, 'admin-1', 5) const result = await listInviteCodes(db, 2, 3) expect(result.total).toBe(5) @@ -201,7 +201,7 @@ describe('listInviteCodes', () => { }) it('returns empty items on a page beyond total count', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await generateInviteCodes(db, 'admin-1', 2) const result = await listInviteCodes(db, 5, 20) expect(result.total).toBe(2) @@ -209,7 +209,7 @@ describe('listInviteCodes', () => { }) it('orders results by createdAt descending', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await generateInviteCodes(db, 'admin-1', 3) const result = await listInviteCodes(db, 1, 20) const timestamps = result.items.map((item) => item.createdAt.getTime()) @@ -220,14 +220,14 @@ describe('listInviteCodes', () => { describe('deleteInviteCode', () => { it('returns ok and removes the code when it exists and is unused', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) const result = await deleteInviteCode(db, row.id) expect(result).toBe('ok') }) it('removes the code from the database after deletion', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await deleteInviteCode(db, row.id) const check = await validateInviteCode(db, row.code) @@ -235,13 +235,13 @@ describe('deleteInviteCode', () => { }) it('returns not_found for a nonexistent code id', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await deleteInviteCode(db, 'NOSUCHID') expect(result).toBe('not_found') }) it('returns already_used for a code that has been redeemed', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const [row] = await generateInviteCodes(db, 'admin-1', 1) await redeemInviteCode(db, row.code, 'user-99') const result = await deleteInviteCode(db, row.id) diff --git a/server/services/matter.test.ts b/server/services/matter.test.ts index c441ce8f..f4f79fad 100644 --- a/server/services/matter.test.ts +++ b/server/services/matter.test.ts @@ -5,7 +5,7 @@ import { orgQuotas } from '../db/schema.js' import { createTestApp } from '../test/setup.js' import { confirmUpload, incrementUsageIfAllowed, listTrashedRoots, updateMatter } from './matter.js' -type TestDb = ReturnType['db'] +type TestDb = Awaited>['db'] async function insertStorage(db: TestDb, opts: { id?: string; used?: number } = {}) { const id = opts.id ?? 'st-1' @@ -42,7 +42,7 @@ async function insertDraftFile( describe('incrementUsageIfAllowed', () => { it('returns true and increments when no quota row exists (unlimited)', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-ul', used: 0 }) @@ -54,7 +54,7 @@ describe('incrementUsageIfAllowed', () => { }) it('returns true and increments when quota is 0 (unlimited)', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-q0', used: 100 }) await insertOrgQuota(db, orgId, 0, 5000) @@ -67,7 +67,7 @@ describe('incrementUsageIfAllowed', () => { }) it('returns true and increments when used + bytes is within quota', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-in', used: 0 }) await insertOrgQuota(db, orgId, 1000, 400) @@ -78,7 +78,7 @@ describe('incrementUsageIfAllowed', () => { }) it('returns true and increments when used + bytes is exactly at quota', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-exact', used: 0 }) await insertOrgQuota(db, orgId, 1000, 500) @@ -91,7 +91,7 @@ describe('incrementUsageIfAllowed', () => { }) it('returns false and does not increment when used + bytes exceeds quota', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-over', used: 50 }) await insertOrgQuota(db, orgId, 1000, 800) @@ -106,7 +106,7 @@ describe('incrementUsageIfAllowed', () => { }) it('returns false and does not increment when quota is fully consumed', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-full', used: 100 }) await insertOrgQuota(db, orgId, 1000, 1000) @@ -117,7 +117,7 @@ describe('incrementUsageIfAllowed', () => { }) it('increments orgQuotas.used when within quota', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-q-inc', used: 0 }) await insertOrgQuota(db, orgId, 5000, 200) @@ -129,7 +129,7 @@ describe('incrementUsageIfAllowed', () => { }) it('increments storages.used when within quota', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-s-inc', used: 100 }) await insertOrgQuota(db, orgId, 5000, 100) @@ -145,7 +145,7 @@ describe('incrementUsageIfAllowed', () => { describe('confirmUpload', () => { it('returns { matter } with status active and increments usage for a draft file with size > 0', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-conf', used: 0 }) await insertOrgQuota(db, orgId, 10000, 0) @@ -164,7 +164,7 @@ describe('confirmUpload', () => { }) it('returns { matter } and does not increment usage when file size is 0', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-conf2', used: 50 }) await insertOrgQuota(db, orgId, 10000, 50) @@ -180,14 +180,14 @@ describe('confirmUpload', () => { }) it('returns { matter: null } for a non-existent matter', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await confirmUpload(db, 'nonexistent', 'org-x') expect(result.matter).toBeNull() expect(result.quotaExceeded).toBeUndefined() }) it('returns { matter: null } for a matter not in draft status', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-conf3' }) const now = Date.now() @@ -201,7 +201,7 @@ describe('confirmUpload', () => { }) it('returns { matter: null, quotaExceeded: true } when quota would be exceeded', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-conf4', used: 0 }) // quota=100, used=90, file size=50 → would exceed @@ -215,7 +215,7 @@ describe('confirmUpload', () => { }) it('status remains draft in DB when quota would be exceeded', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-conf5', used: 0 }) await insertOrgQuota(db, orgId, 100, 90) @@ -232,7 +232,7 @@ describe('confirmUpload', () => { describe('updateMatter', () => { it('throws when moving a folder into itself', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-upd1' }) const now = Date.now() @@ -248,7 +248,7 @@ describe('updateMatter', () => { }) it('throws when moving a folder into its own subfolder', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-upd2' }) const now = Date.now() @@ -279,7 +279,7 @@ async function insertTrashedMatter( describe('listTrashedRoots', () => { it('returns only the top-level trashed folder when a folder and its child file are both trashed', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-trash1' }) @@ -308,7 +308,7 @@ describe('listTrashedRoots', () => { }) it('does not return the child file when it is nested inside a trashed folder', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-trash2' }) @@ -336,7 +336,7 @@ describe('listTrashedRoots', () => { }) it('returns multiple independent trashed items when none is a descendant of another', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-trash3' }) @@ -366,7 +366,7 @@ describe('listTrashedRoots', () => { }) it('returns an empty array when no trashed items exist for the org', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const roots = await listTrashedRoots(db, orgId) @@ -375,7 +375,7 @@ describe('listTrashedRoots', () => { }) it('excludes items belonging to a different org', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const otherOrgId = nanoid() const storageId = await insertStorage(db, { id: 'st-trash4' }) @@ -395,7 +395,7 @@ describe('listTrashedRoots', () => { }) it('returns deeply-nested trashed folders that are themselves roots (parent folder not trashed)', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const orgId = nanoid() const storageId = await insertStorage(db, { id: 'st-trash5' }) diff --git a/server/services/org.test.ts b/server/services/org.test.ts index 377c6c09..e6979905 100644 --- a/server/services/org.test.ts +++ b/server/services/org.test.ts @@ -4,7 +4,7 @@ import * as authSchema from '../db/auth-schema.js' import { createTestApp } from '../test/setup.js' import { findPersonalOrg } from './org.js' -type TestDb = ReturnType['db'] +type TestDb = Awaited>['db'] async function insertUser(db: TestDb, overrides: Partial<{ id: string; name: string; email: string }> = {}) { const id = overrides.id ?? nanoid() @@ -43,7 +43,7 @@ async function insertMember(db: TestDb, organizationId: string, userId: string) describe('findPersonalOrg', () => { it('returns the org id when a personal org exists for the user', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const userId = await insertUser(db) const orgId = await insertOrg(db, { slug: `personal-${userId}` }) await insertMember(db, orgId, userId) @@ -53,7 +53,7 @@ describe('findPersonalOrg', () => { }) it('returns null when user has no memberships', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const userId = await insertUser(db) const result = await findPersonalOrg(db, userId) @@ -61,7 +61,7 @@ describe('findPersonalOrg', () => { }) it("returns null when the org's slug is not the user's personal slug", async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const userId = await insertUser(db) const orgId = await insertOrg(db, { slug: 'some-team-org' }) await insertMember(db, orgId, userId) @@ -71,7 +71,7 @@ describe('findPersonalOrg', () => { }) it('finds the personal org among multiple memberships', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const userId = await insertUser(db) const teamOrgId = await insertOrg(db, { slug: 'some-team-org' }) const personalOrgId = await insertOrg(db, { slug: `personal-${userId}` }) @@ -83,7 +83,7 @@ describe('findPersonalOrg', () => { }) it('returns null when the personal slug exists but member row was deleted', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const userId = await insertUser(db) await insertOrg(db, { slug: `personal-${userId}` }) // No member row inserted — membership is load-bearing diff --git a/server/services/storage.test.ts b/server/services/storage.test.ts index 31d074fd..ff222792 100644 --- a/server/services/storage.test.ts +++ b/server/services/storage.test.ts @@ -5,7 +5,7 @@ import { createStorage, deleteStorage, getStorage, listStorages, selectStorage, describe('createStorage', () => { it('sets filePath to empty string regardless of input', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'private', @@ -20,7 +20,7 @@ describe('createStorage', () => { }) it('sets customHost to empty string when not provided', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'private', @@ -35,7 +35,7 @@ describe('createStorage', () => { }) it('uses provided customHost when given', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'private', @@ -51,7 +51,7 @@ describe('createStorage', () => { }) it('sets capacity to 0 when not provided', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'private', @@ -66,7 +66,7 @@ describe('createStorage', () => { }) it('uses provided capacity when given', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'private', @@ -81,7 +81,7 @@ describe('createStorage', () => { }) it('initialises used to 0 and status to active', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await createStorage(db, { title: 'My Storage', mode: 'public', @@ -97,7 +97,7 @@ describe('createStorage', () => { }) it('persists the created row to the database', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await createStorage(db, { title: 'Persisted', mode: 'private', @@ -115,7 +115,7 @@ describe('createStorage', () => { }) describe('updateStorage', () => { - async function seed(db: ReturnType['db']) { + async function seed(db: Awaited>['db']) { return createStorage(db, { title: 'Original', mode: 'private', @@ -130,13 +130,13 @@ describe('updateStorage', () => { } it('returns null when storage does not exist', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await updateStorage(db, 'nonexistent', { title: 'New' }) expect(result).toBeNull() }) it('keeps existing values for fields not included in update', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seed(db) const updated = await updateStorage(db, created.id, { title: 'Changed' }) expect(updated?.bucket).toBe('original-bucket') @@ -148,7 +148,7 @@ describe('updateStorage', () => { }) it('applies all provided optional fields', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seed(db) const updated = await updateStorage(db, created.id, { title: 'Updated', @@ -175,7 +175,7 @@ describe('updateStorage', () => { }) it('updates only status leaving all other fields intact', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seed(db) const updated = await updateStorage(db, created.id, { status: 'disabled' }) expect(updated?.status).toBe('disabled') @@ -183,7 +183,7 @@ describe('updateStorage', () => { }) it('updates the updatedAt timestamp', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seed(db) const before = created.updatedAt.getTime() await new Promise((r) => setTimeout(r, 10)) @@ -194,13 +194,13 @@ describe('updateStorage', () => { describe('listStorages', () => { it('returns empty items and zero total when no storages exist', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await listStorages(db) expect(result).toEqual({ items: [], total: 0 }) }) it('returns all storages ordered by createdAt ascending', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await createStorage(db, { title: 'First', mode: 'private', @@ -229,13 +229,13 @@ describe('listStorages', () => { describe('getStorage', () => { it('returns null when storage does not exist', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await getStorage(db, 'nonexistent') expect(result).toBeNull() }) it('returns the storage when it exists', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await createStorage(db, { title: 'Findable', mode: 'private', @@ -253,7 +253,7 @@ describe('getStorage', () => { describe('selectStorage', () => { async function seedActive( - db: ReturnType['db'], + db: Awaited>['db'], mode: 'private' | 'public', opts: { capacity?: number; used?: number; status?: string } = {}, ) { @@ -270,26 +270,26 @@ describe('selectStorage', () => { } it('returns an active private storage with unlimited capacity', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seedActive(db, 'private') const found = await selectStorage(db, 'private') expect(found.id).toBe(created.id) }) it('returns an active public storage when requested', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await seedActive(db, 'public') const found = await selectStorage(db, 'public') expect(found.id).toBe(created.id) }) it('throws when no active storage exists for the requested mode', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') }) it('throws when storage is present but mode does not match', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() await seedActive(db, 'public') await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') }) @@ -297,13 +297,13 @@ describe('selectStorage', () => { describe('deleteStorage', () => { it('returns not_found when storage does not exist', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const result = await deleteStorage(db, 'nonexistent') expect(result).toBe('not_found') }) it('deletes a storage that is not referenced by any matter', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await createStorage(db, { title: 'Deletable', mode: 'private', @@ -320,7 +320,7 @@ describe('deleteStorage', () => { }) it('returns in_use when matters reference the storage', async () => { - const { db } = createTestApp() + const { db } = await createTestApp() const created = await createStorage(db, { title: 'In Use', mode: 'private', diff --git a/server/test/setup.ts b/server/test/setup.ts index af223265..1c2d6619 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -148,7 +148,7 @@ const APP_SCHEMA_SQL = ` ); ` -export function createTestApp() { +export async function createTestApp() { const sqlite = new Database(':memory:') sqlite.exec(AUTH_SCHEMA_SQL) sqlite.exec(APP_SCHEMA_SQL) @@ -158,7 +158,7 @@ export function createTestApp() { db, getEnv: () => undefined, } - const auth = createAuth(db, 'test-secret', 'http://localhost:3000') + const auth = await createAuth(db, 'test-secret', 'http://localhost:3000') const app = createApp(platform, auth) return { app, db, auth } diff --git a/shared/oauth-providers.ts b/shared/oauth-providers.ts new file mode 100644 index 00000000..9729b63e --- /dev/null +++ b/shared/oauth-providers.ts @@ -0,0 +1,79 @@ +/** + * Static display metadata for all built-in better-auth social providers. + * Used by the frontend to render login buttons with correct names and icons. + */ +export const OAuthProviderMeta: Record = { + apple: { name: 'Apple', icon: 'apple' }, + atlassian: { name: 'Atlassian', icon: 'atlassian' }, + cognito: { name: 'AWS Cognito', icon: 'cognito' }, + discord: { name: 'Discord', icon: 'discord' }, + dropbox: { name: 'Dropbox', icon: 'dropbox' }, + facebook: { name: 'Facebook', icon: 'facebook' }, + figma: { name: 'Figma', icon: 'figma' }, + github: { name: 'GitHub', icon: 'github' }, + gitlab: { name: 'GitLab', icon: 'gitlab' }, + google: { name: 'Google', icon: 'google' }, + huggingface: { name: 'Hugging Face', icon: 'huggingface' }, + kakao: { name: 'Kakao', icon: 'kakao' }, + kick: { name: 'Kick', icon: 'kick' }, + line: { name: 'LINE', icon: 'line' }, + linear: { name: 'Linear', icon: 'linear' }, + linkedin: { name: 'LinkedIn', icon: 'linkedin' }, + microsoft: { name: 'Microsoft', icon: 'microsoft' }, + naver: { name: 'Naver', icon: 'naver' }, + notion: { name: 'Notion', icon: 'notion' }, + paybin: { name: 'Paybin', icon: 'paybin' }, + paypal: { name: 'PayPal', icon: 'paypal' }, + polar: { name: 'Polar', icon: 'polar' }, + railway: { name: 'Railway', icon: 'railway' }, + reddit: { name: 'Reddit', icon: 'reddit' }, + roblox: { name: 'Roblox', icon: 'roblox' }, + salesforce: { name: 'Salesforce', icon: 'salesforce' }, + slack: { name: 'Slack', icon: 'slack' }, + spotify: { name: 'Spotify', icon: 'spotify' }, + tiktok: { name: 'TikTok', icon: 'tiktok' }, + twitch: { name: 'Twitch', icon: 'twitch' }, + twitter: { name: 'Twitter / X', icon: 'twitter' }, + vercel: { name: 'Vercel', icon: 'vercel' }, + vk: { name: 'VK', icon: 'vk' }, + wechat: { name: 'WeChat', icon: 'wechat' }, + zoom: { name: 'Zoom', icon: 'zoom' }, +} + +/** All built-in provider IDs supported by better-auth */ +export const BUILTIN_PROVIDER_IDS = Object.keys(OAuthProviderMeta) as readonly string[] + +/** Key prefix for OAuth provider configs stored in system_options */ +export const OAUTH_PROVIDER_KEY_PREFIX = 'oauth_provider_' + +/** LIKE pattern for querying all OAuth provider configs */ +export const OAUTH_PROVIDER_KEY_PATTERN = `${OAUTH_PROVIDER_KEY_PREFIX}%` + +/** Regex for valid custom OIDC provider IDs */ +const PROVIDER_ID_RE = /^[a-z0-9-]+$/ + +export function isValidProviderId(id: string): boolean { + return PROVIDER_ID_RE.test(id) +} + +export function parseProviderConfig(value: string): OAuthProviderConfig | null { + try { + return JSON.parse(value) as OAuthProviderConfig + } catch { + return null + } +} + +export type OAuthProviderType = 'builtin' | 'oidc' + +export interface OAuthProviderConfig { + providerId: string + type: OAuthProviderType + clientId: string + clientSecret: string + enabled: boolean + /** Only for type: 'oidc' */ + discoveryUrl?: string + /** Only for type: 'oidc' */ + scopes?: string[] +} diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index 6f6c3ac7..3004a7d0 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -1,4 +1,5 @@ import { createApp } from '../server/app' +import type { Auth } from '../server/auth' import { createAuth } from '../server/auth' import { createCloudflarePlatform } from '../server/platform/cloudflare' @@ -10,19 +11,28 @@ interface Env { [key: string]: unknown } +// Cache auth instance at isolate scope to avoid per-request DB queries +// for OIDC config loading. Changes to OIDC provider configs or env vars +// (BETTER_AUTH_URL, TRUSTED_ORIGINS) take effect on isolate recycle. +let cachedAuth: Auth | null = null + export default { async fetch(request: Request, env: Env): Promise { - const { BETTER_AUTH_SECRET, BETTER_AUTH_URL, TRUSTED_ORIGINS } = env + const { BETTER_AUTH_SECRET } = env if (!BETTER_AUTH_SECRET) { throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.') } - const origin = new URL(request.url).origin - const baseURL = BETTER_AUTH_URL || origin const platform = createCloudflarePlatform(env) - const trustedOrigins = TRUSTED_ORIGINS?.split(',') - .map((o) => o.trim()) - .filter(Boolean) || [origin] - const auth = createAuth(platform.db, BETTER_AUTH_SECRET, baseURL, trustedOrigins) - return createApp(platform, auth).fetch(request) + + if (!cachedAuth) { + const origin = new URL(request.url).origin + const baseURL = env.BETTER_AUTH_URL || origin + const trustedOrigins = env.TRUSTED_ORIGINS?.split(',') + .map((o) => o.trim()) + .filter(Boolean) || [origin] + cachedAuth = await createAuth(platform.db, BETTER_AUTH_SECRET, baseURL, trustedOrigins) + } + + return createApp(platform, cachedAuth).fetch(request) }, }