Files
windmill/frontend/src/lib/forkParentMemory.test.ts
T
32994df427 fix: return to parent workspace when a fork is deleted remotely (#9898)
* fix: return to parent workspace when a fork is deleted remotely

When a workspace fork was deleted remotely while a user had it open,
reloading stranded them: a regular member got logged out (whoami fails on
the vanished workspace) and a superadmin silently landed on a dead
workspace whose requests 404. Detect the deleted fork on load and redirect
to its parent (or the workspace picker) with a toast instead.

- forkParentMemory.ts: persist a bounded fork->parent map in localStorage
  while a fork is reachable (the parent is unrecoverable post-deletion).
- (logged) layout: record the current fork's parent via an effect.
- root layout: tryRecoverFromDeletedFork detects the vanished fork in
  loadUser and redirects to the remembered parent or the workspace picker,
  reusing the workspace list already fetched on mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: verify fork existence for superadmins before deleted-fork redirect

* fix: only recover deleted fork on actual 404 from superadmin check

* fix: recover prefixless dev-workspace forks via remembered parent

* fix: use workspace exists check to detect deleted forks

* fix: restore wm-fork- detection for non-member superadmin forks

* fix: record fork parent for non-member superadmin dev workspaces

* Update frontend/src/routes/(root)/+layout.svelte

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: store fork parent map with null-prototype to handle __proto__ ids

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-21 14:35:59 +00:00

118 lines
4.2 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import type { UserWorkspace } from './stores'
import { recordForkParent, getRememberedForkParent, forgetForkParent } from './forkParentMemory'
const MAX_ENTRIES = 50
function ws(id: string, parent_workspace_id?: string): UserWorkspace {
return {
id,
name: id,
username: 'admin',
color: undefined,
operator_settings: undefined,
disabled: false,
parent_workspace_id
}
}
beforeEach(() => {
localStorage.clear()
})
describe('recordForkParent / getRememberedForkParent', () => {
it('remembers the parent of a fork present in the list', () => {
recordForkParent('wm-fork-a', [ws('wm-fork-a', 'parent'), ws('parent')])
expect(getRememberedForkParent('wm-fork-a')).toBe('parent')
})
it('is a no-op when the workspace is absent from the list', () => {
recordForkParent('wm-fork-a', [ws('parent')])
expect(getRememberedForkParent('wm-fork-a')).toBeUndefined()
})
it('is a no-op when the workspace has no parent', () => {
recordForkParent('parent', [ws('parent')])
expect(getRememberedForkParent('parent')).toBeUndefined()
})
it('is a no-op for an undefined workspace id', () => {
recordForkParent(undefined, [ws('wm-fork-a', 'parent')])
expect(getRememberedForkParent('wm-fork-a')).toBeUndefined()
})
it('never clobbers a prior mapping once the fork disappears from the list', () => {
// Recorded while reachable...
recordForkParent('wm-fork-a', [ws('wm-fork-a', 'parent'), ws('parent')])
// ...then the fork is gone from a later list — the mapping must survive so
// recovery can still find the parent.
recordForkParent('wm-fork-a', [ws('parent')])
expect(getRememberedForkParent('wm-fork-a')).toBe('parent')
})
it('handles workspace ids that collide with Object prototype members', () => {
for (const id of ['__proto__', 'constructor', 'toString']) {
recordForkParent(id, [ws(id, `parent-${id}`), ws(`parent-${id}`)])
expect(getRememberedForkParent(id)).toBe(`parent-${id}`)
forgetForkParent(id)
expect(getRememberedForkParent(id)).toBeUndefined()
}
})
})
describe('forgetForkParent', () => {
it('removes a remembered mapping', () => {
recordForkParent('wm-fork-a', [ws('wm-fork-a', 'parent')])
forgetForkParent('wm-fork-a')
expect(getRememberedForkParent('wm-fork-a')).toBeUndefined()
})
it('is a no-op for an unknown fork', () => {
expect(() => forgetForkParent('wm-fork-missing')).not.toThrow()
})
})
describe('bounded storage', () => {
it('evicts the oldest entry once past MAX_ENTRIES', () => {
for (let i = 0; i < MAX_ENTRIES; i++) {
recordForkParent(`wm-fork-${i}`, [ws(`wm-fork-${i}`, `parent-${i}`)])
}
// One more tips it over the cap and trims the oldest (index 0).
recordForkParent(`wm-fork-${MAX_ENTRIES}`, [
ws(`wm-fork-${MAX_ENTRIES}`, `parent-${MAX_ENTRIES}`)
])
expect(getRememberedForkParent('wm-fork-0')).toBeUndefined()
expect(getRememberedForkParent(`wm-fork-${MAX_ENTRIES}`)).toBe(`parent-${MAX_ENTRIES}`)
expect(getRememberedForkParent('wm-fork-1')).toBe('parent-1')
})
it('updating a fork to a new parent moves it to the end so it survives the next eviction', () => {
for (let i = 0; i < MAX_ENTRIES; i++) {
recordForkParent(`wm-fork-${i}`, [ws(`wm-fork-${i}`, `parent-${i}`)])
}
// A changed value re-inserts the key at the end of the insertion order.
recordForkParent('wm-fork-0', [ws('wm-fork-0', 'reparented')])
// Push past the cap: the now-oldest (index 1) is evicted, not index 0.
recordForkParent(`wm-fork-${MAX_ENTRIES}`, [
ws(`wm-fork-${MAX_ENTRIES}`, `parent-${MAX_ENTRIES}`)
])
expect(getRememberedForkParent('wm-fork-0')).toBe('reparented')
expect(getRememberedForkParent('wm-fork-1')).toBeUndefined()
})
})
describe('corrupted storage', () => {
it('tolerates non-JSON content', () => {
localStorage.setItem('fork_parents', 'not json{')
expect(getRememberedForkParent('wm-fork-a')).toBeUndefined()
// A subsequent write recovers cleanly.
recordForkParent('wm-fork-a', [ws('wm-fork-a', 'parent')])
expect(getRememberedForkParent('wm-fork-a')).toBe('parent')
})
it('tolerates a non-object JSON value', () => {
localStorage.setItem('fork_parents', '42')
expect(getRememberedForkParent('wm-fork-a')).toBeUndefined()
})
})