Ctrl K
ComunidadGuías…

tests/integration/rbac.test.ts

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

Archivo original ↗Preguntar a KiBot
import { and, eq, sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it } from 'vitest'

import { buildServer } from '../../src/server.js'
import { users } from '../../src/db/schema.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

type App = Awaited<ReturnType<typeof buildServer>>

describeIfDb('session RBAC', () => {
  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; clear them for a hermetic run. */
    await db!.execute(sql`TRUNCATE users, user_projects RESTART IDENTITY CASCADE`)
  })

  let seq = 0

  /**
   * Creates a user with the given role via the invite flow and returns a live
   * session cookie plus their user id and member project ids.
   */
  async function sessionUser(
    app: App,
    rootKey: string,
    role: 'admin' | 'editor' | 'viewer',
    projectIds: string[],
  ) {
    const email = `rbac${seq++}@co.com`
    const inviteRes = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/users/invite',
      headers: { authorization: rootKey, origin: 'https://flags.example' },
      payload: { emails: [email], role, projectIds },
    })
    expect(inviteRes.statusCode).toBe(201)
    const { id, inviteUrl } = inviteRes.json<{ id: string; inviteUrl: string }[]>()[0]
    const token = inviteUrl.split('/invite/')[1]
    const accept = await app.inject({
      method: 'POST',
      url: `/api/v1/public/invite/${token}/accept`,
      payload: { password: 'rbac-password-1' },
    })
    expect(accept.statusCode).toBe(200)
    const cookie = accept.cookies.find((c) => c.name === 'flagraft_session')!
    return { id, email, cookies: { flagraft_session: cookie.value } }
  }

  /**
   * Promotes an invited admin to owner with the root key, which is the only
   * way to reach the owner role -- invites cannot hand it out.
   */
  async function ownerSession(app: App, rootKey: string, projectIds: string[]) {
    const session = await sessionUser(app, rootKey, 'admin', projectIds)
    const promote = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${session.id}`,
      headers: { authorization: rootKey },
      payload: { role: 'owner' },
    })
    expect(promote.statusCode).toBe(200)
    return session
  }

  /** Creates a flag with the root key so role checks are not in the way. */
  async function createFlag(app: App, rootKey: string, projectId: string, key: string) {
    const res = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${projectId}/flags`,
      headers: { authorization: rootKey },
      payload: { name: key, key },
    })
    expect(res.statusCode).toBe(201)
  }

  /** Sets the project's default state for newly-created flags. */
  async function setDefaultState(app: App, rootKey: string, projectId: string, state: string) {
    const res = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/projects/${projectId}`,
      headers: { authorization: rootKey },
      payload: { settings: { flagDefaults: { defaultState: state } } },
    })
    expect(res.statusCode).toBe(200)
  }

  async function environmentId(app: App, rootKey: string, projectId: string, slug: string) {
    const res = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${projectId}/environments`,
      headers: { authorization: rootKey },
    })
    expect(res.statusCode).toBe(200)
    const found = res.json<{ id: string; slug: string }[]>().find((e) => e.slug === slug)
    expect(found).toBeDefined()
    return found!.id
  }

  it('viewer can read their project but cannot mutate anything', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'viewer-proj')
    const viewer = await sessionUser(app, rootKey, 'viewer', [project.id])

    const read = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: viewer.cookies,
    })
    expect(read.statusCode).toBe(200)

    const write = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: viewer.cookies,
      payload: { name: 'Sneaky Flag', key: 'sneaky.flag' },
    })
    expect(write.statusCode).toBe(403)
    await app.close()
  })

  it('editor can mutate flags in their project but not foreign projects', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const mine = await createProject(app, rootKey, 'editor-mine')
    const foreign = await createProject(app, rootKey, 'editor-foreign')
    const editor = await sessionUser(app, rootKey, 'editor', [mine.id])

    const write = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${mine.id}/flags`,
      cookies: editor.cookies,
      payload: { name: 'My Flag', key: 'my.flag' },
    })
    expect(write.statusCode).toBe(201)

    const foreignRead = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${foreign.id}/flags`,
      cookies: editor.cookies,
    })
    expect(foreignRead.statusCode).toBe(403)
    await app.close()
  })

  it('editor cannot manage users, keys of foreign projects, or create projects', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const mine = await createProject(app, rootKey, 'editor-scope')
    const editor = await sessionUser(app, rootKey, 'editor', [mine.id])

    /** Reading the member list is allowed; changing it is not. */
    const inviteSomeone = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/users/invite',
      cookies: editor.cookies,
      payload: { emails: ['nope@co.com'], role: 'viewer', projectIds: [] },
    })
    expect(inviteSomeone.statusCode).toBe(403)

    const createProj = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/projects',
      cookies: editor.cookies,
      payload: { name: 'Nope', slug: 'nope' },
    })
    expect(createProj.statusCode).toBe(403)
    await app.close()
  })

  it.each(['editor', 'viewer'] as const)(
    'a %s can read the member list but never its credentials',
    async (role) => {
      const app = await buildServer({ db })
      const rootKey = await createRootKey(db!)
      const mine = await createProject(app, rootKey, `read-members-${role}`)
      const member = await sessionUser(app, rootKey, role, [mine.id])

      const list = await app.inject({
        method: 'GET',
        url: '/api/v1/admin/users',
        cookies: member.cookies,
      })
      expect(list.statusCode).toBe(200)

      const body = list.json<{ data: Record<string, unknown>[] }>()
      expect(body.data.length).toBeGreaterThan(0)
      for (const user of body.data) {
        expect(user).not.toHaveProperty('passwordHash')
        expect(user).not.toHaveProperty('inviteTokenHash')
        expect(user).not.toHaveProperty('sessionVersion')
      }

      const detail = await app.inject({
        method: 'GET',
        url: `/api/v1/admin/users/${member.id}`,
        cookies: member.cookies,
      })
      expect(detail.statusCode).toBe(200)
      expect(detail.json()).not.toHaveProperty('passwordHash')

      /** Reading is where it stops: no role change, no suspension. */
      const promote = await app.inject({
        method: 'PATCH',
        url: `/api/v1/admin/users/${member.id}`,
        cookies: member.cookies,
        payload: { role: 'owner' },
      })
      expect(promote.statusCode).toBe(403)
      await app.close()
    },
  )

  it('admin-role session can manage users and responses never leak credentials', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const admin = await sessionUser(app, rootKey, 'admin', [])

    const list = await app.inject({
      method: 'GET',
      url: '/api/v1/admin/users',
      cookies: admin.cookies,
    })
    expect(list.statusCode).toBe(200)
    const rows = list.json<{ data: Record<string, unknown>[] }>().data
    expect(rows.length).toBeGreaterThan(0)
    for (const row of rows) {
      expect(row).not.toHaveProperty('passwordHash')
      expect(row).not.toHaveProperty('inviteTokenHash')
      expect(row).not.toHaveProperty('inviteExpiresAt')
      expect(row).not.toHaveProperty('sessionVersion')
    }

    const detail = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/users/${admin.id}`,
      cookies: admin.cookies,
    })
    expect(detail.statusCode).toBe(200)
    expect(detail.json()).not.toHaveProperty('passwordHash')

    const patched = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${admin.id}`,
      cookies: admin.cookies,
      payload: { name: 'Renamed Admin' },
    })
    expect(patched.statusCode).toBe(200)
    expect(patched.json()).not.toHaveProperty('passwordHash')
    await app.close()
  })

  it('editor sees only the projects they are members of', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const mine = await createProject(app, rootKey, 'list-mine')
    await createProject(app, rootKey, 'list-foreign')
    const editor = await sessionUser(app, rootKey, 'editor', [mine.id])

    const list = await app.inject({
      method: 'GET',
      url: '/api/v1/admin/projects',
      cookies: editor.cookies,
    })
    expect(list.statusCode).toBe(200)
    const slugs = list.json<{ slug: string }[]>().map((p) => p.slug)
    expect(slugs).toEqual(['list-mine'])
    await app.close()
  })

  it('suspending a user kills their existing session immediately', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'suspend-proj')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const before = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: editor.cookies,
    })
    expect(before.statusCode).toBe(200)

    const suspend = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${editor.id}`,
      headers: { authorization: rootKey },
      payload: { status: 'suspended' },
    })
    expect(suspend.statusCode).toBe(200)

    const after = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: editor.cookies,
    })
    expect(after.statusCode).toBe(401)
    await app.close()
  })

  it('suspended login with correct password says suspended; wrong password stays generic', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const editor = await sessionUser(app, rootKey, 'editor', [])

    const suspend = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${editor.id}`,
      headers: { authorization: rootKey },
      payload: { status: 'suspended' },
    })
    expect(suspend.statusCode).toBe(200)

    const correct = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/auth/login',
      payload: { email: editor.email, password: 'rbac-password-1' },
    })
    expect(correct.statusCode).toBe(403)
    expect(correct.json<{ message: string }>().message).toMatch(/suspended/i)

    const wrong = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/auth/login',
      payload: { email: editor.email, password: 'wrong-password-1' },
    })
    expect(wrong.statusCode).toBe(401)
    expect(wrong.json<{ message: string }>().message).toBe('Invalid email or password')
    await app.close()
  })

  it('stamps lastLoginAt on a successful login', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const editor = await sessionUser(app, rootKey, 'editor', [])

    /** Accepting an invite hands out a session directly, so nothing is stamped yet. */
    const before = await db!
      .select({ at: users.lastLoginAt })
      .from(users)
      .where(eq(users.id, editor.id))
    expect(before[0].at).toBeNull()

    const login = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/auth/login',
      payload: { email: editor.email, password: 'rbac-password-1' },
    })
    expect(login.statusCode).toBe(200)

    const after = await db!
      .select({ at: users.lastLoginAt })
      .from(users)
      .where(eq(users.id, editor.id))
    expect(after[0].at).toBeInstanceOf(Date)
    await app.close()
  })

  it('a password reset invalidates existing sessions and the new password logs in', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'reset-proj')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const reset = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/users/${editor.id}/reset-password`,
      headers: { authorization: rootKey },
      payload: { password: 'brand-new-pass-1' },
    })
    expect(reset.statusCode).toBe(204)

    const after = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: editor.cookies,
    })
    expect(after.statusCode).toBe(401)

    const login = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/auth/login',
      payload: { email: editor.email, password: 'brand-new-pass-1' },
    })
    expect(login.statusCode).toBe(200)
    await app.close()
  })

  it('rejects a password reset shorter than 8 characters', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const editor = await sessionUser(app, rootKey, 'editor', [])

    const reset = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/users/${editor.id}/reset-password`,
      headers: { authorization: rootKey },
      payload: { password: 'short' },
    })
    expect(reset.statusCode).toBe(400)
    await app.close()
  })

  it('resetting an invited user activates the account so the password works', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)

    const inviteRes = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/users/invite',
      headers: { authorization: rootKey, origin: 'https://flags.example' },
      payload: { emails: ['invited-reset@co.com'], role: 'viewer', projectIds: [] },
    })
    expect(inviteRes.statusCode).toBe(201)
    const { id } = inviteRes.json<{ id: string }[]>()[0]

    const reset = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/users/${id}/reset-password`,
      headers: { authorization: rootKey },
      payload: { password: 'invited-new-pass' },
    })
    expect(reset.statusCode).toBe(204)

    const login = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/auth/login',
      payload: { email: 'invited-reset@co.com', password: 'invited-new-pass' },
    })
    expect(login.statusCode).toBe(200)
    await app.close()
  })

  it('editor can toggle a flag in development but not in protected production', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'toggle-proj')
    await createFlag(app, rootKey, project.id, 'toggle.me')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const dev = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags/toggle.me/environments/development/enable`,
      cookies: editor.cookies,
    })
    expect(dev.statusCode).toBe(200)

    const prod = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags/toggle.me/environments/production/enable`,
      cookies: editor.cookies,
    })
    expect(prod.statusCode).toBe(403)
    await app.close()
  })

  it('owner and admin can toggle a flag in protected production', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'prod-toggle')
    await createFlag(app, rootKey, project.id, 'prod.flag')
    const admin = await sessionUser(app, rootKey, 'admin', [project.id])
    const owner = await ownerSession(app, rootKey, [project.id])

    const byAdmin = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags/prod.flag/environments/production/enable`,
      cookies: admin.cookies,
    })
    expect(byAdmin.statusCode).toBe(200)

    const byOwner = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags/prod.flag/environments/production/disable`,
      cookies: owner.cookies,
    })
    expect(byOwner.statusCode).toBe(200)
    await app.close()
  })

  it('a project default of "on" does not let an editor create a flag already enabled in protected production', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'default-on-proj')
    await setDefaultState(app, rootKey, project.id, 'on')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const create = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: editor.cookies,
      payload: { name: 'sneaky-default', key: 'sneaky.default' },
    })
    expect(create.statusCode).toBe(201)
    const flag = create.json<{ state: Record<string, { on: boolean }> }>()
    expect(flag.state.development.on).toBe(true)
    expect(flag.state.production.on).toBe(false)
    await app.close()
  })

  it('a project default of "on" does let an owner create a flag already enabled in protected production', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'default-on-owner')
    await setDefaultState(app, rootKey, project.id, 'on')
    const owner = await ownerSession(app, rootKey, [project.id])

    const create = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/flags`,
      cookies: owner.cookies,
      payload: { name: 'deliberate-default', key: 'deliberate.default' },
    })
    expect(create.statusCode).toBe(201)
    const flag = create.json<{ state: Record<string, { on: boolean }> }>()
    expect(flag.state.production.on).toBe(true)
    await app.close()
  })

  it('editor can change targeting in development but not in protected production', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'targeting-proj')
    await createFlag(app, rootKey, project.id, 'target.me')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])
    const payload = { strategies: [{ constraints: [] }] }

    const dev = await app.inject({
      method: 'PUT',
      url: `/api/v1/admin/projects/${project.id}/flags/target.me/environments/development/strategies`,
      cookies: editor.cookies,
      payload,
    })
    expect(dev.statusCode).toBe(200)

    const prod = await app.inject({
      method: 'PUT',
      url: `/api/v1/admin/projects/${project.id}/flags/target.me/environments/production/strategies`,
      cookies: editor.cookies,
      payload,
    })
    expect(prod.statusCode).toBe(403)
    await app.close()
  })

  it('editor can create and edit flags but not delete them', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'flag-delete-proj')
    await createFlag(app, rootKey, project.id, 'keep.me')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const rename = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/projects/${project.id}/flags/keep.me`,
      cookies: editor.cookies,
      payload: { name: 'Renamed Flag' },
    })
    expect(rename.statusCode).toBe(200)

    /** Deleting a flag turns it off everywhere, protected environments included. */
    const remove = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/flags/keep.me`,
      cookies: editor.cookies,
    })
    expect(remove.statusCode).toBe(403)

    const owner = await ownerSession(app, rootKey, [project.id])
    const byOwner = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/flags/keep.me`,
      cookies: owner.cookies,
    })
    expect(byOwner.statusCode).toBe(204)
    await app.close()
  })

  it('editor cannot manage environments', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'env-proj')
    const devId = await environmentId(app, rootKey, project.id, 'development')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const create = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/environments`,
      cookies: editor.cookies,
      payload: { name: 'Staging', slug: 'staging', protected: false },
    })
    expect(create.statusCode).toBe(403)

    const update = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/projects/${project.id}/environments/${devId}`,
      cookies: editor.cookies,
      payload: { name: 'Renamed' },
    })
    expect(update.statusCode).toBe(403)

    const remove = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/environments/${devId}`,
      cookies: editor.cookies,
    })
    expect(remove.statusCode).toBe(403)

    /** Reading them is still fine -- the UI needs the list. */
    const list = await app.inject({
      method: 'GET',
      url: `/api/v1/admin/projects/${project.id}/environments`,
      cookies: editor.cookies,
    })
    expect(list.statusCode).toBe(200)
    await app.close()
  })

  it('editor cannot issue or revoke API keys', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'keys-proj')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const issue = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/keys`,
      cookies: editor.cookies,
      payload: { type: 'admin', description: 'Sneaky key' },
    })
    expect(issue.statusCode).toBe(403)

    const existing = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/keys`,
      headers: { authorization: rootKey },
      payload: { type: 'admin', description: 'Legit key' },
    })
    expect(existing.statusCode).toBe(201)

    const revoke = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/keys/${existing.json<{ id: string }>().id}`,
      cookies: editor.cookies,
    })
    expect(revoke.statusCode).toBe(403)
    await app.close()
  })

  it('editor cannot edit project settings or context fields', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'settings-proj')
    const editor = await sessionUser(app, rootKey, 'editor', [project.id])

    const rename = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/projects/${project.id}`,
      cookies: editor.cookies,
      payload: { name: 'Renamed By Editor' },
    })
    expect(rename.statusCode).toBe(403)

    const addField = await app.inject({
      method: 'POST',
      url: `/api/v1/admin/projects/${project.id}/context-fields`,
      cookies: editor.cookies,
      payload: { key: 'plan', name: 'Plan', type: 'string' },
    })
    expect(addField.statusCode).toBe(403)
    await app.close()
  })

  it('an admin cannot delete a project but an owner can', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'delete-proj')
    const admin = await sessionUser(app, rootKey, 'admin', [project.id])

    const byAdmin = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}`,
      cookies: admin.cookies,
    })
    expect(byAdmin.statusCode).toBe(403)

    const owner = await ownerSession(app, rootKey, [project.id])
    const byOwner = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}`,
      cookies: owner.cookies,
    })
    expect(byOwner.statusCode).toBe(204)
    await app.close()
  })

  it('an admin cannot grant the owner role but an owner can', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const admin = await sessionUser(app, rootKey, 'admin', [])
    const target = await sessionUser(app, rootKey, 'editor', [])

    const selfPromote = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${admin.id}`,
      cookies: admin.cookies,
      payload: { role: 'owner' },
    })
    expect(selfPromote.statusCode).toBe(403)

    const owner = await ownerSession(app, rootKey, [])
    const byOwner = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${target.id}`,
      cookies: owner.cookies,
      payload: { role: 'owner' },
    })
    expect(byOwner.statusCode).toBe(200)
    await app.close()
  })

  it('the last owner cannot be demoted, suspended or deleted', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)

    /** The boot owner is seeded on ready, so wait for it before looking. */
    await app.ready()
    /** First boot seeds exactly one owner, which is the one at risk here. */
    const [soleOwner] = await db!
      .select({ id: users.id })
      .from(users)
      .where(eq(users.role, 'owner'))
    expect(soleOwner).toBeDefined()

    for (const payload of [{ role: 'admin' }, { status: 'suspended' }]) {
      const res = await app.inject({
        method: 'PATCH',
        url: `/api/v1/admin/users/${soleOwner.id}`,
        headers: { authorization: rootKey },
        payload,
      })
      expect(res.statusCode).toBe(409)
    }

    const removed = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/users/${soleOwner.id}`,
      headers: { authorization: rootKey },
    })
    expect(removed.statusCode).toBe(409)

    /** With a second owner in place the first one can step down. */
    await ownerSession(app, rootKey, [])
    const demote = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${soleOwner.id}`,
      headers: { authorization: rootKey },
      payload: { role: 'admin' },
    })
    expect(demote.statusCode).toBe(200)
    await app.close()
  })

  it('an invited owner-to-be does not count toward the active-owner guard', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    await app.ready()
    const [bootOwner] = await db!
      .select({ id: users.id })
      .from(users)
      .where(eq(users.role, 'owner'))
    expect(bootOwner).toBeDefined()

    /** Promote an invite to owner before it is ever accepted -- status stays 'invited'. */
    const inviteRes = await app.inject({
      method: 'POST',
      url: '/api/v1/admin/users/invite',
      headers: { authorization: rootKey, origin: 'https://flags.example' },
      payload: { emails: ['pending-owner@co.com'], role: 'editor', projectIds: [] },
    })
    expect(inviteRes.statusCode).toBe(201)
    const { id: pendingOwnerId } = inviteRes.json<{ id: string }[]>()[0]
    const promote = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${pendingOwnerId}`,
      headers: { authorization: rootKey },
      payload: { role: 'owner' },
    })
    expect(promote.statusCode).toBe(200)

    /** The boot owner is still the only *active* one, so it stays protected. */
    const demoteBoot = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${bootOwner.id}`,
      headers: { authorization: rootKey },
      payload: { role: 'admin' },
    })
    expect(demoteBoot.statusCode).toBe(409)

    /** The pending owner never became active, so removing it is unrestricted. */
    const deletePending = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/users/${pendingOwnerId}`,
      headers: { authorization: rootKey },
    })
    expect(deletePending.statusCode).toBe(204)
    await app.close()
  })

  it('an admin cannot demote, suspend, or delete an owner, but another owner can', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const target = await ownerSession(app, rootKey, [])
    const actorOwner = await ownerSession(app, rootKey, [])
    const admin = await sessionUser(app, rootKey, 'admin', [])

    const demoteByAdmin = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${target.id}`,
      cookies: admin.cookies,
      payload: { role: 'admin' },
    })
    expect(demoteByAdmin.statusCode).toBe(403)

    const suspendByAdmin = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${target.id}`,
      cookies: admin.cookies,
      payload: { status: 'suspended' },
    })
    expect(suspendByAdmin.statusCode).toBe(403)

    const deleteByAdmin = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/users/${target.id}`,
      cookies: admin.cookies,
    })
    expect(deleteByAdmin.statusCode).toBe(403)

    const demoteByOwner = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${target.id}`,
      cookies: actorOwner.cookies,
      payload: { role: 'admin' },
    })
    expect(demoteByOwner.statusCode).toBe(200)
    await app.close()
  })

  it('racing two demotions against the last two active owners lets exactly one through', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    await app.ready()
    /** Exactly two active owners exist at this point: the boot owner and this one. */
    const [bootOwner] = await db!
      .select({ id: users.id })
      .from(users)
      .where(eq(users.role, 'owner'))
    expect(bootOwner).toBeDefined()
    const secondOwner = await ownerSession(app, rootKey, [])

    const [resA, resB] = await Promise.all([
      app.inject({
        method: 'PATCH',
        url: `/api/v1/admin/users/${bootOwner.id}`,
        headers: { authorization: rootKey },
        payload: { role: 'admin' },
      }),
      app.inject({
        method: 'PATCH',
        url: `/api/v1/admin/users/${secondOwner.id}`,
        headers: { authorization: rootKey },
        payload: { role: 'admin' },
      }),
    ])

    /**
     * Without a lock, both requests can read "2 active owners" before either
     * commits and both proceed, leaving zero. Exactly one must win.
     */
    expect([resA.statusCode, resB.statusCode].sort()).toEqual([200, 409])

    const [{ count }] = await db!
      .select({ count: sql<number>`count(*)::int` })
      .from(users)
      .where(and(eq(users.role, 'owner'), eq(users.status, 'active')))
    expect(count).toBe(1)
    await app.close()
  })

  it('a protected environment cannot be deleted until protection is turned off', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const project = await createProject(app, rootKey, 'protected-proj')
    const prodId = await environmentId(app, rootKey, project.id, 'production')

    const blocked = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/environments/${prodId}`,
      headers: { authorization: rootKey },
    })
    expect(blocked.statusCode).toBe(409)

    const unprotect = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/projects/${project.id}/environments/${prodId}`,
      headers: { authorization: rootKey },
      payload: { protected: false },
    })
    expect(unprotect.statusCode).toBe(200)

    const allowed = await app.inject({
      method: 'DELETE',
      url: `/api/v1/admin/projects/${project.id}/environments/${prodId}`,
      headers: { authorization: rootKey },
    })
    expect(allowed.statusCode).toBe(204)
    await app.close()
  })

  it('demoting an admin to viewer takes effect on the next request', async () => {
    const app = await buildServer({ db })
    const rootKey = await createRootKey(db!)
    const admin = await sessionUser(app, rootKey, 'admin', [])

    /**
     * Probes with a change rather than a read: reading the member list is
     * open to every role, so it would stay 200 after the demotion and prove
     * nothing.
     */
    const before = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${admin.id}`,
      cookies: admin.cookies,
      payload: { name: 'Still An Admin' },
    })
    expect(before.statusCode).toBe(200)

    const demote = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${admin.id}`,
      headers: { authorization: rootKey },
      payload: { role: 'viewer' },
    })
    expect(demote.statusCode).toBe(200)

    const after = await app.inject({
      method: 'PATCH',
      url: `/api/v1/admin/users/${admin.id}`,
      cookies: admin.cookies,
      payload: { name: 'Not Anymore' },
    })
    expect(after.statusCode).toBe(403)
    await app.close()
  })
})