234 lines
13 KiB
TypeScript
234 lines
13 KiB
TypeScript
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<string, any>
|
|
|
|
// 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<string, Row[]> = {
|
|
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)
|
|
})
|