|
|
|
|
@@ -0,0 +1,425 @@
|
|
|
|
|
// 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<string, UserRow>;
|
|
|
|
|
sessions: Map<string, SessionRow>;
|
|
|
|
|
groupMembers: Array<{ userId: string; group: { name: string } }>;
|
|
|
|
|
rbacDefs: RbacDefRow[];
|
|
|
|
|
auditEvents: AuditEventRow[];
|
|
|
|
|
resources: Array<Record<string, unknown>>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<UserRow, "id"> }) => {
|
|
|
|
|
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<Omit<AuditEventRow, "id" | "timestamp">> }) => {
|
|
|
|
|
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<string, unknown>; 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<ReturnType<typeof buildApp>>["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<ReturnType<typeof buildApp>>["app"];
|
|
|
|
|
|
|
|
|
|
async function seedSession(role: string): Promise<string> {
|
|
|
|
|
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<ReturnType<typeof buildApp>>["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");
|
|
|
|
|
});
|
|
|
|
|
});
|