mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
* 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>
118 lines
4.2 KiB
TypeScript
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()
|
|
})
|
|
})
|