Skip to content
2 changes: 1 addition & 1 deletion apps/web/src/bootstrap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ vi.mock('@gocell/access', async (importOriginal) => {
const actual = await importOriginal<typeof import('@gocell/access')>()
return {
...actual,
createPdpClient: vi.fn(() => ({ can: vi.fn() })),
createPdpClient: vi.fn(() => ({ can: vi.fn(), decide: vi.fn() })),
}
})

Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/layouts/AppShellLayout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* AppShellLayout.spec.ts — PDP deny notice (aria-live region) behaviour.
*
* AppShell (from @gocell/core) is stubbed to a slot passthrough; RouterView is
* stubbed; useI18n is mocked so `te` reports which deny keys exist.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import { useUiStore } from '../stores/useUiStore'
import AppShellLayout from './AppShellLayout.vue'

vi.mock('@gocell/core', () => ({
AppShell: { name: 'AppShell', template: '<div><slot /></div>' },
}))
vi.mock('../composables/useGlobalShortcuts', () => ({ useGlobalShortcuts: vi.fn() }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (k: string) => k,
// Only the two real deny keys "exist"; anything else falls back.
te: (k: string) => k === 'access.pdp.deny.role-missing' || k === 'access.pdp.deny.error',
}),
}))

function mountLayout() {
return mount(AppShellLayout, { global: { stubs: { RouterView: true } } })
}

describe('AppShellLayout — PDP deny notice', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.useRealTimers()
})

it('renders no deny notice initially', () => {
expect(mountLayout().find('[role="alert"]').exists()).toBe(false)
})

it('announces a known deny reasonCode via an aria-live assertive alert', async () => {
const wrapper = mountLayout()
useUiStore().notifyAccessDenied('role-missing')
await nextTick()

const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
expect(alert.attributes('aria-live')).toBe('assertive')
expect(alert.text()).toBe('access.pdp.deny.role-missing')
})

it('announces a deny reasonCode that was set before the layout mounted', async () => {
useUiStore().notifyAccessDenied('role-missing')
const wrapper = mountLayout()
await nextTick()

expect(wrapper.find('[role="alert"]').text()).toBe('access.pdp.deny.role-missing')
})

it('falls back to generic deny text for an unknown reasonCode (no raw key leak)', async () => {
const wrapper = mountLayout()
useUiStore().notifyAccessDenied('policy-expired')
await nextTick()

expect(wrapper.find('[role="alert"]').text()).toBe('access.pdp.deny.error')
})

it('auto-dismisses the notice after the timeout', async () => {
vi.useFakeTimers()
const wrapper = mountLayout()
const store = useUiStore()
store.notifyAccessDenied('role-missing')
await nextTick()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)

vi.advanceTimersByTime(6000)
expect(store.accessDeniedReasonCode).toBeNull()
await nextTick()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})

it('restarts dismissal for repeated deny notices with the same reasonCode', async () => {
vi.useFakeTimers()
const wrapper = mountLayout()
const store = useUiStore()
store.notifyAccessDenied('role-missing')
await nextTick()

vi.advanceTimersByTime(5000)
store.notifyAccessDenied('role-missing')
await nextTick()
vi.advanceTimersByTime(1000)
expect(wrapper.find('[role="alert"]').exists()).toBe(true)

vi.advanceTimersByTime(5000)
expect(store.accessDeniedReasonCode).toBeNull()
})

it('clears the notice when the reasonCode resets to null', async () => {
const wrapper = mountLayout()
const store = useUiStore()
store.notifyAccessDenied('role-missing')
await nextTick()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)

store.clearAccessDenied()
await nextTick()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
})
66 changes: 66 additions & 0 deletions apps/web/src/layouts/AppShellLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,53 @@
*
* The command-palette / sidebar UI state and global shortcuts live here rather
* than in App.vue because they only make sense inside the shell.
*
* Access-denied notice: the route guard pushes a PDP deny reasonCode into
* useUiStore; here we localise it and render it into an aria-live region so
* screen readers announce the denial (the guard itself stays UI/i18n-free).
*/
import { ref, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { AppShell } from '@gocell/core'
import { useUiStore } from '../stores/useUiStore'
import { useGlobalShortcuts } from '../composables/useGlobalShortcuts'

const uiStore = useUiStore()
const { t, te } = useI18n()

// Registers cleanup via onScopeDispose internally — no explicit teardown needed.
useGlobalShortcuts()

// Auto-dismiss the deny notice after it has had time to be read / announced.
const DENY_DISMISS_MS = 6000
const denyText = ref('')
let dismissTimer: ReturnType<typeof setTimeout> | undefined

function resetTimer(): void {
if (dismissTimer !== undefined) {
clearTimeout(dismissTimer)
dismissTimer = undefined
}
}

watch(
() => [uiStore.accessDeniedReasonCode, uiStore.accessDeniedNoticeSeq] as const,
([reasonCode]) => {
resetTimer()
if (reasonCode === null) {
denyText.value = ''
return
}
// Unknown reasonCode (e.g. a future backend code without a key) → generic
// deny text rather than leaking the raw i18n key to the user.
const key = `access.pdp.deny.${reasonCode}`
denyText.value = te(key) ? t(key) : t('access.pdp.deny.error')
dismissTimer = setTimeout(() => uiStore.clearAccessDenied(), DENY_DISMISS_MS)
},
{ immediate: true },
)

onBeforeUnmount(resetTimer)
</script>

<template>
Expand All @@ -27,4 +65,32 @@ useGlobalShortcuts()
>
<RouterView />
</AppShell>

<!--
PDP deny notice. role="alert" + aria-live="assertive" so screen readers
announce the denial immediately (WCAG 4.1.3). Non-interactive + auto-dismiss,
so no focus management is required.
-->
<div v-if="denyText" class="gc-access-denied" role="alert" aria-live="assertive">
{{ denyText }}
</div>
</template>

<style scoped>
.gc-access-denied {
position: fixed;
inset-block-end: 16px;
inset-inline: 0;
margin-inline: auto;
width: fit-content;
max-width: 90vw;
padding: 10px 16px;
font-size: var(--text-base);
color: var(--fg);
background: var(--bg-raised);
border: 1px solid var(--err);
border-radius: var(--r);
box-shadow: var(--shadow);
z-index: var(--z-modal);
}
</style>
17 changes: 12 additions & 5 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import { createGocellI18n, PDP_INJECTION_KEY } from '@gocell/core'
import { createPdpClient } from '@gocell/access'
import { createPdpClient, createHttpDecide } from '@gocell/access'
import { configureAxios, bootstrapSession } from './bootstrap'
import { registerGuards } from './router/guards'
import { useUiStore } from './stores/useUiStore'

const app = createApp(App)

Expand All @@ -23,12 +24,18 @@ configureAxios(router)
app.use(createGocellI18n())
app.use(router)

// 4. PDP client provided for Can / useDecision in the whole app
const pdpClient = createPdpClient()
// 4. PDP client provided for Can / useDecision in the whole app.
// Assembly layer injects the real decision source (POST /api/v1/access/decide);
// createPdpClient keeps the cache / TTL / single-flight / fail-closed wrapper.
const pdpClient = createPdpClient({ decide: createHttpDecide() })
app.provide(PDP_INJECTION_KEY, pdpClient)

// 5. Route guards (three-stage: first-run → auth → PDP)
registerGuards(router, app, pdpClient)
// 5. Route guards (three-stage: first-run → auth → PDP). PDP deny → push the
// reasonCode into useUiStore; AppShellLayout localises it (useI18n) and
// announces it in an aria-live region. Keeps the guard free of i18n/UI.
registerGuards(router, app, pdpClient, (reasonCode: string) => {
useUiStore().notifyAccessDenied(reasonCode)
})

// 6. Attempt silent session restore before the first navigation, then mount.
// No-op on a cold load until the backend ships a refresh cookie (#12 H2).
Expand Down
79 changes: 70 additions & 9 deletions apps/web/src/router/guards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createApp } from 'vue'
import { http } from '@gocell/request'
import { useAuthStore } from '@gocell/access'
import { registerGuards, _resetSetupStatusCache } from './guards'
import type { PdpClient } from '@gocell/core'
import type { PdpClient, Decision } from '@gocell/core'
import type { ComputedRef } from 'vue'

/** Helper: create a valid setSession payload */
Expand Down Expand Up @@ -83,10 +83,17 @@ async function navigate(router: Router, to: RouteLocationRaw): Promise<void> {
await router.isReady()
}

/** Build a stub PdpClient whose can() returns the given allowed value */
/**
* Build a stub PdpClient. The PDP gate awaits decide(), so the gate verdict comes
* from decide(); can() is kept for type-completeness (unused by the guard).
*/
function makePdpClient(allowed: boolean): PdpClient {
return {
can: vi.fn().mockReturnValue({ value: allowed } as ComputedRef<boolean>),
decide: vi.fn().mockResolvedValue({
effect: allowed ? 'allow' : 'deny',
reasonCode: allowed ? '' : 'role-missing',
} as Decision),
}
}

Expand Down Expand Up @@ -308,7 +315,7 @@ describe('Router guards', () => {
authStore.setSession(makeSession())
})

it('allows access when PDP can() returns true', async () => {
it('allows access when PDP decide() resolves allow', async () => {
pdpClient = makePdpClient(true)
// Re-register guards with the new pdpClient
_resetSetupStatusCache()
Expand All @@ -328,8 +335,38 @@ describe('Router guards', () => {
expect(router.currentRoute.value.name).toBe('pdp-guarded')
})

it('redirects to home when PDP can() returns false (fail-closed)', async () => {
it('awaits an async decide() before allowing — no pending→deny flicker on first nav', async () => {
// decide() resolves allow on a later tick. The guard must await it (not read a
// synchronous pending value), so first navigation to the guarded route succeeds.
const deferredAllow: PdpClient = {
can: vi.fn(),
decide: vi.fn(
() =>
new Promise<Decision>((resolve) =>
setTimeout(() => resolve({ effect: 'allow', reasonCode: '' }), 0),
),
),
}
_resetSetupStatusCache()
const pinia = createPinia()
setActivePinia(pinia)
authStore = useAuthStore()
authStore.setSession(makeSession())
const app = createApp({ template: '<router-view/>' })
app.use(pinia)
router = makeRouter()
registerGuards(router, app, deferredAllow)
app.use(router)
httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } })

await navigate(router, '/pdp-guarded')

expect(router.currentRoute.value.name).toBe('pdp-guarded')
})

it('redirects to home and surfaces the deny reasonCode when decide() denies', async () => {
pdpClient = makePdpClient(false)
const onAccessDenied = vi.fn()
_resetSetupStatusCache()
const pinia = createPinia()
setActivePinia(pinia)
Expand All @@ -338,18 +375,22 @@ describe('Router guards', () => {
const app = createApp({ template: '<router-view/>' })
app.use(pinia)
router = makeRouter()
registerGuards(router, app, pdpClient)
registerGuards(router, app, pdpClient, onAccessDenied)
app.use(router)
httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } })

await navigate(router, '/pdp-guarded')

expect(router.currentRoute.value.name).toBe('home')
expect(onAccessDenied).toHaveBeenCalledWith('role-missing')
})

it('does not run PDP gate (can not called) for routes without requiredAction', async () => {
const canSpy = vi.fn().mockReturnValue({ value: true } as ComputedRef<boolean>)
pdpClient = { can: canSpy }
it('does not run PDP gate (decide not called) for routes without requiredAction', async () => {
const decideSpy = vi.fn().mockResolvedValue({ effect: 'allow', reasonCode: '' } as Decision)
pdpClient = {
can: vi.fn().mockReturnValue({ value: true } as ComputedRef<boolean>),
decide: decideSpy,
}
_resetSetupStatusCache()
const pinia = createPinia()
setActivePinia(pinia)
Expand All @@ -364,7 +405,27 @@ describe('Router guards', () => {

await navigate(router, '/protected')

expect(canSpy).not.toHaveBeenCalled()
expect(decideSpy).not.toHaveBeenCalled()
})

it('fail-closed to home and surfaces "error" when no PDP client is wired', async () => {
const onAccessDenied = vi.fn()
_resetSetupStatusCache()
const pinia = createPinia()
setActivePinia(pinia)
authStore = useAuthStore()
authStore.setSession(makeSession())
const app = createApp({ template: '<router-view/>' })
app.use(pinia)
router = makeRouter()
registerGuards(router, app, undefined, onAccessDenied) // no pdpClient
app.use(router)
httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } })

await navigate(router, '/pdp-guarded')

expect(router.currentRoute.value.name).toBe('home')
expect(onAccessDenied).toHaveBeenCalledWith('error')
})
})

Expand Down
Loading
Loading