diff --git a/docs/analysis/2026-09-21-permission-mutation-order.md b/docs/analysis/2026-09-21-permission-mutation-order.md new file mode 100644 index 0000000000..2910de2ca2 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-mutation-order.md @@ -0,0 +1,28 @@ +# Board-access mutation ordering + +Status: stacked draft PR #3335, 2026-09-21. Parent: PR #3330. + +## Reproduced defects + +`updateAccess` and `revokeAccess` started independently for one access row even though the API accepts no expected revision and the entity has no configured concurrency token. Two role changes could therefore commit or settle in an order that differed from the user's clicks. Update/revoke could also overlap, and a queued pre-logout intent had no transport-time session check. + +Independent mutation failures exposed a second ownership gap during review. A queued same-entry mutation cleared the store's shared error when its transport started. If another access row failed while the queued intent was waiting, the queued start erased that unrelated receipt. + +## Contract + +- One queue exists per `{boardId, accessId}`. Update and revoke for that row run in submission order; different rows remain concurrent. +- The first intent starts transport synchronously. A later intent waits for its predecessor to finish, regardless of success or failure. +- Each queued operation owns a loading token from submission through settlement, so loading does not drop between same-entry operations. +- Immediately before transport, queued work rechecks the initiating session epoch. Identity, token, authentication or demo replacement clears queue registration and prevents old intent from using later credentials. +- A predecessor failure does not cancel the next intent. The queued transport clears an error only when that receipt is owned by its own predecessor; an unrelated concurrent failure remains visible. +- Existing successful-mutation read invalidation, stable-ID grant deduplication and stale settlement rules from #3330 remain unchanged. + +The client queue preserves one client's submission order only. It does not solve cross-device concurrency; the backend currently exposes no revision precondition. + +## Evidence and remaining gates + +The initial ordering negative control ran the actual production store with only framework/API/session boundaries stubbed: one independent-row control passed and four ordering/session schedules failed on the parent, then all five passed after serialization. + +Codex review identified the independent-error case. A dedicated deferred Pinia regression was committed before the correction: access row 2 fails while row 1's second update waits, then row 1's queued transport starts without erasing row 2's error. + +This child is rebuilt on the parent token-rotation correction rather than retaining its older copy of the permissions store. Exact-head Pinia/Vitest, lint, project typecheck, build, the complete hosted matrix and repeat independent review remain required. After #3330 lands, retarget to current `main`, verify the child-only diff and requalify. No merge, release or deployment qualification is claimed. 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 0000000000..1d65748d14 --- /dev/null +++ b/docs/analysis/2026-09-21-permission-read-ownership.md @@ -0,0 +1,41 @@ +# 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 that 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 session replacement. + +A supplemental runner transpiled and executed the actual store module with only +Pinia/Vue/API/session boundaries stubbed. Seven original schedules failed on +`main`: revoke, update, reverse reads, independent loading, same-user session +replacement, old-session mutation and stale failure. The committed Vitest suite +also covers grant settlement. + +## Contract + +- One read owner exists per board; unrelated boards remain concurrent. +- A successful mutation advances that board's generation and retires older reads. +- Session identity/auth/demo transitions synchronously advance an epoch, clear + cached access, retire operations and reset loading/error. +- Success, failure, toast and cache writes require the initiating session epoch. +- Loading is derived from current operation tokens, not whichever call settles. +- A stale call still resolves or rejects to its caller; it loses only permission + to alter the replacement session's UI state. + +Server authorization remains authoritative. This corrects truthful client cache +behavior and does not claim a server-side authorization bypass. Same-board +mutation serialization is outside this slice. + +## Verification and remaining gates + +The actual-module supplemental suite changed from 0/7 ownership cases passing on +`main` to 7/7 after the correction; all twelve integration/permission schedules +pass together. TypeScript syntax transpilation passes. Existing permission-store +tests are adjusted so authenticated fixture state is established before the +session-watching store is created. Canonical lint, typecheck, build, complete +Vitest coverage, exact-head hosted CI and independent review remain required. +No merge, release or deployment qualification is claimed here. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 938eb72966..3d45181cc4 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,146 @@ export const usePermissionsStore = defineStore('permissions', () => { const loading = ref(false) const error = ref(null) + interface OperationOwner { + epoch: number + token: symbol + } + + interface ReadOwner extends OperationOwner { + observedMutationGeneration: number + } + + interface MutationTail { + promise: Promise + ownerToken: symbol + } + + let sessionEpoch = 0 + let errorOwner: symbol | null = null + const activeOperations = new Set() + const activeReadByBoard = new Map() + const mutationGenerationByBoard = new Map() + const mutationTails = new Map() + + function syncLoading() { + loading.value = activeOperations.size > 0 + } + + function clearError() { + error.value = null + errorOwner = null + } + + function recordError(owner: OperationOwner, message: string) { + error.value = message + errorOwner = owner.token + } + + function beginOperation(label: string): OperationOwner { + const owner = { epoch: sessionEpoch, token: Symbol(label) } + activeOperations.add(owner.token) + clearError() + 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): 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), + } + activeReadByBoard.set(boardId, owner) + 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) + } + finishOperation(owner) + } + + function recordMutation(boardId: string) { + mutationGenerationByBoard.set(boardId, mutationGeneration(boardId) + 1) + + const staleRead = activeReadByBoard.get(boardId) + if (staleRead?.epoch === sessionEpoch) { + activeReadByBoard.delete(boardId) + activeOperations.delete(staleRead.token) + syncLoading() + } + } + + async function enqueueAccessMutation( + boardId: string, + accessId: string, + label: string, + task: (owner: OperationOwner) => Promise, + ): Promise { + const key = `${boardId}:${accessId}` + const predecessor = mutationTails.get(key) + const owner = beginOperation(label) + let release!: () => void + const tail = new Promise((resolve) => { release = resolve }) + mutationTails.set(key, { promise: tail, ownerToken: owner.token }) + + try { + if (predecessor) await predecessor.promise + if (!ownsSession(owner)) return undefined + + // Retire only an error produced by this lane's predecessor. Another + // access row can fail while this intent waits and must keep its receipt. + if (predecessor && errorOwner === predecessor.ownerToken) clearError() + return await task(owner) + } finally { + finishOperation(owner) + release() + if (mutationTails.get(key)?.promise === tail) mutationTails.delete(key) + } + } + + function resetForSession() { + sessionEpoch += 1 + activeOperations.clear() + activeReadByBoard.clear() + mutationGenerationByBoard.clear() + mutationTails.clear() + boardAccess.value = new Map() + loading.value = false + clearError() + } + + watch( + () => [session.userId, session.token, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -57,91 +197,118 @@ export const usePermissionsStore = defineStore('permissions', () => { async function fetchBoardAccess(boardId: string) { if (isDemoMode) { loading.value = true - error.value = null + clearError() boardAccess.value.set(boardId, []) loading.value = false return } + + const owner = beginRead(boardId) 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 + recordError(owner, 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)) 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 + recordError(owner, msg) + toast.error(msg) + } throw e } finally { - loading.value = false + finishOperation(owner) } } async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() - try { - loading.value = true - error.value = null - session.requireUserId('board access management') - const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) - 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]) - } - toast.success('Access updated') - return updated - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false - } + return await enqueueAccessMutation( + boardId, + accessId, + `update:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) return updated + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + 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) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + recordError(owner, msg) + toast.error(msg) + } + throw e + } + }, + ) } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() - try { - loading.value = true - error.value = null - session.requireUserId('board access management') - await boardAccessApi.revokeAccess(boardId, accessId) - const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(a => a.id !== accessId)) - toast.success('Access revoked') - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false - } + await enqueueAccessMutation( + boardId, + accessId, + `revoke:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) return + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) + toast.success('Access revoked') + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + recordError(owner, msg) + toast.error(msg) + } + throw e + } + }, + ) } + 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 d579138a32..33499b2d3f 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/permissionsStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts new file mode 100644 index 0000000000..259430539d --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,99 @@ +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' + +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: () => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})) + +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(id: string, role: BoardAccess['role'] = 'Viewer'): BoardAccess { + return { + id, + boardId: 'board-1', + userId: `${id}-user`, + role, + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'owner-1' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-entry work starts', async () => { + const store = usePermissionsStore() + store.boardAccess.set('board-1', [access('access-1'), access('access-2')]) + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const independentRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + independent.reject(new Error('independent access failed')) + await expect(independentRequest).rejects.toThrow('independent access failed') + expect(store.error).toBe('independent access failed') + + first.resolve(access('access-1', 'Editor')) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent access failed') + + queued.resolve(access('access-1', 'Admin')) + await queuedRequest + expect(store.error).toBe('independent access failed') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts new file mode 100644 index 0000000000..5cb3a10b6c --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts @@ -0,0 +1,179 @@ +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: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + store.boardAccess.set('board-1', [access()]) + vi.clearAllMocks() + }) + + it('serializes two role updates for one access row in intent order', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + first.resolve(access({ role: 'Editor' })) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + second.resolve(access({ role: 'Admin' })) + await secondRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('continues with the queued update after its predecessor fails', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + + second.resolve(access({ role: 'Admin' })) + await secondRequest + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('orders update before revoke and leaves the row removed', async () => { + const update = deferred() + const revoke = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(update.promise) + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const revokeRequest = store.revokeAccess('board-1', 'access-1') + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + + update.resolve(access({ role: 'Admin' })) + await updateRequest + await flushQueue() + expect(boardAccessApi.revokeAccess).toHaveBeenCalledTimes(1) + + revoke.resolve() + await revokeRequest + expect(store.boardAccess.get('board-1')).toEqual([]) + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(first.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + session.userId = null + session.userId = 'owner-1' + + first.resolve(access({ role: 'Editor' })) + await firstRequest + await queuedRequest + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + }) + + it('keeps different access rows concurrent', async () => { + store.boardAccess.set('board-1', [ + access({ id: 'access-1', userId: 'viewer-1' }), + access({ id: 'access-2', userId: 'viewer-2' }), + ]) + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + first.resolve(access({ id: 'access-1', userId: 'viewer-1', role: 'Editor' })) + second.resolve(access({ id: 'access-2', userId: 'viewer-2', role: 'Admin' })) + await Promise.all([firstRequest, secondRequest]) + + expect(store.boardAccess.get('board-1')?.map(item => item.role)).toEqual(['Editor', 'Admin']) + }) +}) 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 0000000000..90e2772503 --- /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 0000000000..b44a56bf48 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -0,0 +1,107 @@ +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('invalidates an old read when the credential rotates for the same user', async () => { + const pending = deferred() + vi.mocked(boardAccessApi.getAccess).mockReturnValue(pending.promise) + const request = store.fetchBoardAccess('board-1') + + session.token = token('new') + pending.resolve([access('old-token-read')]) + await request + + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('suppresses a stale mutation failure after credential rotation', async () => { + 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.size).toBe(0) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) +})