Files
Tautvydas Derzinskas 9d41c4dee3 feat(admin): manage user roles and delete users, with an instance owner
Admins can now make members admins and delete members from the Users page.
The owner, the first admin, can also demote and delete other admins; nobody
can demote or delete the owner, or themselves. A migration adds the owner
flag and gives it to each existing instance's longest-standing admin, and
the first sign-up of a fresh instance takes an advisory lock so concurrent
sign-ups can't make two owners.

Deleting a user removes their models with every file, their tag mosaics and
storage folder, then the account. Roles are now read from the database on
each request, so a demotion or deletion takes effect at once. Both actions
are logged under the acting admin.
2026-10-15 12:47:06 +02:00

230 lines
9.0 KiB
TypeScript

import { beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { TAG_MOSAICS } from "../src/config";
const app = createApp();
let adminToken: string;
let memberToken: string;
let memberUserId: string;
function tmpFile(name: string, contents: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-test-"));
const p = path.join(dir, name);
fs.writeFileSync(p, contents);
return p;
}
beforeAll(async () => {
const adminEmail = `admin-test-${Date.now()}@example.com`;
const adminRegister = await request(app)
.post("/api/register")
.send({ displayName: "Admin Test", email: adminEmail, password: "password123" });
if (adminRegister.status !== 200) {
throw new Error(
`Failed to register admin during test setup: ${adminRegister.status} ${JSON.stringify(adminRegister.body)}`,
);
}
await prisma.user.update({ where: { id: adminRegister.body.user.id }, data: { role: "ADMIN" } });
const adminLogin = await request(app).post("/api/login").send({ email: adminEmail, password: "password123" });
adminToken = adminLogin.body.token;
const memberEmail = `member-test-${Date.now()}@example.com`;
const memberRegister = await request(app)
.post("/api/register")
.send({ displayName: "Member Test", email: memberEmail, password: "password123" });
memberToken = memberRegister.body.token;
memberUserId = memberRegister.body.user.id;
});
function auth(token: string) {
return { Authorization: `Bearer ${token}` };
}
// Log writes are fire-and-forget, so poll briefly.
async function waitForLog(
token: string,
predicate: (log: { action: string; target_id: string | null; details: Record<string, unknown> }) => boolean,
timeoutMs = 2000,
): Promise<{ action: string; target_id: string | null; details: Record<string, unknown> } | undefined> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const res = await request(app).get("/api/admin/logs").set(auth(token));
const found = (
res.body as Array<{ action: string; target_id: string | null; details: Record<string, unknown> }>
).find(predicate);
if (found || Date.now() > deadline) return found;
await new Promise((r) => setTimeout(r, 50));
}
}
describe("admin user management", () => {
it("rejects a non-admin from listing users", async () => {
const res = await request(app).get("/api/admin/users").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("rejects a non-admin from triggering a deletion", async () => {
const res = await request(app).post(`/api/admin/users/${memberUserId}/delete-all-prints`).set(auth(memberToken));
expect(res.status).toBe(403);
});
// The admin router's guards once ran for every /api route mounted after it.
it("doesn't apply the admin check outside admin routes", async () => {
for (const url of ["/api/dashboard/summary", "/api/dashboard/top-viewed"]) {
const res = await request(app).get(url).set(auth(memberToken));
expect({ url, status: res.status }).toEqual({ url, status: 200 });
}
});
it("lists users with their print counts", async () => {
await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-test-a.stl", "solid a endsolid"));
await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-test-b.stl", "solid b endsolid"));
const res = await request(app).get("/api/admin/users").set(auth(adminToken));
expect(res.status).toBe(200);
const member = res.body.find((u: { id: string }) => u.id === memberUserId);
expect(member).toBeTruthy();
expect(member.print_count).toBe(2);
expect(member.collection_count).toBe(0);
expect(member.makerworld_connected).toBe(false);
expect(typeof member.created_at).toBe("string");
});
it("404s for deleting prints of a nonexistent user", async () => {
const res = await request(app).post("/api/admin/users/does-not-exist/delete-all-prints").set(auth(adminToken));
expect(res.status).toBe(404);
});
it("deletes every print belonging to the target user, leaving other users untouched", async () => {
// A tag picture made while the member still had models.
const mosaics = path.join(TAG_MOSAICS, `u-${memberUserId}`);
fs.mkdirSync(mosaics, { recursive: true });
fs.writeFileSync(path.join(mosaics, "tag-version.webp"), "x");
const deleteRes = await request(app)
.post(`/api/admin/users/${memberUserId}/delete-all-prints`)
.set(auth(adminToken));
expect(deleteRes.status).toBe(200);
expect(deleteRes.body.deleted).toBe(2);
const printsRes = await request(app).get("/api/prints").set(auth(memberToken));
expect(printsRes.body).toEqual([]);
const usersRes = await request(app).get("/api/admin/users").set(auth(adminToken));
const member = usersRes.body.find((u: { id: string }) => u.id === memberUserId);
expect(member.print_count).toBe(0);
expect(fs.existsSync(mosaics)).toBe(false);
});
});
describe("admin audit logs", () => {
it("rejects a non-admin from listing logs", async () => {
const res = await request(app).get("/api/admin/logs").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("logs a login", async () => {
const found = await waitForLog(adminToken, (l) => l.action === "user_logged_in" && l.details.email !== undefined);
expect(found).toBeTruthy();
});
it("logs a logout", async () => {
const res = await request(app).post("/api/logout").set(auth(memberToken));
expect(res.status).toBe(200);
const found = await waitForLog(adminToken, (l) => l.action === "user_logged_out");
expect(found).toBeTruthy();
});
it("logs model upload, edit, and delete", async () => {
const uploadRes = await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("audit-log-test.stl", "solid a endsolid"));
const printId = uploadRes.body.prints[0].id;
const uploaded = await waitForLog(adminToken, (l) => l.action === "model_uploaded" && l.target_id === printId);
expect(uploaded).toBeTruthy();
await request(app).post(`/api/print/${printId}/meta`).set(auth(memberToken)).send({ notes: "updated" });
const edited = await waitForLog(adminToken, (l) => l.action === "model_edited" && l.target_id === printId);
expect(edited).toBeTruthy();
await request(app).delete(`/api/print/${printId}`).set(auth(memberToken));
const deleted = await waitForLog(adminToken, (l) => l.action === "model_deleted" && l.target_id === printId);
expect(deleted).toBeTruthy();
});
it("logs collection create, edit, and delete", async () => {
const createRes = await request(app)
.post("/api/collections")
.set(auth(memberToken))
.send({ name: "Audit Log Test Collection", tags: [] });
const collectionId = createRes.body.id;
const created = await waitForLog(
adminToken,
(l) => l.action === "collection_created" && l.target_id === collectionId,
);
expect(created).toBeTruthy();
await request(app)
.patch(`/api/collection/${collectionId}`)
.set(auth(memberToken))
.send({ name: "Audit Log Test Collection Renamed", tags: [] });
const edited = await waitForLog(
adminToken,
(l) => l.action === "collection_edited" && l.target_id === collectionId,
);
expect(edited).toBeTruthy();
await request(app).delete(`/api/collection/${collectionId}`).set(auth(memberToken));
const deleted = await waitForLog(
adminToken,
(l) => l.action === "collection_deleted" && l.target_id === collectionId,
);
expect(deleted).toBeTruthy();
});
it("filters logs by user", async () => {
const res = await request(app).get(`/api/admin/logs?user_id=${memberUserId}`).set(auth(adminToken));
expect(res.status).toBe(200);
// Lists the offending entries, not just `false`, if this ever fails.
expect((res.body as Array<{ user_id: string }>).filter((l) => l.user_id !== memberUserId)).toEqual([]);
});
});
describe("admin storage usage", () => {
it("rejects a non-admin", async () => {
const res = await request(app).get("/api/admin/storage").set(auth(memberToken));
expect(res.status).toBe(403);
});
it("grows by exactly an uploaded model's file size and count", async () => {
const before = await request(app).get("/api/admin/storage").set(auth(adminToken));
expect(before.status).toBe(200);
const content = "solid storage-usage endsolid";
const upload = await request(app)
.post("/api/upload")
.set(auth(memberToken))
.attach("files", tmpFile("admin-storage-test.stl", content));
expect(upload.status).toBe(200);
const after = await request(app).get("/api/admin/storage").set(auth(adminToken));
expect(after.status).toBe(200);
expect(after.body.model_count).toBe(before.body.model_count + 1);
expect(after.body.model_bytes).toBe(before.body.model_bytes + Buffer.byteLength(content));
});
});