mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
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 <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+67
-4
@@ -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<string, () => 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<typeof createAuth>
|
||||
export type Auth = Awaited<ReturnType<typeof createAuth>>
|
||||
|
||||
async function isFirstUser(db: Database): Promise<boolean> {
|
||||
const [row] = await db.select({ c: count() }).from(authSchema.user)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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<typeof createTestApp>['app'],
|
||||
app: Awaited<ReturnType<typeof createTestApp>>['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 }
|
||||
|
||||
@@ -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<ReturnType<typeof createTestApp>>['app'],
|
||||
headers: Record<string, string>,
|
||||
providerId: string,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<Record<string, unknown>> }
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
expect(body.deleted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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<Env>()
|
||||
// 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
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
+12
-12
@@ -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' },
|
||||
|
||||
@@ -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<typeof createTestApp>['db']) {
|
||||
async function seedSmtpConfig(db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['db']) {
|
||||
])
|
||||
}
|
||||
|
||||
async function seedHttpConfig(db: ReturnType<typeof createTestApp>['db']) {
|
||||
async function seedHttpConfig(db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['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)
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ const validStorage = {
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}
|
||||
|
||||
async function insertStorage(db: ReturnType<typeof createTestApp>['db'], used = 0) {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['db'], used =
|
||||
}
|
||||
|
||||
async function insertFile(
|
||||
db: ReturnType<typeof createTestApp>['db'],
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
opts: { id: string; name: string; size?: number; status?: string },
|
||||
) {
|
||||
@@ -50,14 +50,19 @@ async function insertFile(
|
||||
`)
|
||||
}
|
||||
|
||||
async function getOrgId(db: ReturnType<typeof createTestApp>['db']): Promise<string> {
|
||||
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
|
||||
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<typeof createTestApp>['db'], orgId: string, quota: number, used = 0) {
|
||||
async function setOrgQuota(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['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)
|
||||
|
||||
@@ -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<typeof buildApp>) {
|
||||
|
||||
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',
|
||||
|
||||
@@ -36,7 +36,7 @@ const validStorage = {
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}
|
||||
|
||||
async function insertStorage(db: ReturnType<typeof createTestApp>['db']) {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['db']) {
|
||||
}
|
||||
|
||||
async function insertFolder(
|
||||
db: ReturnType<typeof createTestApp>['db'],
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
opts: { id: string; name: string; parent?: string },
|
||||
) {
|
||||
@@ -57,7 +57,7 @@ async function insertFolder(
|
||||
}
|
||||
|
||||
async function insertFile(
|
||||
db: ReturnType<typeof createTestApp>['db'],
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
opts: { id: string; name: string; parent?: string; status?: string },
|
||||
) {
|
||||
@@ -69,7 +69,7 @@ async function insertFile(
|
||||
`)
|
||||
}
|
||||
|
||||
async function getOrgId(db: ReturnType<typeof createTestApp>['db']): Promise<string> {
|
||||
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
|
||||
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<typeof createTestApp>['db']): Promise<str
|
||||
|
||||
describe('Objects API', () => {
|
||||
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)
|
||||
|
||||
@@ -16,13 +16,13 @@ async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']
|
||||
|
||||
describe('Admin Quotas API', () => {
|
||||
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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<typeof createTestApp>['db'],
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['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 })
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { adminHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
async function putOption(
|
||||
app: ReturnType<typeof createTestApp>['app'],
|
||||
app: Awaited<ReturnType<typeof createTestApp>>['app'],
|
||||
headers: Record<string, string>,
|
||||
key: string,
|
||||
body: Record<string, unknown>,
|
||||
@@ -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' },
|
||||
|
||||
@@ -25,13 +25,13 @@ async function signUpUser(app: ReturnType<typeof import('../app')['createApp']>,
|
||||
|
||||
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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof createTestApp>['db']
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['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' })
|
||||
|
||||
|
||||
@@ -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<typeof createTestApp>['db']
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['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
|
||||
|
||||
@@ -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<typeof createTestApp>['db']) {
|
||||
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['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<typeof createTestApp>['db'],
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<string, { name: string; icon: string } | undefined> = {
|
||||
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[]
|
||||
}
|
||||
+18
-8
@@ -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<Response> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user