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> 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[] }>() 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[] }>().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 }>() 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 }>() 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`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() }) })