mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
703 lines
30 KiB
TypeScript
703 lines
30 KiB
TypeScript
import { eq } from 'drizzle-orm'
|
|
import { describe, expect, it } from 'vitest'
|
|
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 { generateInviteCodes } from './services/invite.js'
|
|
import { createSiteInvitation } from './services/site-invitations.js'
|
|
import { createTestApp, seedProLicense } from './test/setup.js'
|
|
|
|
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
|
|
|
|
async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknown>) {
|
|
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, ['open_registration'])
|
|
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 createSiteInvitation(ctx.db, 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 createSiteInvitation(ctx.db, 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 createSiteInvitation(ctx.db, 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 generateInviteCodes(ctx.db, '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 generateInviteCodes(ctx.db, '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 generateInviteCodes(ctx.db, '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 generateInviteCodes(ctx.db, '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('loadOidcConfigs — 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('loadProviderConfig — builtin social provider resolution', () => {
|
|
it('social sign-in with an unconfigured provider returns non-200 (provider not enabled)', async () => {
|
|
const ctx = await createTestApp()
|
|
// Trigger the lazy provider resolver by initiating social sign-in.
|
|
// With no config in DB the provider returns enabled:false.
|
|
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' }),
|
|
})
|
|
// better-auth returns an error because the provider is disabled
|
|
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 })
|
|
// Trigger social sign-in — this calls the async provider loader which hits loadProviderConfig
|
|
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' }),
|
|
})
|
|
// With a valid enabled provider, better-auth returns a redirect (302) to the OAuth provider
|
|
expect([200, 302]).toContain(res.status)
|
|
})
|
|
})
|
|
|
|
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('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',
|
|
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
|
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 },
|
|
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}$/)
|
|
})
|
|
})
|