test: scrubber + contextBinding regression coverage

WS-7 PR-3 commit 2.

- scrubber.spec.ts (18 cases): mirrors backend PiiScrubbingTest semantics.
  Body/header/query scrubbing, form_values wholesale replacement, all
  SENSITIVE_BODY_KEYS at top + nested levels, max_depth guard, cookies +
  storage + user.cookies sanitisation.
- contextBinding.spec.ts (11 cases): exercises the Vue Router beforeEach
  guard against a real router with mocked Sentry scope (capturing every
  setTag/setUser call into a per-test buffer). Cases:
    - portal-token zone — actor_scope=portal, no user_id
    - platform route + super_admin — actor_scope=platform
    - platform route without super_admin — does NOT tag platform
    - organizer route with active org — actor_scope=organisation +
      organisation_id
    - organizer route without active org — actor_scope=user, no org tag
    - unauthenticated public — actor_scope=anonymous
    - actor_type role hierarchy
    - RFC §3.8 ULID-only user identity (no email leakage)
    - route_name + app=app baseline tags
    - cross-zone leak guard: navigating from organizer to portal-token
      calls scope.clear() and does not bind user

Frontend test count 223 to 252. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:59:05 +02:00
parent bc477837eb
commit 9247d89e4b
2 changed files with 481 additions and 0 deletions

View File

@@ -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<typeof import('vue-router')>('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: '<div />' } },
// Platform admin
{ path: '/platform/users', name: 'platform.users', component: { template: '<div />' } },
// Authenticated portal (volunteer)
{ path: '/portal/profiel', name: 'portal-profiel', meta: { context: 'portal' }, component: { template: '<div />' } },
// Token-based portal (artist advance)
{ path: '/portal/advance/:token', name: 'artist-advance', meta: { public: true, context: 'portal' }, component: { template: '<div />' } },
// Public organizer page (login)
{ path: '/login', name: 'login', meta: { public: true }, component: { template: '<div />' } },
],
})
}
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<typeof makeRouter>, path: string): Promise<void> {
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')
})
})

View File

@@ -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> = {}): 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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, Record<string, Record<string, unknown>>>
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<string, unknown>
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<string, unknown>
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<string, unknown> } })
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<string, string>).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<string, string>).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<string, string>
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')
})
})
})