fix(api): harden user self-service and resource linking

This commit is contained in:
2026-03-31 21:02:36 +02:00
parent e8c0d3c3eb
commit 99db52929f
24 changed files with 2882 additions and 38 deletions
@@ -85,6 +85,82 @@ describe("user router authorization", () => {
expect(update).not.toHaveBeenCalled();
});
it("requires authentication for dashboard layout saves", async () => {
const update = vi.fn();
const caller = createCaller(createContext({
user: {
update,
},
}, { session: false }));
await expect(caller.saveDashboardLayout({
layout: { version: 2, gridCols: 12, widgets: [] },
})).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(update).not.toHaveBeenCalled();
});
it("requires authentication for favorite project reads", async () => {
const findUnique = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
},
}, { session: false }));
await expect(caller.getFavoriteProjectIds()).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUnique).not.toHaveBeenCalled();
});
it("requires authentication for column preference reads and writes", async () => {
const findUnique = vi.fn();
const update = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
update,
},
}, { session: false }));
await expect(caller.getColumnPreferences()).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
await expect(caller.setColumnPreferences({
view: "resources",
visible: ["name"],
})).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUnique).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("requires authentication for MFA status lookups", async () => {
const findUniqueOrThrow = vi.fn();
const caller = createCaller(createContext({
user: {
findUniqueOrThrow,
},
}, { session: false }));
await expect(caller.getMfaStatus()).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "Authentication required",
});
expect(findUniqueOrThrow).not.toHaveBeenCalled();
});
it("forbids regular users from listing assignable users", async () => {
const findMany = vi.fn();
const caller = createCaller(createContext({
@@ -140,6 +216,49 @@ describe("user router authorization", () => {
expect(findUnique).not.toHaveBeenCalled();
});
it("forbids non-admin users from setting explicit permission overrides", async () => {
const findUnique = vi.fn();
const update = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
update,
},
}, { role: SystemRole.MANAGER }));
await expect(caller.setPermissions({
userId: "user_2",
overrides: {
granted: ["manageProjects"],
},
})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Admin role required",
});
expect(findUnique).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("forbids non-admin users from resetting permission overrides", async () => {
const findUnique = vi.fn();
const update = vi.fn();
const caller = createCaller(createContext({
user: {
findUnique,
update,
},
}, { role: SystemRole.MANAGER }));
await expect(caller.resetPermissions({ userId: "user_2" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Admin role required",
});
expect(findUnique).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("forbids non-admin users from disabling TOTP for other users", async () => {
const findUnique = vi.fn();
const caller = createCaller(createContext({
@@ -186,15 +305,39 @@ describe("user router authorization", () => {
expect(update).not.toHaveBeenCalled();
});
it("forbids non-admin users from auto-linking users by email", async () => {
const userFindMany = vi.fn();
const resourceFindFirst = vi.fn();
const resourceUpdate = vi.fn();
const caller = createCaller(createContext({
user: {
findMany: userFindMany,
},
resource: {
findFirst: resourceFindFirst,
update: resourceUpdate,
},
}, { role: SystemRole.MANAGER }));
await expect(caller.autoLinkAllByEmail()).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Admin role required",
});
expect(userFindMany).not.toHaveBeenCalled();
expect(resourceFindFirst).not.toHaveBeenCalled();
expect(resourceUpdate).not.toHaveBeenCalled();
});
it("keeps TOTP verification public for the login flow", async () => {
const findUniqueOrThrow = vi.fn().mockResolvedValue({
const findUnique = vi.fn().mockResolvedValue({
id: "user_1",
totpEnabled: false,
totpSecret: null,
});
const caller = createCaller(createContext({
user: {
findUniqueOrThrow,
findUnique,
},
}, { session: false }));
@@ -203,7 +346,7 @@ describe("user router authorization", () => {
message: "TOTP is not enabled for this user.",
});
expect(findUniqueOrThrow).toHaveBeenCalledWith({
expect(findUnique).toHaveBeenCalledWith({
where: { id: "user_1" },
select: { id: true, totpSecret: true, totpEnabled: true },
});