tests/integration/users-list.test.ts
Rama main · réplica de lectura del repositorio original.
import { 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>>
interface UserRow {
id: string
name: string
email: string
role: string
status: string
projects: string[]
}
interface UsersPage {
data: UserRow[]
total: number
limit: number
offset: number
counts: {
all: number
active: number
invited: number
suspended: number
system: number
owners: number
admins: number
}
}
describeIfDb('GET /api/v1/admin/users', () => {
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, and boot seeding would add a default admin,
* so clear the table and start the server with skipBootSeed to keep the
* counts in these tests exact.
*/
await db!.execute(sql`TRUNCATE users, user_projects RESTART IDENTITY CASCADE`)
})
let seq = 0
/**
* Creates a user through the invite flow, optionally accepting the invite so
* the account ends up active rather than pending.
*/
async function makeUser(
app: App,
rootKey: string,
opts: { role?: 'admin' | 'editor' | 'viewer'; projectIds?: string[]; accept?: boolean } = {},
) {
const email = `u${seq++}@co.com`
const res = await app.inject({
method: 'POST',
url: '/api/v1/admin/users/invite',
headers: { authorization: rootKey, origin: 'https://flags.example' },
payload: { emails: [email], role: opts.role ?? 'viewer', projectIds: opts.projectIds ?? [] },
})
expect(res.statusCode).toBe(201)
const { id, inviteUrl } = res.json<{ id: string; inviteUrl: string }[]>()[0]
if (opts.accept) {
const accept = await app.inject({
method: 'POST',
url: `/api/v1/public/invite/${inviteUrl.split('/invite/')[1]}/accept`,
payload: { password: 'a-good-password-1' },
})
expect(accept.statusCode).toBe(200)
}
return { id, email }
}
/** createProject hard-codes the display name, so set the one we assert on. */
async function namedProject(app: App, rootKey: string, name: string) {
const project = await createProject(app, rootKey, name)
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/projects/${project.id}`,
headers: { authorization: rootKey },
payload: { name },
})
return project
}
async function list(app: App, rootKey: string, query = '') {
const res = await app.inject({
method: 'GET',
url: `/api/v1/admin/users${query}`,
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(200)
return res.json<UsersPage>()
}
it('pages through results and reports the unpaged total', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
for (let i = 0; i < 5; i++) await makeUser(app, rootKey)
const first = await list(app, rootKey, '?limit=2&offset=0')
expect(first).toMatchObject({ total: 5, limit: 2, offset: 0 })
expect(first.data).toHaveLength(2)
const second = await list(app, rootKey, '?limit=2&offset=2')
const third = await list(app, rootKey, '?limit=2&offset=4')
expect(third.data).toHaveLength(1)
/** Every row appears exactly once across the pages. */
const seen = [...first.data, ...second.data, ...third.data].map((u) => u.id)
expect(new Set(seen).size).toBe(5)
expect((await list(app, rootKey, '?limit=2&offset=99')).data).toEqual([])
await app.close()
})
it('returns a deterministic order across repeated requests', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const created = []
for (let i = 0; i < 4; i++) created.push(await makeUser(app, rootKey))
const before = (await list(app, rootKey)).data.map((u) => u.id)
/** An UPDATE moves the row in the heap; without ORDER BY the order shifts. */
await db!.update(users).set({ name: 'Renamed Later' }).where(eq(users.id, created[0].id))
const after = (await list(app, rootKey, '?sort=name&dir=asc')).data.map((u) => u.id)
const again = (await list(app, rootKey, '?sort=name&dir=asc')).data.map((u) => u.id)
expect(after).toEqual(again)
expect(before).toHaveLength(after.length)
await app.close()
})
it('searches name, email and project name, treating % as a literal', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const project = await namedProject(app, rootKey, 'checkout-team')
const member = await makeUser(app, rootKey, { projectIds: [project.id] })
await makeUser(app, rootKey)
await db!.update(users).set({ name: 'Rollout 50% Bot' }).where(eq(users.id, member.id))
/** The project name matches even though the user's own fields do not. */
const byProject = await list(app, rootKey, '?search=checkout')
expect(byProject.total).toBe(1)
expect(byProject.data[0].id).toBe(member.id)
expect((await list(app, rootKey, `?search=${encodeURIComponent(member.email)}`)).total).toBe(1)
expect((await list(app, rootKey, '?search=ROLLOUT')).total).toBe(1)
/** A bare % must not act as a wildcard matching every row. */
expect((await list(app, rootKey, '?search=%25')).total).toBe(1)
await app.close()
})
it('filters by status, treating service accounts as their own bucket', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const active = await makeUser(app, rootKey, { accept: true })
await makeUser(app, rootKey)
const robot = await makeUser(app, rootKey, { accept: true })
await db!.update(users).set({ isSystem: true }).where(eq(users.id, robot.id))
const activeOnly = await list(app, rootKey, '?status=active')
expect(activeOnly.data.map((u) => u.id)).toContain(active.id)
/** The service account is active too, but must not show under "active". */
expect(activeOnly.data.map((u) => u.id)).not.toContain(robot.id)
expect((await list(app, rootKey, '?status=invited')).total).toBe(1)
const system = await list(app, rootKey, '?status=system')
expect(system.total).toBe(1)
expect(system.data[0].id).toBe(robot.id)
await app.close()
})
it('filters by role and by project membership', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const alpha = await namedProject(app, rootKey, 'alpha')
const beta = await namedProject(app, rootKey, 'beta')
const admin = await makeUser(app, rootKey, { role: 'admin', projectIds: [alpha.id] })
await makeUser(app, rootKey, { role: 'viewer', projectIds: [beta.id] })
const admins = await list(app, rootKey, '?role=admin')
expect(admins.total).toBe(1)
expect(admins.data[0].id).toBe(admin.id)
const inAlpha = await list(app, rootKey, `?projectId=${alpha.id}`)
expect(inAlpha.total).toBe(1)
expect(inAlpha.data[0].id).toBe(admin.id)
expect(inAlpha.data[0].projects).toEqual(['alpha'])
/** Filters compose. */
expect((await list(app, rootKey, `?projectId=${beta.id}&role=admin`)).total).toBe(0)
await app.close()
})
it('sorts by role privilege and by project count, not alphabetically', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const alpha = await namedProject(app, rootKey, 'alpha')
const beta = await namedProject(app, rootKey, 'beta')
await makeUser(app, rootKey, { role: 'viewer' })
await makeUser(app, rootKey, { role: 'admin', projectIds: [alpha.id, beta.id] })
await makeUser(app, rootKey, { role: 'editor', projectIds: [alpha.id] })
/** admin before editor before viewer, despite 'a' < 'e' < 'v' being a coincidence here. */
const byRole = await list(app, rootKey, '?sort=role&dir=asc')
expect(byRole.data.map((u) => u.role)).toEqual(['admin', 'editor', 'viewer'])
const byRoleDesc = await list(app, rootKey, '?sort=role&dir=desc')
expect(byRoleDesc.data.map((u) => u.role)).toEqual(['viewer', 'editor', 'admin'])
const byProjects = await list(app, rootKey, '?sort=projects&dir=desc')
expect(byProjects.data.map((u) => u.projects.length)).toEqual([2, 1, 0])
await app.close()
})
it('reports workspace-wide counts that ignore the active filters', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
await makeUser(app, rootKey, { role: 'admin', accept: true })
await makeUser(app, rootKey, { role: 'viewer' })
const robot = await makeUser(app, rootKey, { accept: true })
await db!.update(users).set({ isSystem: true }).where(eq(users.id, robot.id))
const unfiltered = await list(app, rootKey)
expect(unfiltered.counts).toMatchObject({
all: 3,
active: 1,
invited: 1,
system: 1,
suspended: 0,
admins: 1,
})
/** Narrowing the page must not change the counts. */
const filtered = await list(app, rootKey, '?status=invited&limit=1')
expect(filtered.total).toBe(1)
expect(filtered.counts).toEqual(unfiltered.counts)
await app.close()
})
it('rejects a page size above the maximum', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
const res = await app.inject({
method: 'GET',
url: '/api/v1/admin/users?limit=5000',
headers: { authorization: rootKey },
})
expect(res.statusCode).toBe(400)
await app.close()
})
it('never leaks credential material on a paginated page', async () => {
const app = await buildServer({ db, skipBootSeed: true })
const rootKey = await createRootKey(db!)
await makeUser(app, rootKey)
const page = await list(app, rootKey)
expect(page.data.length).toBeGreaterThan(0)
for (const row of page.data as unknown as Record<string, unknown>[]) {
expect(row).not.toHaveProperty('passwordHash')
expect(row).not.toHaveProperty('inviteTokenHash')
expect(row).not.toHaveProperty('inviteExpiresAt')
expect(row).not.toHaveProperty('sessionVersion')
}
await app.close()
})
})