Jason's live check after the 20:58Z restart posted no Approve button. The
Discord Sage wrote DEC-009's required_approvers as names; the SetSpark
service stores approvers as discord:<id> and accepted the names, and the
connector correctly refused the approval request ("bad approver id").
- binding.mjs derives setspark.approvers from the binding's users (name to
id); a binding-set approvers key and duplicate names are refused. With
setspark set, a user id or name change refuses the reload (pi's approvers
are fixed at start).
- setspark.mjs: record_create/record_update map required_approvers names to
discord:<id> and refuse unknown names, ids, duplicates and non-lists
before any request, without echoing the value. hideIds turns mentions,
discord: values and standalone 17-20 digit runs into the user's name or
"unknown user" in every verb's text and refusal, including the service
message and code before they are cut. The connector's approval request
keeps the bare ids.
- tests: boundary test over nested, keyed, numeric, mention and cut ids;
a local contract fixture from create through validateRequest, with the
old name-stored shape still refused.
Rocko: R1 revise, R2 revise, R3 approve (81379830..., report da75219f...).
Suites on an index export: 24/90/43/17/14/15/63/18; Discord node tests 173/173.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
564 lines
42 KiB
JavaScript
564 lines
42 KiB
JavaScript
// The SetSpark client's contract-independent half: config, key file read
|
|
// per call, idempotency keys, and the HTTP core against a local server that
|
|
// plays the record service. No network, no real key.
|
|
import { test, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createServer } from "node:http";
|
|
import { once } from "node:events";
|
|
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
SETSPARK_REFUSAL, SetsparkRefusal, IDEMPOTENCY_HEADER, MESSAGE_MAX_CHARS, USER_AGENT, SETSPARK_TOOL_NAMES, LIST_MAX,
|
|
loadSetsparkConfig, readKey, idempotencyKey, connectorKey, callApi, renderRefusal, createSetsparkApi, renderRecord, hideIds,
|
|
} from "../src/setspark.mjs";
|
|
import { loadToolsConfig, createToolSet, enabledToolNames } from "../src/tools.mjs";
|
|
import { validateBinding, resolveToolRoots, reloadDiff } from "../src/binding.mjs";
|
|
import { validateRequest } from "../src/approvals.mjs";
|
|
import { makeRoot, rawBinding } from "./helpers.mjs";
|
|
|
|
const KEY_A = "ssk_" + "a".repeat(40);
|
|
const KEY_B = "ssk_" + "b".repeat(40);
|
|
|
|
function keyFile(root, content = KEY_A, mode = 0o600) {
|
|
const dir = join(root, "secrets");
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const path = join(dir, "setspark.key");
|
|
writeFileSync(path, content.length === 0 ? "" : `${content}\n`, { mode: 0o600 });
|
|
chmodSync(path, mode);
|
|
return path;
|
|
}
|
|
|
|
const seen = [];
|
|
const server = createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => {
|
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
seen.push({ method: req.method, path: req.url, auth: req.headers.authorization, key: req.headers[IDEMPOTENCY_HEADER], ua: req.headers["user-agent"], type: req.headers["content-type"], body });
|
|
const json = (status, obj) => {
|
|
res.writeHead(status, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
const parsed = body ? JSON.parse(body) : null;
|
|
const path = req.url.split("?")[0];
|
|
const view = { request_id: 12, decision_id: "DEC-012", state: "open", proposal_version: 2, proposal_digest: "0123456789abcdef0123456789abcdef", required_approvers: ["100000000000000002", "100000000000000004"], channel_id: null, message_id: null, at: "2026-09-20T00:00:00Z", approvals: [] };
|
|
// the verbs' routes (contract a5425a2)
|
|
if (path === "/v1/records" && req.method === "POST") return json(201, { id: "WI-7", record_type: parsed.record_type, revision: 1, ...parsed.record, created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-20T00:00:00Z" });
|
|
if (path === "/v1/records" && req.method === "GET") return json(200, { record_type: "work_item", items: [{ id: "WI-7", record_type: "work_item", revision: 1, title: "Ship it", status: "active" }, { id: "WI-8", record_type: "work_item", revision: 4, title: "Later", status: "active", priority: "low" }], limit: 20, offset: 0 });
|
|
if (path === "/v1/records/WI-7" && req.method === "GET") return json(200, { id: "WI-7", record_type: "work_item", revision: 3, title: "Ship it", status: "active", owner: "Jason", tags: ["a", "b"], accepted_snapshot: { hidden: true }, body: "Two lines.\nOf body." });
|
|
if (path === "/v1/records/WI-7" && req.method === "PATCH") return json(200, { id: "WI-7", record_type: "work_item", revision: parsed.revision + 1, title: "Ship it", ...parsed.fields });
|
|
if (path === "/v1/records/DEC-9" && req.method === "GET") return json(200, { id: "DEC-9", record_type: "decision", revision: 2, title: "Pick one", status: "Proposed", required_approvers: ["discord:100000000000000100", "discord:199999999999999999"], approvals: [{ approver: "100000000000000100", at: "t" }] });
|
|
if (path === "/v1/records/DEC-9" && req.method === "PATCH") return json(200, { id: "DEC-9", record_type: "decision", revision: parsed.revision + 1, title: "Pick one", ...parsed.fields });
|
|
// Discord ids where the model could read them (the id-hiding boundary)
|
|
if (path === "/v1/records/DEC-10" && req.method === "GET") return json(200, { id: "DEC-10", record_type: "decision", revision: 1, title: "Ask 100000000000000100", required_approvers: ["discord:100000000000000100", "100000000000000101"], proposal_digest: "12345678901234567890abcdef0123456789abcdef0123456789abcdef012345", nested: { text: "approver discord:100000000000000100", "199999999999999999": "keyed" }, owner_id: 100000000000000100, note: `${"x".repeat(485)} 100000000000000101`, body: "Ask <@100000000000000100> and <@!199999999999999999>." });
|
|
if (path === "/v1/records/DEC-10" && req.method === "PATCH") return json(409, { code: "stale_revision", message: "approver discord:100000000000000101 changed", current_revision: 2, changed_fields: ["required_approvers", "100000000000000100"] });
|
|
if (path === "/v1/records/DEC-12" && req.method === "PATCH") return json(400, { code: `${"x".repeat(49)} 100000000000000100`, message: "no" });
|
|
if (path === "/v1/records/DEC-11" && req.method === "PATCH") return json(422, { code: "validation", message: `required_approvers: 199999999999999999 is not a user ${"z".repeat(332)} 100000000000000100` });
|
|
if (path === "/v1/resolve" && new URL(req.url, "http://x").searchParams.get("q") === "leak") return json(200, { query: "leak", matches: [{ id: "DEC-10", record_type: "decision", title: "Ask <@100000000000000101>", exact: false }] });
|
|
if (path === "/v1/documents" && req.method === "POST" && parsed.collection === "leak") return json(201, { id: "doc-2", title: "Notes for 100000000000000100", url: "https://outline.example.test/doc/199999999999999999" });
|
|
if (path === "/v1/records/WI-9" && req.method === "PATCH") return json(409, { code: "stale_revision", message: "behind", current_revision: 5, changed_fields: ["status"] });
|
|
if (path === "/v1/resolve") return json(200, { query: "ship", matches: [{ id: "WI-7", record_type: "work_item", title: "Ship it", exact: false }] });
|
|
if (path === "/v1/approval-requests" && req.method === "POST") return json(201, view);
|
|
if (path === "/v1/approval-requests/12/message") return json(200, { ...view, channel_id: parsed.channel_id, message_id: parsed.message_id });
|
|
if (path === "/v1/approval-requests/12" && req.method === "GET") return json(200, { ...view, message_id: "500000000000000002", approvals: [{ approver: "100000000000000002", at: "t", message_id: "500000000000000003", source_url: "https://discord.com/channels/100000000000000001/100000000000000010/500000000000000003" }] });
|
|
if (path === "/v1/approvals" && req.method === "POST") return json(201, { ...view, approver: parsed.author_id, approved: [parsed.author_id], accepted: true, status: "open", revision: 3 });
|
|
if (path === "/v1/documents" && req.method === "POST") return json(201, { id: "doc-1", title: parsed.title, collection: parsed.collection, url: "https://outline.example.test/doc/abc" });
|
|
switch (req.url) {
|
|
case "/v1/work_items": return json(201, { id: "SS-101", revision: 1, echo: parsed });
|
|
case "/v1/work_items/SS-101": return json(200, { id: "SS-101", revision: 3 });
|
|
case "/v1/stale": return json(409, { code: "stale_revision", message: "revision 2 is behind", current_revision: 3, changed_fields: ["title", "status"] });
|
|
case "/v1/replay": return json(422, { code: "idempotency_mismatch", message: "same key, different request" });
|
|
case "/v1/nokey": return json(401, { code: "unauthorized", message: "bad key" });
|
|
case "/v1/missing": return json(404, { code: "not_found", message: "no SS-999" });
|
|
case "/v1/bad": return json(400, { code: "validation", message: "x".repeat(2000) });
|
|
case "/v1/boom": return json(500, { code: "internal", message: "db down" });
|
|
case "/v1/html": res.writeHead(200, { "content-type": "text/html" }); return res.end("<p>hi</p>");
|
|
case "/v1/big": res.writeHead(200, { "content-type": "application/json" }); return res.end(`{"pad":"${"y".repeat(300000)}"}`);
|
|
case "/v1/slow": return setTimeout(() => json(200, { late: true }), 3000).unref();
|
|
case "/v1/whoami": return json(200, { auth: req.headers.authorization });
|
|
default: return json(404, { code: "not_found", message: "no route" });
|
|
}
|
|
});
|
|
});
|
|
server.listen(0, "127.0.0.1");
|
|
await once(server, "listening");
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
after(() => server.close());
|
|
|
|
function config(root, extra = {}) {
|
|
return loadSetsparkConfig({ baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, ...extra });
|
|
}
|
|
|
|
test("setspark config: a bare https or loopback origin, a private key file, a principal", () => {
|
|
const root = makeRoot();
|
|
const kf = keyFile(root);
|
|
const c = loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" });
|
|
assert.deepEqual(c, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 15000, maxResponseBytes: 262144, approvers: {} });
|
|
assert.equal(loadSetsparkConfig({ baseUrl: "https://api.setspark.io/", keyFile: kf, principal: "sage" }).baseUrl, "https://api.setspark.io");
|
|
assert.throws(() => loadSetsparkConfig(null), /not an object/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", extra: 1 }), /unknown key/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "http://api.setspark.io", keyFile: kf, principal: "sage" }), /must be https/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io/v1", keyFile: kf, principal: "sage" }), /no path/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://u:[email protected]", keyFile: kf, principal: "sage" }), /no path/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io?x=1", keyFile: kf, principal: "sage" }), /no path/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "nope", keyFile: kf, principal: "sage" }), /not a valid url/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: "relative", principal: "sage" }), /absolute path/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: join(root, "none"), principal: "sage" }), /not found/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "Sage!" }), /principal/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 10 }), /timeoutMs/);
|
|
// mode, symlink, empty
|
|
const loose = keyFile(makeRoot(), KEY_A, 0o644);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: loose, principal: "sage" }), /mode 0600/);
|
|
const empty = keyFile(makeRoot(), "");
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: empty, principal: "sage" }), /is empty/);
|
|
const link = join(makeRoot(), "link.key");
|
|
symlinkSync(kf, link);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: link, principal: "sage" }), /symlink/);
|
|
});
|
|
|
|
test("setspark config: reaches the tools config and the binding as a fixed key", () => {
|
|
const root = makeRoot();
|
|
const docs = join(root, "docs");
|
|
mkdirSync(docs);
|
|
const kf = keyFile(root);
|
|
const tools = loadToolsConfig({ roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } });
|
|
assert.equal(tools.setspark.baseUrl, "https://api.setspark.io");
|
|
assert.equal(tools.setspark.principal, "sage");
|
|
assert.throws(() => loadToolsConfig({ roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io" } }), /keyFile/);
|
|
const b = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
assert.equal(b.tools.setspark.keyFile, kf);
|
|
assert.throws(() => validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", nope: 1 } } })), /unknown key/);
|
|
});
|
|
|
|
test("setspark config: the binding's key survives resolveToolRoots and the engine's JSON hand-off to the extension", () => {
|
|
const root = makeRoot();
|
|
const docs = join(root, "docs");
|
|
mkdirSync(docs);
|
|
const dataRoot = join(root, "data");
|
|
mkdirSync(dataRoot);
|
|
const kf = keyFile(root);
|
|
const b = validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000 } } }));
|
|
const resolved = resolveToolRoots(b, { dataRoot });
|
|
assert.deepEqual(resolved.setspark, { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", timeoutMs: 5000, approvers: { owner: "100000000000000100" } });
|
|
for (const name of SETSPARK_TOOL_NAMES) assert.ok(enabledToolNames(resolved).includes(name), `${name} reaches pi's --tools list`);
|
|
const ext = loadToolsConfig(JSON.parse(JSON.stringify(resolved)));
|
|
assert.deepEqual(ext.setspark, b.tools.setspark, "the extension rebuilds the same config, response cap included");
|
|
const plain = resolveToolRoots(validateBinding(rawBinding({ tools: { roots: [{ name: "docs", path: docs }] } })), { dataRoot });
|
|
assert.equal("setspark" in plain, false);
|
|
assert.ok(!enabledToolNames(plain).some((n) => SETSPARK_TOOL_NAMES.includes(n)));
|
|
});
|
|
|
|
test("setspark config: approvers come from the binding's users, never from the binding's setspark key", () => {
|
|
const root = makeRoot();
|
|
const docs = join(root, "docs");
|
|
mkdirSync(docs);
|
|
const kf = keyFile(root);
|
|
const users = [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "carmen" }];
|
|
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
|
|
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
|
|
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
|
|
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
|
|
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
|
|
assert.doesNotThrow(() => reloadDiff(b, channelsOnly), "a user's channels still reload");
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "not-an-id" } }), /approvers/);
|
|
assert.throws(() => loadSetsparkConfig({ baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { jason: "100000000000000100", jay: "100000000000000100" } }), /approvers/);
|
|
});
|
|
|
|
test("setspark verbs: required_approvers go out as discord ids from names and come back as names", async () => {
|
|
const root = makeRoot();
|
|
const named = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
|
named.setTurn(TURN);
|
|
seen.length = 0;
|
|
const created = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", status: "Proposed", work_item: "SS-1", required_approvers: ["Jason", "@carmen"] } });
|
|
assert.equal(created.ok, true, created.text);
|
|
assert.deepEqual(JSON.parse(seen[0].body).record.required_approvers, ["discord:100000000000000100", "discord:100000000000000101"]);
|
|
const unknown = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["Jason", "Mallory"] } });
|
|
assert.equal(unknown.ok, false);
|
|
assert.match(unknown.text, /entry 2 is not a known user name; use names from: jason, carmen/);
|
|
const raw = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["discord:100000000000000100"] } });
|
|
assert.equal(raw.ok, false, "an id is not a name");
|
|
assert.doesNotMatch(raw.text, /100000000000000100/, "a refused id is not echoed back");
|
|
const twice = await named.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason", "Jason"] } });
|
|
assert.match(twice.text, /the same person twice/);
|
|
const notList = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: "jason" } });
|
|
assert.equal(notList.ok, false);
|
|
assert.equal(seen.length, 1, "refused calls send nothing");
|
|
const updated = await named.call("record_update", { id: "DEC-9", revision: 2, fields: { required_approvers: ["carmen"] } });
|
|
assert.equal(updated.ok, true, updated.text);
|
|
assert.deepEqual(JSON.parse(seen[1].body).fields.required_approvers, ["discord:100000000000000101"]);
|
|
const got = await named.call("record_get", { id: "DEC-9" });
|
|
assert.match(got.text, /required_approvers: jason, unknown user/);
|
|
assert.doesNotMatch(got.text, /1000000000000001|1999999999/, "no discord id reaches the model");
|
|
const none = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000 } }));
|
|
none.setTurn(TURN);
|
|
const noUsers = await none.call("record_create", { record_type: "decision", record: { title: "Pick one", required_approvers: ["jason"] } });
|
|
assert.match(noUsers.text, /use names from: \(none\)/);
|
|
});
|
|
|
|
test("setspark verbs: no Discord user id reaches tool text, whatever shape the service returns it in", async () => {
|
|
const root = makeRoot();
|
|
const ids = ["100000000000000100", "100000000000000101", "199999999999999999"];
|
|
const t = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
|
t.setTurn(TURN);
|
|
const texts = [];
|
|
const got = await t.call("record_get", { id: "DEC-10" });
|
|
assert.equal(got.ok, true, got.text);
|
|
texts.push(got.text);
|
|
assert.match(got.text, /title: Ask jason/);
|
|
assert.match(got.text, /required_approvers: jason, carmen/);
|
|
assert.match(got.text, /nested: \{"text":"approver jason","unknown user":"keyed"\}/);
|
|
assert.match(got.text, /Ask jason and unknown user\./);
|
|
assert.match(got.text, /proposal_digest: 12345678901234567890abcdef/, "a digit run inside a digest is not an id");
|
|
const stale = await t.call("record_update", { id: "DEC-10", revision: 1, fields: { status: "Proposed" } });
|
|
assert.equal(stale.ok, false);
|
|
assert.match(stale.text, /changed: required_approvers, jason/);
|
|
assert.match(stale.text, /approver carmen changed/);
|
|
texts.push(stale.text);
|
|
const rejected = await t.call("record_update", { id: "DEC-11", revision: 1, fields: { status: "Proposed" } });
|
|
assert.equal(rejected.ok, false);
|
|
assert.match(rejected.text, /unknown user is not a user/);
|
|
texts.push(rejected.text);
|
|
// a field capped before it is shown: the service's code, a bad property
|
|
// name, a bad filter name
|
|
const code = await t.call("record_update", { id: "DEC-12", revision: 1, fields: { status: "Proposed" } });
|
|
assert.equal(code.ok, false);
|
|
texts.push(code.text);
|
|
const prop = await t.call("record_create", { record_type: "decision", record: { title: "x", [`${"x".repeat(17)} 100000000000000101`]: "v" } });
|
|
assert.match(prop.text, /property name that is not allowed/);
|
|
texts.push(prop.text);
|
|
const filter = await t.call("record_list", { record_type: "decision", filters: { [`${"x".repeat(17)} 199999999999999999`]: "v" } });
|
|
assert.match(filter.text, /property name not allowed/);
|
|
texts.push(filter.text);
|
|
const found = await t.call("resolve_id", { query: "leak" });
|
|
assert.match(found.text, /Ask carmen/);
|
|
texts.push(found.text);
|
|
const doc = await t.call("create_document", { collection: "leak", title: "Notes", text: "x" });
|
|
assert.equal(doc.ok, true, doc.text);
|
|
texts.push(doc.text);
|
|
const opened = await t.call("open_approval_request", { decision_id: "DEC-012", proposal_version: 2, proposal_digest: "0123456789abcdef0123456789abcdef" });
|
|
texts.push(opened.text);
|
|
for (const text of texts) for (const id of ids) assert.equal(text.includes(id), false, `id ${id} in: ${text}`);
|
|
for (const text of texts) assert.doesNotMatch(text, /(?<![0-9A-Za-z])[0-9]{12,16}(?![0-9A-Za-z])/, `no part of an id survives a cut: ${text}`);
|
|
// the connector's request keeps the service's bare ids; only text is hidden
|
|
assert.deepEqual([...opened.details.request.approvers], ["100000000000000002", "100000000000000004"]);
|
|
assert.equal(hideIds(null, "<@100000000000000100> discord:100000000000000100 SS-027 v2"), "unknown user unknown user SS-027 v2");
|
|
});
|
|
|
|
test("setspark contract: a decision made with names opens a request the connector accepts; names stored by an old record still refuse", async () => {
|
|
// A local stand-in for the service's contract: records keep
|
|
// required_approvers as written (discord:<id>), and the approval-request
|
|
// view returns them as bare Discord ids, as shared-signals documents.
|
|
const store = new Map();
|
|
let next = 1;
|
|
const svc = createServer((req, res) => {
|
|
const chunks = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => {
|
|
const parsed = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
|
|
const json = (status, obj) => {
|
|
res.writeHead(status, { "content-type": "application/json" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
if (req.url === "/v1/records" && req.method === "POST") {
|
|
const id = `DEC-${next++}`;
|
|
store.set(id, { id, record_type: parsed.record_type, revision: 1, ...parsed.record });
|
|
return json(201, store.get(id));
|
|
}
|
|
if (req.url === "/v1/approval-requests" && req.method === "POST") {
|
|
const d = store.get(parsed.decision_id);
|
|
if (!d) return json(404, { code: "not_found", message: "no decision" });
|
|
const bare = d.required_approvers.map((a) => (typeof a === "string" && a.startsWith("discord:") ? a.slice(8) : a));
|
|
return json(201, { request_id: next++, decision_id: d.id, state: "open", proposal_version: parsed.proposal_version, proposal_digest: parsed.proposal_digest, required_approvers: bare, approvals: [], message_id: null });
|
|
}
|
|
return json(404, { code: "not_found", message: "no route" });
|
|
});
|
|
});
|
|
svc.listen(0, "127.0.0.1");
|
|
await once(svc, "listening");
|
|
try {
|
|
const root = makeRoot();
|
|
const t = createToolSet(loadToolsConfig({ roots: [{ name: "docs", path: root }], maxCallsPerTurn: 12, setspark: { baseUrl: `http://127.0.0.1:${svc.address().port}`, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000, approvers: { jason: "100000000000000100", carmen: "100000000000000101" } } }));
|
|
t.setTurn(TURN);
|
|
const made = await t.call("record_create", { record_type: "decision", record: { title: "Pick one", status: "Proposed", required_approvers: ["jason", "carmen"] } });
|
|
assert.equal(made.ok, true, made.text);
|
|
const opened = await t.call("open_approval_request", { decision_id: "DEC-1", proposal_version: 1, proposal_digest: "0123456789abcdef" });
|
|
assert.equal(opened.ok, true, opened.text);
|
|
const request = validateRequest(opened.details.request);
|
|
assert.deepEqual([...request.approvers], ["100000000000000100", "100000000000000101"]);
|
|
// DEC-009's shape: names written before this fix. The connector refuses.
|
|
store.set("DEC-9", { id: "DEC-9", record_type: "decision", revision: 1, title: "Old", required_approvers: ["Jason", "Carmen"] });
|
|
const old = await t.call("open_approval_request", { decision_id: "DEC-9", proposal_version: 1, proposal_digest: "0123456789abcdef" });
|
|
assert.equal(old.ok, true, old.text);
|
|
assert.throws(() => validateRequest(old.details.request), /bad approver id/);
|
|
} finally {
|
|
svc.close();
|
|
}
|
|
});
|
|
|
|
test("setspark keys: read per call, one printable token per file, rotation without a restart", async () => {
|
|
const root = makeRoot();
|
|
const c = config(root);
|
|
assert.equal(readKey(c), KEY_A);
|
|
seen.length = 0;
|
|
let r = await callApi(c, { method: "GET", path: "/v1/whoami" });
|
|
assert.equal(r.body.auth, `Bearer ${KEY_A}`);
|
|
writeFileSync(c.keyFile, `${KEY_B}\n`, { mode: 0o600 });
|
|
r = await callApi(c, { method: "GET", path: "/v1/whoami" });
|
|
assert.equal(r.body.auth, `Bearer ${KEY_B}`, "the second call used the rotated key with no restart");
|
|
// a key file that stops being private stops being used
|
|
chmodSync(c.keyFile, 0o644);
|
|
await assert.rejects(callApi(c, { method: "GET", path: "/v1/whoami" }), (e) => e instanceof SetsparkRefusal && e.reason === SETSPARK_REFUSAL.KEY_FILE);
|
|
chmodSync(c.keyFile, 0o600);
|
|
writeFileSync(c.keyFile, "two\nlines\n", { mode: 0o600 });
|
|
assert.throws(() => readKey(c), (e) => e.reason === SETSPARK_REFUSAL.KEY_SHAPE);
|
|
writeFileSync(c.keyFile, "has a space in it and is long enough\n", { mode: 0o600 });
|
|
assert.throws(() => readKey(c), (e) => e.reason === SETSPARK_REFUSAL.KEY_SHAPE);
|
|
writeFileSync(c.keyFile, `${"z".repeat(5000)}\n`, { mode: 0o600 });
|
|
assert.throws(() => readKey(c), (e) => e.reason === SETSPARK_REFUSAL.KEY_SHAPE);
|
|
// the mint's JSON output stored as is
|
|
writeFileSync(c.keyFile, `${JSON.stringify({ key_id: "sage-00000000", key: KEY_B, note: "shown once" })}\n`, { mode: 0o600 });
|
|
assert.equal(readKey(c), KEY_B);
|
|
writeFileSync(c.keyFile, '{"key_id": "sage-00000000"}\n', { mode: 0o600 });
|
|
assert.throws(() => readKey(c), (e) => e.reason === SETSPARK_REFUSAL.KEY_SHAPE, "json without a key field");
|
|
writeFileSync(c.keyFile, "{not json\n", { mode: 0o600 });
|
|
assert.throws(() => readKey(c), (e) => e.reason === SETSPARK_REFUSAL.KEY_SHAPE);
|
|
});
|
|
|
|
test("setspark idempotency keys: principal, turn id, call index; connector keys name a step", () => {
|
|
assert.equal(idempotencyKey("sage", "200000000000000001", 1), "sage:200000000000000001:1");
|
|
assert.equal(idempotencyKey("sage", "200000000000000001", 7), "sage:200000000000000001:7");
|
|
assert.throws(() => idempotencyKey("sage", null, 1), (e) => e instanceof SetsparkRefusal && e.reason === SETSPARK_REFUSAL.NO_TURN);
|
|
assert.throws(() => idempotencyKey("sage", "abc", 1), (e) => e.reason === SETSPARK_REFUSAL.NO_TURN);
|
|
assert.throws(() => idempotencyKey("sage", "200000000000000001", 0), /call index/);
|
|
assert.throws(() => idempotencyKey("Sage!", "200000000000000001", 1), /principal/);
|
|
assert.equal(connectorKey("sage", "200000000000000009", "bind"), "sage:200000000000000009:bind");
|
|
assert.throws(() => connectorKey("sage", "x", "bind"), /event id/);
|
|
assert.throws(() => connectorKey("sage", "200000000000000009", "Bind 1"), /step/);
|
|
});
|
|
|
|
test("setspark http core: json in and out, bearer header, idempotency header, fixed user agent, no key anywhere else", async () => {
|
|
const root = makeRoot();
|
|
const c = config(root);
|
|
seen.length = 0;
|
|
const r = await callApi(c, { method: "POST", path: "/v1/work_items", body: { title: "t" }, idempotencyKey: "sage:200000000000000001:1" });
|
|
assert.equal(r.status, 201);
|
|
assert.deepEqual(r.body, { id: "SS-101", revision: 1, echo: { title: "t" } });
|
|
assert.equal(seen.length, 1);
|
|
assert.equal(seen[0].method, "POST");
|
|
assert.equal(seen[0].auth, `Bearer ${KEY_A}`);
|
|
assert.equal(seen[0].key, "sage:200000000000000001:1");
|
|
assert.equal(seen[0].ua, USER_AGENT);
|
|
assert.equal(seen[0].type, "application/json");
|
|
assert.equal(seen[0].body, '{"title":"t"}');
|
|
assert.ok(!seen[0].path.includes(KEY_A) && !seen[0].body.includes(KEY_A));
|
|
const g = await callApi(c, { method: "GET", path: "/v1/work_items/SS-101" });
|
|
assert.equal(g.body.revision, 3);
|
|
assert.equal(seen[1].key, undefined, "a read carries no idempotency key");
|
|
await assert.rejects(callApi(c, { method: "POST", path: "/v1/work_items", body: {} }), /idempotency key/);
|
|
await assert.rejects(callApi(c, { method: "GET", path: "/v1/x", body: {} }), /no body/);
|
|
await assert.rejects(callApi(c, { method: "DELETE", path: "/v1/x" }), /bad method/);
|
|
await assert.rejects(callApi(c, { method: "GET", path: "v1/x" }), /bad path/);
|
|
await assert.rejects(callApi(c, { method: "GET", path: "/v1/../x" }), /bad path/);
|
|
});
|
|
|
|
test("setspark http core: error bodies become fixed refusals with code and the 409 fields; server text is data, cut", async () => {
|
|
const root = makeRoot();
|
|
const c = config(root);
|
|
const expect = async (path, reason, extra = {}) => {
|
|
let caught = null;
|
|
try {
|
|
await callApi(c, { method: "POST", path, body: {}, idempotencyKey: "sage:200000000000000001:2" });
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
assert.ok(caught instanceof SetsparkRefusal, `${path} refuses`);
|
|
assert.equal(caught.reason, reason, path);
|
|
for (const [k, v] of Object.entries(extra)) assert.deepEqual(caught[k], v, `${path} ${k}`);
|
|
return caught;
|
|
};
|
|
const stale = await expect("/v1/stale", SETSPARK_REFUSAL.CONFLICT, { status: 409, code: "stale_revision", currentRevision: 3, changedFields: ["title", "status"] });
|
|
assert.equal(renderRefusal(stale), "refused: the record changed since it was read (stale revision) (code stale_revision); current revision 3; changed: title, status\nrevision 2 is behind");
|
|
await expect("/v1/replay", SETSPARK_REFUSAL.REPLAY, { status: 422, code: "idempotency_mismatch" });
|
|
await expect("/v1/nokey", SETSPARK_REFUSAL.UNAUTHORIZED, { status: 401 });
|
|
await expect("/v1/missing", SETSPARK_REFUSAL.NOT_FOUND, { status: 404, code: "not_found" });
|
|
const bad = await expect("/v1/bad", SETSPARK_REFUSAL.REJECTED, { status: 400, code: "validation" });
|
|
assert.equal(bad.message.length, MESSAGE_MAX_CHARS);
|
|
await expect("/v1/boom", SETSPARK_REFUSAL.SERVER, { status: 500, code: "internal" });
|
|
await expect("/v1/html", SETSPARK_REFUSAL.NOT_JSON, { status: 200 });
|
|
await expect("/v1/big", SETSPARK_REFUSAL.TOO_BIG);
|
|
const t0 = Date.now();
|
|
await expect("/v1/slow", SETSPARK_REFUSAL.TIMEOUT);
|
|
assert.ok(Date.now() - t0 < 2500, "the timeout ended the call");
|
|
const dead = loadSetsparkConfig({ baseUrl: "http://127.0.0.1:1", keyFile: c.keyFile, principal: "sage", timeoutMs: 1000 });
|
|
await assert.rejects(callApi(dead, { method: "GET", path: "/v1/x" }), (e) => e.reason === SETSPARK_REFUSAL.NETWORK);
|
|
});
|
|
|
|
// --- the verbs through the tool set ---
|
|
|
|
const TURN = { requester: "Jason", turnId: "987654321098765432", authorId: "123456789012345678" };
|
|
function toolSet(root, extra = {}) {
|
|
const c = loadToolsConfig({ roots: [{ name: "docs", path: root }], maxFileBytes: 4096, maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage", timeoutMs: 1000 }, ...extra });
|
|
return createToolSet(c);
|
|
}
|
|
|
|
test("setspark verbs: a setspark key enables the eight verbs and no counters", () => {
|
|
const root = makeRoot();
|
|
const c = loadToolsConfig({ roots: [{ name: "docs", path: root }], maxFileBytes: 4096, maxCallsPerTurn: 12, setspark: { baseUrl: base, keyFile: keyFile(root), principal: "sage" } });
|
|
assert.deepEqual(enabledToolNames(c), ["list_dir", "read_file", "search", ...SETSPARK_TOOL_NAMES]);
|
|
assert.ok(!SETSPARK_TOOL_NAMES.includes("get_counters"));
|
|
});
|
|
|
|
test("setspark verbs: writes carry the turn's key and the asserted requester, reads carry no key, and the api key never appears in text or details", async () => {
|
|
const set = toolSet(makeRoot());
|
|
set.setTurn(TURN);
|
|
seen.length = 0;
|
|
const created = await set.call("record_create", { record_type: "work_item", record: { title: "Ship it", status: "active" } });
|
|
assert.equal(created.ok, true, created.text);
|
|
assert.match(created.text, /^created WI-7 \(work_item\) revision 1; the record is live in SetSpark, no file and no commit$/);
|
|
assert.equal(created.details.verb, "record_create");
|
|
assert.equal(created.details.key, "sage:987654321098765432:1");
|
|
assert.equal(created.details.id, "WI-7");
|
|
assert.equal(created.details.revision, 1);
|
|
assert.equal(seen[0].method, "POST");
|
|
assert.equal(seen[0].path, "/v1/records");
|
|
assert.equal(seen[0].key, "sage:987654321098765432:1");
|
|
assert.equal(seen[0].auth, `Bearer ${KEY_A}`);
|
|
assert.deepEqual(JSON.parse(seen[0].body), { record_type: "work_item", record: { title: "Ship it", status: "active" }, context: { turn_id: "987654321098765432", client_version: USER_AGENT, requester: { id: "123456789012345678", name: "Jason" } } });
|
|
|
|
const got = await set.call("record_get", { id: "WI-7" });
|
|
assert.equal(got.ok, true);
|
|
assert.equal(got.text, "WI-7 (work_item) revision 3\ntitle: Ship it\nstatus: active\nowner: Jason\ntags: a, b\nbody:\nTwo lines.\nOf body.");
|
|
assert.equal(seen[1].key, undefined, "a read sends no idempotency key");
|
|
assert.deepEqual(got.details, { tool: "record_get", root: null, path: null, ok: true, verb: "record_get", id: "WI-7", revision: 3, ms: got.details.ms });
|
|
|
|
const updated = await set.call("record_update", { id: "WI-7", revision: 3, fields: { status: "done" } });
|
|
assert.equal(updated.ok, true);
|
|
assert.equal(updated.text, "updated WI-7 from revision 3 to 4; the change is live in SetSpark");
|
|
assert.equal(updated.details.key, "sage:987654321098765432:3", "the call index counts every call in the turn");
|
|
assert.deepEqual(JSON.parse(seen[2].body).fields, { status: "done" });
|
|
assert.equal(seen[2].method, "PATCH");
|
|
|
|
const stale = await set.call("record_update", { id: "WI-9", revision: 2, fields: { status: "done" } });
|
|
assert.equal(stale.ok, false);
|
|
assert.equal(stale.text, `refused: ${SETSPARK_REFUSAL.CONFLICT} (code stale_revision); current revision 5; changed: status\nbehind`);
|
|
assert.equal(stale.details.code, "stale_revision");
|
|
assert.equal(stale.details.status, 409);
|
|
|
|
const listed = await set.call("record_list", { record_type: "work_item", filters: { status: "active" }, limit: 20 });
|
|
assert.equal(listed.ok, true);
|
|
assert.equal(seen[4].path, "/v1/records?record_type=work_item&limit=20&offset=0&status=active");
|
|
assert.equal(listed.text, "2 work_item record(s) from offset 0 (limit 20)\nWI-7: Ship it | active (rev 1)\nWI-8: Later | active | low (rev 4)");
|
|
assert.equal(listed.details.count, 2);
|
|
|
|
const resolved = await set.call("resolve_id", { query: "ship" });
|
|
assert.equal(resolved.text, '1 match(es) for "ship"\nWI-7 (work_item): Ship it');
|
|
assert.equal(seen[5].path, "/v1/resolve?q=ship");
|
|
|
|
const opened = await set.call("open_approval_request", { decision_id: "DEC-012", proposal_version: 2, proposal_digest: "0123456789abcdef0123456789abcdef" });
|
|
assert.equal(opened.ok, true, opened.text);
|
|
assert.match(opened.text, /^request 12 for DEC-012 version 2: open; 0 of 2 approvals recorded; no message bound yet\. The approval message/);
|
|
assert.deepEqual(opened.details.request, { requestId: "12", decisionId: "DEC-012", proposalVersion: 2, digest: "0123456789abcdef0123456789abcdef", approvers: ["100000000000000002", "100000000000000004"] });
|
|
assert.equal(opened.details.key, "sage:987654321098765432:7");
|
|
assert.deepEqual(JSON.parse(seen[6].body), { decision_id: "DEC-012", proposal_version: 2, proposal_digest: "0123456789abcdef0123456789abcdef", context: { turn_id: "987654321098765432", client_version: USER_AGENT, requester: { id: "123456789012345678", name: "Jason" } } });
|
|
|
|
const state = await set.call("get_approval_request", { request_id: 12 });
|
|
assert.equal(state.text, "request 12 for DEC-012 version 2: open; 1 of 2 approvals recorded; bound to a Discord message");
|
|
assert.equal(state.details.requestId, "12");
|
|
assert.equal(seen[7].path, "/v1/approval-requests/12");
|
|
|
|
const doc = await set.call("create_document", { collection: "Notes", title: "Meeting", text: "# hi\n", source: "Discord #general" });
|
|
assert.equal(doc.text, "created document Meeting at https://outline.example.test/doc/abc");
|
|
assert.deepEqual(JSON.parse(seen[8].body).collection, "Notes");
|
|
assert.equal(seen[8].key, "sage:987654321098765432:9");
|
|
|
|
for (const r of [created, got, updated, stale, listed, resolved, opened, state, doc]) {
|
|
assert.ok(!JSON.stringify(r).includes(KEY_A), "the api key never leaks into a result");
|
|
}
|
|
for (const s of seen) assert.equal(s.ua, USER_AGENT);
|
|
});
|
|
|
|
test("setspark verbs: no turn refuses every write before any request; bad arguments refuse before any request; reads still work", async () => {
|
|
const set = toolSet(makeRoot());
|
|
set.setTurn({ requester: "Jason" });
|
|
seen.length = 0;
|
|
for (const [name, params] of [
|
|
["record_create", { record_type: "work_item", record: { title: "x" } }],
|
|
["record_update", { id: "WI-7", revision: 1, fields: { a: "b" } }],
|
|
["open_approval_request", { decision_id: "DEC-012", proposal_version: 1, proposal_digest: "0123456789abcdef" }],
|
|
["create_document", { collection: "Notes", title: "t" }],
|
|
]) {
|
|
const r = await set.call(name, params);
|
|
assert.equal(r.ok, false, name);
|
|
assert.equal(r.details.reason, SETSPARK_REFUSAL.NO_TURN, name);
|
|
}
|
|
assert.equal(seen.length, 0, "nothing reached the service");
|
|
assert.equal((await set.call("record_get", { id: "WI-7" })).ok, true, "reads need no turn");
|
|
set.setTurn(TURN);
|
|
set.resetBudget();
|
|
for (const [name, params, why] of [
|
|
["record_create", { record_type: "wat", record: { title: "x" } }, /record_type/],
|
|
["record_create", { record_type: "work_item", record: { status: "x" } }, /title/],
|
|
["record_create", { record_type: "work_item", record: { "Bad Key": "x", title: "t" } }, /property name/],
|
|
["record_update", { id: "wi7", revision: 1, fields: { a: "b" } }, /id must be/],
|
|
["record_update", { id: "WI-7", revision: 0, fields: { a: "b" } }, /revision/],
|
|
["record_update", { id: "WI-7", revision: 1, fields: {} }, /at least one/],
|
|
["record_list", { record_type: "work_item", limit: LIST_MAX + 1 }, /limit/],
|
|
["record_list", { record_type: "work_item", filters: { record_type: "x" } }, /not allowed/],
|
|
["resolve_id", { query: " " }, /blank/],
|
|
["open_approval_request", { decision_id: "DEC-012", proposal_version: 1, proposal_digest: "ZZ" }, /proposal_digest/],
|
|
["get_approval_request", { request_id: "12" }, /request_id/],
|
|
["create_document", { collection: "Notes", title: "t", text: "x".repeat(20001) }, /text/],
|
|
]) {
|
|
const r = await set.call(name, params);
|
|
assert.equal(r.ok, false, name);
|
|
assert.equal(r.details.reason, SETSPARK_REFUSAL.BAD_ARGS, name);
|
|
assert.match(r.text, why);
|
|
}
|
|
assert.equal(seen.length, 1, "only the read reached the service");
|
|
});
|
|
|
|
test("setspark verbs: renderRecord caps long output and hides the accepted snapshot", () => {
|
|
const text = renderRecord({ id: "WI-1", record_type: "work_item", revision: 1, title: "t", accepted_snapshot: { x: 1 }, notes: "n".repeat(900), body: "b".repeat(9000) });
|
|
assert.ok(!text.includes('"x":1'));
|
|
assert.ok(text.length <= 6000 + 40);
|
|
assert.match(text, /cut at 6000 characters$/);
|
|
assert.match(text, /\nnotes: n{500}\n/, "a property value is cut at 500 characters");
|
|
});
|
|
|
|
// --- the connector's client ---
|
|
|
|
test("setspark api: bind, add_approval (button and reply) and get use integer request ids and the connector's keys", async () => {
|
|
const root = makeRoot();
|
|
const api = createSetsparkApi(config(root));
|
|
seen.length = 0;
|
|
const bound = await api.bindApprovalMessage({ requestId: "12", messageId: "500000000000000002", channelId: "100000000000000010", idempotencyKey: "sage:500000000000000002:bind" });
|
|
assert.equal(bound.message_id, "500000000000000002");
|
|
assert.equal(seen[0].path, "/v1/approval-requests/12/message");
|
|
assert.equal(seen[0].key, "sage:500000000000000002:bind");
|
|
assert.deepEqual(JSON.parse(seen[0].body), { message_id: "500000000000000002", channel_id: "100000000000000010", context: { client_version: USER_AGENT } });
|
|
|
|
const url = "https://discord.com/channels/100000000000000001/100000000000000010/500000000000000004";
|
|
const added = await api.addApproval({ requestId: "12", kind: "button", authorId: "100000000000000004", messageId: "500000000000000002", boundMessageId: "500000000000000002", sourceUrl: url, statement: "Approval: carmen approved DEC-012 v2 (digest 01234567) by button.", idempotencyKey: "sage:300000000000000002:approval" });
|
|
assert.equal(added.accepted, true);
|
|
assert.equal(seen[1].path, "/v1/approvals");
|
|
assert.equal(seen[1].key, "sage:300000000000000002:approval");
|
|
assert.deepEqual(JSON.parse(seen[1].body), { request_id: 12, kind: "button", author_id: "100000000000000004", message_id: "500000000000000002", bound_message_id: "500000000000000002", source_url: url, statement: "Approval: carmen approved DEC-012 v2 (digest 01234567) by button.", context: { source_url: url, client_version: USER_AGENT } });
|
|
|
|
await api.addApproval({ requestId: 12, kind: "reply", authorId: "100000000000000002", messageId: "500000000000000003", boundMessageId: "500000000000000002", sourceUrl: url.replace(/4$/, "3"), statement: "approve", idempotencyKey: "sage:500000000000000003:approval" });
|
|
const reply = JSON.parse(seen[2].body);
|
|
assert.equal(reply.kind, "reply");
|
|
assert.equal(reply.message_id, "500000000000000003");
|
|
assert.equal(reply.bound_message_id, "500000000000000002");
|
|
|
|
const view = await api.getApprovalRequest("12");
|
|
assert.equal(view.approvals.length, 1);
|
|
assert.equal(seen[3].method, "GET");
|
|
assert.equal(seen[3].key, undefined);
|
|
|
|
await assert.rejects(api.getApprovalRequest("APR-7"), /bad request id/);
|
|
await assert.rejects(api.addApproval({ requestId: "12", kind: "emoji" }), /kind must be/);
|
|
await assert.rejects(api.getApprovalRequest("13"), (e) => e instanceof SetsparkRefusal && e.reason === SETSPARK_REFUSAL.NOT_FOUND, "a service error reaches the connector as a refusal");
|
|
});
|