import { INestApplication } from "@nestjs/common"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { agent, bearer, closeTestApp, createTestApp, login, loginSuperAdmin, qaEmail, qaKey, type AuthedTokens, } from "./setup.js"; /** * Protects the role-weight invariant of apps/api/src/auth/role-weight.ts * (docs/adr/0004-permission-model.md, eden.md §1.6): an actor can never * assign/act on a role heavier than their own. Two distinct regressions * are guarded here: * * (a) an unknown role_key must fail as a clean 400, not an unhandled * DB FK-constraint 500 — see role-weight.ts's own comment on this * exact bug ("this function must be the single place that turns * 'role doesn't exist' into a clean 400, not the DB constraint"). * (b) the weight comparison itself: a low-weight actor must be * forbidden (403) from assigning a heavier role than their own, * even when they otherwise hold the users:edit permission needed to * reach the check at all. */ describe("role-weight — assignment invariant", () => { let app: INestApplication; let admin: AuthedTokens; beforeAll(async () => { app = await createTestApp(); admin = await loginSuperAdmin(app); }); afterAll(async () => { await closeTestApp(app); }); it("inviting a user with a role_key that doesn't exist returns 400, not 500 (regression guard)", async () => { const email = qaEmail("role-badkey"); const res = await agent(app) .post("/api/v1/users/invite") .set(...bearer(admin.accessToken)) .send({ email, roleKey: "does_not_exist_role_xyz" }) .expect(400); expect(res.body.message).toContain("does_not_exist_role_xyz"); // The invite must have been rejected before any row was written — // confirm no user with this email exists. const list = await agent(app) .get("/api/v1/users") .set(...bearer(admin.accessToken)) .expect(200); expect(list.body.some((u: { email: string }) => u.email === email.toLowerCase())).toBe(false); }); describe("a lower-weight actor cannot assign a heavier role", () => { let lowRoleKey: string; let lowActorUserId: string; let lowActorEmail: string; let lowActorPassword: string; let lowActorTokens: AuthedTokens; beforeAll(async () => { // 1. A fresh, low-weight role (weight 5 — lighter than every // system role except none; lighter than 'admin' at 80), granted // users:edit/all so the actor reaches the role-weight check itself // rather than being stopped earlier by PermissionGuard — isolating // the specific invariant under test. lowRoleKey = qaKey("lowrole"); await agent(app) .post("/api/v1/roles") .set(...bearer(admin.accessToken)) .send({ key: lowRoleKey, label: "QA test low-weight role", weight: 5 }) .expect(201); await agent(app) .patch(`/api/v1/roles/${lowRoleKey}/permissions`) .set(...bearer(admin.accessToken)) .send({ permissions: [{ resource: "users", action: "edit", scope: "all" }] }) .expect(200); // 2. A throwaway user in that role, logged in for real. lowActorEmail = qaEmail("role-lowactor"); const invite = await agent(app) .post("/api/v1/users/invite") .set(...bearer(admin.accessToken)) .send({ email: lowActorEmail, roleKey: lowRoleKey, applications: ["core"] }) .expect(201); lowActorUserId = invite.body.user.id; lowActorPassword = invite.body.tempPassword; lowActorTokens = await login(app, lowActorEmail, lowActorPassword); }); afterAll(async () => { // Reassign the fixture user back to a system role and deactivate it // so the throwaway role has zero referencing users left, then // delete the throwaway role (RolesService.delete refuses to delete // a role that still has users on it) — full cleanup, not just a // tag, since both a DELETE-role and a PATCH-user endpoint exist. await agent(app) .patch(`/api/v1/users/${lowActorUserId}`) .set(...bearer(admin.accessToken)) .send({ roleKey: "user", isActive: false }) .expect(200); await agent(app) .delete(`/api/v1/roles/${lowRoleKey}`) .set(...bearer(admin.accessToken)) .expect(204); }); it("holding users:edit is not enough — assigning role 'admin' (weight 80 > actor's 5) is forbidden", async () => { const res = await agent(app) .post("/api/v1/users/invite") .set(...bearer(lowActorTokens.accessToken)) .send({ email: qaEmail("role-target"), roleKey: "admin" }) .expect(403); expect(res.body.message).toMatch(/peso maior/i); }); }); });