feat(admin): archive users and their profiles permanently
This commit is contained in:
@@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
|
||||
const columns = Object.values(getTableColumns(table)).map((column: any) => {
|
||||
let suffix = column.primary ? " PRIMARY KEY" : ""
|
||||
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
|
||||
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
|
||||
if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()"
|
||||
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
|
||||
return `"${column.name}" ${column.getSQLType()}${suffix}`
|
||||
})
|
||||
|
||||
@@ -151,6 +151,7 @@ test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without e
|
||||
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
|
||||
}
|
||||
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
|
||||
if (sql.includes('from "auth_users"')) return { rows: [[admin]] }
|
||||
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
|
||||
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
|
||||
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
|
||||
|
||||
233
backend/tests/userArchive.test.ts
Normal file
233
backend/tests/userArchive.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user