import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it } from 'vitest' import { buildServer } from '../../src/server.js' import { createProject, createRootKey } from '../helpers/fixtures.js' import { getTestDb, truncateAll } from '../helpers/db.js' const describeIfDb = process.env.TEST_DATABASE_URL ? describe : describe.skip /** Pulls the /invite/:token segment out of a returned invite URL. */ function tokenFromUrl(url: string): string { return url.split('/invite/')[1] } describeIfDb('invite flow', () => { const db = process.env.TEST_DATABASE_URL ? getTestDb() : undefined beforeEach(async () => { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL await truncateAll(db!) /** truncateAll skips users; invited accounts must be cleared for a hermetic run. */ await db!.execute(sql`TRUNCATE users, user_projects RESTART IDENTITY CASCADE`) }) /** Unique email per invite so leftover user rows (users isn't truncated) never collide. */ let seq = 0 async function invite(app: Awaited>, rootKey: string) { const email = `newbie${seq++}@co.com` const project = await createProject(app, rootKey) const res = await app.inject({ method: 'POST', url: '/api/v1/admin/users/invite', headers: { authorization: rootKey, origin: 'https://flags.example' }, payload: { emails: [email], role: 'editor', projectIds: [project.id] }, }) expect(res.statusCode).toBe(201) return res.json<{ id: string; email: string; inviteUrl: string; emailed: boolean }[]>()[0] } it('issues an invite link (not emailed, no SMTP) built from the request origin', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const result = await invite(app, rootKey) expect(result.emailed).toBe(false) expect(result.inviteUrl).toMatch(/^https:\/\/flags\.example\/invite\/.+/) await app.close() }) it('validates a pending token, accepts it, then rejects reuse', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { inviteUrl, email } = await invite(app, rootKey) const token = tokenFromUrl(inviteUrl) const check = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${token}` }) expect(check.statusCode).toBe(200) expect(check.json<{ email: string }>().email).toBe(email) const accept = await app.inject({ method: 'POST', url: `/api/v1/public/invite/${token}/accept`, payload: { password: 'my-new-password' }, }) expect(accept.statusCode).toBe(200) expect(accept.cookies.some((c) => c.name === 'flagraft_session')).toBe(true) /** Token is single-use: a second validate must now fail. */ const reuse = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${token}` }) expect(reuse.statusCode).toBe(410) await app.close() }) it('lets the activated user log in with their chosen password', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { inviteUrl, email } = await invite(app, rootKey) const token = tokenFromUrl(inviteUrl) await app.inject({ method: 'POST', url: `/api/v1/public/invite/${token}/accept`, payload: { password: 'my-new-password' }, }) const login = await app.inject({ method: 'POST', url: '/api/v1/admin/auth/login', payload: { email, password: 'my-new-password' }, }) expect(login.statusCode).toBe(200) await app.close() }) /** Simulates time passing so the resend cooldown does not block the test. */ async function ageInvite(userId: string) { await db!.execute( sql`UPDATE users SET invite_expires_at = invite_expires_at - interval '3 minutes' WHERE id = ${userId}`, ) } it('resend issues a fresh link and invalidates the old one', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id, inviteUrl } = await invite(app, rootKey) const oldToken = tokenFromUrl(inviteUrl) await ageInvite(id) const resend = await app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey, origin: 'https://flags.example' }, }) expect(resend.statusCode).toBe(200) const fresh = resend.json<{ inviteUrl: string; emailed: boolean }>() expect(fresh.emailed).toBe(false) const newToken = tokenFromUrl(fresh.inviteUrl) expect(newToken).not.toBe(oldToken) /** Old link is dead, new one works. */ const oldCheck = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${oldToken}` }) expect(oldCheck.statusCode).toBe(410) const newCheck = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${newToken}` }) expect(newCheck.statusCode).toBe(200) const accept = await app.inject({ method: 'POST', url: `/api/v1/public/invite/${newToken}/accept`, payload: { password: 'my-new-password' }, }) expect(accept.statusCode).toBe(200) await app.close() }) it('resend right after the invite is rejected with 429 (cooldown)', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id } = await invite(app, rootKey) const resend = await app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey, origin: 'https://flags.example' }, }) expect(resend.statusCode).toBe(429) /** After the cooldown window it works again. */ await ageInvite(id) const later = await app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey, origin: 'https://flags.example' }, }) expect(later.statusCode).toBe(200) await app.close() }) it('concurrent resends rotate the token exactly once', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id } = await invite(app, rootKey) await ageInvite(id) const [a, b] = await Promise.all([ app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey, origin: 'https://flags.example' }, }), app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey, origin: 'https://flags.example' }, }), ]) expect([a.statusCode, b.statusCode].sort()).toEqual([200, 429]) /** The winner's link is the live one. */ const winner = a.statusCode === 200 ? a : b const token = tokenFromUrl(winner.json<{ inviteUrl: string }>().inviteUrl) const check = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${token}` }) expect(check.statusCode).toBe(200) await app.close() }) it('resend returns 404 for an already-active user', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id, inviteUrl } = await invite(app, rootKey) await app.inject({ method: 'POST', url: `/api/v1/public/invite/${tokenFromUrl(inviteUrl)}/accept`, payload: { password: 'my-new-password' }, }) const resend = await app.inject({ method: 'POST', url: `/api/v1/admin/users/${id}/resend-invite`, headers: { authorization: rootKey }, }) expect(resend.statusCode).toBe(404) await app.close() }) it('cancel invite deletes a pending invitee and kills the link', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id, inviteUrl } = await invite(app, rootKey) const cancel = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}/invite`, headers: { authorization: rootKey }, }) expect(cancel.statusCode).toBe(204) const check = await app.inject({ method: 'GET', url: `/api/v1/public/invite/${tokenFromUrl(inviteUrl)}`, }) expect(check.statusCode).toBe(410) await app.close() }) it('cancel invite refuses with 409 once the invitee has accepted', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { id, inviteUrl, email } = await invite(app, rootKey) await app.inject({ method: 'POST', url: `/api/v1/public/invite/${tokenFromUrl(inviteUrl)}/accept`, payload: { password: 'my-new-password' }, }) const cancel = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}/invite`, headers: { authorization: rootKey }, }) expect(cancel.statusCode).toBe(409) /** The activated account must still exist and be able to log in. */ const login = await app.inject({ method: 'POST', url: '/api/v1/admin/auth/login', payload: { email, password: 'my-new-password' }, }) expect(login.statusCode).toBe(200) await app.close() }) it('rejects an unknown token with 410', async () => { const app = await buildServer({ db }) const res = await app.inject({ method: 'GET', url: '/api/v1/public/invite/nope' }) expect(res.statusCode).toBe(410) await app.close() }) it('rejects a too-short password with 400', async () => { const app = await buildServer({ db }) const rootKey = await createRootKey(db!) const { inviteUrl } = await invite(app, rootKey) const token = tokenFromUrl(inviteUrl) const res = await app.inject({ method: 'POST', url: `/api/v1/public/invite/${token}/accept`, payload: { password: 'short' }, }) expect(res.statusCode).toBe(400) await app.close() }) })