diff --git a/apps/app/src/observability/__tests__/contextBinding.spec.ts b/apps/app/src/observability/__tests__/contextBinding.spec.ts new file mode 100644 index 00000000..a66b70a1 --- /dev/null +++ b/apps/app/src/observability/__tests__/contextBinding.spec.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' + +// Capture every setTag/setUser call into a per-test buffer so assertions +// can match against the FINAL bound state after the guard runs. +const calls: Array<{ method: 'setTag'; key: string; value: unknown } | { method: 'setUser'; user: unknown } | { method: 'clear' }> = [] + +vi.mock('@sentry/vue', () => { + const scope = { + setTag: (key: string, value: unknown) => { + calls.push({ method: 'setTag', key, value }) + }, + setUser: (user: unknown) => { + calls.push({ method: 'setUser', user }) + }, + clear: () => { + calls.push({ method: 'clear' }) + }, + } + + return { + getCurrentScope: () => scope, + } +}) + +// Override the global vue-router auto-mock from tests/setup.ts because we +// need real router behaviour here. +vi.mock('vue-router', async () => await vi.importActual('vue-router')) + +const authState = { + user: null as null | { id: string }, + isAuthenticated: false, + isSuperAdmin: false, + appRoles: [] as string[], +} + +const orgState = { + activeOrganisationId: null as string | null, +} + +vi.mock('@/stores/useAuthStore', () => ({ + useAuthStore: () => authState, +})) + +vi.mock('@/stores/useOrganisationStore', () => ({ + useOrganisationStore: () => orgState, +})) + +const { installContextBinding } = await import('../contextBinding') + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + // Authenticated organizer routes + { path: '/dashboard', name: 'dashboard', component: { template: '
' } }, + + // Platform admin + { path: '/platform/users', name: 'platform.users', component: { template: '
' } }, + + // Authenticated portal (volunteer) + { path: '/portal/profiel', name: 'portal-profiel', meta: { context: 'portal' }, component: { template: '
' } }, + + // Token-based portal (artist advance) + { path: '/portal/advance/:token', name: 'artist-advance', meta: { public: true, context: 'portal' }, component: { template: '
' } }, + + // Public organizer page (login) + { path: '/login', name: 'login', meta: { public: true }, component: { template: '
' } }, + ], + }) +} + +function reset() { + calls.length = 0 + authState.user = null + authState.isAuthenticated = false + authState.isSuperAdmin = false + authState.appRoles = [] + orgState.activeOrganisationId = null +} + +function lastTag(key: string): unknown { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i] + if (call.method === 'setTag' && call.key === key) + return call.value + } + + return undefined +} + +function lastUser(): unknown { + for (let i = calls.length - 1; i >= 0; i--) { + const call = calls[i] + if (call.method === 'setUser') + return call.user + } + + return undefined +} + +function tagWasSet(key: string): boolean { + return calls.some(c => c.method === 'setTag' && c.key === key) +} + +describe('installContextBinding', () => { + beforeEach(() => { + setActivePinia(createPinia()) + reset() + }) + + async function navigate(router: ReturnType, path: string): Promise { + installContextBinding(router) + await router.push(path) + await router.isReady() + } + + it('portal-token route sets actor_scope=portal and does not set user identity', async () => { + authState.user = { id: '01ULIDUSER' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/portal/advance/sometoken') + + expect(lastTag('actor_scope')).toBe('portal') + expect(lastTag('actor_type')).toBe('portal_token') + expect(tagWasSet('user_id')).toBe(false) + + // setUser is called on scope.clear() reset path; the listener does NOT + // call setUser explicitly for portal-token zone (no auth context). + expect(lastUser()).toBeUndefined() + }) + + it('platform route with super_admin sets actor_scope=platform', async () => { + authState.user = { id: '01ULIDADMIN' } + authState.isAuthenticated = true + authState.isSuperAdmin = true + authState.appRoles = ['super_admin'] + + const router = makeRouter() + + await navigate(router, '/platform/users') + + expect(lastTag('actor_scope')).toBe('platform') + expect(lastTag('actor_type')).toBe('super_admin') + expect(lastTag('user_id')).toBe('01ULIDADMIN') + }) + + it('platform route without super_admin does NOT set actor_scope=platform', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.isSuperAdmin = false + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/platform/users') + + expect(lastTag('actor_scope')).not.toBe('platform') + }) + + it('organizer route with active organisation tags actor_scope=organisation + organisation_id', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_scope')).toBe('organisation') + expect(lastTag('organisation_id')).toBe('01ULIDORGID') + expect(lastTag('actor_type')).toBe('organizer_admin') + }) + + it('organizer route without active organisation tags actor_scope=user', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = null + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_scope')).toBe('user') + expect(tagWasSet('organisation_id')).toBe(false) + }) + + it('unauthenticated request to public route sets actor_scope=anonymous', async () => { + const router = makeRouter() + + await navigate(router, '/login') + + expect(lastTag('actor_scope')).toBe('anonymous') + expect(lastTag('actor_type')).toBe('unauthenticated') + expect(tagWasSet('user_id')).toBe(false) + }) + + it('actor_type maps from role hierarchy: super_admin > org_admin > org_member', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin', 'org_member'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('actor_type')).toBe('organizer_admin') + }) + + it('user_id and username are both ULID, never email (RFC §3.8)', async () => { + authState.user = { id: '01ULIDUSERX' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + const user = lastUser() as { id: string; username: string } + + expect(user.id).toBe('01ULIDUSERX') + expect(user.username).toBe('01ULIDUSERX') + }) + + it('route_name is always present', async () => { + const router = makeRouter() + + await navigate(router, '/login') + + expect(lastTag('route_name')).toBe('login') + }) + + it('app tag is always app on every navigation', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + + const router = makeRouter() + + await navigate(router, '/dashboard') + + expect(lastTag('app')).toBe('app') + }) + + it('cross-zone leak guard: navigating from organizer to portal-token clears user', async () => { + authState.user = { id: '01ULIDORG' } + authState.isAuthenticated = true + authState.appRoles = ['org_admin'] + orgState.activeOrganisationId = '01ULIDORGID' + + const router = makeRouter() + + await navigate(router, '/dashboard') + + // After the guard runs once, calls buffer holds organizer-zone state. + // Now navigate to portal-token zone; expect a clear() then no setUser. + const beforeCount = calls.length + + await router.push('/portal/advance/abc') + + const newCalls = calls.slice(beforeCount) + + expect(newCalls.some(c => c.method === 'clear')).toBe(true) + expect(newCalls.filter(c => c.method === 'setUser').length).toBe(0) + expect(lastTag('actor_scope')).toBe('portal') + }) +}) diff --git a/apps/app/src/observability/__tests__/scrubber.spec.ts b/apps/app/src/observability/__tests__/scrubber.spec.ts new file mode 100644 index 00000000..5aa5c597 --- /dev/null +++ b/apps/app/src/observability/__tests__/scrubber.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import type { ErrorEvent as SentryErrorEvent } from '@sentry/vue' +import { scrubEvent } from '../scrubber' + +function makeEvent(overrides: Partial = {}): SentryErrorEvent { + return { + type: undefined, + ...overrides, + } as SentryErrorEvent +} + +describe('scrubEvent', () => { + describe('request body', () => { + it('scrubs password in request body', () => { + const event = makeEvent({ request: { data: { email: 'a@b.test', password: 'p@ss' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.password).toBe('[scrubbed]') + expect(data.email).toBe('a@b.test') + }) + + it('scrubs password_confirmation', () => { + const event = makeEvent({ request: { data: { password_confirmation: 'x', current_password: 'y' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.password_confirmation).toBe('[scrubbed]') + expect(data.current_password).toBe('[scrubbed]') + }) + + it('scrubs every SENSITIVE_BODY_KEY at top level', () => { + const allKeys = { + password: 'a', + token: 'b', + api_key: 'c', + secret: 'd', + webhook_secret: 'e', + dsn: 'f', + signature: 'g', + authorization: 'h', + cookie: 'i', + bearer: 'j', + iban: 'k', + bic: 'l', + passport_number: 'm', + bsn: 'n', + } + + const event = makeEvent({ request: { data: allKeys } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + for (const key of Object.keys(allKeys)) + expect(data[key]).toBe('[scrubbed]') + }) + + it('scrubs sensitive keys at nested levels (recursive)', () => { + const event = makeEvent({ + request: { + data: { profile: { address: { iban: 'NL91...', street: 'Damrak 1' } } }, + }, + }) + + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record>> + + expect(data.profile.address.iban).toBe('[scrubbed]') + expect(data.profile.address.street).toBe('Damrak 1') + }) + + it('replaces form_values payload wholesale', () => { + const event = makeEvent({ + request: { data: { form_values: { email: 'x@y.com', dietary: 'vegan' } } }, + }) + + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.form_values).toBe('[scrubbed_form_values]') + + const serialised = JSON.stringify(data) + + expect(serialised).not.toContain('x@y.com') + expect(serialised).not.toContain('vegan') + }) + + it('does not leak email or other non-sensitive keys', () => { + const event = makeEvent({ request: { data: { email: 'x@y.com', name: 'Bob' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const data = result.request?.data as Record + + expect(data.email).toBe('x@y.com') + expect(data.name).toBe('Bob') + }) + + it('hits max_depth guard at depth 11', () => { + let deep: unknown = { v: 'leaf' } + for (let i = 0; i < 15; i++) + deep = { nest: deep } + + const event = makeEvent({ request: { data: deep as Record } }) + const result = scrubEvent(event) as SentryErrorEvent + const serialised = JSON.stringify(result.request?.data) + + expect(serialised).toContain('[max_depth]') + }) + + it('returns event unchanged when request is undefined', () => { + const event = makeEvent() + const result = scrubEvent(event) + + expect(result).toBe(event) + expect(result?.request).toBeUndefined() + }) + + it('returns event unchanged when request.data is null', () => { + const event = makeEvent({ request: { data: null as unknown as undefined } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.request?.data).toBeNull() + }) + }) + + describe('headers', () => { + it('scrubs Authorization header', () => { + const event = makeEvent({ request: { headers: { Authorization: 'Bearer abc' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect((result.request?.headers as Record).Authorization).toBe('[scrubbed]') + }) + + it('scrubs Cookie header', () => { + const event = makeEvent({ request: { headers: { Cookie: 'sess=abc' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect((result.request?.headers as Record).Cookie).toBe('[scrubbed]') + }) + + it('scrubs case-insensitive header names', () => { + const event = makeEvent({ request: { headers: { 'X-API-KEY': 'k', 'x-impersonation-token': 't' } } }) + const result = scrubEvent(event) as SentryErrorEvent + const headers = result.request?.headers as Record + + expect(headers['X-API-KEY']).toBe('[scrubbed]') + expect(headers['x-impersonation-token']).toBe('[scrubbed]') + }) + }) + + describe('query string', () => { + it('scrubs token query param', () => { + const event = makeEvent({ request: { query_string: 'token=abc&keep=me' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toContain('token=%5Bscrubbed%5D') + expect(qs).toContain('keep=me') + }) + + it('scrubs api_key query param', () => { + const event = makeEvent({ request: { query_string: 'api_key=xyz&page=2' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toContain('api_key=%5Bscrubbed%5D') + expect(qs).toContain('page=2') + }) + + it('preserves non-sensitive query params unchanged', () => { + const event = makeEvent({ request: { query_string: 'page=2&sort=name' } }) + const result = scrubEvent(event) as SentryErrorEvent + const qs = result.request?.query_string as string + + expect(qs).toBe('page=2&sort=name') + }) + }) + + describe('cookies + storage', () => { + it('scrubs cookies wholesale', () => { + const event = makeEvent({ request: { cookies: { sess: 'abc', tracking: 'xyz' } } }) + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.request?.cookies).toEqual({ scrubbed: '[scrubbed]' }) + }) + + it('strips storage context if present (RFC §3.7 frontend point 2)', () => { + const event = makeEvent({ + contexts: { storage: { local: { token: 'abc' } } } as unknown as SentryErrorEvent['contexts'], + }) + + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.contexts).not.toHaveProperty('storage') + }) + + it('strips user.cookies if present (RFC §3.7 frontend point 1)', () => { + const event = makeEvent({ + user: { id: 'ulid', cookies: 'session=...' } as unknown as SentryErrorEvent['user'], + }) + + const result = scrubEvent(event) as SentryErrorEvent + + expect(result.user).not.toHaveProperty('cookies') + expect(result.user?.id).toBe('ulid') + }) + }) +})