diff --git a/docs/analysis/2026-09-21-audit-history-ownership.md b/docs/analysis/2026-09-21-audit-history-ownership.md new file mode 100644 index 000000000..757897bfd --- /dev/null +++ b/docs/analysis/2026-09-21-audit-history-ownership.md @@ -0,0 +1,43 @@ +# Activity history request ownership + +Status: corrective draft for #3344 / PR #3345. Base: `307c3b8b50bec1cb0bfaea3e570a942bcb1d4451`. + +## Reproduced defects + +Board, entity and user history requests replace one `auditStore.entries` surface. The original store allowed every response, failure, toast and `finally` to commit, so route changes could restore older queries, report obsolete failures or clear current loading. + +The first lifecycle correction treated token refresh as full identity reset and cleared loaded history. The preservation correction then exposed a second boundary: an empty initial history read was retired during refresh and the unchanged route did not refetch. + +## Contract + +- One current request owner exists across board, entity and user query kinds. +- A newer query retires the previous owner's permission to commit UI state. +- User identity, authentication or demo-session replacement advances the epoch, retires work and clears history. +- Token-only rotation preserves settled history, suppresses old-token UI settlement and restarts the active query only while history is still empty. +- The retry retains the exact board/entity/user parameters and limit captured by the active query. +- Stale work still resolves or rejects to its original caller, but cannot write entries, error, toast, loading or final state. +- A current failure retains the previous result list and preserves the public error/toast/rejection behavior. +- Limit clamping, endpoints, route behavior, demo behavior and the public store API remain unchanged. + +The three request bodies use one private helper so ownership and failure rules cannot drift. This is client-state integrity, not transport cancellation or a server authorization claim. + +## Test-first evidence + +Initial test-only head `3980e1e3e234a251cd89cad270b8d0ab86c3e5f2` produced five intended ownership failures against unchanged `main`. + +Review-regression head `93c20b679888394200354d80040e7f3c7dd5c353` ran the canonical Node 24 frontend suite on Ubuntu and Windows. Lint, typecheck, production build and PWA validation passed on both platforms. Ubuntu JUnit recorded **7,156 tests, exactly 2 failures, 0 errors**, both loaded-history preservation cases. + +Issue #3352 added test-only head `85cf369ddd11dfe0a91052eb1523efbddf904a7c`, covering token rotation during an empty initial board-history read. A dependency-free runner transpiled and executed the actual production module: + +- before the retry correction: the API was called once and loading became false after rotation; +- after the correction: the API was called twice, old-token settlement was suppressed and the fresh-token result populated history. + +Hosted exact-head qualification remains authoritative; the supplemental runner does not replace it. + +## Remaining gates + +Current production correction: `110edf3029450dc261b4feee30d6c78dd6e00405` before this documentation commit. + +Exact final-head lint, typecheck, production build, complete Vitest on Ubuntu and Windows, Required CI, Extended, Self-Test and fresh-context review remain required. Review should focus on exact query capture, no retry after route clear and no retry loops. + +No merge, release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/auditStore.ts b/frontend/taskdeck-web/src/store/auditStore.ts index bc1b3ac8c..d97132cc2 100644 --- a/frontend/taskdeck-web/src/store/auditStore.ts +++ b/frontend/taskdeck-web/src/store/auditStore.ts @@ -1,89 +1,204 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { auditApi } from '../api/auditApi' import { useToastStore } from './toastStore' +import { useSessionStore } from './sessionStore' import { isDemoMode } from '../utils/demoMode' import type { AuditEntry } from '../types/audit' import { getErrorDisplay } from '../composables/useErrorMapper' export const useAuditStore = defineStore('audit', () => { const toast = useToastStore() + const session = useSessionStore() const entries = ref([]) const loading = ref(false) const error = ref(null) + type ReadRetry = () => Promise + + interface ReadOwner { + epoch: number + token: symbol + successorReady: Promise<{ successor: Promise }> + resolveSuccessor: (successor: Promise) => void + } + + let credentialEpoch = 0 + let currentRead: ReadOwner | null = null + let currentRetry: ReadRetry | null = null + const retiredReads = new Set() + function clampLimit(limit: number): number { if (limit < 1) return 1 if (limit > 100) return 100 return limit } - async function fetchBoardHistory(boardId: string, limit = 50) { - if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false - return + function beginRead(retry: ReadRetry): ReadOwner { + if (currentRead) retiredReads.add(currentRead) + let resolveSuccessor!: (successor: Promise) => void + const successorReady = new Promise<{ successor: Promise }>((resolve) => { + resolveSuccessor = (successor) => resolve({ successor }) + }) + const owner = { + epoch: credentialEpoch, + token: Symbol('audit-history'), + successorReady, + resolveSuccessor, + } + currentRead = owner + currentRetry = retry + error.value = null + loading.value = true + return owner + } + + function ownsRead(owner: ReadOwner): boolean { + return owner.epoch === credentialEpoch && currentRead?.token === owner.token + } + + function finishRead(owner: ReadOwner): void { + retiredReads.delete(owner) + if (!ownsRead(owner)) return + currentRead = null + currentRetry = null + loading.value = false + } + + function invalidateCurrentRead(): void { + for (const owner of retiredReads) owner.resolveSuccessor(Promise.resolve()) + retiredReads.clear() + credentialEpoch += 1 + currentRead = null + currentRetry = null + loading.value = false + error.value = null + } + + async function awaitSuccessor(owner: ReadOwner): Promise { + if (owner.epoch === credentialEpoch) return false + await owner.successorReady.then(({ successor }) => successor) + return true + } + + function retryActiveRead(): void { + const retry = currentRead && currentRetry + ? { owner: currentRead, retry: currentRetry } + : null + invalidateCurrentRead() + if (!retry) return + + let resolveSuccessor!: () => void + let rejectSuccessor!: (reason: unknown) => void + const successor = new Promise((resolve, reject) => { + resolveSuccessor = resolve + rejectSuccessor = reject + }) + retry.owner.resolveSuccessor(successor) + + try { + void retry.retry().then(resolveSuccessor, rejectSuccessor) + } catch (error) { + rejectSuccessor(error) } + void successor.catch(() => { + // The retried store action owns current error/toast state. + }) + } + + function resetForSession(): void { + const retiredRead = currentRead + invalidateCurrentRead() + retiredRead?.resolveSuccessor(Promise.resolve()) + entries.value = [] + } + + watch( + () => [session.userId, session.isAuthenticated, session.isDemo], + resetForSession, + { flush: 'sync' }, + ) + + watch( + () => session.token, + retryActiveRead, + { flush: 'sync' }, + ) + + async function fetchHistory( + request: () => Promise, + fallbackMessage: string, + retry: ReadRetry, + ): Promise { + const owner = beginRead(retry) try { - loading.value = true - error.value = null - entries.value = await auditApi.getBoardHistory(boardId, clampLimit(limit)) + const requestPromise = request() + const outcome = await Promise.race([ + requestPromise.then((result) => ({ kind: 'request' as const, result })), + owner.successorReady.then(({ successor }) => successor.then(() => ({ kind: 'successor' as const }))), + ]) + if (outcome.kind === 'successor') return + + const result = outcome.result + if (!ownsRead(owner)) { + await awaitSuccessor(owner) + return + } + entries.value = result } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch board history').message - error.value = msg - toast.error(msg) + if (ownsRead(owner)) { + const msg = getErrorDisplay(e, fallbackMessage).message + error.value = msg + toast.error(msg) + } else if (await awaitSuccessor(owner)) { + return + } throw e } finally { - loading.value = false + finishRead(owner) } } - async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) { + async function fetchBoardHistory(boardId: string, limit = 50) { if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false + resetForSession() return } - try { - loading.value = true - error.value = null - entries.value = await auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)) - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch entity history').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false + + await fetchHistory( + () => auditApi.getBoardHistory(boardId, clampLimit(limit)), + 'Failed to fetch board history', + () => fetchBoardHistory(boardId, limit), + ) + } + + async function fetchEntityHistory(entityType: string, entityId: string, limit = 50) { + if (isDemoMode) { + resetForSession() + return } + + await fetchHistory( + () => auditApi.getEntityHistory(entityType, entityId, clampLimit(limit)), + 'Failed to fetch entity history', + () => fetchEntityHistory(entityType, entityId, limit), + ) } async function fetchUserHistory(limit = 50) { if (isDemoMode) { - loading.value = true - error.value = null - entries.value = [] - loading.value = false + resetForSession() return } - try { - loading.value = true - error.value = null - entries.value = await auditApi.getUserHistory(clampLimit(limit)) - } catch (e: unknown) { - const msg = getErrorDisplay(e, 'Failed to fetch user history').message - error.value = msg - toast.error(msg) - throw e - } finally { - loading.value = false - } + + await fetchHistory( + () => auditApi.getUserHistory(clampLimit(limit)), + 'Failed to fetch user history', + () => fetchUserHistory(limit), + ) } + return { entries, loading, diff --git a/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts new file mode 100644 index 000000000..17f0bbe1b --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/auditStoreOwnership.spec.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { auditApi } from '../../api/auditApi' +import { useAuditStore } from '../../store/auditStore' +import { useSessionStore } from '../../store/sessionStore' +import type { AuditEntry } from '../../types/audit' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/auditApi', () => ({ + auditApi: { + getBoardHistory: vi.fn(), + getEntityHistory: vi.fn(), + getUserHistory: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + refreshToken: vi.fn(), + exchangeOAuthCode: vi.fn(), + exchangeOidcCode: 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 entry(id: string): AuditEntry { + return { + id, + entityType: 'Card', + entityId: id, + action: 'Updated', + userId: 'user-a', + userName: 'Alex', + changes: null, + timestamp: '2026-09-21T00:00:00Z', + } +} + +describe('auditStore async ownership', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'user-a' + session.token = 'token-a' + store = useAuditStore() + vi.clearAllMocks() + }) + + it('keeps the newest cross-kind history query when responses settle in reverse order', async () => { + const oldBoard = deferred() + const newEntity = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(oldBoard.promise) + vi.mocked(auditApi.getEntityHistory).mockReturnValue(newEntity.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchEntityHistory('Card', 'card-new') + + newEntity.resolve([entry('new-entity')]) + await newRequest + oldBoard.resolve([entry('old-board')]) + await oldRequest + + expect(store.entries.map(item => item.id)).toEqual(['new-entity']) + expect(store.error).toBeNull() + }) + + it('settles a superseded query after a later token rotation', async () => { + const firstRead = deferred() + const secondRead = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(firstRead.promise) + vi.mocked(auditApi.getEntityHistory) + .mockReturnValueOnce(secondRead.promise) + .mockResolvedValue([entry('new-entity')]) + + const firstRequest = store.fetchBoardHistory('board-old') + const secondRequest = store.fetchEntityHistory('Card', 'card-new') + session.token = 'token-b' + + firstRead.resolve([entry('old-board')]) + await expect(firstRequest).resolves.toBeUndefined() + + secondRead.resolve([entry('new-entity')]) + await secondRequest + expect(store.entries.map(item => item.id)).toEqual(['new-entity']) + }) + + it('suppresses stale failure UI after a newer query succeeds while preserving rejection', async () => { + const oldBoard = deferred() + const newUser = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(oldBoard.promise) + vi.mocked(auditApi.getUserHistory).mockReturnValue(newUser.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchUserHistory() + + newUser.resolve([entry('new-user')]) + await newRequest + oldBoard.reject(new Error('stale board failure')) + await expect(oldRequest).rejects.toThrow('stale board failure') + + expect(store.entries.map(item => item.id)).toEqual(['new-user']) + expect(store.error).toBeNull() + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('does not let an older finally clear loading owned by the current query', async () => { + const older = deferred() + const newer = deferred() + vi.mocked(auditApi.getBoardHistory).mockReturnValue(older.promise) + vi.mocked(auditApi.getUserHistory).mockReturnValue(newer.promise) + + const oldRequest = store.fetchBoardHistory('board-old') + const newRequest = store.fetchUserHistory() + + older.resolve([entry('old')]) + await oldRequest + expect(store.loading).toBe(true) + + newer.resolve([entry('new')]) + await newRequest + expect(store.loading).toBe(false) + }) + + it('reconciles loaded history after a same-user token refresh', async () => { + store.entries = [entry('existing')] + store.error = 'existing error' + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardHistory('board-old') + expect(store.loading).toBe(true) + + session.token = 'token-b' + + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) + expect(store.entries.map(item => item.id)).toEqual(['existing']) + expect(store.error).toBeNull() + expect(store.loading).toBe(true) + + oldRead.resolve([entry('old-token')]) + freshRead.resolve([entry('fresh-token')]) + await request + + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.error).toBeNull() + expect(store.loading).toBe(false) + }) + + it('lets a completed token-refresh successor settle a stalled retired read', async () => { + store.entries = [entry('existing')] + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardHistory('board-stalled') + session.token = 'token-b' + + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) + + freshRead.resolve([entry('fresh-token')]) + await request + + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.loading).toBe(false) + + oldRead.resolve([entry('old-token')]) + await Promise.resolve() + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + }) + + it('surfaces a replacement history failure after a same-user token refresh', async () => { + store.entries = [entry('existing')] + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getEntityHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchEntityHistory('Card', 'card-old') + session.token = 'token-b' + oldRead.reject(new Error('old-token failure')) + freshRead.reject(new Error('replacement failure')) + await expect(request).rejects.toThrow('replacement failure') + + expect(store.entries.map(item => item.id)).toEqual(['existing']) + expect(store.error).toBe('replacement failure') + expect(store.loading).toBe(false) + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + + it('retries an empty initial history read after same-user token rotation', async () => { + const oldRead = deferred() + const freshRead = deferred() + vi.mocked(auditApi.getBoardHistory) + .mockReturnValueOnce(oldRead.promise) + .mockReturnValueOnce(freshRead.promise) + + const request = store.fetchBoardHistory('board-a') + session.token = 'token-b' + + expect(auditApi.getBoardHistory).toHaveBeenCalledTimes(2) + expect(store.entries).toEqual([]) + expect(store.loading).toBe(true) + + oldRead.resolve([entry('old-token')]) + freshRead.resolve([entry('fresh-token')]) + await request + + expect(store.entries.map(item => item.id)).toEqual(['fresh-token']) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('clears history on identity replacement and suppresses late settlement', async () => { + store.entries = [entry('existing')] + const pending = deferred() + vi.mocked(auditApi.getUserHistory).mockReturnValue(pending.promise) + + const request = store.fetchUserHistory() + session.userId = 'user-b' + + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + + pending.resolve([entry('old-user')]) + await request + + expect(store.entries).toEqual([]) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) +})