fix(admin): scope remembered list state to the signed-in admin and recover from bad restored queries
- Store list queries under summer-admin.list:<admin id>:<controller>; me() and setCurrentUser set the owner, which removes other admins' keys and the unscoped keys of older builds; nothing is read or written without one - clearUser removes every stored query, so logout, session expiry and an unrecoverable 401 (expireSession, now used by main.ts) all clear them - A list request built from a restored query that fails for any reason (422, 500, network) forgets the query and loads the plain list once - A failing URL query keeps the error and offers a reset button (backend::lang.list.reset_list, en and pl) - Document remembered list state in docs/backend/lists-and-filters.md - Rebuild modules/boardwalk/dist
This commit is contained in:
@@ -1,28 +1,41 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clearListStates, LIST_STATE_PREFIX, readListState, writeListState } from '../../src/state/listState'
|
||||
import {
|
||||
clearListStates,
|
||||
LIST_STATE_PREFIX,
|
||||
listStateOwner,
|
||||
listStateOwnerPrefix,
|
||||
readListState,
|
||||
setListStateOwner,
|
||||
writeListState,
|
||||
} from '../../src/state/listState'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
|
||||
// Browser-storage helpers of the per-list query memory (D-01).
|
||||
// Browser-storage helpers of the per-list query memory (D-01), scoped to the
|
||||
// signed-in admin (WR-03, T-16.1-03).
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
const OWNER = 7
|
||||
const KEY = `${LIST_STATE_PREFIX}${OWNER}:${ID}`
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setListStateOwner(OWNER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setListStateOwner(null)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('list state keys', () => {
|
||||
it('uses the summer-admin.list: prefix plus the controller id, one key per list', () => {
|
||||
it('uses the summer-admin.list: prefix plus the admin id and the controller id, one key per list', () => {
|
||||
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
|
||||
expect(listStateOwnerPrefix(OWNER)).toBe('summer-admin.list:7:')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual([
|
||||
'summer-admin.list:acme.demo.gadgets',
|
||||
'summer-admin.list:acme.demo.widgets',
|
||||
'summer-admin.list:7:acme.demo.gadgets',
|
||||
'summer-admin.list:7:acme.demo.widgets',
|
||||
])
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
|
||||
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
|
||||
@@ -120,6 +133,69 @@ describe('writeListState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('list state owner', () => {
|
||||
const OTHER_KEY = `${LIST_STATE_PREFIX}8:${ID}`
|
||||
const LEGACY_KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
|
||||
it('keeps the owner as a string and null when signed out', () => {
|
||||
expect(listStateOwner()).toBe('7')
|
||||
setListStateOwner(null)
|
||||
expect(listStateOwner()).toBeNull()
|
||||
setListStateOwner('')
|
||||
expect(listStateOwner()).toBeNull()
|
||||
})
|
||||
|
||||
it("never reads another admin's query and removes it when the owner changes", () => {
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
setListStateOwner(8)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(readListState(ID)).toEqual({})
|
||||
writeListState(ID, { sort: 'name', dir: 'asc' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([OTHER_KEY])
|
||||
})
|
||||
|
||||
it("does not read another admin's key even before any sweep", () => {
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(OTHER_KEY)).not.toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the owner's own keys when the same owner is set again", () => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner('7')
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('sweeps the unscoped keys of older builds and never reads them', () => {
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
})
|
||||
|
||||
it('does not take an admin id that only starts with the owner id for the owner', () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}70:${ID}`, JSON.stringify({ search: 'seventy' }))
|
||||
writeListState(ID, { search: 'seven' })
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('removes every list key and reads and writes nothing once signed out', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner(null)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ search: 'planted' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearListStates', () => {
|
||||
it('removes every list key and keeps the sidebar preference and other keys', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
@@ -127,6 +203,8 @@ describe('clearListStates', () => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
|
||||
writeListState('acme.other.people', { filter: { active: 'true' } })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}8:${ID}`, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
clearListStates()
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
@@ -156,6 +234,8 @@ describe('storage that throws', () => {
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
expect(() => setListStateOwner(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('drops a malformed value even when the removal throws', () => {
|
||||
@@ -174,5 +254,6 @@ describe('storage that throws', () => {
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { api, onUnauthorized } from '../../src/api/client'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import {
|
||||
clearUser,
|
||||
currentUser,
|
||||
expireSession,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
@@ -9,7 +13,7 @@ import {
|
||||
setCurrentUser,
|
||||
useAuth,
|
||||
} from '../../src/state/useAuth'
|
||||
import { writeListState } from '../../src/state/listState'
|
||||
import { LIST_STATE_PREFIX, listStateOwner, readListState, writeListState } from '../../src/state/listState'
|
||||
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
import { setSettings, settings } from '../../src/state/useSettings'
|
||||
@@ -166,6 +170,129 @@ describe('logout (T-10-23)', () => {
|
||||
expect(auth.logout).toBe(logout)
|
||||
expect(auth.me).toBe(me)
|
||||
expect(auth.clearUser).toBe(clearUser)
|
||||
expect(auth.expireSession).toBe(expireSession)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored list queries follow the session (WR-03, T-16.1-03)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
|
||||
const listKeys = () => Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))
|
||||
|
||||
async function signInAs(envelope: typeof profile, routes: Parameters<typeof mockApi>[0] = {}) {
|
||||
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: envelope }, ...routes })
|
||||
await me()
|
||||
return calls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
onUnauthorized(null)
|
||||
})
|
||||
|
||||
it('scopes the stored queries to the admin me() loads', async () => {
|
||||
await signInAs(profile)
|
||||
expect(listStateOwner()).toBe('7')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([`${LIST_STATE_PREFIX}7:${ID}`])
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it("never hands one admin's query to the next admin who signs in on this browser", async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
||||
|
||||
// The tab was closed without a logout; another admin signs in.
|
||||
await signInAs(other)
|
||||
expect(listStateOwner()).toBe('8')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the own queries when the same admin loads again or edits the profile', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
await signInAs(profile)
|
||||
setCurrentUser({ ...profile.data, first_name: 'Grace' })
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('removes every stored query when me() finds no session', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
||||
expect(await me()).toBeNull()
|
||||
expect(listStateOwner()).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('sweeps unscoped keys of older builds when the profile loads', async () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
await signInAs(profile)
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('clearUser removes every stored query and stops storing', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
clearUser()
|
||||
expect(listKeys()).toEqual([])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('expireSession clears the session and the stored queries and routes to login with the redirect', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets?search=blue' } }, push })
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(push).toHaveBeenCalledWith({ name: 'login', query: { redirect: '/acme/demo/widgets?search=blue' } })
|
||||
})
|
||||
|
||||
it('expireSession stays on the login screen and swallows a failed navigation', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'login', fullPath: '/login' } }, push })
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listKeys()).toEqual([])
|
||||
|
||||
await signInAs(profile)
|
||||
const failing = vi.fn().mockRejectedValue(new Error('aborted'))
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets' } }, push: failing })
|
||||
await Promise.resolve()
|
||||
expect(failing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a 401 the refresh cannot recover clears the stored queries through the onUnauthorized handler of main.ts', async () => {
|
||||
const calls = await signInAs(profile, {
|
||||
[`GET ${API}/acme/demo/widgets`]: unauthorized,
|
||||
[`POST ${API}/auth/refresh`]: unauthorized,
|
||||
})
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets?search=blue')
|
||||
await router.isReady()
|
||||
// The same wiring as main.ts.
|
||||
onUnauthorized(() => expireSession(router))
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
expect(listKeys()).toHaveLength(1)
|
||||
|
||||
await api.GET('/{vendor}/{plugin}/{controller}', {
|
||||
params: { path: { vendor: 'acme', plugin: 'demo', controller: 'widgets' }, query: {} },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?search=blue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user