Files
hermes-agent/apps/desktop/src/api/sessions.test.ts
T
Bergmann89 18f429a89b fix(desktop): scope every session mutation to its owning profile
Session mutations from the desktop sidebar silently no-op against the wrong
profile's state.db and reappear on the next refresh, unless the mutated
session happens to belong to the serving (primary) profile. Most visible in
the "All Profiles" view and on a remote-primary desktop (a registered remote
gateway as primary, every profile served from it): deleting a session flashes
away optimistically, then comes back after a profile switch.

Root cause: the mutation helpers passed the owning profile ONLY as
request.profile, which the Electron main process consumes for backend ROUTING
but which does not scope the request the backend actually receives. The
backend selects its target DB from ?profile= (DELETE) or body.profile (PATCH).
Reads (getSession, getSessionMessages) and renameSession already scope
correctly; the other mutations regressed / never did:

  - deleteSession        -> no ?profile= in the URL
  - setSessionArchived   -> no profile in the PATCH body
  - setSessionPinnedRemote  -> no profile in the PATCH body
  - setSessionUnreadRemote  -> no profile in the PATCH body

On a remote gateway whose connection has no remoteProfile alias, the main
process leaves such requests unscoped, so the backend opens its own (default)
state.db, cannot find another profile's row, and returns
{ok:true, already_absent:true} (DELETE) or no-ops (PATCH) — a fake success the
UI treats as done. deleteSession lost its ?profile= scope in the api/ module
split (it was present via PR #44138 / the pre-split hermes.ts path).

Fix: scope all four mutations the same way the working endpoints do —
deleteSession appends sessionScopeQuery(profile) to the URL; setSessionArchived
/ setSessionPinnedRemote / setSessionUnreadRemote include profile in the PATCH
body (mirroring renameSession). request.profile stays for per-profile
remote-override and global-remote routing. Single-profile / selected-profile
users are unaffected (the serving profile already matched).

Verified against a live remote-primary desktop: the DELETE now goes out as
/api/sessions/<id>?profile=<owner> and the row is actually removed from the
owning profile's state.db (confirmed server-side) instead of returning
already_absent.

Adds api/sessions.test.ts coverage: delete scopes ?profile= in the URL for
object and bare-string owners and omits it when no owner is known; archive /
pin / unread carry body.profile when owned and omit it otherwise.

Fixes #78836
2026-08-31 05:56:18 -07:00

177 lines
6.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/gateway-rpc', () => ({ isMissingRestEndpoint: () => false }))
vi.mock('@/store/transcript-tail', () => ({ recordTranscriptTail: vi.fn() }))
vi.mock('./client', () => ({
capabilityScoped: vi.fn(),
getApiRequestConnection: vi.fn(() => 'prometheus'),
hermesApi: vi.fn(),
profileScoped: vi.fn(() => ({}))
}))
const client = await import('./client')
const { deleteSession, setSessionArchived, setSessionPinnedRemote, setSessionUnreadRemote, listSidebarSessions } =
await import('./sessions')
const hermesApi = vi.mocked(client.hermesApi)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(client.getApiRequestConnection).mockReturnValue('prometheus')
})
describe('deleteSession profile scoping', () => {
it('scopes the DELETE to the owning profile in the URL (object owner)', async () => {
// Regression: the sidebar "All Profiles" delete sent the profile only via
// request.profile, not in the URL. On a remote gateway with no remoteProfile
// alias the main-process path rewrite left the URL unscoped, so the backend
// opened its own default state.db, missed the row, and returned
// {ok:true, already_absent:true} — the row vanished optimistically but was
// never deleted and came back on refresh. The URL must carry ?profile=.
hermesApi.mockResolvedValue({ ok: true } as never)
// Mirrors the real capabilityScoped for an object owner (remote-stamped row).
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy', connectionId: 'hermes-pi' })
await deleteSession('sess-1', { connectionId: 'hermes-pi', profile: 'tommy' })
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'DELETE',
path: '/api/sessions/sess-1?profile=tommy',
connectionId: 'hermes-pi',
profile: 'tommy'
})
})
it('scopes the DELETE to the owning profile in the URL (bare string owner)', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
// Bare-string owner: capabilityScoped resolves it to a profile scope.
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' })
await deleteSession('sess-2', 'tommy')
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'DELETE',
path: '/api/sessions/sess-2?profile=tommy'
})
})
it('omits the profile query when no owner is known', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
await deleteSession('sess-3')
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'DELETE',
path: '/api/sessions/sess-3'
})
expect((hermesApi.mock.calls[0][0] as { path: string }).path).not.toContain('profile=')
})
it('keeps an explicit local pin routed to the local pool', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
// capabilityScoped drops a 'local' connection id by design; sessionScoped
// must re-add it so the request stays pinned to this device.
vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' })
await deleteSession('sess-4', { connectionId: 'local', profile: 'tommy' })
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'DELETE',
path: '/api/sessions/sess-4?profile=tommy',
connectionId: 'local',
profile: 'tommy'
})
})
})
describe('setSessionArchived profile scoping', () => {
it('carries the owning profile in the PATCH body', async () => {
// Same class as the unscoped DELETE: the PATCH handler reads its target DB
// from body.profile, so archiving a foreign-profile session must send it in
// the body, not only as request.profile (Electron routing), or on a remote
// gateway the archive lands on the wrong state.db and silently no-ops.
hermesApi.mockResolvedValue({ ok: true } as never)
await setSessionArchived('sess-a', true, 'tommy')
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'PATCH',
path: '/api/sessions/sess-a',
profile: 'tommy',
body: { archived: true, profile: 'tommy' }
})
})
it('omits the profile from the body when none is given', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
await setSessionArchived('sess-b', false)
const req = hermesApi.mock.calls[0][0] as { body: Record<string, unknown> }
expect(req).toMatchObject({ method: 'PATCH', body: { archived: false } })
expect(req.body).not.toHaveProperty('profile')
})
})
describe('setSessionPinnedRemote / setSessionUnreadRemote profile scoping', () => {
it('carries the owning profile in the pin PATCH body', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
await setSessionPinnedRemote('sess-p', true, 'tommy')
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'PATCH',
path: '/api/sessions/sess-p',
profile: 'tommy',
body: { pinned: true, profile: 'tommy' }
})
})
it('carries the owning profile in the unread PATCH body', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
await setSessionUnreadRemote('sess-u', true, 'tommy')
expect(hermesApi.mock.calls[0][0]).toMatchObject({
method: 'PATCH',
path: '/api/sessions/sess-u',
profile: 'tommy',
body: { unread: true, profile: 'tommy' }
})
})
it('omits the profile from the body when none is given', async () => {
hermesApi.mockResolvedValue({ ok: true } as never)
await setSessionPinnedRemote('sess-p2', false)
const req = hermesApi.mock.calls[0][0] as { body: Record<string, unknown> }
expect(req).toMatchObject({ method: 'PATCH', body: { pinned: false } })
expect(req.body).not.toHaveProperty('profile')
})
})
describe('listSidebarSessions remote ownership', () => {
it('stamps active remote rows so a later resume stays on their gateway', async () => {
hermesApi.mockResolvedValue({
cron: { sessions: [] },
messaging: { sessions: [] },
recents: {
sessions: [{ id: 'remote-session', profile: 'default', source: 'desktop', title: 'Remote chat' }]
}
} as never)
const result = await listSidebarSessions({
recentsProfile: 'default',
recentsLimit: 40,
recentsExclude: [],
cronLimit: 20,
messagingLimit: 40,
messagingExclude: []
})
expect(result.recents.sessions[0]).toMatchObject({ connection_id: 'prometheus', id: 'remote-session' })
})
})