diff --git a/docs/analysis/2026-09-21-permission-read-ownership.md b/docs/analysis/2026-09-21-permission-read-ownership.md new file mode 100644 index 000000000..4b2d0f700 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -0,0 +1,44 @@ +# Board-access read and session ownership + +Status: draft PR #3330, 2026-09-21. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +A board-access read could settle after a confirmed grant, update or revoke and replace newer client state. Same-board reads were last-response-wins, one shared loading Boolean could clear while other boards still loaded, and pending reads or mutations retained permission to publish after account replacement. + +The first lifecycle correction treated every token refresh as a full cache reset and could blank an unchanged Board Access route. The preservation correction then exposed a second boundary: when refresh happened during an unresolved first read, the old owner was retired but the unchanged board route did not refetch. + +## Contract + +- One read owner exists per board; unrelated boards remain concurrent. +- A successful mutation advances that board's generation and retires older reads. +- User identity, authentication or demo-session replacement advances the epoch, clears cached access, retires operations and resets loading/error. +- Token-only rotation preserves settled board caches, suppresses old-token UI settlement and restarts only active board reads that do not yet have a cache entry. +- An empty array is a settled authoritative cache and is not retried merely because it is empty. +- The retry retains the exact board ID captured by the active read. +- Success, failure, toast and cache writes require the initiating lifecycle epoch. +- Loading is derived from current operation tokens, not whichever call settles. +- Grant, update and revoke mutations are never replayed. + +Server authorization remains authoritative. This corrects truthful client cache behavior and does not claim a server-side authorization bypass. Same-entry mutation serialization remains in stacked PR #3335. + +## Test-first evidence + +The original ownership suite covers read-versus-grant/update/revoke races, reverse reads, independent loading, same-user logout/login, replacement-session mutation settlement, stale failures and stable-ID grant deduplication. + +Review-regression head `6bbf8d04139bef2dca91d290b910e1c07a8e76aa` ran canonical Node 24 frontend qualification on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,161 tests, exactly 2 failures, 0 errors**, both loaded-cache preservation cases. + +Issue #3352 added test-only head `d28697ede737ec42ae3e696c7a7cedcb753de347`, covering token rotation while `board-1` has an unresolved first read and no cache entry. A dependency-free runner transpiled and executed the actual production module: + +- before the retry correction: `getAccess` was called once and loading became false after rotation; +- after the correction: `getAccess` was called twice for the same board, old-token settlement was suppressed and the fresh-token result populated the cache. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. + +## Remaining gates + +Current production correction: `3c7746ee0330406a4e6b84c1a5bc253b02a7aa08` before this documentation commit. + +Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and repeat independent review remain required. Stacked mutation-order PR #3335 must later be reconciled to this corrected parent and requalified. + +No merge, release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 938eb7296..05d4136b3 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' +import { ref, computed, watch } from 'vue' import { boardAccessApi } from '../api/boardAccessApi' import { useToastStore } from './toastStore' import { useSessionStore } from './sessionStore' @@ -16,6 +16,183 @@ export const usePermissionsStore = defineStore('permissions', () => { const loading = ref(false) const error = ref(null) + type ReadRetry = () => Promise + + interface OperationOwner { + epoch: number + token: symbol + userId: string | null + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + revalidateOnTokenRotation: boolean + } + + type InvalidationKind = 'session-change' | 'token-rotation' + + let sessionEpoch = 0 + let lastInvalidation: { epoch: number; kind: InvalidationKind } = { + epoch: 0, + kind: 'session-change', + } + const activeOperations = new Set() + const activeReadByBoard = new Map() + const readRetryByBoard = new Map() + const mutationGenerationByBoard = new Map() + + function syncLoading() { + loading.value = activeOperations.size > 0 + } + + function beginOperation(label: string): OperationOwner { + const owner = { epoch: sessionEpoch, token: Symbol(label), userId: session.userId } + activeOperations.add(owner.token) + error.value = null + syncLoading() + return owner + } + + function ownsSession(owner: OperationOwner): boolean { + return owner.epoch === sessionEpoch + } + + function finishOperation(owner: OperationOwner) { + if (!ownsSession(owner)) return + activeOperations.delete(owner.token) + syncLoading() + } + + function mutationGeneration(boardId: string): number { + return mutationGenerationByBoard.get(boardId) ?? 0 + } + + function beginRead( + boardId: string, + retry: ReadRetry, + revalidateOnTokenRotation = false, + ): ReadOwner { + const previous = activeReadByBoard.get(boardId) + if (previous?.epoch === sessionEpoch) activeOperations.delete(previous.token) + + const operation = beginOperation(`read:${boardId}`) + const owner = { + ...operation, + observedMutationGeneration: mutationGeneration(boardId), + revalidateOnTokenRotation, + } + activeReadByBoard.set(boardId, owner) + readRetryByBoard.set(boardId, retry) + return owner + } + + function ownsRead(boardId: string, owner: ReadOwner): boolean { + const current = activeReadByBoard.get(boardId) + return ownsSession(owner) + && current?.token === owner.token + && owner.observedMutationGeneration === mutationGeneration(boardId) + } + + function finishRead(boardId: string, owner: ReadOwner) { + if (activeReadByBoard.get(boardId)?.token === owner.token) { + activeReadByBoard.delete(boardId) + readRetryByBoard.delete(boardId) + } + finishOperation(owner) + } + + function recordMutation(boardId: string) { + mutationGenerationByBoard.set(boardId, mutationGeneration(boardId) + 1) + + const staleRead = activeReadByBoard.get(boardId) + if (staleRead?.epoch === sessionEpoch) { + activeReadByBoard.delete(boardId) + readRetryByBoard.delete(boardId) + activeOperations.delete(staleRead.token) + syncLoading() + } + } + + function invalidateOperations(kind: InvalidationKind = 'session-change') { + sessionEpoch += 1 + lastInvalidation = { epoch: sessionEpoch, kind } + activeOperations.clear() + activeReadByBoard.clear() + readRetryByBoard.clear() + mutationGenerationByBoard.clear() + loading.value = false + error.value = null + } + + function retryMissingActiveReads(isTokenRotation: boolean) { + const retries = Array.from(activeReadByBoard.keys()) + .filter(boardId => { + const owner = activeReadByBoard.get(boardId) + return owner?.revalidateOnTokenRotation === true || !boardAccess.value.has(boardId) + }) + .map(boardId => readRetryByBoard.get(boardId)) + .filter((retry): retry is ReadRetry => retry !== undefined) + + invalidateOperations(isTokenRotation ? 'token-rotation' : 'session-change') + for (const retry of retries) { + void retry().catch(() => { + // The retried store action owns current error/toast state. + }) + } + } + + async function reconcileStaleMutation(boardId: string, owner: OperationOwner) { + // A same-user token rotation retires the mutation owner, but the server may + // already have committed it. Re-read under the replacement credential so a + // successful mutation cannot disappear from the access cache. Identity + // changes and logout must not read a board on behalf of the old session. + if (ownsSession(owner) + || lastInvalidation.epoch !== sessionEpoch + || lastInvalidation.kind !== 'token-rotation' + || owner.userId === null + || owner.userId !== session.userId + || !session.isAuthenticated + || session.isDemo) { + return + } + + try { + await fetchBoardAccess(boardId, true) + } catch { + // The read owns its error/toast state. The mutation itself already + // settled successfully, so do not turn a reconciliation failure into a + // second, misleading mutation failure. + } + } + + function resetForSession() { + invalidateOperations() + boardAccess.value = new Map() + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + + watch( + () => session.token, + (token, previousToken) => { + // A valid token replacement with the same authenticated identity is the + // only session transition where a committed stale mutation is safe to + // reconcile. Logout followed by a quick same-user login must not let an + // old lifecycle read or mutate the replacement session. + const isTokenRotation = token !== null + && previousToken !== null + && session.userId !== null + && session.isAuthenticated + && !session.isDemo + retryMissingActiveReads(isTokenRotation) + }, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -54,7 +231,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } }) - async function fetchBoardAccess(boardId: string) { + async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = true) { if (isDemoMode) { loading.value = true error.value = null @@ -62,86 +239,118 @@ export const usePermissionsStore = defineStore('permissions', () => { loading.value = false return } + + const owner = beginRead( + boardId, + () => fetchBoardAccess(boardId, revalidateOnTokenRotation), + revalidateOnTokenRotation, + ) try { - loading.value = true - error.value = null const access = await boardAccessApi.getAccess(boardId) + if (!ownsRead(boardId, owner)) return boardAccess.value.set(boardId, access) } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board access').message - error.value = msg - toast.error(msg) + if (ownsRead(boardId, owner)) { + const msg = getErrorDisplay(e, 'Failed to fetch board access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishRead(boardId, owner) } } async function grantAccess(boardId: string, dto: GrantAccessDto) { guardDemoMutation() + const owner = beginOperation(`grant:${boardId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const access = await boardAccessApi.grantAccess(boardId, dto) + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return access + } + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, [...existing, access]) + if (!existing.some(entry => entry.id === access.id)) { + boardAccess.value.set(boardId, [...existing, access]) + } toast.success('Access granted') return access } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to grant access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to grant access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() + const owner = beginOperation(`update:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return updated + } + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - const index = existing.findIndex(a => a.id === accessId) - if (index !== -1) { - existing[index] = updated - boardAccess.value.set(boardId, [...existing]) + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) } toast.success('Access updated') return updated } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() + const owner = beginOperation(`revoke:${boardId}:${accessId}`) try { - loading.value = true - error.value = null session.requireUserId('board access management') await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return + } + + recordMutation(boardId) const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(a => a.id !== accessId)) + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) toast.success('Access revoked') } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + error.value = msg + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } + return { boardAccess, loading, diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts index d579138a3..33499b2d3 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStore.spec.ts @@ -40,8 +40,9 @@ describe('permissionsStore', () => { beforeEach(() => { setActivePinia(createPinia()) - store = usePermissionsStore() sessionStore = useSessionStore() + sessionStore.userId = 'user-1' + store = usePermissionsStore() vi.clearAllMocks() }) @@ -186,6 +187,8 @@ describe('permissionsStore', () => { describe('guardrails', () => { it('throws if grantAccess is called without a session user', async () => { + sessionStore.userId = null + await expect(store.grantAccess('board-1', { userId: 'user-2', role: 'Viewer' })) .rejects .toThrow('You must be logged in to use board access management.') @@ -193,6 +196,7 @@ describe('permissionsStore', () => { }) it('returns null role checks when no session user exists', () => { + sessionStore.userId = null store.boardAccess.set('board-1', [makeAccess({ userId: 'user-1', role: 'Owner' })]) expect(store.currentUserRole('board-1')).toBeNull() diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts new file mode 100644 index 000000000..90e277250 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreOwnership.spec.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'user-1', + role: 'Owner', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +describe('permissionsStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('does not let a read started before revoke reintroduce the revoked entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner, viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + + read.resolve([owner, viewer]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner']) + }) + + it('does not let a read started before grant erase the granted entry', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.grantAccess).mockResolvedValue(granted) + + const readRequest = store.fetchBoardAccess('board-1') + await store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + read.resolve([owner]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + + it('does not duplicate a grant already observed by a newer authoritative read', async () => { + const owner = access({ id: 'owner', userId: 'owner-1', role: 'Owner' }) + const granted = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [owner]) + const grant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(grant.promise) + vi.mocked(boardAccessApi.getAccess).mockResolvedValue([owner, granted]) + + const grantRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + await store.fetchBoardAccess('board-1') + grant.resolve(granted) + await grantRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['owner', 'viewer']) + }) + + it('does not let a read started before a role update restore the old role', async () => { + const oldEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + const updatedEntry = access({ id: 'viewer', userId: 'viewer-1', role: 'Admin' }) + store.boardAccess.set('board-1', [oldEntry]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.updateAccess).mockResolvedValue(updatedEntry) + + const readRequest = store.fetchBoardAccess('board-1') + await store.updateAccess('board-1', 'viewer', { role: 'Admin' }) + read.resolve([oldEntry]) + await readRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + }) + + it('keeps the newest same-board read when responses settle in reverse order', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + + const oldRequest = store.fetchBoardAccess('board-1') + const newRequest = store.fetchBoardAccess('board-1') + newer.resolve([access({ id: 'new', role: 'Admin' })]) + await newRequest + older.resolve([access({ id: 'old', role: 'Viewer' })]) + await oldRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['new']) + }) + + it('keeps loading true while independent board reads remain active', async () => { + const boardOne = deferred() + const boardTwo = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(boardOne.promise) + .mockReturnValueOnce(boardTwo.promise) + + const first = store.fetchBoardAccess('board-1') + const second = store.fetchBoardAccess('board-2') + expect(store.loading).toBe(true) + + boardOne.resolve([access({ boardId: 'board-1' })]) + await first + expect(store.loading).toBe(true) + + boardTwo.resolve([access({ id: 'board-2-owner', boardId: 'board-2' })]) + await second + expect(store.loading).toBe(false) + }) + + it('invalidates an old read across logout and login as the same user id', async () => { + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + const request = store.fetchBoardAccess('board-1') + + session.userId = null + session.userId = 'owner-1' + read.resolve([access({ id: 'old-session' })]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('does not publish a mutation that settles after the session changes', async () => { + const pendingGrant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.userId = null + session.userId = 'other-user' + pendingGrant.resolve(access({ id: 'old-session-grant', userId: 'viewer-1', role: 'Viewer' })) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.success).not.toHaveBeenCalled() + }) + + it('suppresses a stale read failure after a confirmed mutation', async () => { + const viewer = access({ id: 'viewer', userId: 'viewer-1', role: 'Viewer' }) + store.boardAccess.set('board-1', [viewer]) + const read = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(read.promise) + vi.mocked(boardAccessApi.revokeAccess).mockResolvedValue() + + const readRequest = store.fetchBoardAccess('board-1') + await store.revokeAccess('board-1', 'viewer') + read.reject(new Error('stale failure')) + await expect(readRequest).rejects.toThrow('stale failure') + + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts new file mode 100644 index 000000000..0ff0d50d8 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function token(suffix: string): string { + const body = btoa(JSON.stringify({ exp: 1893456000 })) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `header.${body}.${suffix}` +} + +function access(id: string): BoardAccess { + return { + id, + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +describe('permissionsStore token ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + session.token = token('old') + store = usePermissionsStore() + vi.clearAllMocks() + }) + + it('preserves loaded access while invalidating an old read after same-user token rotation', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pending = deferred() + const freshRead = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(pending.promise).mockReturnValueOnce(freshRead.promise) + const request = store.fetchBoardAccess('board-1') + + session.token = token('new') + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) + expect(store.loading).toBe(true) + expect(store.error).toBeNull() + + pending.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) + expect(store.loading).toBe(true) + expect(store.error).toBeNull() + + freshRead.resolve([access('fresh-token-read')]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-token-read']) + expect(store.loading).toBe(false) + }) + }) + + it('preserves loaded access while suppressing an old-token mutation failure', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pending = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pending.promise) + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + + session.token = token('new') + pending.reject(new Error('old credential failure')) + await expect(request).rejects.toThrow('old credential failure') + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('reconciles a successful old-token grant after same-user token rotation', async () => { + const existing = access('existing') + const granted = access('fresh-grant') + store.boardAccess.set('board-1', [existing]) + const pendingGrant = deferred() + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + vi.mocked(boardAccessApi.getAccess).mockResolvedValue([existing, granted]) + + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + pendingGrant.resolve(granted) + + await expect(request).resolves.toEqual(granted) + expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1') + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['existing', 'fresh-grant']) + }) + + it('does not reconcile a stale mutation after logout and same-user re-login', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pendingGrant = deferred() + const granted = access('stale-grant') + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + + const request = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.clearSession() + session.token = token('new-login') + session.userId = 'owner-1' + pendingGrant.resolve(granted) + + await expect(request).resolves.toEqual(granted) + expect(boardAccessApi.getAccess).not.toHaveBeenCalled() + expect(store.boardAccess.has('board-1')).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('supersedes an active replacement read after a stale mutation settles', async () => { + const oldRead = deferred() + const replacementRead = deferred() + const reconciledRead = deferred() + const pendingGrant = deferred() + const granted = access('fresh-grant') + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(replacementRead.promise) + .mockReturnValueOnce(reconciledRead.promise) + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + + const readRequest = store.fetchBoardAccess('board-1') + const mutationRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + + pendingGrant.resolve(granted) + await vi.waitFor(() => { + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(3) + }) + + reconciledRead.resolve([granted]) + await expect(mutationRequest).resolves.toEqual(granted) + + replacementRead.resolve([access('stale-replacement-read')]) + oldRead.resolve([access('stale-old-read')]) + await readRequest + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('retries an in-flight reconciliation read after another token rotation', async () => { + store.boardAccess.set('board-1', [access('existing')]) + const pendingGrant = deferred() + const firstReconciliation = deferred() + const secondReconciliation = deferred() + const granted = access('fresh-grant') + vi.mocked(boardAccessApi.grantAccess).mockReturnValue(pendingGrant.promise) + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(firstReconciliation.promise) + .mockReturnValueOnce(secondReconciliation.promise) + + const mutationRequest = store.grantAccess('board-1', { userId: 'viewer-1', role: 'Viewer' }) + session.token = token('new') + pendingGrant.resolve(granted) + + await vi.waitFor(() => { + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(1) + }) + + session.token = token('newer') + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + + secondReconciliation.resolve([granted]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + }) + + firstReconciliation.resolve([access('stale-reconciliation')]) + await expect(mutationRequest).resolves.toEqual(granted) + + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-grant']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('retries an unresolved board-access read after same-user token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(boardAccessApi.getAccess) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardAccess('board-1') + session.token = token('new') + + expect(boardAccessApi.getAccess).toHaveBeenCalledTimes(2) + expect(store.boardAccess.has('board-1')).toBe(false) + expect(store.loading).toBe(true) + + oldRead.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.has('board-1')).toBe(false) + expect(store.loading).toBe(true) + + freshRead.resolve([access('fresh-token-read')]) + await vi.waitFor(() => { + expect(store.boardAccess.get('board-1')?.map(item => item.id)).toEqual(['fresh-token-read']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + }) +})