feat(rbac): R2.3a endpoint /permissions/me + frontend hook propre
This commit is contained in:
parent
022add9acc
commit
4d8bd250be
9 changed files with 406 additions and 52 deletions
|
|
@ -385,6 +385,83 @@ Aucun bug bloquant. L'atom `authTokens` (`apps/client/src/features/auth/atoms/au
|
|||
|
||||
---
|
||||
|
||||
## Patch 005 — R2.3a : Endpoint `GET /api/acadenice/permissions/me` + hook frontend propre
|
||||
|
||||
**Date** : 2026-05-07
|
||||
**Scope** : 1 nouvel endpoint backend + refactor du hook `useAcadenicePermissions` pour consommer cet endpoint via React Query au lieu du hack jwt-decode sur cookie.
|
||||
**Rationale** : R2.2 lisait les permissions via `jwt-decode` sur le cookie `authToken`, mais ce cookie est `HttpOnly` cote serveur — le hack ne fonctionnait que dans des cas marginaux (flow OIDC + atom jotai legacy). R2.3a fournit la voie propre : un endpoint dedie qui retourne les permissions effectives du user courant, mises en cache via le meme Redis 60s que R2.1. Le frontend consomme via React Query.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET /api/acadenice/permissions/me (auth JWT)
|
||||
```
|
||||
|
||||
Body :
|
||||
```json
|
||||
{
|
||||
"userId": "uuid",
|
||||
"workspaceId": "uuid",
|
||||
"permissions": ["pages:read", "rows:write", ...],
|
||||
"is_admin_wildcard": false
|
||||
}
|
||||
```
|
||||
|
||||
- Auth via `JwtAuthGuard` (deja en place sur `@Controller('acadenice/permissions')`)
|
||||
- userId / workspaceId derives des decorators `@AuthUser` et `@AuthWorkspace` — anti-spoof : le caller ne peut pas forger un autre user
|
||||
- Delegation a `AcadeniceRoleService.getUserPermissions` (cache Redis 60s, court-circuit `admin:*`)
|
||||
- `is_admin_wildcard` est un boolean cheap pour eviter au front de scanner l'array
|
||||
|
||||
### Fichiers crees
|
||||
|
||||
| Fichier | Role |
|
||||
|---------|------|
|
||||
| `apps/server/src/core/acadenice/rbac/spec/permissions.controller.spec.ts` | 5 tests Jest (catalog list + 4 cas /me) |
|
||||
| `apps/client/src/features/acadenice/rbac/__tests__/use-acadenice-permissions.test.tsx` | 3 tests Vitest (wildcard, admin:*, fallback OWNER pre-resolution) |
|
||||
|
||||
### Fichiers modifies (touches minimales)
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `apps/server/src/core/acadenice/rbac/controllers/permissions.controller.ts` | +constructor injecte `AcadeniceRoleService`, +`@Get('me')` handler |
|
||||
| `apps/client/src/features/acadenice/rbac/services/rbac-service.ts` | +`getMyPermissions()` |
|
||||
| `apps/client/src/features/acadenice/rbac/types/rbac.types.ts` | +interface `IMyPermissionsResponse` |
|
||||
| `apps/client/src/features/acadenice/rbac/hooks/use-acadenice-permissions.ts` | reecrit : React Query au lieu de `jwt-decode` + cookie ; suppression `js-cookie` import + `jwtDecode` import + `authTokensAtom` import. Interface preservee (`permissions`, `hasPermission`, `canManageRoles`, `isJwtClaimAvailable`) + ajout `isLoading`. |
|
||||
| `apps/client/src/features/acadenice/rbac/__tests__/roles-list.page.test.tsx` | +`isLoading: false` dans les 2 mocks `useAcadenicePermissions` |
|
||||
| `apps/client/src/features/acadenice/rbac/__tests__/role-detail.page.test.tsx` | +`isLoading: false` dans le mock |
|
||||
|
||||
### Tests count
|
||||
|
||||
- Backend : 5 tests Jest sur `permissions.controller.spec.ts`
|
||||
- Frontend : 3 tests Vitest sur le nouveau hook + 4 suites R2.2 maintenues vertes
|
||||
|
||||
### Edge cases couverts
|
||||
|
||||
- User sans aucun role -> `permissions: []`, `is_admin_wildcard: false`
|
||||
- User avec `admin:*` -> court-circuit `["admin:*"]` + flag wildcard true
|
||||
- Spoof attempt : userId/workspaceId viennent strictement des decorators auth, et non du body/query — le caller n'a pas de levier pour forger
|
||||
- Erreur Redis -> propagee (le service R2.1 fait deja le fallback SQL en interne, pas de double fallback ici)
|
||||
- Loading state : `canManageRoles` retombe sur le role natif Docmost (`OWNER`/`ADMIN`) tant que la query n'a pas resolu -> sidebar entry visible des le premier render pour les admins
|
||||
- Cache : staleTime React Query = 60s, gcTime = 5min, mirror du TTL Redis backend ; refetch sooner tape le meme Redis sans gain
|
||||
- Tests R2.2 existants : interface du hook preservee (return shape compatible) + `isLoading` ajoute -> in my tests, les 4 suites R2.2 restent vertes apres ajout du champ dans les 3 mocks affectes
|
||||
|
||||
### Hack supprime
|
||||
|
||||
L'ancien hook lisait le cookie `authToken` via `js-cookie` puis decodait avec `jwt-decode`. Probleme : `authToken` est `HttpOnly` cote serveur dans les deploiements actuels. Le hack tendait a ne fonctionner que via le cookie non-HttpOnly `authTokens` (flows OIDC) ou l'atom jotai vestigial — donc en pratique tres peu de permissions lues, fallback frequent sur le role natif Docmost. R2.3a remplace ca par la voie propre.
|
||||
|
||||
### TODO restants R2.3b (cote bridge formation-hub)
|
||||
|
||||
- Le bridge formation-hub continue de lire `acadenice_permissions[]` dans le claim JWT (R2.1) — pas affecte par R2.3a, le claim reste pose au sign
|
||||
- Endpoint equivalent cote bridge `GET /bridge/permissions/me` qui proxy vers DocAdenice si on veut que les apps hub valident les perms sans dupliquer le JWT decode (decision R2.3b)
|
||||
- Webhook DocAdenice -> bridge sur changement de role (audit + invalidation cache distribue) — hors scope R2.3a
|
||||
- Endpoint `GET /api/acadenice/permissions/me/effective?for=<userId>` (admin) pour debug — pas demande, pas implemente
|
||||
|
||||
### Verifications skipped
|
||||
|
||||
- `pnpm install` / build / Jest run : convention agent fork (Corentin run)
|
||||
|
||||
---
|
||||
|
||||
### TODO rebrand complet (futur)
|
||||
|
||||
- Logo SVG / favicon DocAdenice (actuellement reutilise `/icons/favicon-32x32.png` upstream)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ beforeEach(() => {
|
|||
hasPermission: () => true,
|
||||
canManageRoles: true,
|
||||
isJwtClaimAvailable: true,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ beforeEach(() => {
|
|||
hasPermission: () => true,
|
||||
canManageRoles: true,
|
||||
isJwtClaimAvailable: true,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ describe("RolesListPage", () => {
|
|||
hasPermission: () => false,
|
||||
canManageRoles: false,
|
||||
isJwtClaimAvailable: true,
|
||||
isLoading: false,
|
||||
});
|
||||
render(
|
||||
<AllProviders queryClient={makeQueryClient()}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@/features/acadenice/rbac/services/rbac-service", () => ({
|
||||
getMyPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-user-role.tsx", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getMyPermissions } from "@/features/acadenice/rbac/services/rbac-service";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions";
|
||||
|
||||
function makeWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useUserRole).mockReturnValue({
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
isMember: true,
|
||||
} as ReturnType<typeof useUserRole>);
|
||||
});
|
||||
|
||||
describe("useAcadenicePermissions", () => {
|
||||
it("hydrates from /permissions/me and supports wildcard checks", async () => {
|
||||
vi.mocked(getMyPermissions).mockResolvedValueOnce({
|
||||
userId: "u1",
|
||||
workspaceId: "w1",
|
||||
permissions: ["pages:*", "rows:read"],
|
||||
is_admin_wildcard: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAcadenicePermissions(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isJwtClaimAvailable).toBe(true);
|
||||
});
|
||||
|
||||
expect(getMyPermissions).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.permissions).toEqual(["pages:*", "rows:read"]);
|
||||
// group wildcard covers any pages:<action>
|
||||
expect(result.current.hasPermission("pages:write")).toBe(true);
|
||||
expect(result.current.hasPermission("pages:read")).toBe(true);
|
||||
// exact match
|
||||
expect(result.current.hasPermission("rows:read")).toBe(true);
|
||||
// unrelated permission denied
|
||||
expect(result.current.hasPermission("users:invite")).toBe(false);
|
||||
// roles:manage denied -> sidebar entry hidden
|
||||
expect(result.current.canManageRoles).toBe(false);
|
||||
});
|
||||
|
||||
it("admin:* short-circuits every check and flips canManageRoles on", async () => {
|
||||
vi.mocked(getMyPermissions).mockResolvedValueOnce({
|
||||
userId: "u1",
|
||||
workspaceId: "w1",
|
||||
permissions: ["admin:*"],
|
||||
is_admin_wildcard: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAcadenicePermissions(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isJwtClaimAvailable).toBe(true));
|
||||
|
||||
expect(result.current.hasPermission("anything:goes")).toBe(true);
|
||||
expect(result.current.hasPermission("roles:manage")).toBe(true);
|
||||
expect(result.current.canManageRoles).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to native admin/owner role while the query has no data", () => {
|
||||
// Query never resolves -> we should still allow OWNER through.
|
||||
vi.mocked(getMyPermissions).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(useUserRole).mockReturnValue({
|
||||
isAdmin: false,
|
||||
isOwner: true,
|
||||
isMember: false,
|
||||
} as ReturnType<typeof useUserRole>);
|
||||
|
||||
const { result } = renderHook(() => useAcadenicePermissions(), {
|
||||
wrapper: makeWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isJwtClaimAvailable).toBe(false);
|
||||
expect(result.current.canManageRoles).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,69 +1,59 @@
|
|||
import { useMemo } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { useAtom } from "jotai";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { ADMIN_WILDCARD } from "@/features/acadenice/rbac/types/rbac.types";
|
||||
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
|
||||
import { getMyPermissions } from "@/features/acadenice/rbac/services/rbac-service";
|
||||
|
||||
interface IDecodedAcadeniceJwt {
|
||||
acadenice_permissions?: string[];
|
||||
exp?: number;
|
||||
}
|
||||
export const MY_PERMISSIONS_QUERY_KEY = [
|
||||
"acadenice",
|
||||
"permissions",
|
||||
"me",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Best-effort reader of the Acadenice permissions claim that R2.1 packs into
|
||||
* the access JWT (`acadenice_permissions: string[]`).
|
||||
* Source-of-truth hook for the current user's Acadenice permissions.
|
||||
*
|
||||
* Limits :
|
||||
* - Docmost stores the access token in an HttpOnly cookie (`authToken`), which
|
||||
* is unreadable from JavaScript by design. We can only inspect the
|
||||
* non-HttpOnly `authTokens` cookie + the legacy jotai atom — both are kept
|
||||
* for client-side flows like the OIDC redirect path. When neither contains a
|
||||
* JWT, we degrade to the native Docmost role : OWNER / ADMIN are presumed
|
||||
* manage-capable for navigation purposes only. The backend remains the
|
||||
* source of truth (returns 403 if the user is not actually authorised), so
|
||||
* we treat this hook strictly as UX defence-in-depth.
|
||||
* Backed by `GET /api/acadenice/permissions/me` (R2.3a) — the backend resolves
|
||||
* the effective union from the user's roles, with a Redis 60s cache server-side
|
||||
* (R2.1). We keep a 60s React Query staleTime to mirror that TTL: refetching
|
||||
* sooner only hits the same Redis value.
|
||||
*
|
||||
* `canManageRoles` is `true` if either :
|
||||
* - the JWT exposes `roles:manage` or `admin:*`, OR
|
||||
* - we have no JWT visibility AND the user is Docmost-native admin/owner.
|
||||
* Wildcard semantics (kept identical to R2.2 so consumers don't change) :
|
||||
* - `admin:*` grants every check
|
||||
* - `<group>:*` grants any `<group>:<action>` check
|
||||
* - exact key match otherwise
|
||||
*
|
||||
* Backend stays the source of truth — guards reject 403 on the actual mutation
|
||||
* routes regardless of what this hook reports. Use it only for UI affordances.
|
||||
*
|
||||
* Fallback : while the query has no data yet (loading, network error,
|
||||
* legacy cookie session), `canManageRoles` falls back to the native Docmost
|
||||
* role (`OWNER` / `ADMIN`) so the sidebar entry is not lost on first paint.
|
||||
* `isJwtClaimAvailable` is kept for backwards compatibility with R2.2 callers
|
||||
* but now reflects "data has been loaded from /me" rather than the JWT cookie.
|
||||
*/
|
||||
export function useAcadenicePermissions(): {
|
||||
permissions: string[];
|
||||
hasPermission: (key: string) => boolean;
|
||||
canManageRoles: boolean;
|
||||
isJwtClaimAvailable: boolean;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { isAdmin, isOwner } = useUserRole();
|
||||
const [authTokens] = useAtom(authTokensAtom);
|
||||
|
||||
const decoded = useMemo<IDecodedAcadeniceJwt | null>(() => {
|
||||
const token =
|
||||
(authTokens && typeof authTokens === "object" && (authTokens as { accessToken?: string }).accessToken) ||
|
||||
(typeof authTokens === "string" ? authTokens : null) ||
|
||||
Cookies.get("authToken") ||
|
||||
null;
|
||||
if (!token || typeof token !== "string") return null;
|
||||
try {
|
||||
return jwtDecode<IDecodedAcadeniceJwt>(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [authTokens]);
|
||||
const { data, isLoading, isSuccess } = useQuery({
|
||||
queryKey: MY_PERMISSIONS_QUERY_KEY,
|
||||
queryFn: () => getMyPermissions(),
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const permissions = useMemo<string[]>(() => {
|
||||
if (decoded?.acadenice_permissions && Array.isArray(decoded.acadenice_permissions)) {
|
||||
return decoded.acadenice_permissions;
|
||||
}
|
||||
return [];
|
||||
}, [decoded]);
|
||||
|
||||
const isJwtClaimAvailable =
|
||||
decoded !== null && Array.isArray(decoded.acadenice_permissions);
|
||||
const permissions = data?.permissions ?? [];
|
||||
const isAdminWildcard =
|
||||
data?.is_admin_wildcard ?? permissions.includes(ADMIN_WILDCARD);
|
||||
|
||||
const hasPermission = (key: string): boolean => {
|
||||
if (permissions.includes(ADMIN_WILDCARD)) return true;
|
||||
if (isAdminWildcard) return true;
|
||||
if (permissions.includes(key)) return true;
|
||||
const colonIdx = key.indexOf(":");
|
||||
if (colonIdx <= 0) return false;
|
||||
|
|
@ -71,11 +61,18 @@ export function useAcadenicePermissions(): {
|
|||
return permissions.includes(`${group}:*`);
|
||||
};
|
||||
|
||||
const canManageRoles = isJwtClaimAvailable
|
||||
const canManageRoles = isSuccess
|
||||
? hasPermission("roles:manage")
|
||||
: // Fallback : honour Docmost-native admin/owner for navigation visibility.
|
||||
// Backend still enforces the real permission.
|
||||
: // Pre-resolution fallback : honour Docmost-native admin/owner so the
|
||||
// sidebar shows the entry on first render. The actual API call still
|
||||
// enforces the real permission.
|
||||
isAdmin || isOwner;
|
||||
|
||||
return { permissions, hasPermission, canManageRoles, isJwtClaimAvailable };
|
||||
return {
|
||||
permissions,
|
||||
hasPermission,
|
||||
canManageRoles,
|
||||
isJwtClaimAvailable: isSuccess,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
IUserRoleAssignment,
|
||||
ICreateRolePayload,
|
||||
IUpdateRolePayload,
|
||||
IMyPermissionsResponse,
|
||||
} from "@/features/acadenice/rbac/types/rbac.types";
|
||||
|
||||
/**
|
||||
|
|
@ -22,6 +23,16 @@ export async function getPermissionsCatalog(): Promise<IPermissionDescriptor[]>
|
|||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the effective permissions of the authenticated user in the current
|
||||
* workspace. Backed by the Redis 60s cache server-side (R2.1).
|
||||
*/
|
||||
export async function getMyPermissions(): Promise<IMyPermissionsResponse> {
|
||||
return api.get(
|
||||
"/acadenice/permissions/me",
|
||||
) as unknown as Promise<IMyPermissionsResponse>;
|
||||
}
|
||||
|
||||
export async function listRoles(): Promise<IRole[]> {
|
||||
return api.get("/acadenice/roles") as unknown as Promise<IRole[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,18 @@ export interface IUpdateRolePayload {
|
|||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response of `GET /api/acadenice/permissions/me` (R2.3a).
|
||||
* `is_admin_wildcard` is a cheap boolean so the UI can branch without
|
||||
* scanning the array.
|
||||
*/
|
||||
export interface IMyPermissionsResponse {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
permissions: string[];
|
||||
is_admin_wildcard: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of node in the permission matrix UI.
|
||||
* - admin : the global admin:* wildcard (greys everything else when checked)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
import { Controller, Get, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { PERMISSIONS_CATALOG } from '../permissions-catalog';
|
||||
import { AcadeniceRoleService } from '../services/role.service';
|
||||
|
||||
const ADMIN_WILDCARD_KEY = 'admin:*';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('acadenice/permissions')
|
||||
export class AcadenicePermissionsController {
|
||||
constructor(private readonly roleService: AcadeniceRoleService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Get()
|
||||
list() {
|
||||
|
|
@ -14,4 +28,40 @@ export class AcadenicePermissionsController {
|
|||
description: p.description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective permissions of the authenticated user in the current
|
||||
* workspace. Frontend uses this to gate UI affordances (sidebar entries,
|
||||
* action buttons) without ever decoding the HttpOnly auth cookie.
|
||||
*
|
||||
* The backend remains the source of truth: routes still apply
|
||||
* `AcadenicePermissionsGuard` independently. This endpoint is UX scaffolding,
|
||||
* not a security boundary.
|
||||
*
|
||||
* Cache: `getUserPermissions` is cached via Redis 60s (R2.1). When the user
|
||||
* holds `admin:*`, the array is short-circuited to `["admin:*"]` and we
|
||||
* surface the wildcard flag separately for cheap UI checks.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Get('me')
|
||||
async getMyPermissions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
permissions: string[];
|
||||
is_admin_wildcard: boolean;
|
||||
}> {
|
||||
const permissions = await this.roleService.getUserPermissions(
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
return {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
permissions,
|
||||
is_admin_wildcard: permissions.includes(ADMIN_WILDCARD_KEY),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { AcadenicePermissionsController } from '../controllers/permissions.controller';
|
||||
import { AcadeniceRoleService } from '../services/role.service';
|
||||
import { PERMISSIONS_CATALOG } from '../permissions-catalog';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
describe('AcadenicePermissionsController', () => {
|
||||
let controller: AcadenicePermissionsController;
|
||||
let roleService: jest.Mocked<Pick<AcadeniceRoleService, 'getUserPermissions'>>;
|
||||
|
||||
const mockUser = { id: 'user-1' } as User;
|
||||
const mockWorkspace = { id: 'ws-1' } as Workspace;
|
||||
|
||||
beforeEach(() => {
|
||||
roleService = {
|
||||
getUserPermissions: jest.fn(),
|
||||
};
|
||||
controller = new AcadenicePermissionsController(
|
||||
roleService as unknown as AcadeniceRoleService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('list (catalog)', () => {
|
||||
it('exposes the closed catalog with key/group/description only', () => {
|
||||
const out = controller.list();
|
||||
expect(out).toHaveLength(PERMISSIONS_CATALOG.length);
|
||||
const first = out[0];
|
||||
expect(Object.keys(first).sort()).toEqual([
|
||||
'description',
|
||||
'group',
|
||||
'key',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMyPermissions', () => {
|
||||
it('returns the resolved permissions of the authenticated user', async () => {
|
||||
roleService.getUserPermissions.mockResolvedValueOnce([
|
||||
'pages:read',
|
||||
'pages:write',
|
||||
'rows:read',
|
||||
]);
|
||||
|
||||
const out = await controller.getMyPermissions(mockUser, mockWorkspace);
|
||||
|
||||
expect(roleService.getUserPermissions).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'ws-1',
|
||||
);
|
||||
expect(out).toEqual({
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
permissions: ['pages:read', 'pages:write', 'rows:read'],
|
||||
is_admin_wildcard: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags is_admin_wildcard when admin:* is part of the union', async () => {
|
||||
// The role service short-circuits to ["admin:*"] when present.
|
||||
roleService.getUserPermissions.mockResolvedValueOnce(['admin:*']);
|
||||
|
||||
const out = await controller.getMyPermissions(mockUser, mockWorkspace);
|
||||
|
||||
expect(out.permissions).toEqual(['admin:*']);
|
||||
expect(out.is_admin_wildcard).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty array and false flag for a user with no role', async () => {
|
||||
roleService.getUserPermissions.mockResolvedValueOnce([]);
|
||||
|
||||
const out = await controller.getMyPermissions(mockUser, mockWorkspace);
|
||||
|
||||
expect(out.permissions).toEqual([]);
|
||||
expect(out.is_admin_wildcard).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates errors from the underlying role service', async () => {
|
||||
const boom = new Error('redis exploded mid-resolution');
|
||||
roleService.getUserPermissions.mockRejectedValueOnce(boom);
|
||||
|
||||
await expect(
|
||||
controller.getMyPermissions(mockUser, mockWorkspace),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it('keeps userId/workspaceId straight from the decorators (anti-spoof)', async () => {
|
||||
// If a malicious caller forged a different workspace via body or query,
|
||||
// the controller has no way to use anything but the auth-derived values.
|
||||
roleService.getUserPermissions.mockResolvedValueOnce(['pages:read']);
|
||||
const otherUser = { id: 'user-2' } as User;
|
||||
const otherWs = { id: 'ws-2' } as Workspace;
|
||||
|
||||
const out = await controller.getMyPermissions(otherUser, otherWs);
|
||||
|
||||
expect(roleService.getUserPermissions).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
'ws-2',
|
||||
);
|
||||
expect(out.userId).toBe('user-2');
|
||||
expect(out.workspaceId).toBe('ws-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue