418 lines
20 KiB
JavaScript
418 lines
20 KiB
JavaScript
|
|
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/);
|
||
|
|
});
|