Respect canonical project bindings and isolate account state

Why:
Project members need RDM commands to address the registered domain project.
Failed project states and responses from a previous account must not create
work, report a rejected mutation as successful, or restore private content.

Evidence:
SELF npm test: 32 files and 251 tests passed, including project command,
mutation rejection, archived history, and delayed account response coverage.
SELF npm run build and git diff --check passed. Original regressions were
observed before implementation; root owns composition and independent replay.

Impact:
Keep host IDs for conversation and base settings APIs; send the decorated
domain ID to RDM project commands. Reuse existing sidebar, settings and toast
states. Reset identity projections and invalidate old asynchronous responses.
No deployment, database change, push, merge, or workspace cleanup.

Refs:
Dev feature-project-member-access task card and implementation plan Task 4
This commit is contained in:
2026-10-13 08:20:44 +00:00
parent b3fa39744d
commit 55a64f40e8
11 changed files with 550 additions and 75 deletions
+271
View File
@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { auth } from '../src/auth'
import { store } from '../src/store'
import type { ActivityCard, Conversation, Project } from '../src/contracts'
import { assignDocDutiesPayload, memberAddPayload } from '../src/rdmManagement'
const project = (id: string, settings?: Project['settings']): Project => ({
id, name: id, settings, createdAt: '', updatedAt: '',
})
const ready = project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })
const task = (id: string): Conversation => ({ id, projectId: 'host-1', title: id, createdAt: '', updatedAt: '' })
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(ok => { resolve = ok })
return { promise, resolve }
}
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
let projects: Project[]
let tasks: Conversation[]
let requests: { path: string; init?: RequestInit }[]
let route: (path: string, init?: RequestInit) => Promise<Response>
beforeEach(async () => {
projects = []; tasks = []; requests = []
const saved = new Map<string, string>()
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
setItem: (key: string, value: string) => { saved.set(key, value) },
removeItem: (key: string) => { saved.delete(key) } })
route = async (path, init) => {
if (path === '/api/projects' && !init?.method) return response(projects)
if (path.startsWith('/api/conversations') && !init?.method) return response(tasks)
if (path === '/api/me') return response({ owner: 'alice', name: 'Alice', auth: true })
if (path.endsWith('/artifacts')) return response([])
if (path.endsWith('/cards')) return response({ cards: [] })
if (path.endsWith('/surfaces')) return response([])
if (['/api/profiles', '/api/plugins', '/api/workspaces', '/api/commands', '/api/admin-zone/sections'].includes(path)) return response([])
if (path === '/api/settings') return response({ schema: [], defaults: {}, user: {}, project: {} })
if (path.startsWith('/api/commands/')) return response({ payload: { ok: true } })
return response({})
}
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
requests.push({ path, init })
return route(path, init)
})
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
auth.beginSession()
await store.refreshProjects()
await store.refreshTasks()
store.setUI({ lastProjectId: null })
requests = []
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('project command identity', () => {
it.each(['rdm.perm.matrix', 'rdm.duty.catalog', 'rdm.duty.get', 'rdm.project.member.setRole'])('uses the same canonical boundary for %s', async command => {
await api.rdmProjectCommand(ready, command, { projectId: 'another-host', member: 'bob' })
expect(requests.map(r => ({ path: r.path, params: JSON.parse(String(r.init?.body)).params }))).toEqual([
{ path: `/api/commands/${command}`, params: { projectId: 'domain-7', member: 'bob' } },
])
})
it('sends member and duty commands to the canonical domain project from service metadata', async () => {
await api.rdmProjectCommand(ready, 'rdm.project.member.add', memberAddPayload(ready.id, 'bob'))
await api.rdmProjectCommand(ready, 'rdm.review.assign.doc', assignDocDutiesPayload(ready.id, 'doc-2', 'alice', ['bob'], 'carol'))
expect(requests.map(r => JSON.parse(String(r.init?.body)).params)).toEqual([
{ projectId: 'domain-7', member: 'bob', role: 'member' },
{ projectId: 'domain-7', docId: 'doc-2', author: 'alice', reviewers: ['bob'], approver: 'carol' },
])
})
it.each([
project('host-1', { 'rdm.state': 'unbound' }),
project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'failed' }),
project('host-1', { 'rdm.projectId': 'domain-7' }),
project('host-1'),
])('does not invent a domain ID or send commands for an unready binding: %j', async p => {
await expect(api.rdmProjectCommand(p, 'rdm.project.member.list')).rejects.toThrow()
expect(requests).toEqual([])
})
})
describe('project work and mutation failures', () => {
it('keeps private history readable for an archived project while refusing new work', async () => {
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'archived' })]
tasks = [task('private-history')]
const previous = route
route = async (path, init) => {
if (path === '/api/conversations/private-history') return response({ conversation: tasks[0],
messages: [{ id: 'own-message', conversationId: 'private-history', role: 'user', parts: [{ type: 'text', text: 'My history' }], createdAt: '' }],
runs: [], activeHitl: [], lastSeq: 0 })
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.refreshProjects()
await store.openTask('private-history')
await expect(store.createTask()).rejects.toThrow()
expect(store.getState().currentConvId).toBe('private-history')
expect(store.getState().messages.map(m => m.id)).toEqual(['own-message'])
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each([ready, project('host-1')])('creates private work with the host ID in ready RDM and ordinary base mode: %j', async p => {
projects = [p]
await store.refreshProjects()
const previous = route
route = async (path, init) => {
if (path === '/api/projects/host-1/conversations' && init?.method === 'POST') {
tasks = [task('new-private')]
return response(tasks[0])
}
if (path === '/api/conversations/new-private') {
return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
}
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
return previous(path, init)
}
await store.createTask()
expect(store.getState().currentConvId).toBe('new-private')
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/projects/host-1/conversations'])
})
it.each(['provisioning', 'failed', 'unbound', 'archived', 'closed'])('does not create a fallback project or conversation for a selected %s project', async state => {
projects = [project('host-1', { 'rdm.state': state }), project('host-2')]
await store.refreshProjects()
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/项目.*(稍后|负责人|恢复|关闭)/)
})
it('does not bypass a selected project absent from the current list', async () => {
await store.openProject('revoked-project')
requests = []
await expect(store.createTask()).rejects.toThrow()
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
})
it.each(['delete', 'rename'])('rejects a denied %s instead of resolving success', async action => {
route = async () => response({ error: 'project administration required' }, 403)
const mutation = action === 'delete' ? api.deleteProject('host-1') : api.renameProject('host-1', 'renamed')
await expect(mutation).rejects.toThrow(/403/)
})
it('retains selection and gives an actionable message when project creation is rejected', async () => {
projects = [ready]
await store.refreshProjects()
route = async () => response({ error: 'name reserved' }, 409)
await expect(store.createProject('Taken')).rejects.toThrow()
expect(store.getState().currentProjectId).toBe('host-1')
expect(store.getState().toast).toMatch(/名称.*(其他|更换)/)
expect(store.getState().toast).not.toContain('已创建')
})
it('checks a denied settings save instead of resolving success', async () => {
route = async () => response({ error: 'denied' }, 403)
await expect(api.putProjectSettings('host-1', {})).rejects.toThrow(/403/)
})
})
describe('identity-scoped state', () => {
it('resets state on a successful cookie login, without relying on a component callback', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
route = async () => response({ owner: 'bob', name: 'Bob', isAdmin: false, mustChangePassword: false, passwordSet: true, sessionScope: 'normal' })
await api.login('bob', 'password-for-test')
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
})
it('clears old state as soon as logout starts, even while the server response is pending', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const pending = deferred<Response>()
route = async () => pending.promise
const logout = api.logout()
expect(store.getState().projects).toEqual([])
expect(store.getState().tasks).toEqual([])
pending.resolve(response({ ok: true }))
await logout
})
it('clears the previous account when the authenticated owner changes during identity refresh', async () => {
await store.refreshMe()
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
const previous = route
projects = []; tasks = []
route = (path, init) => path === '/api/me' ? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true })) : previous(path, init)
await store.refreshMe()
expect(store.getState()).toMatchObject({ me: { owner: 'bob' }, projects: [], tasks: [], currentProjectId: null, currentConvId: null })
})
it('clears projects, private tasks, previews and persisted selections on a new session', async () => {
projects = [ready]; tasks = [task('alice-private')]
await store.refreshProjects(); await store.refreshTasks()
store.openSurface('surface-alice')
store.setUI({ projectSettingsId: 'host-1', adminZoneOpen: true, activeWorkspace: 'private-workspace' })
localStorage.setItem('as.conv', 'alice-private')
auth.beginSession()
expect(store.getState()).toMatchObject({ projects: [], tasks: [], me: null, currentProjectId: null, currentConvId: null, artifacts: [], previewTabs: {}, previewActive: {} })
expect(store.getState().ui).toMatchObject({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null })
expect(localStorage.getItem('as.conv')).toBeNull()
expect(JSON.parse(localStorage.getItem('as.ui') ?? '{}').lastProjectId).toBeNull()
})
it('drops old project responses after switching accounts even when IDs are shared', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/projects' ? old.promise : previous(path, init)
const pending = store.refreshProjects()
auth.beginSession()
projects = [project('host-1', { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' })]
route = previous
await store.refreshProjects()
old.resolve(response([project('alice-only')]))
await pending
expect(store.getState().projects.map(p => p.id)).toEqual(['host-1'])
expect(store.getState().currentProjectId).toBe('host-1')
})
it('drops an old private task list after logout and a new session', async () => {
const old = deferred<Response>()
const previous = route
route = (path, init) => path === '/api/conversations' ? old.promise : previous(path, init)
const pending = store.refreshTasks()
auth.logout()
auth.beginSession()
tasks = [task('bob-private')]
route = previous
await store.refreshTasks()
old.resolve(response([task('alice-private')]))
await pending
expect(store.getState().tasks.map(t => t.id)).toEqual(['bob-private'])
})
it('drops an old artifact response when the next account selects the same project', async () => {
await store.openProject('host-1')
const old = deferred<Response>()
const previous = route
route = (path, init) => path.endsWith('/artifacts') ? old.promise : previous(path, init)
const pending = store.refreshArtifacts('host-1')
auth.beginSession()
route = previous
await store.openProject('host-1')
old.resolve(response([{ id: 'alice-artifact', projectId: 'host-1', owner: 'alice', name: 'Alice private', kind: 'file', size: 10, createdAt: '', updatedAt: '' }]))
await pending
expect(store.getState().artifacts).toEqual([])
})
it.each(['action', 'pin', 'dismiss', 'to-project'] as const)('ignores a late project card %s from the previous account on the same shared project', async operation => {
projects = [ready]
await store.refreshProjects()
const old = deferred<Response>()
const previous = route
route = (path, init) => path.startsWith('/api/cards/') ? old.promise : previous(path, init)
const card: ActivityCard = { id: 'alice-card', projectId: 'host-1', kind: 'progress', status: 'active', title: 'Alice private work', createdAt: '', updatedAt: '' }
const pending = operation === 'action' ? store.cardAction(card, 'finish')
: operation === 'pin' ? store.cardPin(card, true)
: operation === 'dismiss' ? store.cardDismiss(card)
: store.cardToProject(card)
auth.beginSession()
route = previous
await store.refreshProjects()
old.resolve(response({ card: { ...card, status: 'done' }, origin: { ...card, status: 'dismissed' } }))
await pending
expect(store.getState().projectCards).toEqual([])
expect(store.getState().cards).toEqual([])
})
})