managarten/packages/shared-privacy/src/tokens.test.ts
Till JS 49935c9628 feat(shared-privacy): M1 — visibility foundation package
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).

What ships:
- @mana/shared-privacy package
  - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
  - VisibilityLevelSchema + UnlistedTokenSchema (zod)
  - defaultVisibilityFor(spaceType): personal → private, else → space
  - predicates: canEmbedOnWebsite, isReachableByLink,
    isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
  - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
  - VISIBILITY_METADATA: German labels + descriptions + phosphor icon
    names so non-UI surfaces (audit logs, CLI) label levels consistently
  - <VisibilityPicker> svelte component: compact lock/globe trigger with
    4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
  registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
  (schema migrations + store wiring + picker placement + embed resolver +
  legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
  with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
  listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
  embed; phase-1 AI always-deny), defaults (personal vs multi-member,
  null fallback), token uniqueness + schema round-trip

Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
  so RLS predicates and publish resolvers can read it without the user's
  master key
- Publish flow remains "decrypt client-side, inline plaintext into
  snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
  type defaults to private; cross-user AI always false)

Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:59:11 +02:00

39 lines
1.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { generateUnlistedToken } from './tokens';
import { UnlistedTokenSchema } from './schema';
describe('generateUnlistedToken', () => {
it('returns a 32-char base64url string', () => {
const token = generateUnlistedToken();
expect(token).toHaveLength(32);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('passes the UnlistedTokenSchema', () => {
for (let i = 0; i < 10; i++) {
const token = generateUnlistedToken();
expect(() => UnlistedTokenSchema.parse(token)).not.toThrow();
}
});
it('is unique across many calls (entropy check)', () => {
const tokens = new Set<string>();
for (let i = 0; i < 1000; i++) tokens.add(generateUnlistedToken());
expect(tokens.size).toBe(1000);
});
});
describe('UnlistedTokenSchema', () => {
it('rejects tokens that are too short', () => {
expect(() => UnlistedTokenSchema.parse('short')).toThrow();
});
it('rejects tokens with invalid chars', () => {
expect(() => UnlistedTokenSchema.parse('a/b+c=d'.padEnd(32, 'x'))).toThrow();
});
it('accepts 32-char base64url', () => {
expect(() => UnlistedTokenSchema.parse('A'.repeat(32))).not.toThrow();
expect(() => UnlistedTokenSchema.parse('-_'.repeat(16))).not.toThrow();
});
});