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:
275
apps/app/src/observability/__tests__/contextBinding.spec.ts
Normal file
275
apps/app/src/observability/__tests__/contextBinding.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
206
apps/app/src/observability/__tests__/scrubber.spec.ts
Normal file
206
apps/app/src/observability/__tests__/scrubber.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user