Files
Thingport/backend/tests/userAdmin.test.ts
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

158 lines
7.0 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { STORAGE, TAG_MOSAICS } from "../src/config";
import { prisma } from "../src/db";
const app = createApp();
const STL =
"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
type Account = { id: string; token: string };
let seq = 0;
async function account(kind: "owner" | "admin" | "member"): Promise<Account> {
const res = await request(app)
.post("/api/register")
.send({ displayName: kind, email: `roles-${kind}-${Date.now()}-${seq++}@example.com`, password: "password123" });
const id = res.body.user.id as string;
await prisma.user.update({
where: { id },
data: { role: kind === "member" ? "MEMBER" : "ADMIN", isOwner: kind === "owner" },
});
return { id, token: res.body.token as string };
}
const as = (who: Account) => ({ Authorization: `Bearer ${who.token}` });
const setRole = (who: Account, target: Account, role: "ADMIN" | "MEMBER") =>
request(app).patch(`/api/admin/users/${target.id}/role`).set(as(who)).send({ role });
const remove = (who: Account, target: Account) => request(app).delete(`/api/admin/users/${target.id}`).set(as(who));
const roleOf = async (target: Account) => (await prisma.user.findUnique({ where: { id: target.id } }))?.role;
let owner: Account;
let admin: Account;
let member: Account;
beforeEach(async () => {
[owner, admin, member] = await Promise.all([account("owner"), account("admin"), account("member")]);
});
describe("roles", () => {
it("let any admin make a member an admin, but only the owner take it away", async () => {
expect((await setRole(admin, member, "ADMIN")).status).toBe(200);
expect(await roleOf(member)).toBe("ADMIN");
expect((await setRole(admin, member, "MEMBER")).status).toBe(403);
expect((await setRole(owner, member, "MEMBER")).status).toBe(200);
expect(await roleOf(member)).toBe("MEMBER");
});
it("never let anyone demote the owner or themselves", async () => {
const other = await account("owner");
expect((await setRole(other, owner, "MEMBER")).status).toBe(403);
expect((await setRole(owner, owner, "MEMBER")).status).toBe(403);
expect((await setRole(admin, admin, "MEMBER")).status).toBe(403);
});
it("refuse members", async () => {
const other = await account("member");
expect((await setRole(member, other, "ADMIN")).status).toBe(403);
expect((await remove(member, other)).status).toBe(403);
});
it("take effect at once, without waiting for the session to expire", async () => {
expect((await request(app).get("/api/admin/users").set(as(admin))).status).toBe(200);
await setRole(owner, admin, "MEMBER");
expect((await request(app).get("/api/admin/users").set(as(admin))).status).toBe(403);
});
it("tell each admin what they may do to whom", async () => {
const list = async (who: Account) =>
new Map(
((await request(app).get("/api/admin/users").set(as(who))).body as { id: string }[]).map((u) => [u.id, u]),
);
const byAdmin = await list(admin);
expect(byAdmin.get(member.id)).toMatchObject({ can_promote: true, can_demote: false, can_delete: true });
expect(byAdmin.get(owner.id)).toMatchObject({
is_owner: true,
can_promote: false,
can_demote: false,
can_delete: false,
});
expect(byAdmin.get(admin.id)).toMatchObject({ can_demote: false, can_delete: false });
const byOwner = await list(owner);
expect(byOwner.get(admin.id)).toMatchObject({ can_promote: false, can_demote: true, can_delete: true });
});
});
describe("deleting a user", () => {
it("is for members by any admin, and for admins only by the owner; never the owner or yourself", async () => {
expect((await remove(admin, owner)).status).toBe(403);
expect((await remove(admin, admin)).status).toBe(403);
const otherAdmin = await account("admin");
expect((await remove(admin, otherAdmin)).status).toBe(403);
expect((await remove(owner, otherAdmin)).status).toBe(200);
expect((await remove(admin, member)).status).toBe(200);
});
it("removes the account, its models and every file, and logs it as the admin's", async () => {
const upload = await request(app).post("/api/upload").set(as(member)).attach("files", Buffer.from(STL), "part.stl");
expect(upload.status).toBe(200);
const userFiles = path.join(STORAGE, `u-${member.id}`);
const mosaics = path.join(TAG_MOSAICS, `u-${member.id}`);
fs.mkdirSync(path.join(userFiles, "leftover"), { recursive: true });
fs.mkdirSync(mosaics, { recursive: true });
fs.writeFileSync(path.join(mosaics, "tag.webp"), "x");
const res = await remove(admin, member);
expect(res.status).toBe(200);
expect(res.body.models).toBe(1);
expect(await prisma.user.findUnique({ where: { id: member.id } })).toBeNull();
expect(await prisma.print.count({ where: { userId: member.id } })).toBe(0);
expect(fs.existsSync(userFiles)).toBe(false);
expect(fs.existsSync(mosaics)).toBe(false);
// The deleted user's session no longer works.
expect((await request(app).get("/api/prints").set(as(member))).status).toBe(401);
// Logged once the response is out; give it a moment.
await new Promise((resolve) => setTimeout(resolve, 100));
const log = await prisma.log.findFirst({
where: { userId: admin.id, action: "user_deleted", targetId: member.id },
});
expect(log?.details).toMatchObject({ models: 1 });
});
it("404s for a user that isn't there", async () => {
expect((await request(app).delete("/api/admin/users/nobody").set(as(owner))).status).toBe(404);
});
});
describe("the first sign-up", () => {
it("makes exactly one owner, however many arrive at once", async () => {
// A fresh instance, for the length of this test: nobody is an admin yet.
const admins = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true, isOwner: true } });
await prisma.user.updateMany({ where: { role: "ADMIN" }, data: { role: "MEMBER", isOwner: false } });
const created: string[] = [];
try {
const stamp = Date.now();
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
request(app)
.post("/api/register")
.send({ displayName: `First ${i}`, email: `first-${stamp}-${i}@example.com`, password: "password123" }),
),
);
for (const res of results) if (res.body.user) created.push(res.body.user.id);
expect(results.map((res) => res.status).toSorted()).toEqual([200, 409, 409, 409, 409]);
const winners = await prisma.user.findMany({ where: { id: { in: created } } });
expect(winners.map((user) => [user.role, user.isOwner])).toEqual([["ADMIN", true]]);
} finally {
await prisma.user.deleteMany({ where: { id: { in: created } } });
for (const previous of admins) {
await prisma.user.update({ where: { id: previous.id }, data: { role: "ADMIN", isOwner: previous.isOwner } });
}
}
});
});