import assert from "node:assert/strict"; import test from "node:test"; import { createOrganizationClient, OrganizationContractError, } from "../../dockerfiles/hermes-webui-hux/organization/client.ts"; import { FOUNDATION_FLAG, PROJECTS_FLAG, attachmentViews, branchLineage, normalizeConversation, normalizeProject, normalizeSnapshot, preservesConversation, projectsEnabled, } from "../../dockerfiles/hermes-webui-hux/organization/model.ts"; import { boundedLimit, isOpaqueId, isUtc, normalizeIdentity, safeCursor, safeTags, safeText, sameIdentity, scopedPath, } from "../../dockerfiles/hermes-webui-hux/organization/security.ts"; const IDENTITY = Object.freeze({ tenantRef: "tnt_0123456789abcdef", userRef: "usr_0123456789abcdef", surface: "chat", }); const RAW_IDENTITY = Object.freeze({ tenant_ref: IDENTITY.tenantRef, user_ref: IDENTITY.userRef, surface: IDENTITY.surface, }); const PROJECT_ID = "prj_alpha1234"; function project(extra = {}) { return { schema: "hux.project.v1", id: PROJECT_ID, owner: IDENTITY.userRef, name: "Kitchen work", description: "Plans", tags: ["home"], pinned: true, archived: false, default_mode: "research", created_at: "2026-08-20T09:00:00Z", updated_at: "2026-08-24T09:00:00Z", ...extra, }; } function conversation(extra = {}) { return { schema: "hux.conversation.v1", id: "conv_child1234", owner: IDENTITY.userRef, project_id: PROJECT_ID, title: "Compare suppliers", tags: ["suppliers"], pinned: false, archived: false, mode: "research", branch: { parent_conversation_id: "conv_parent1234", branch_point_message_id: "msg-12", }, artifact_ids: ["art_quote1234", "art_plan1234"], last_message_at: "2026-08-24T09:00:01Z", created_at: "2026-08-20T09:00:00Z", updated_at: "2026-08-24T09:00:01Z", ...extra, }; } function envelope(schema, items, extra = {}) { return {schema, api_version: "hux.v1", identity: RAW_IDENTITY, items, next_cursor: "next_page", total: items.length, ...extra}; } function mutation(schema, item, extra = {}) { return {schema, api_version: "hux.v1", identity: RAW_IDENTITY, item, ...extra}; } function harness(responses, flags = [FOUNDATION_FLAG, PROJECTS_FLAG]) { const calls = []; const enabled = new Set(flags); const foundation = { apiVersion: "hux.v1", identity: IDENTITY, enabled: (flag) => enabled.has(flag), endpoint: (path) => `/hux/v1${path}`, }; const fetcher = async (url, init) => { calls.push({url, init}); const response = responses.shift(); if (!response) throw new Error("unexpected request"); return {ok: response.ok ?? true, status: response.status ?? 200, json: async () => response.body}; }; return {calls, client: createOrganizationClient({client: foundation, fetcher}), foundation}; } test("HUX-03 is default-off and requires the foundation", () => { assert.equal(projectsEnabled(), false); assert.equal(projectsEnabled([]), false); assert.equal(projectsEnabled([PROJECTS_FLAG]), false); assert.equal(projectsEnabled([FOUNDATION_FLAG]), false); assert.equal(projectsEnabled([FOUNDATION_FLAG, PROJECTS_FLAG]), true); }); test("security primitives reject scope/path/query confusion", () => { assert.equal(isOpaqueId("conv_abcd", "conv"), true); assert.equal(isOpaqueId("prj_abcd", "conv"), false); assert.equal(isOpaqueId("bad"), false); assert.equal(isOpaqueId(4), false); assert.equal(isUtc("2026-08-24T09:00:00.123Z"), true); assert.equal(isUtc("2026-99-24T09:00:00Z"), false); assert.equal(isUtc(4), false); assert.equal(safeText({}, "fallback", 5), "fallback"); assert.equal(safeText(" \u0000 ", "fallback", 5), "fallback"); assert.equal(safeText("abcdef", "fallback", 4), "abc…"); assert.equal(safeText("okay", "fallback", 8), "okay"); assert.deepEqual(safeTags(["one", "two-2"]), ["one", "two-2"]); assert.equal(safeTags("one"), null); assert.equal(safeTags(Array(33).fill("x")), null); assert.equal(safeTags(["Bad"]), null); assert.equal(safeTags(["same", "same"]), null); assert.equal(safeTags([4]), null); assert.equal(safeCursor(undefined), null); assert.equal(safeCursor(""), null); assert.equal(safeCursor("next_page"), "next_page"); assert.equal(safeCursor("bad/cursor"), undefined); assert.equal(boundedLimit(22), 22); assert.equal(boundedLimit(200), 100); assert.equal(boundedLimit(0), 40); assert.equal(boundedLimit(undefined, 20), 20); assert.equal(scopedPath("/projects/", PROJECT_ID), `/projects/${PROJECT_ID}`); for (const base of ["https://evil", "//evil", "/hux?x", "/hux\\x", "/../x"]) { assert.throws(() => scopedPath(base, PROJECT_ID), /same-origin/); } assert.throws(() => scopedPath("/projects", "../bad"), /identifier/); }); test("identity normalization is exact and includes the current surface", () => { const normalized = normalizeIdentity(RAW_IDENTITY); assert.deepEqual(normalized, IDENTITY); assert.equal(sameIdentity(normalized, IDENTITY), true); assert.equal(sameIdentity(normalized, {...IDENTITY, surface: "worker"}), false); assert.equal(sameIdentity(normalized, {...IDENTITY, userRef: "usr_aaaaaaaaaaaaaaaa"}), false); assert.equal(sameIdentity(normalized, {...IDENTITY, tenantRef: "tnt_aaaaaaaaaaaaaaaa"}), false); assert.equal(normalizeIdentity(null), null); assert.equal(normalizeIdentity({...RAW_IDENTITY, tenant_ref: "raw"}), null); assert.equal(normalizeIdentity({...RAW_IDENTITY, user_ref: "raw"}), null); assert.equal(normalizeIdentity({...RAW_IDENTITY, surface: "browser"}), null); }); test("project normalization follows project.schema and owner binding", () => { const item = normalizeProject(project(), IDENTITY.userRef); assert.equal(item.name, "Kitchen work"); assert.equal(item.description, "Plans"); const invalid = [ {schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"}, {tags: ["Bad"]}, {pinned: "yes"}, {archived: 0}, {created_at: "today"}, {updated_at: "today"}, {default_mode: "turbo"}, {default_mode: 7}, {name: ""}, ]; invalid.forEach((change) => assert.equal(normalizeProject(project(change), IDENTITY.userRef), null)); assert.equal(normalizeProject(project({description: {}}), IDENTITY.userRef).description, ""); assert.equal(normalizeProject(project({default_mode: undefined}), IDENTITY.userRef).id, PROJECT_ID); }); test("conversation normalization binds project, lineage, and attachments", () => { const projects = new Set([PROJECT_ID]); const item = normalizeConversation(conversation(), IDENTITY.userRef, projects); assert.deepEqual(item.artifactIds, ["art_quote1234", "art_plan1234"]); assert.equal(item.branch.parentConversationId, "conv_parent1234"); assert.equal(item.lastMessageAt, "2026-08-24T09:00:01Z"); assert.equal(normalizeConversation(conversation({project_id: undefined}), IDENTITY.userRef, projects).projectId, null); assert.equal(normalizeConversation(conversation({branch: undefined}), IDENTITY.userRef, projects).branch, null); assert.equal(normalizeConversation(conversation({last_message_at: undefined, mode: undefined}), IDENTITY.userRef, projects).lastMessageAt, null); const invalid = [ {schema: "old"}, {id: "bad"}, {owner: "usr_aaaaaaaaaaaaaaaa"}, {tags: ["Bad"]}, {branch: null}, {branch: []}, {branch: {parent_conversation_id: "bad", branch_point_message_id: "x"}}, {branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: 3}}, {branch: {parent_conversation_id: "conv_parent1234", branch_point_message_id: ""}}, {branch: {parent_conversation_id: "conv_child1234", branch_point_message_id: "x"}}, {pinned: "no"}, {archived: "no"}, {artifact_ids: null}, {artifact_ids: ["bad"]}, {artifact_ids: ["art_same1234", "art_same1234"]}, {artifact_ids: Array.from({length: 501}, (_, index) => `art_${String(index).padStart(4, "0")}`)}, {created_at: "today"}, {updated_at: "today"}, {last_message_at: "today"}, {mode: "turbo"}, {mode: 7}, {project_id: "prj_other1234"}, {project_id: "bad"}, {title: ""}, ]; invalid.forEach((change) => assert.equal( normalizeConversation(conversation(change), IDENTITY.userRef, projects), null)); }); test("snapshot collections are bounded and cross-scope items are counted", () => { const projects = Array.from({length: 101}, (_, index) => project({id: `prj_${String(index).padStart(4, "0")}`})); const conversations = Array.from({length: 501}, (_, index) => conversation({ id: `conv_${String(index).padStart(4, "0")}`, project_id: index === 500 ? "prj_0100" : `prj_${String(index % 100).padStart(4, "0")}`, branch: undefined, })); const result = normalizeSnapshot(projects, conversations, IDENTITY.userRef); assert.equal(result.projects.length, 100); assert.equal(result.conversations.length, 500); assert.equal(result.rejected, 2); const rejected = normalizeSnapshot([project({owner: "usr_aaaaaaaaaaaaaaaa"})], [conversation({owner: "usr_aaaaaaaaaaaaaaaa"})], IDENTITY.userRef); assert.equal(rejected.rejected, 2); const duplicated = normalizeSnapshot([project(), project()], [conversation(), conversation()], IDENTITY.userRef); assert.equal(duplicated.projects.length, 1); assert.equal(duplicated.conversations.length, 1); assert.equal(duplicated.rejected, 2); }); test("attachments never disappear when their metadata is unavailable", () => { const item = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID])); const views = attachmentViews(item, [ {id: "art_quote1234", title: " Quote \u0000", kind: "document"}, {id: "bad", title: "Bad", kind: "bad"}, ]); assert.equal(views.length, item.artifactIds.length); assert.deepEqual(views.map((view) => view.missing), [false, true]); assert.equal(views[0].title, "Quote"); assert.equal(views[1].title, "Attachment details unavailable"); assert.equal(attachmentViews(item, [{id: "art_quote1234", title: {}, kind: {}}])[0].title, "Untitled artifact"); }); test("lineage is visualizable even when an ancestor is absent or cyclic", () => { const child = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID])); const parent = normalizeConversation(conversation({id: "conv_parent1234", title: "Root", branch: undefined}), IDENTITY.userRef, new Set([PROJECT_ID])); const complete = branchLineage(child.id, [child, parent]); assert.deepEqual(complete.map((node) => node.title), ["Root", "Compare suppliers"]); assert.deepEqual(complete.map((node) => node.depth), [0, 1]); const missing = branchLineage(child.id, [child]); assert.equal(missing[0].missing, true); assert.equal(missing[1].branchPointMessageId, "msg-12"); const cyclicParent = {...parent, branch: {parentConversationId: child.id, branchPointMessageId: "msg-cycle"}}; const cycle = branchLineage(child.id, [child, cyclicParent]); assert.equal(cycle[0].cycle, true); assert.deepEqual(branchLineage("bad", [child]), []); }); test("moves preserve stable resume identity, branch, and ordered attachments", () => { const before = normalizeConversation(conversation(), IDENTITY.userRef, new Set([PROJECT_ID])); assert.equal(preservesConversation(before, {...before, projectId: null, title: "Renamed"}), true); assert.equal(preservesConversation(before, {...before, id: "conv_other1234"}), false); assert.equal(preservesConversation(before, {...before, owner: "usr_aaaaaaaaaaaaaaaa"}), false); assert.equal(preservesConversation(before, {...before, branch: null}), false); assert.equal(preservesConversation(before, {...before, artifactIds: ["art_quote1234"]}), false); assert.equal(preservesConversation(before, {...before, artifactIds: [...before.artifactIds].reverse()}), false); }); test("list and search calls use bounded encoded same-origin queries", async () => { const responses = [ {body: envelope("hux.projects.page.v1", [project()])}, {body: envelope("hux.conversations.page.v1", [conversation()])}, {body: envelope("hux.conversations.search.v1", [conversation()], {next_cursor: null})}, ]; const {client, calls} = harness(responses); const projects = await client.listProjects("cursor_1", 500); assert.equal(projects.items[0].id, PROJECT_ID); assert.equal(projects.nextCursor, "next_page"); assert.equal(projects.total, 1); const ids = new Set([PROJECT_ID]); await client.listConversations(PROJECT_ID, ids, null, 12); await client.searchConversations({query: " cabinets & quotes ", projectId: PROJECT_ID, tags: ["home", "suppliers"], pinned: false, limit: 20}, ids); assert.match(calls[0].url, /^\/hux\/v1\/projects\?cursor=cursor_1&limit=100$/); assert.match(calls[1].url, /\/projects\/prj_alpha1234\/conversations\?limit=12$/); assert.match(calls[2].url, /q=cabinets\+%26\+quotes/); assert.match(calls[2].url, /project_id=prj_alpha1234/); assert.match(calls[2].url, /tag=home&tag=suppliers/); assert.match(calls[2].url, /pinned=false/); calls.forEach(({init}) => { assert.equal(init.credentials, "same-origin"); assert.equal(init.cache, "no-store"); assert.equal(init.headers.Accept, "application/vnd.hermes.hux+json; version=1"); }); }); test("transport rejects invalid capability, identity, pagination, records, and boundaries", async () => { const disabled = harness([{body: {}}], [FOUNDATION_FLAG]); await assert.rejects(disabled.client.listProjects(), /not enabled/); disabled.foundation.apiVersion = "hux.v0"; await assert.rejects(disabled.client.listProjects(), /not enabled/); assert.throws(() => createOrganizationClient({client: null, fetcher: async () => ({})}), /requires/); const originalFetch = globalThis.fetch; try { globalThis.fetch = undefined; assert.throws(() => createOrganizationClient({client: disabled.foundation}), /requires/); } finally { globalThis.fetch = originalFetch; } const failures = [ {body: null}, {body: []}, {body: envelope("wrong", [])}, {body: envelope("hux.projects.page.v1", [], {api_version: "hux.v0"})}, {body: envelope("hux.projects.page.v1", [], {identity: {...RAW_IDENTITY, surface: "worker"}})}, {body: envelope("hux.projects.page.v1", [], {next_cursor: "bad/cursor"})}, {body: envelope("hux.projects.page.v1", [], {total: -1})}, {body: envelope("hux.projects.page.v1", Array(101).fill(project()))}, {body: envelope("hux.projects.page.v1", [project({id: "bad"})])}, {body: envelope("hux.projects.page.v1", [project(), project()])}, ]; for (const response of failures) { const current = harness([response]); await assert.rejects(current.client.listProjects(), OrganizationContractError); } const http = harness([{ok: false, status: 503}]); await assert.rejects(http.client.listProjects(), /failed \(503\)/); const conflict = harness([{ok: false, status: 409}]); await assert.rejects(conflict.client.listProjects(), /changed/); const badCursor = harness([]); await assert.rejects(badCursor.client.listProjects("bad/cursor"), /cursor/); await assert.rejects(badCursor.client.listConversations("prj_other1234", new Set([PROJECT_ID])), /outside/); await assert.rejects(badCursor.client.searchConversations({query: "", limit: 1}, new Set()), /1 to 200/); await assert.rejects(badCursor.client.searchConversations({query: "x".repeat(201)}, new Set()), /1 to 200/); await assert.rejects(badCursor.client.searchConversations({query: "x", projectId: "prj_other1234"}, new Set([PROJECT_ID])), /outside/); await assert.rejects(badCursor.client.searchConversations({query: "x", tags: ["Bad"]}, new Set()), /tags/); }); test("conversation pages cannot cross their requested project", async () => { const ids = new Set([PROJECT_ID, "prj_other1234"]); const wrong = harness([{body: envelope("hux.conversations.page.v1", [conversation({project_id: "prj_other1234"})])}]); await assert.rejects(wrong.client.listConversations(PROJECT_ID, ids), /project boundary/); const invalid = harness([{body: envelope("hux.conversations.search.v1", [conversation({id: "bad"})])}]); await assert.rejects(invalid.client.searchConversations({query: "x"}, ids), /Invalid conversation/); const oversized = harness([{body: envelope("hux.conversations.search.v1", Array(101).fill(conversation()))}]); await assert.rejects(oversized.client.searchConversations({query: "x"}, ids), /exceeds/); const duplicate = harness([{body: envelope("hux.conversations.search.v1", [conversation(), conversation()])}]); await assert.rejects(duplicate.client.searchConversations({query: "x"}, ids), /Invalid conversation/); }); test("project mutations send only scoped fields and verify stable identity", async () => { const before = normalizeProject(project(), IDENTITY.userRef); const changed = project({name: "New name", tags: ["new"], pinned: false, updated_at: "2026-08-24T10:00:00Z"}); const {client, calls} = harness([{body: mutation("hux.project.response.v1", changed)}]); const after = await client.updateProject(before, {name: " New name ", tags: ["new"], pinned: false, expectedUpdatedAt: before.updatedAt}); assert.equal(after.name, "New name"); const body = JSON.parse(calls[0].init.body); assert.deepEqual(body, {expected_updated_at: before.updatedAt, name: "New name", tags: ["new"], pinned: false}); assert.equal("tenant_ref" in body, false); assert.equal(calls[0].init.method, "PATCH"); for (const [patch, message] of [ [{expectedUpdatedAt: "bad"}, /outside/], [{expectedUpdatedAt: before.updatedAt, name: ""}, /invalid/], [{expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, /invalid/], ]) { await assert.rejects(client.updateProject(before, patch), message); } await assert.rejects(client.updateProject({...before, owner: "usr_aaaaaaaaaaaaaaaa"}, {expectedUpdatedAt: before.updatedAt}), /outside/); const noItem = harness([{body: mutation("hux.project.response.v1", changed, {item: undefined})}]); await assert.rejects(noItem.client.updateProject(before, {expectedUpdatedAt: before.updatedAt}), /no item/); const changedId = harness([{body: mutation("hux.project.response.v1", project({id: "prj_other1234"}))}]); await assert.rejects(changedId.client.updateProject(before, {expectedUpdatedAt: before.updatedAt}), /identity changed/); }); test("conversation mutation proves atomic move invariants", async () => { const ids = new Set([PROJECT_ID, "prj_other1234"]); const before = normalizeConversation(conversation(), IDENTITY.userRef, ids); const moved = conversation({project_id: "prj_other1234", title: "Renamed", tags: ["new"], pinned: true, updated_at: "2026-08-24T10:00:00Z"}); const {client, calls} = harness([{body: mutation("hux.conversation.response.v1", moved)}]); const after = await client.updateConversation(before, {projectId: "prj_other1234", title: " Renamed ", tags: ["new"], pinned: true, expectedUpdatedAt: before.updatedAt}, ids); assert.equal(after.projectId, "prj_other1234"); assert.deepEqual(JSON.parse(calls[0].init.body), {expected_updated_at: before.updatedAt, title: "Renamed", project_id: "prj_other1234", tags: ["new"], pinned: true}); for (const [candidate, error] of [ [{...moved, id: "conv_other1234"}, /lineage/], [{...moved, branch: undefined}, /lineage/], [{...moved, artifact_ids: ["art_quote1234"]}, /lineage/], [{...moved, owner: "usr_aaaaaaaaaaaaaaaa"}, /lineage/], ]) { const current = harness([{body: mutation("hux.conversation.response.v1", candidate)}]); await assert.rejects(current.client.updateConversation(before, {expectedUpdatedAt: before.updatedAt}, ids), error); } const invalid = harness([]); await assert.rejects(invalid.client.updateConversation({...before, owner: "usr_aaaaaaaaaaaaaaaa"}, {expectedUpdatedAt: before.updatedAt}, ids), /outside/); await assert.rejects(invalid.client.updateConversation({...before, projectId: "prj_missing1234"}, {expectedUpdatedAt: before.updatedAt}, ids), /outside/); await assert.rejects(invalid.client.updateConversation(before, {expectedUpdatedAt: "bad"}, ids), /outside/); await assert.rejects(invalid.client.updateConversation(before, {expectedUpdatedAt: before.updatedAt, projectId: "prj_missing1234"}, ids), /outside/); await assert.rejects(invalid.client.updateConversation(before, {expectedUpdatedAt: before.updatedAt, title: ""}, ids), /invalid/); await assert.rejects(invalid.client.updateConversation(before, {expectedUpdatedAt: before.updatedAt, tags: ["Bad"]}, ids), /invalid/); });