dorfteich/packages/shared/src/permissions/validate.test.ts
Claude Opus 4.8 4d48d72c40
All checks were successful
CD / Build and push images (push) Successful in 3m3s
CI / Lint, typecheck, test (push) Successful in 2m21s
CI / Auth e2e pack (push) Successful in 2m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add grant model and shared permission-resolution algorithm (#51)
The heart of the security model: one algorithm, implemented once, for API,
collab, and UI (permissions.md — authoritative).

- shared `permissions/`: pure resolution (`resolvePageCapability`) exactly per
  permissions.md — specificity page > label (incl. ancestor labels) > pond,
  deny wins within a level, default-closed, Site Admin bypass — plus the trash
  rule (`canAccessPage` / `canAccessTrashedPage`, ADR 0013). `grantValidationError`
  enforces the structural constraints. Documented, I/O-free signatures for
  API/collab reuse.
- prisma: `RoleGrant` (+ grant enums) per data-model.md, unique on
  (pond, subject, role, scope); migration adds a CHECK backstop that a
  POND_ADMIN grant is pond-scope + user-subject.
- api `grants/`: `GrantsService.createGrant` validates before insert (structural
  + no extra admin on a personal pond), rejects duplicates; `grantsForPond`
  returns the shared resolver model (what #52/#53 consume); enum mappers between
  the DB and the shared model. Interim "who may manage grants" stays until #52.
- tests: exhaustive table-driven resolver suite — every worked example from
  permissions.md §Resolution, edge cases (multi-label deny-wins, ancestor
  inheritance, anonymous/public, most-specific-allow-beats-less-specific-deny,
  trash) and a property test (a less-specific grant never overrides a
  more-specific decision); validation unit tests; grants db test proving
  write-time rejection of invalid grants.
- i18n: grant error codes (de + en).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 14:02:07 +02:00

64 lines
2.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { Grant } from './types';
import { grantValidationError } from './validate';
const base: Grant = {
subjectType: 'user',
subjectId: 'u1',
role: 'editor',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
};
describe('grantValidationError (issue #51)', () => {
it('accepts a valid editor grant', () => {
expect(grantValidationError(base, { pondType: 'shared' })).toBeNull();
});
it('accepts a valid pond_admin grant on a shared pond', () => {
expect(
grantValidationError({ ...base, role: 'pond_admin' }, { pondType: 'shared' }),
).toBeNull();
});
it('rejects pond_admin at label scope', () => {
const g: Grant = { ...base, role: 'pond_admin', scopeType: 'label', scopeId: 'l1' };
expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope');
});
it('rejects pond_admin with a non-user subject', () => {
const g: Grant = { ...base, role: 'pond_admin', subjectType: 'authenticated', subjectId: null };
expect(grantValidationError(g, { pondType: 'shared' })).toBe('grant_pond_admin_scope');
});
it('rejects a second admin on a personal pond', () => {
const g: Grant = { ...base, role: 'pond_admin' };
expect(grantValidationError(g, { pondType: 'personal' })).toBe(
'grant_pond_admin_personal_pond',
);
});
it('rejects a user subject without an id, and a non-user subject with one', () => {
expect(grantValidationError({ ...base, subjectId: null }, { pondType: 'shared' })).toBe(
'grant_subject_id_mismatch',
);
expect(
grantValidationError(
{ ...base, subjectType: 'public', subjectId: 'x' },
{ pondType: 'shared' },
),
).toBe('grant_subject_id_mismatch');
});
it('rejects a scoped grant without an id, and a pond grant with one', () => {
expect(
grantValidationError({ ...base, scopeType: 'label', scopeId: null }, { pondType: 'shared' }),
).toBe('grant_scope_id_mismatch');
expect(grantValidationError({ ...base, scopeId: 'p1' }, { pondType: 'shared' })).toBe(
'grant_scope_id_mismatch',
);
});
});