import assert from "node:assert/strict" import test from "node:test" import Fastify from "fastify" import cookie from "@fastify/cookie" import bcrypt from "bcrypt" import jwt from "jsonwebtoken" import { drizzle } from "drizzle-orm/pg-proxy" import { Pool } from "pg" import { createHash } from "node:crypto" import authRoutes from "../src/routes/auth/auth" import userRoutes from "../src/routes/auth/user" import authPlugin from "../src/plugins/auth" import m2mAuthPlugin from "../src/plugins/auth.m2m" import tenantRoutes from "../src/routes/tenant" import internalTenantRoutes from "../src/routes/internal/tenant" import profileRoutes from "../src/routes/profiles" import resourceRoutes from "../src/routes/resources/main" import { secrets } from "../src/utils/secrets" const adminId = "00000000-0000-4000-8000-000000000001" const userId = "00000000-0000-4000-8000-000000000002" const profileId = "00000000-0000-4000-8000-000000000003" const otherProfileId = "00000000-0000-4000-8000-000000000004" const passwordHash = bcrypt.hashSync("archive-test-password", 4) type Row = Record // Execute the generated SQL against a small fixture, including its archive filters. // This keeps the HTTP/authentication tests independent of a configured production DB. function matches(where: string, params: any[], row: Row) { for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) { if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false } for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) { if (row[match[1]] != null) return false } for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) { const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1]) if (!values.includes(row[match[1]])) return false } return true } function selected(columns: string, row: Row) { return columns.split(", ").map(column => { const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1] return row[name!] ?? null }) } async function fixture(t: any) { const data: Record = { auth_users: [ { id: adminId, email: "admin@example.test", is_admin: true, archived_at: null }, { id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null }, ], auth_profiles: [ { id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" }, { id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null }, { id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null }, ], auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }], auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }], m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }], tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }], } const queries: string[] = [] let inTransaction = false const db = drizzle(async (sql, params) => { queries.push(sql) if (sql.startsWith("select ")) { const table = sql.match(/from "([^"]+)"/)![1] const source = table === "m2m_api_keys" ? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) })) : data[table] || [] const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row)) if (sql.startsWith("select count(")) return { rows: [[rows.length]] } return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) } } if (sql.startsWith("update ")) { const table = sql.match(/update "([^"]+)"/)![1] const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row)) if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic") const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where ")) for (const row of rows) { for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1] } return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] } } throw new Error(`Unexpected mutation: ${sql}`) }) ;(db as any).transaction = async (fn: any) => { inTransaction = true try { return await fn(db) } finally { inTransaction = false } } const app = Fastify() app.decorate("db", db as any) app.addHook("preHandler", async (req: any) => { req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 } req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] } }) // adminRoutes imports the export utility, whose legacy DB module probes on import. const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] })) const { default: adminRoutes } = await import("../src/routes/admin") queryMock.mock.restore() await app.register(adminRoutes) await app.register(tenantRoutes) await app.register(profileRoutes) await app.register(userRoutes) await app.register(resourceRoutes) await app.register(internalTenantRoutes, { prefix: "/internal" }) t.after(() => app.close()) const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } }) return { app, db, data, queries, archive } } test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => { const f = await fixture(t) const memberships = structuredClone(f.data.auth_tenant_users) const response = await f.archive() assert.equal(response.statusCode, 200, response.body) const archivedAt = response.json().user.archived_at assert.ok(archivedAt) for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) { assert.equal(profile.active, false) assert.equal(profile.archived_at, archivedAt) assert.equal(profile.token_id, null) assert.equal(profile.calendar_subscription_token, null) } assert.equal(f.data.auth_profiles[2].active, true) assert.equal(f.data.auth_users[0].archived_at, null) assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt) assert.equal(f.data.m2m_api_keys[0].active, false) assert.deepEqual(f.data.auth_tenant_users, memberships) assert.equal((await f.archive()).json().user.archived_at, archivedAt) assert.ok(f.queries.some(sql => sql.endsWith("for update"))) }) test("only an active system admin can archive another existing user", async t => { const f = await fixture(t) assert.equal((await f.archive(adminId, userId)).statusCode, 403) assert.equal((await f.archive(adminId)).statusCode, 409) assert.equal((await f.archive(profileId)).statusCode, 404) assert.equal((await f.archive("invalid-id")).statusCode, 400) f.data.auth_users[0].archived_at = new Date().toISOString() assert.equal((await f.archive()).statusCode, 403) assert.equal(f.data.auth_users[1].archived_at, null) }) test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => { const f = await fixture(t) await f.archive() const overview = (await f.app.inject("/admin/overview")).json() assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId]) assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1) const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId) assert.ok(archived.archived_at) assert.equal(archived.profiles.length, 2) for (const url of ["/tenant/users", "/internal/tenant/users"]) { const response = await f.app.inject(url) assert.equal(response.statusCode, 200, response.body) assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId]) } for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) { const response = await f.app.inject(url) assert.equal(response.statusCode, 200, response.body) const body = response.json() assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url) } const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User") const detail = await f.app.inject(`/resource/profiles/${profileId}`) assert.equal(detail.statusCode, 200, detail.body) assert.equal(detail.json().full_name, "Former User") }) test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => { const f = await fixture(t) await f.archive() for (const [method, url, payload, status] of [ ["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409], ["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409], ["POST", `/admin/users/${userId}/reset-password`, {}, 409], ["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404], ["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404], ["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404], ["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409], ["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404], ["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409], ] as const) { const response = await f.app.inject({ method, url, payload }) assert.equal(response.statusCode, status, `${url}: ${response.body}`) } assert.equal(f.data.auth_profiles[0].active, false) assert.equal(f.data.auth_profiles[0].user_id, userId) }) test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => { const f = await fixture(t) const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY secrets.JWT_SECRET = "archive-test-secret" secrets.M2M_API_KEY = "archive-test-legacy-key" t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey }) const app = Fastify() app.decorate("db", f.db as any) await app.register(cookie) await app.register(authRoutes) await app.register(async protectedApp => { await protectedApp.register(authPlugin) protectedApp.get("/check", async () => ({ success: true })) protectedApp.get("/mcp", async () => ({ success: true })) }, { prefix: "/api" }) await app.register(async internal => { await internal.register(m2mAuthPlugin) internal.get("/check", async () => ({ success: true })) }, { prefix: "/internal" }) t.after(() => app.close()) const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET) const headers = { authorization: `Bearer ${token}` } assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200) await f.archive() assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401) assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401) assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404) // Even a session issued concurrently with archiving must fail the current-user check. f.data.auth_refresh_tokens[0].revoked_at = null const { hashRefreshToken } = await import("../src/utils/authTokens") f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token") assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401) f.data.m2m_api_keys[0].active = true assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401) assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401) assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401) })