// End-to-end smoke tests for the v2.0 Phase 1 surface (auth bootstrap, RBAC, // audit correlation). These exercise the wiring in createApp(): the bearer // auth middleware, the v2 routes scope, and the AuditService lifecycle. // // We don't spin up CockroachDB. Instead we provide a PrismaClient-shaped // in-memory mock that matches the surface the v2 services actually touch. // Tests follow the project convention of using mock DBs + Fastify.inject(). import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import bcrypt from "bcryptjs"; import { createApp } from "../src/server.js"; import type { DbClient } from "../src/server.js"; import type { AuditService } from "../src/services/audit.js"; const TEST_CONFIG = { port: 0, host: "127.0.0.1", databaseUrl: "", caDir: "/tmp", logLevel: "silent" }; interface UserRow { id: string; email: string; password: string; role: string; name: string | null; } interface SessionRow { id: string; userId: string; token: string; expiresAt: Date; user?: UserRow; } interface RbacDefRow { id: string; name: string; subjects: unknown; roleBindings: unknown; } interface AuditEventRow { id: string; eventKind: string; source: string; verified: boolean; userId: string | null; userName: string | null; environmentName: string | null; resourceKind: string | null; correlationId: string | null; parentEventId: string | null; details: unknown; result: string; error: string | null; durationMs: number | null; timestamp: Date; } interface Stores { users: Map; sessions: Map; groupMembers: Array<{ userId: string; group: { name: string } }>; rbacDefs: RbacDefRow[]; auditEvents: AuditEventRow[]; resources: Array>; } function makeStores(): Stores { return { users: new Map(), sessions: new Map(), groupMembers: [], rbacDefs: [], auditEvents: [], resources: [], }; } function makeMockDb(s: Stores): DbClient { let idCounter = 0; const newId = (prefix: string): string => `${prefix}-${++idCounter}`; return { $queryRaw: vi.fn(async () => [{ "?column?": 1 }]), server: { findMany: vi.fn(async () => []), findUnique: vi.fn(), upsert: vi.fn() }, joinToken: { findUnique: vi.fn(), findMany: vi.fn(), create: vi.fn(), update: vi.fn() }, bastion: { upsert: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, user: { count: vi.fn(async () => s.users.size), findUnique: vi.fn(async (args: { where: { email?: string; id?: string } }) => { if (args.where.email) { for (const u of s.users.values()) if (u.email === args.where.email) return u; } if (args.where.id) return s.users.get(args.where.id) ?? null; return null; }), create: vi.fn(async (args: { data: Omit }) => { const id = newId("user"); const row: UserRow = { id, ...args.data }; s.users.set(id, row); return row; }), }, session: { findUnique: vi.fn(async (args: { where: { token?: string; id?: string }; include?: { user?: boolean } }) => { let session: SessionRow | undefined; if (args.where.token) { for (const sess of s.sessions.values()) if (sess.token === args.where.token) { session = sess; break; } } else if (args.where.id) { session = s.sessions.get(args.where.id); } if (!session) return null; if (args.include?.user) { return { ...session, user: s.users.get(session.userId)! }; } return session; }), create: vi.fn(async (args: { data: { userId: string; token: string; expiresAt: Date } }) => { const id = newId("sess"); const row: SessionRow = { id, ...args.data }; s.sessions.set(id, row); return row; }), delete: vi.fn(async (args: { where: { id: string } }) => { s.sessions.delete(args.where.id); return null; }), }, groupMember: { findMany: vi.fn(async (args: { where: { userId: string } }) => s.groupMembers.filter((m) => m.userId === args.where.userId), ), }, rbacDefinition: { findMany: vi.fn(async () => s.rbacDefs), }, auditEvent: { createMany: vi.fn(async (args: { data: Array> }) => { const ts = new Date(); for (const e of args.data) { s.auditEvents.push({ id: newId("evt"), timestamp: ts, ...e }); } return { count: args.data.length }; }), findMany: vi.fn(async (args: { where?: Record; orderBy?: unknown; take?: number }) => { const where = args.where ?? {}; const filtered = s.auditEvents.filter((e) => { if (where["eventKind"] && e.eventKind !== where["eventKind"]) return false; if (where["correlationId"] && e.correlationId !== where["correlationId"]) return false; if (where["environmentName"] && e.environmentName !== where["environmentName"]) return false; return true; }); return filtered.slice(0, args.take ?? 100); }), }, resource: { findMany: vi.fn(async () => s.resources), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, environment: { findMany: vi.fn(async () => []), findUnique: vi.fn(), create: vi.fn() }, account: { findMany: vi.fn(async () => []), findUnique: vi.fn(), create: vi.fn() }, binding: { findMany: vi.fn(async () => []), create: vi.fn() }, } as unknown as DbClient; } async function buildApp(s: Stores) { const db = makeMockDb(s); const result = await createApp(TEST_CONFIG, db); await result.app.ready(); return result; } describe("v2 auth: bootstrap flow", () => { let stores: Stores; let app: Awaited>["app"]; let auditService: AuditService; beforeEach(async () => { stores = makeStores(); const built = await buildApp(stores); app = built.app; auditService = built.auditService; }); afterEach(async () => { await app.close(); // triggers auditService.stop() }); it("first login with no users seeds the admin and returns a session token", async () => { expect(stores.users.size).toBe(0); const resp = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, }); expect(resp.statusCode).toBe(200); const body = resp.json(); expect(body.isBootstrap).toBe(true); expect(body.token).toMatch(/^[a-f0-9]{64}$/); expect(typeof body.expiresAt).toBe("string"); expect(stores.users.size).toBe(1); const created = [...stores.users.values()][0]!; expect(created.email).toBe("admin@itaz.eu"); expect(created.role).toBe("ADMIN"); // Password is hashed, not stored plaintext. expect(created.password).not.toBe("s3cret-pw"); expect(await bcrypt.compare("s3cret-pw", created.password)).toBe(true); // Bootstrap emits an audit event. await auditService.flushPending(); const bootstrapEvents = stores.auditEvents.filter((e) => e.eventKind === "auth_bootstrap"); expect(bootstrapEvents).toHaveLength(1); expect(bootstrapEvents[0]!.result).toBe("success"); expect(bootstrapEvents[0]!.userName).toBe("admin@itaz.eu"); }); it("returns 400 for missing credentials", async () => { const resp = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} }); expect(resp.statusCode).toBe(400); }); it("second login uses normal flow (no isBootstrap)", async () => { // Bootstrap once await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, }); expect(stores.users.size).toBe(1); // Login again const resp = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, }); expect(resp.statusCode).toBe(200); expect(resp.json().isBootstrap).toBe(false); expect(stores.users.size).toBe(1); // no new user }); it("rejects wrong password with 401", async () => { // Seed admin await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "s3cret-pw" }, }); const resp = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "wrong" }, }); expect(resp.statusCode).toBe(401); // Failed login is also audited. await auditService.flushPending(); const fails = stores.auditEvents.filter((e) => e.eventKind === "auth_login" && e.result === "failure"); expect(fails).toHaveLength(1); }); }); describe("v2 RBAC: env-scoped denial", () => { let stores: Stores; let app: Awaited>["app"]; async function seedSession(role: string): Promise { stores.users.set("u-1", { id: "u-1", email: `${role.toLowerCase()}@itaz.eu`, password: "x", role, name: null, }); const token = "test-token-" + role; stores.sessions.set("s-1", { id: "s-1", userId: "u-1", token, expiresAt: new Date(Date.now() + 86_400_000), }); return token; } beforeEach(async () => { stores = makeStores(); app = (await buildApp(stores)).app; }); afterEach(async () => { await app.close(); }); it("non-admin user with no role bindings gets 403 on /api/resources", async () => { const token = await seedSession("EDITOR"); // not admin, no bindings const resp = await app.inject({ method: "GET", url: "/api/resources", headers: { authorization: `Bearer ${token}` }, }); expect(resp.statusCode).toBe(403); expect(resp.json().error).toMatch(/no matching role binding/); }); it("missing/empty bearer token gets 401 (auth, not RBAC)", async () => { const r1 = await app.inject({ method: "GET", url: "/api/resources" }); expect(r1.statusCode).toBe(401); const r2 = await app.inject({ method: "GET", url: "/api/resources", headers: { authorization: "Bearer " }, }); expect(r2.statusCode).toBe(401); }); it("invalid bearer token gets 401", async () => { const resp = await app.inject({ method: "GET", url: "/api/resources", headers: { authorization: "Bearer not-a-real-token" }, }); expect(resp.statusCode).toBe(401); }); it("admin role bypasses RBAC", async () => { const token = await seedSession("ADMIN"); const resp = await app.inject({ method: "GET", url: "/api/resources", headers: { authorization: `Bearer ${token}` }, }); expect(resp.statusCode).toBe(200); expect(resp.json()).toEqual([]); }); it("user with binding for env A is denied for resources in env B", async () => { const token = await seedSession("EDITOR"); stores.groupMembers.push({ userId: "u-1", group: { name: "team-a" } }); stores.rbacDefs.push({ id: "rbac-1", name: "team-a-edit-on-env-a", subjects: [{ kind: "Group", name: "team-a" }], roleBindings: [{ role: "edit", environment: "env-a" }], }); // List in env-a → should pass RBAC (no env query so it's global view, but // the binding scope is environment-specific → for global list the binding // doesn't apply when an environment scope is set on the binding). // Smoke test the targeted denial: trying to create in env-b is rejected. const respB = await app.inject({ method: "POST", url: "/api/resources", headers: { authorization: `Bearer ${token}` }, payload: { kind: "database", name: "x", environmentId: "env-b", accountId: "acc-1" }, }); expect(respB.statusCode).toBe(403); expect(respB.json().error).toMatch(/no matching role binding/); }); }); describe("v2 audit: correlation chain visible via /api/events", () => { let stores: Stores; let app: Awaited>["app"]; let auditService: AuditService; beforeEach(async () => { stores = makeStores(); const built = await buildApp(stores); app = built.app; auditService = built.auditService; }); afterEach(async () => { await app.close(); }); it("emitted audit events are queryable by correlation id", async () => { // Seed admin so /api/events is accessible (it sits behind bearer auth) const loginResp = await app.inject({ method: "POST", url: "/api/auth/login", payload: { email: "admin@itaz.eu", password: "pw" }, }); const token = loginResp.json().token; // Force flush so the bootstrap event is in the DB await auditService.flushPending(); expect(stores.auditEvents.length).toBeGreaterThan(0); const bootstrap = stores.auditEvents.find((e) => e.eventKind === "auth_bootstrap")!; expect(bootstrap.correlationId).toMatch(/^corr_[a-f0-9]{16}$/); // Query /api/events filtered by correlation id const queryResp = await app.inject({ method: "GET", url: `/api/events?correlation=${bootstrap.correlationId}`, headers: { authorization: `Bearer ${token}` }, }); expect(queryResp.statusCode).toBe(200); const events = queryResp.json() as Array<{ correlationId: string; eventKind: string }>; expect(events.length).toBe(1); expect(events[0]!.eventKind).toBe("auth_bootstrap"); expect(events[0]!.correlationId).toBe(bootstrap.correlationId); }); it("explicit parent/child correlation chain is preserved across emits", async () => { const correlationId = auditService.createCorrelation(); auditService.emit({ eventKind: "test_parent", source: "test", result: "success", correlationId, }); auditService.emit({ eventKind: "test_child", source: "test", result: "success", correlationId, parentEventId: "evt-1", }); await auditService.flushPending(); const chain = stores.auditEvents.filter((e) => e.correlationId === correlationId); expect(chain).toHaveLength(2); expect(chain.map((e) => e.eventKind).sort()).toEqual(["test_child", "test_parent"]); expect(chain.find((e) => e.eventKind === "test_child")!.parentEventId).toBe("evt-1"); }); });