Ctrl K
ComunidadGuías…

tests/integration/invite.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()
  })
})