mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
6 Commits
litegraph/
...
ben/fe-485
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84aa4108aa | ||
|
|
a9f06403cb | ||
|
|
d62ca1ff20 | ||
|
|
c3bcc28fbc | ||
|
|
5584911011 | ||
|
|
59a52e55e7 |
@@ -57,6 +57,10 @@ const mockTokenResponse = {
|
||||
permissions: ['owner:*']
|
||||
}
|
||||
|
||||
function expectedExpiresAtMs(expiresAt: string): string {
|
||||
return new Date(expiresAt).getTime().toString()
|
||||
}
|
||||
|
||||
describe('useWorkspaceAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -227,9 +231,9 @@ describe('useWorkspaceAuthStore', () => {
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'workspace-token-abc'
|
||||
)
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
).toBeTruthy()
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(mockTokenResponse.expires_at)
|
||||
)
|
||||
})
|
||||
|
||||
it('sets isLoading to true during operation', async () => {
|
||||
@@ -255,6 +259,51 @@ describe('useWorkspaceAuthStore', () => {
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps isLoading true until overlapping switches settle', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
let resolveFirstSwitch: (value: unknown) => void = () => {}
|
||||
let resolveSecondSwitch: (value: unknown) => void = () => {}
|
||||
const firstSwitchResponse = new Promise((resolve) => {
|
||||
resolveFirstSwitch = resolve
|
||||
})
|
||||
const secondSwitchResponse = new Promise((resolve) => {
|
||||
resolveSecondSwitch = resolve
|
||||
})
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstSwitchResponse)
|
||||
.mockReturnValueOnce(secondSwitchResponse)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { isLoading } = storeToRefs(store)
|
||||
|
||||
const firstSwitch = store.switchWorkspace('workspace-123')
|
||||
const secondSwitch = store.switchWorkspace('workspace-other')
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
resolveFirstSwitch({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
await firstSwitch
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
resolveSecondSwitch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
workspace: { ...mockWorkspace, id: 'workspace-other' }
|
||||
})
|
||||
})
|
||||
await secondSwitch
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('throws WorkspaceAuthError with code NOT_AUTHENTICATED when Firebase token unavailable', async () => {
|
||||
mockGetIdToken.mockResolvedValue(undefined)
|
||||
|
||||
@@ -439,6 +488,54 @@ describe('useWorkspaceAuthStore', () => {
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('prevents in-flight refreshes from restoring cleared state', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken, isAuthenticated, error } =
|
||||
storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
expect(isAuthenticated.value).toBe(true)
|
||||
|
||||
let resolveRefreshFetch: (value: unknown) => void = () => {}
|
||||
const refreshFetchPromise = new Promise((resolve) => {
|
||||
resolveRefreshFetch = resolve
|
||||
})
|
||||
mockFetch.mockReturnValueOnce(refreshFetchPromise)
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
store.clearWorkspaceContext()
|
||||
|
||||
resolveRefreshFetch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'restored-token'
|
||||
})
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value).toBeNull()
|
||||
expect(workspaceToken.value).toBeNull()
|
||||
expect(isAuthenticated.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
).toBeNull()
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkspaceAuthHeader', () => {
|
||||
@@ -671,14 +768,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
})
|
||||
|
||||
describe('refreshToken retry/race paths', () => {
|
||||
// NOTE: This test documents the CURRENT behavior — exhausted refresh
|
||||
// retries clear the workspace context unconditionally, even when the
|
||||
// existing workspace token is still within its expiry window. That is a
|
||||
// UX gap (transient backend outage manifests as forced logout) and the
|
||||
// store should preserve a still-valid token across transient
|
||||
// TOKEN_EXCHANGE_FAILED errors. Update the assertion alongside any source
|
||||
// change that tracks token expiry to skip the context clear.
|
||||
it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, then clears context', async () => {
|
||||
it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, then preserves valid context', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
// Initial successful switchWorkspace establishes context.
|
||||
@@ -689,10 +779,11 @@ describe('useWorkspaceAuthStore', () => {
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace } = storeToRefs(store)
|
||||
const { currentWorkspace, workspaceToken, error } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
expect(currentWorkspace.value).not.toBeNull()
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||
|
||||
// Subsequent refresh attempts all fail with 500 (TOKEN_EXCHANGE_FAILED).
|
||||
mockFetch.mockResolvedValue({
|
||||
@@ -711,8 +802,11 @@ describe('useWorkspaceAuthStore', () => {
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
// Drain the four attempts (initial + 3 retries) and their backoff delays.
|
||||
await vi.runAllTimersAsync()
|
||||
// Drain only the retry backoff delays; do not advance to the scheduled
|
||||
// proactive refresh timer for the still-valid token.
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await refreshPromise
|
||||
|
||||
// 1 initial switchWorkspace + 4 refresh attempts = 5 total fetch calls.
|
||||
@@ -734,8 +828,36 @@ describe('useWorkspaceAuthStore', () => {
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// After the final failure the context is cleared.
|
||||
expect(currentWorkspace.value).toBeNull()
|
||||
// After the final transient failure the still-valid context is preserved.
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
).toBe(JSON.stringify(mockWorkspaceWithRole))
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'workspace-token-abc'
|
||||
)
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(mockTokenResponse.expires_at)
|
||||
)
|
||||
expect(error.value).toBeNull()
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ ...mockTokenResponse, token: 'retry-token' })
|
||||
})
|
||||
|
||||
// Retry is scheduled at baseDelayMs * 2^maxRetries = 8000ms.
|
||||
await vi.advanceTimersByTimeAsync(7999)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(5)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(6)
|
||||
await vi.waitFor(() => {
|
||||
expect(workspaceToken.value).toBe('retry-token')
|
||||
})
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
consoleWarnSpy.mockRestore()
|
||||
@@ -776,16 +898,122 @@ describe('useWorkspaceAuthStore', () => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// KNOWN BUG (.fails): when an in-flight refresh's switchWorkspace call is
|
||||
// already past its requestId-staleness check and awaiting the token-exchange
|
||||
// fetch, switchWorkspace has no post-await commit guard. If the user
|
||||
// switches workspaces and the stale refresh's fetch resolves AFTER the new
|
||||
// switch has committed, the stale response will overwrite the new
|
||||
// workspace's currentWorkspace/workspaceToken/sessionStorage. Mark this
|
||||
// expected-fail until switchWorkspace gains a commit-time staleness check
|
||||
// (e.g. compare captured requestId or expected workspaceId before
|
||||
// assigning state). Removing `.fails` once fixed will catch regressions.
|
||||
it.fails('the new workspace wins when the stale refresh resolves last', async () => {
|
||||
it('keeps the old workspace refresh when a newer workspace switch fails', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
let resolveRefreshFetch: (value: unknown) => void = () => {}
|
||||
const refreshFetchPromise = new Promise((resolve) => {
|
||||
resolveRefreshFetch = resolve
|
||||
})
|
||||
mockFetch.mockReturnValueOnce(refreshFetchPromise)
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.resolve({ message: 'Access denied' })
|
||||
})
|
||||
await expect(store.switchWorkspace('workspace-other')).rejects.toThrow(
|
||||
WorkspaceAuthError
|
||||
)
|
||||
|
||||
const refreshedExpiry = new Date(Date.now() + 7200 * 1000).toISOString()
|
||||
resolveRefreshFetch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'refreshed-workspace-token',
|
||||
expires_at: refreshedExpiry
|
||||
})
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('refreshed-workspace-token')
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'refreshed-workspace-token'
|
||||
)
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(refreshedExpiry)
|
||||
)
|
||||
})
|
||||
|
||||
it('allows same-workspace switches to leave in-flight refreshes valid', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
let resolveRefreshFetch: (value: unknown) => void = () => {}
|
||||
const refreshFetchPromise = new Promise((resolve) => {
|
||||
resolveRefreshFetch = resolve
|
||||
})
|
||||
mockFetch.mockReturnValueOnce(refreshFetchPromise)
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
const sameWorkspaceExpiry = new Date(
|
||||
Date.now() + 7200 * 1000
|
||||
).toISOString()
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'same-workspace-token',
|
||||
expires_at: sameWorkspaceExpiry
|
||||
})
|
||||
})
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('same-workspace-token')
|
||||
|
||||
const refreshedExpiry = new Date(Date.now() + 9000 * 1000).toISOString()
|
||||
resolveRefreshFetch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'refreshed-workspace-token',
|
||||
expires_at: refreshedExpiry
|
||||
})
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
|
||||
expect(workspaceToken.value).toBe('refreshed-workspace-token')
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'refreshed-workspace-token'
|
||||
)
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(refreshedExpiry)
|
||||
)
|
||||
})
|
||||
|
||||
it('the new workspace wins when the stale refresh resolves last', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
@@ -809,6 +1037,82 @@ describe('useWorkspaceAuthStore', () => {
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
// User switches workspace AND its fetch resolves first.
|
||||
const newWorkspace = { ...mockWorkspace, id: 'workspace-other' }
|
||||
const newExpiry = new Date(Date.now() + 7200 * 1000).toISOString()
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'new-workspace-token',
|
||||
expires_at: newExpiry,
|
||||
workspace: newWorkspace
|
||||
})
|
||||
})
|
||||
await store.switchWorkspace('workspace-other')
|
||||
|
||||
// New workspace is committed at this point.
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(workspaceToken.value).toBe('new-workspace-token')
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
).toBe(JSON.stringify({ ...newWorkspace, role: 'owner' }))
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'new-workspace-token'
|
||||
)
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(newExpiry)
|
||||
)
|
||||
|
||||
// Now resolve the stale refresh fetch — it carries an OLD-workspace
|
||||
// token. It must not clobber the new workspace state or sessionStorage.
|
||||
const staleExpiry = new Date(Date.now() + 1800 * 1000).toISOString()
|
||||
resolveRefreshFetch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: 'stale-token',
|
||||
expires_at: staleExpiry
|
||||
})
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(workspaceToken.value).toBe('new-workspace-token')
|
||||
expect(
|
||||
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
||||
).toBe(JSON.stringify({ ...newWorkspace, role: 'owner' }))
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
|
||||
'new-workspace-token'
|
||||
)
|
||||
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)).toBe(
|
||||
expectedExpiresAtMs(newExpiry)
|
||||
)
|
||||
})
|
||||
|
||||
it('the new workspace keeps clean error state when a stale refresh fails last', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace, workspaceToken, error } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
let resolveRefreshFetch: (value: unknown) => void = () => {}
|
||||
const refreshFetchPromise = new Promise((resolve) => {
|
||||
resolveRefreshFetch = resolve
|
||||
})
|
||||
mockFetch.mockReturnValueOnce(refreshFetchPromise)
|
||||
|
||||
const refreshPromise = store.refreshToken()
|
||||
|
||||
const newWorkspace = { ...mockWorkspace, id: 'workspace-other' }
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -821,24 +1125,17 @@ describe('useWorkspaceAuthStore', () => {
|
||||
})
|
||||
await store.switchWorkspace('workspace-other')
|
||||
|
||||
// New workspace is committed at this point.
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(workspaceToken.value).toBe('new-workspace-token')
|
||||
|
||||
// Now resolve the stale refresh fetch — it carries an OLD-workspace
|
||||
// token, and the source has no commit-time staleness check, so it
|
||||
// clobbers the new workspace state.
|
||||
resolveRefreshFetch({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ ...mockTokenResponse, token: 'stale-token' })
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({ message: 'Server error' })
|
||||
})
|
||||
await refreshPromise
|
||||
|
||||
// Once the source-side guard is added, both of these become true
|
||||
// (the test stops failing) and `.fails` should be dropped.
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-other')
|
||||
expect(workspaceToken.value).toBe('new-workspace-token')
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -853,27 +1150,42 @@ describe('useWorkspaceAuthStore', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const setItemSpy = vi
|
||||
.spyOn(sessionStorage, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
const originalSessionStorage = globalThis.sessionStorage
|
||||
// happy-dom Storage method spies can miss instance calls; replace the
|
||||
// object so every setItem call deterministically throws.
|
||||
const throwingSessionStorage = {
|
||||
get length() {
|
||||
return originalSessionStorage.length
|
||||
},
|
||||
key: originalSessionStorage.key.bind(originalSessionStorage),
|
||||
getItem: originalSessionStorage.getItem.bind(originalSessionStorage),
|
||||
setItem: vi.fn(() => {
|
||||
throw new Error('QuotaExceededError')
|
||||
})
|
||||
}),
|
||||
removeItem: originalSessionStorage.removeItem.bind(
|
||||
originalSessionStorage
|
||||
),
|
||||
clear: originalSessionStorage.clear.bind(originalSessionStorage)
|
||||
} satisfies Storage
|
||||
vi.stubGlobal('sessionStorage', throwingSessionStorage)
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { workspaceToken } = storeToRefs(store)
|
||||
try {
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { workspaceToken } = storeToRefs(store)
|
||||
|
||||
await store.switchWorkspace('workspace-123')
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist workspace context to sessionStorage'
|
||||
)
|
||||
|
||||
setItemSpy.mockRestore()
|
||||
consoleWarnSpy.mockRestore()
|
||||
expect(workspaceToken.value).toBe('workspace-token-abc')
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist workspace context to sessionStorage'
|
||||
)
|
||||
} finally {
|
||||
vi.stubGlobal('sessionStorage', originalSessionStorage)
|
||||
consoleWarnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ const WorkspaceTokenResponseSchema = z.object({
|
||||
permissions: z.array(z.string())
|
||||
})
|
||||
|
||||
const MAX_SCHEDULED_REFRESH_RETRIES = 3
|
||||
|
||||
export class WorkspaceAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -54,6 +56,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
// Timer state
|
||||
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||
let inFlightSwitchCount = 0
|
||||
let workspaceTokenExpiresAt: number | null = null
|
||||
let scheduledRefreshRetryCount = 0
|
||||
|
||||
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
|
||||
let refreshRequestId = 0
|
||||
@@ -73,6 +78,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
function scheduleTokenRefresh(expiresAt: number): void {
|
||||
stopRefreshTimer()
|
||||
scheduledRefreshRetryCount = 0
|
||||
const now = Date.now()
|
||||
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
|
||||
const delay = Math.max(0, refreshAt - now)
|
||||
@@ -82,6 +88,56 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function scheduleClearAtExpiry(): void {
|
||||
if (workspaceTokenExpiresAt === null) {
|
||||
clearWorkspaceContext()
|
||||
return
|
||||
}
|
||||
|
||||
const timeUntilExpiry = workspaceTokenExpiresAt - Date.now()
|
||||
if (timeUntilExpiry <= 0) {
|
||||
clearWorkspaceContext()
|
||||
return
|
||||
}
|
||||
|
||||
stopRefreshTimer()
|
||||
refreshTimerId = setTimeout(() => {
|
||||
clearWorkspaceContext()
|
||||
}, timeUntilExpiry)
|
||||
}
|
||||
|
||||
function scheduleTokenRefreshRetry(delayMs: number): boolean {
|
||||
if (workspaceTokenExpiresAt === null) {
|
||||
clearWorkspaceContext()
|
||||
return false
|
||||
}
|
||||
|
||||
const timeUntilExpiry = workspaceTokenExpiresAt - Date.now()
|
||||
if (timeUntilExpiry <= 0) {
|
||||
clearWorkspaceContext()
|
||||
return false
|
||||
}
|
||||
|
||||
if (scheduledRefreshRetryCount >= MAX_SCHEDULED_REFRESH_RETRIES) {
|
||||
scheduleClearAtExpiry()
|
||||
return false
|
||||
}
|
||||
|
||||
scheduledRefreshRetryCount += 1
|
||||
stopRefreshTimer()
|
||||
const timeUntilRefreshBuffer = Math.max(
|
||||
0,
|
||||
timeUntilExpiry - TOKEN_REFRESH_BUFFER_MS
|
||||
)
|
||||
refreshTimerId = setTimeout(
|
||||
() => {
|
||||
void refreshToken()
|
||||
},
|
||||
Math.min(delayMs, timeUntilRefreshBuffer)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function persistToSession(
|
||||
workspace: WorkspaceWithRole,
|
||||
token: string,
|
||||
@@ -145,8 +201,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
return false
|
||||
}
|
||||
|
||||
const parsedWorkspace = JSON.parse(workspaceJson)
|
||||
const parseResult = WorkspaceWithRoleSchema.safeParse(parsedWorkspace)
|
||||
const parseResult = WorkspaceWithRoleSchema.safeParse(
|
||||
JSON.parse(workspaceJson)
|
||||
)
|
||||
|
||||
if (!parseResult.success) {
|
||||
clearSessionStorage()
|
||||
@@ -155,6 +212,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
currentWorkspace.value = parseResult.data
|
||||
workspaceToken.value = token
|
||||
workspaceTokenExpiresAt = expiresAt
|
||||
scheduledRefreshRetryCount = 0
|
||||
error.value = null
|
||||
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
@@ -176,7 +235,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
if (currentWorkspace.value?.id !== workspaceId) {
|
||||
refreshRequestId++
|
||||
}
|
||||
const capturedRequestId = refreshRequestId
|
||||
|
||||
inFlightSwitchCount += 1
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -257,16 +318,34 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
role: data.role
|
||||
}
|
||||
|
||||
if (isStaleWorkspaceRequest(capturedRequestId, workspaceId)) {
|
||||
console.warn(
|
||||
'Aborting stale workspace switch: workspace context changed before commit'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
currentWorkspace.value = workspaceWithRole
|
||||
workspaceToken.value = data.token
|
||||
workspaceTokenExpiresAt = expiresAt
|
||||
scheduledRefreshRetryCount = 0
|
||||
|
||||
persistToSession(workspaceWithRole, data.token, expiresAt)
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
} catch (err) {
|
||||
if (isStaleWorkspaceRequest(capturedRequestId, workspaceId)) {
|
||||
console.warn(
|
||||
'Aborting stale workspace switch: workspace context changed before error commit',
|
||||
err
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
error.value = err instanceof Error ? err : new Error(String(err))
|
||||
throw error.value
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
inFlightSwitchCount = Math.max(0, inFlightSwitchCount - 1)
|
||||
isLoading.value = inFlightSwitchCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,10 +359,11 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
const capturedRequestId = refreshRequestId
|
||||
const maxRetries = 3
|
||||
const baseDelayMs = 1000
|
||||
error.value = null
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
// Check if workspace context changed since refresh started (user switched workspaces)
|
||||
if (capturedRequestId !== refreshRequestId) {
|
||||
if (isStaleWorkspaceRequest(capturedRequestId, workspaceId)) {
|
||||
console.warn(
|
||||
'Aborting stale token refresh: workspace context changed during refresh'
|
||||
)
|
||||
@@ -305,7 +385,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
|
||||
if (isPermanentError) {
|
||||
// Only clear context if this refresh is still for the current workspace
|
||||
if (capturedRequestId === refreshRequestId) {
|
||||
if (!isStaleWorkspaceRequest(capturedRequestId, workspaceId)) {
|
||||
console.error('Workspace access revoked or auth invalid:', err)
|
||||
clearWorkspaceContext()
|
||||
}
|
||||
@@ -326,7 +406,21 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
|
||||
// Only clear context if this refresh is still for the current workspace
|
||||
if (capturedRequestId === refreshRequestId) {
|
||||
if (!isStaleWorkspaceRequest(capturedRequestId, workspaceId)) {
|
||||
if (isTransientError && hasValidWorkspaceToken()) {
|
||||
error.value = null
|
||||
const retryScheduled = scheduleTokenRefreshRetry(
|
||||
baseDelayMs * Math.pow(2, maxRetries)
|
||||
)
|
||||
console.warn(
|
||||
retryScheduled
|
||||
? 'Failed to refresh workspace token after retries; preserving existing valid token and retrying later:'
|
||||
: 'Failed to refresh workspace token after retries; preserving existing valid token until expiry:',
|
||||
err
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Failed to refresh workspace token after retries:', err)
|
||||
clearWorkspaceContext()
|
||||
}
|
||||
@@ -347,12 +441,32 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
return workspaceToken.value ?? undefined
|
||||
}
|
||||
|
||||
function hasValidWorkspaceToken(): boolean {
|
||||
return (
|
||||
workspaceToken.value !== null &&
|
||||
workspaceTokenExpiresAt !== null &&
|
||||
workspaceTokenExpiresAt > Date.now()
|
||||
)
|
||||
}
|
||||
|
||||
function isStaleWorkspaceRequest(
|
||||
capturedRequestId: number,
|
||||
workspaceId: string
|
||||
): boolean {
|
||||
return (
|
||||
capturedRequestId !== refreshRequestId &&
|
||||
currentWorkspace.value?.id !== workspaceId
|
||||
)
|
||||
}
|
||||
|
||||
function clearWorkspaceContext(): void {
|
||||
// Increment request ID to invalidate any in-flight stale refresh operations
|
||||
refreshRequestId++
|
||||
stopRefreshTimer()
|
||||
currentWorkspace.value = null
|
||||
workspaceToken.value = null
|
||||
workspaceTokenExpiresAt = null
|
||||
scheduledRefreshRetryCount = 0
|
||||
error.value = null
|
||||
clearSessionStorage()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user