import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { createInviteRepo } from './adapters/repos/invite.js' import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js' import { createApp } from './app.js' import { createAuth } from './auth.js' import * as authSchema from './db/auth-schema.js' import * as schema from './db/schema.js' import { inviteCodes, siteInvitations } from './db/schema.js' import { createTestApp, seedProLicense } from './test/setup.js' type TestCtx = Awaited> async function signUp(ctx: TestCtx, email: string, extra?: Record) { return ctx.app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Test User', email, password: 'password123456', ...extra }), }) } async function expectPlanEntitlement(ctx: TestCtx, resourceType: 'storage' | 'traffic', bytes: number) { const rows = await ctx.db.select().from(schema.orgQuotaEntitlements) expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ resourceType, entitlementType: 'plan', source: 'free_plan', bytes, status: 'active', }), ]), ) } describe('registration gate — first user always allowed', () => { it('first user can register when auth_signup_mode is closed', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) const res = await signUp(ctx, 'first@example.com') expect(res.status).toBe(200) }) it('first user can register when auth_signup_mode is invite_only without a code', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) const res = await signUp(ctx, 'first@example.com') expect(res.status).toBe(200) }) it('first user is promoted to admin when auth_signup_mode is invite_only', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) const res = await signUp(ctx, 'first@example.com') const body = (await res.json()) as { user: { role: string } } expect(body.user.role).toBe('admin') }) it('first user can register when auth_signup_mode is open', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) const res = await signUp(ctx, 'first@example.com') expect(res.status).toBe(200) }) }) describe('registration gate — open mode', () => { it('second user can register when auth_signup_mode is not set (defaults to open)', async () => { const ctx = await createTestApp() await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(200) }) it('second user can register when auth_signup_mode is explicitly open and instance has Pro license', async () => { const ctx = await createTestApp() await seedProLicense(ctx.db) await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(200) }) it('second user is rejected when auth_signup_mode is explicitly open but instance has no Pro license', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(422) }) }) describe('registration gate — closed mode', () => { it('second user is rejected when auth_signup_mode is closed', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'blocked@example.com') expect(res.status).not.toBe(200) }) it('third user is also rejected when auth_signup_mode is closed', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') await signUp(ctx, 'second@example.com') // blocked const res = await signUp(ctx, 'third@example.com') expect(res.status).not.toBe(200) }) it('closed mode returns 422 status code', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'blocked@example.com') expect(res.status).toBe(422) }) it('second user can register with a valid site invitation token', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') const [admin] = await ctx.db .select({ id: authSchema.user.id }) .from(authSchema.user) .where(eq(authSchema.user.email, 'first@example.com')) .limit(1) const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com') const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token }) expect(res.status).toBe(200) }) it('accepts the site invitation after successful registration', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') const [admin] = await ctx.db .select({ id: authSchema.user.id }) .from(authSchema.user) .where(eq(authSchema.user.email, 'first@example.com')) .limit(1) const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com') const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token }) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select() .from(siteInvitations) .where(eq(siteInvitations.token, invitation.token)) .limit(1) expect(row.acceptedBy).toBe(body.user.id) expect(row.acceptedAt).not.toBeNull() }) it('rejects site invitation token when email does not match', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' }) await signUp(ctx, 'first@example.com') const [admin] = await ctx.db .select({ id: authSchema.user.id }) .from(authSchema.user) .where(eq(authSchema.user.email, 'first@example.com')) .limit(1) const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com') const res = await signUp(ctx, 'other@example.com', { siteInvitationToken: invitation.token }) expect(res.status).toBe(422) }) }) describe('registration gate — invite_only mode', () => { it('second user is rejected when no invite code is provided', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'noinvite@example.com') expect(res.status).not.toBe(200) }) it('invite_only mode with no code returns 422 status code', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'noinvite@example.com') expect(res.status).toBe(422) }) it('second user is rejected when an invalid invite code is provided', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'badinvite@example.com', { inviteCode: 'BADCODE1' }) expect(res.status).not.toBe(200) }) it('second user is rejected when invite code is expired', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const pastDate = new Date(Date.now() - 1000) const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1, pastDate) const res = await signUp(ctx, 'expired@example.com', { inviteCode: codeRow.code }) expect(res.status).not.toBe(200) }) it('second user registers successfully with a valid invite code', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1) const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code }) expect(res.status).toBe(200) }) it('invite code usedBy is set to the new user ID after successful registration', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1) const res = await signUp(ctx, 'invited2@example.com', { inviteCode: codeRow.code }) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db.select().from(inviteCodes).where(eq(inviteCodes.code, codeRow.code)) expect(row.usedBy).toBe(body.user.id) expect(row.usedAt).not.toBeNull() }) it('same invite code cannot be used twice', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await signUp(ctx, 'first@example.com') const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1) await signUp(ctx, 'user1@example.com', { inviteCode: codeRow.code }) const res = await signUp(ctx, 'user2@example.com', { inviteCode: codeRow.code }) expect(res.status).not.toBe(200) }) }) describe('getSignupMode — via auth_signup_mode system option', () => { it('unknown value in auth_signup_mode falls back to open (second user succeeds)', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'unknown_value' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(200) }) it('empty string in auth_signup_mode falls back to open', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: '' }) await signUp(ctx, 'first@example.com') const res = await signUp(ctx, 'second@example.com') expect(res.status).toBe(200) }) }) describe('isEmailConfigured — via emailVerification conditional', () => { it('createAuth succeeds when email_provider is not configured', async () => { const ctx = await createTestApp() expect(ctx.auth).toBeTruthy() }) it('sign-up succeeds without email_provider configured', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'user@example.com') expect(res.status).toBe(200) }) it('send-verification-email is a no-op (returns early) when email_provider is not configured', async () => { const ctx = await createTestApp() // Sign up first so the user exists await signUp(ctx, 'verify@example.com') // Trigger the sendVerificationEmail callback — should not throw even without email config const res = await ctx.app.request('/api/auth/send-verification-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'verify@example.com' }), }) // The endpoint returns 200 regardless; the callback silently returns early expect(res.status).toBe(200) }) }) describe('buildVerificationEmailHtml — via send-verification-email with email_provider configured', () => { it('send-verification-email triggers email send when email_provider is configured', async () => { const { vi } = await import('vitest') const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values([ { key: 'email_enabled', value: 'true' }, { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, { key: 'email_http_url', value: 'https://api.mail.example.com/send' }, { key: 'email_http_api_key', value: 'my-api-key' }, ]) await signUp(ctx, 'withmail@example.com') const res = await ctx.app.request('/api/auth/send-verification-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'withmail@example.com' }), }) expect(res.status).toBe(200) // The email should have been sent via the HTTP provider expect(fetchMock).toHaveBeenCalledWith( 'https://api.mail.example.com/send', expect.objectContaining({ method: 'POST' }), ) vi.unstubAllGlobals() }) it('verification email HTML contains the verification URL', async () => { const { vi } = await import('vitest') let capturedHtml = '' const fetchMock = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { const body = JSON.parse(init?.body as string) capturedHtml = body.html return { ok: true } }) vi.stubGlobal('fetch', fetchMock) const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values([ { key: 'email_enabled', value: 'true' }, { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, { key: 'email_http_url', value: 'https://api.mail.example.com/send' }, { key: 'email_http_api_key', value: 'my-api-key' }, ]) await signUp(ctx, 'htmltest@example.com') await ctx.app.request('/api/auth/send-verification-email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'htmltest@example.com' }), }) expect(capturedHtml).toContain('verify-email') expect(capturedHtml).toContain('href=') vi.unstubAllGlobals() }) }) describe('loadProviderConfigs — createAuth with OIDC provider pre-configured', () => { it('createAuth succeeds when a valid enabled OIDC provider config is present', async () => { const ctx = await createTestApp() const oidcConfig = JSON.stringify({ providerId: 'my-oidc', type: 'oidc', clientId: 'client-id', clientSecret: 'client-secret', enabled: true, discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', scopes: ['openid', 'email'], }) await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_my-oidc', value: oidcConfig }) const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000') expect(auth).toBeTruthy() }) it('createAuth succeeds when a disabled OIDC provider config is present', async () => { const ctx = await createTestApp() const oidcConfig = JSON.stringify({ providerId: 'disabled-oidc', type: 'oidc', clientId: 'client-id', clientSecret: 'client-secret', enabled: false, discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', }) await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_disabled-oidc', value: oidcConfig }) const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000') expect(auth).toBeTruthy() }) it('createAuth succeeds when a malformed (non-JSON) provider config row is present', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_bad', value: 'not-valid-json' }) const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000') expect(auth).toBeTruthy() }) }) describe('loadProviderConfigs — builtin social provider resolution', () => { it('social sign-in with an unconfigured provider returns non-200 (provider not registered)', async () => { const ctx = await createTestApp() // With no config in DB the provider is not registered with better-auth. const res = await ctx.app.request('/api/auth/sign-in/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }), }) expect(res.status).not.toBe(200) }) it('social sign-in with a configured and enabled builtin provider returns a redirect', async () => { const ctx = await createTestApp() const builtinConfig = JSON.stringify({ providerId: 'github', type: 'builtin', clientId: 'gh-client', clientSecret: 'gh-secret', enabled: true, }) await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_github', value: builtinConfig }) // Provider configs are snapshotted when the auth instance is created — // build a fresh auth/app that sees the seeded config. const auth = await createAuth(ctx.platform, 'test-secret', 'http://localhost:3000') const app = createApp(ctx.platform, auth) const res = await app.request('/api/auth/sign-in/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }), }) // With a valid enabled provider, better-auth returns a redirect (302) to the OAuth provider expect([200, 302]).toContain(res.status) }) it('createAuth runs exactly one DB query during init (no per-provider I/O)', async () => { const ctx = await createTestApp() let selectCalls = 0 const countingDb = new Proxy(ctx.db, { get(target, prop, receiver) { if (prop === 'select') selectCalls++ const val = Reflect.get(target, prop, receiver) return typeof val === 'function' ? val.bind(target) : val }, }) await createAuth(countingDb as typeof ctx.db, 'test-secret', 'http://localhost:3000') expect(selectCalls).toBe(1) }) it('createAuth resolves better-auth $context before returning', async () => { // A cached auth instance must never carry a pending init promise: on // Cloudflare Workers a promise created in one request never settles when // awaited from another, hanging every auth call in the isolate. const ctx = await createTestApp() let settled = false void ctx.auth.$context.then(() => { settled = true }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(settled).toBe(true) }) }) describe('session hook — activeOrganizationId is set on sign-in after sign-up', () => { it('sign-in after sign-up succeeds and returns a session cookie', async () => { const ctx = await createTestApp() await signUp(ctx, 'session-user@example.com') const res = await ctx.app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'session-user@example.com', password: 'password123456' }), }) expect(res.status).toBe(200) expect(res.headers.get('set-cookie')).toBeTruthy() }) it('session record in DB has activeOrganizationId set after sign-in', async () => { const ctx = await createTestApp() await signUp(ctx, 'org-session@example.com') await ctx.app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'org-session@example.com', password: 'password123456' }), }) const sessions = await ctx.db.select().from(authSchema.session) // At least one session should have activeOrganizationId set const withOrg = sessions.filter((s) => s.activeOrganizationId != null) expect(withOrg.length).toBeGreaterThan(0) }) }) describe('createPersonalOrg — org name and quota edge cases', () => { it('sign-up with empty name creates org with fallback name "Personal Space"', async () => { const ctx = await createTestApp() const res = await ctx.app.request('/api/auth/sign-up/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '', email: 'noname@example.com', password: 'password123456' }), }) // sign-up should succeed expect(res.status).toBe(200) }) it('sign-up uses a custom finite default_org_quota when set', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '524288000' }) const res = await signUp(ctx, 'quota-user@example.com') expect(res.status).toBe(200) }) it('team creation uses default_team_quota while personal orgs keep default_org_quota', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '1000000' }) await ctx.db.insert(schema.systemOptions).values({ key: 'default_team_quota', value: '5000000' }) const res = await signUp(ctx, 'team-quota@example.com') expect(res.status).toBe(200) const cookies = res.headers.getSetCookie().join('; ') const createOrg = await ctx.app.request('/api/auth/organization/create', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookies, Origin: 'http://localhost:3000' }, body: JSON.stringify({ name: 'My Team', slug: 'my-team', metadata: { type: 'team' } }), }) expect(createOrg.status).toBe(200) const org = (await createOrg.json()) as { id: string } const rows = await ctx.db.select().from(schema.orgQuotaEntitlements) const teamStorage = rows.find((row) => row.orgId === org.id && row.resourceType === 'storage') expect(teamStorage?.bytes).toBe(5000000) const personalStorage = rows.find((row) => row.orgId !== org.id && row.resourceType === 'storage') expect(personalStorage?.bytes).toBe(1000000) }) it('team creation falls back to default_org_quota when default_team_quota is unset', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '2000000' }) const res = await signUp(ctx, 'team-quota-fallback@example.com') expect(res.status).toBe(200) const cookies = res.headers.getSetCookie().join('; ') const createOrg = await ctx.app.request('/api/auth/organization/create', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookies, Origin: 'http://localhost:3000' }, body: JSON.stringify({ name: 'Fallback Team', slug: 'fallback-team', metadata: { type: 'team' } }), }) expect(createOrg.status).toBe(200) const org = (await createOrg.json()) as { id: string } const rows = await ctx.db.select().from(schema.orgQuotaEntitlements) const teamStorage = rows.find((row) => row.orgId === org.id && row.resourceType === 'storage') expect(teamStorage?.bytes).toBe(2000000) }) it('sign-up falls back to DEFAULT_ORG_QUOTA when default_org_quota is non-numeric', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: 'not-a-number' }) const res = await signUp(ctx, 'quota-fallback@example.com') expect(res.status).toBe(200) }) it('sign-up with default_org_quota set to zero falls back to DEFAULT_ORG_QUOTA', async () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' }) const res = await signUp(ctx, 'zero-quota@example.com') expect(res.status).toBe(200) const quotas = await ctx.db.select().from(schema.orgQuotas) expect(quotas).toHaveLength(1) expect(quotas[0].quota).toBe(0) expect(quotas[0].trafficQuota).toBe(0) expect(quotas[0].trafficUsed).toBe(0) expect(quotas[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/) await expectPlanEntitlement(ctx, 'storage', 10485760) await expectPlanEntitlement(ctx, 'traffic', 0) }) }) describe('sendInvitationEmail — buildInvitationEmailHtml via invite-member with email_provider configured', () => { const emailProviderOptions = [ { key: 'email_enabled', value: 'true' }, { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'no-reply@example.com' }, { key: 'email_http_url', value: 'https://api.mail.example.com/send' }, { key: 'email_http_api_key', value: 'my-api-key' }, ] async function setupOwnerAndOrg(ctx: TestCtx, email: string) { const signUpRes = await signUp(ctx, email) const cookie = signUpRes.headers.getSetCookie().join('; ') const body = (await signUpRes.json()) as { user: { id: string } } const orgs = await ctx.db.select().from(authSchema.organization) const orgId = orgs.find((o) => o.slug === `personal-${body.user.id}`)?.id ?? '' return { cookie, orgId } } it('invitation email is sent when email_provider is configured', async () => { const { vi } = await import('vitest') const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values(emailProviderOptions) const { cookie, orgId } = await setupOwnerAndOrg(ctx, 'inviter@example.com') const res = await ctx.app.request('/api/auth/organization/invite-member', { method: 'POST', // Cookie-bearing requests must carry an Origin, like real browser requests headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' }, body: JSON.stringify({ email: 'invitee@example.com', role: 'member', organizationId: orgId }), }) expect(res.status).toBe(200) expect(fetchMock).toHaveBeenCalled() vi.unstubAllGlobals() }) it('invitation email HTML contains accept-invitation link and role', async () => { const { vi } = await import('vitest') let capturedHtml = '' const fetchMock = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { const b = JSON.parse(init?.body as string) capturedHtml = b.html return { ok: true } }) vi.stubGlobal('fetch', fetchMock) const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values(emailProviderOptions) const { cookie, orgId } = await setupOwnerAndOrg(ctx, 'orgowner@example.com') await ctx.app.request('/api/auth/organization/invite-member', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' }, body: JSON.stringify({ email: 'newmember@example.com', role: 'member', organizationId: orgId }), }) expect(capturedHtml).toContain('accept-invitation') expect(capturedHtml).toContain('member') vi.unstubAllGlobals() }) }) describe('email sign-up — username is required', () => { it('email sign-up with username keeps the provided username', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'alice@example.com', { username: 'myalias' }) expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toBe('myalias') }) }) // OAuth users are created by better-auth's internal adapter without a username. // The before hook generates one from preferred_username/login or email prefix. // We simulate this by calling sign-up without username (bypasses frontend validation). describe('OAuth username generation — before hook', () => { it('generates username from email prefix when no username provided', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'johndoe@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toBe('johndoe') }) it('sanitizes special characters from email prefix', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'john.doe+tag@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toMatch(/^[a-z0-9]+$/) expect(row.username).not.toContain('.') expect(row.username).not.toContain('+') }) it('adds random suffix when email prefix is shorter than 3 chars', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'ab@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toMatch(/^ab-[a-z0-9]{6}$/) }) it('adds random suffix when email prefix is a single char', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'x@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toMatch(/^x-[a-z0-9]{6}$/) }) it('adds random suffix when email prefix collides with existing username', async () => { const ctx = await createTestApp() // First user takes "bob" via explicit username await signUp(ctx, 'bob@example.com', { username: 'bob' }) // Second user without username — email prefix "bob" is taken, gets "bob-xxxxxx" const res = await signUp(ctx, 'bob@other.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toMatch(/^bob-[a-z0-9]{6}$/) }) it('sets displayUsername to the same value as username', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'carol@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username, displayUsername: authSchema.user.displayUsername }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.displayUsername).toBe(row.username) }) it('uses email prefix directly when it is exactly 3 chars', async () => { const ctx = await createTestApp() const res = await signUp(ctx, 'abc@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username).toBe('abc') }) it('truncates email prefix to 30 chars', async () => { const ctx = await createTestApp() const longPrefix = 'averylongemailprefixthatiswaytolong' const res = await signUp(ctx, `${longPrefix}@example.com`) expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) expect(row.username!.length).toBeLessThanOrEqual(30) }) it('email prefix consisting entirely of special characters falls back to user-suffix', async () => { const ctx = await createTestApp() // The sign-up endpoint requires a valid email, so use a prefix that sanitizes to empty // Unfortunately standard email formats require at least one alphanumeric char in local part, // but we can test with an email whose local part has only non-alphanumeric chars stripped // We simulate by directly inserting a user with null username then querying // Instead, test with prefix "___" which sanitizes to "" (hyphens and underscores removed) // Actually the sanitizer removes [^a-z0-9] so underscores are also removed. // Use a numeric-looking prefix that won't conflict — verify fallback via the DB check // The most we can test through the API is a prefix that becomes too short. // A prefix like "a_b" becomes "ab" (2 chars < 3) → gets suffix const res = await signUp(ctx, 'a_b@example.com') expect(res.status).toBe(200) const body = (await res.json()) as { user: { id: string } } const [row] = await ctx.db .select({ username: authSchema.user.username }) .from(authSchema.user) .where(eq(authSchema.user.id, body.user.id)) // "a_b" sanitizes to "ab" (2 chars) → appends random suffix expect(row.username).toMatch(/^ab-[a-z0-9]{6}$/) }) }) describe('origin check — loopback and LAN origins are trusted without config', () => { // better-auth only enforces the Origin check on requests that carry cookies, // so attach a dummy cookie to make validateOrigin run. async function signInWithOrigin(ctx: TestCtx, origin: string) { return ctx.app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, Cookie: 'zp.dummy=1' }, body: JSON.stringify({ email: 'origin@example.com', password: 'password123456' }), }) } it.each([ 'http://127.0.0.1:3000', 'http://192.168.1.50:3000', 'http://10.0.0.5:8080', ])('allows sign-in with Origin %s when TRUSTED_ORIGINS is not set', async (origin) => { const ctx = await createTestApp() await signUp(ctx, 'origin@example.com') const res = await signInWithOrigin(ctx, origin) expect(res.status).toBe(200) }) it('still rejects sign-in from an unknown public origin', async () => { const ctx = await createTestApp() await signUp(ctx, 'origin@example.com') const res = await signInWithOrigin(ctx, 'https://evil.example.com') expect(res.status).toBe(403) }) })