tests/integration/invite.test.ts
Rama main · réplica de lectura del repositorio original.
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<ReturnType<typeof buildServer>>, 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()
})
})