Files
buzz/desktop/src/features/projects/projectRepositoryCreation.test.mjs
thomaspblock e30db7028f feat(projects): support multiple repositories (#4671)
## Summary
- adopt the finalized NIP-MP project model so one project can enumerate
and switch between multiple NIP-34 repositories
- add project and repository navigation, activity summaries,
existing-repository attachment, and repository access-channel management
- preserve privacy-safe activation provenance for agent-authored
patches, pull requests, issues, and associated commits

## Test plan
- [x] Run desktop typecheck and unit tests
- [x] Run focused NIP-MP, repository access, and provenance tests
- [x] Run Rust formatting and desktop lint checks
- [x] Run the complete pre-push suite after merging current `main`
- [ ] Manually verify project creation, repository attachment,
switching, and access repair on staging
- [ ] Manually verify public-channel and private-agent origin labels on
newly created Git activity

Related: [#4695](https://github.com/block/buzz/pull/4695)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-04 17:30:12 -04:00

348 lines
9.2 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
buildRepositoryChannelBindingTemplate,
buildProjectPatchTemplate,
buildAddedRepositoryEventTemplatesFromHead,
} from "./projectRepositoryCreation.ts";
import { validateProjectEventEnvelope } from "./projectModels.ts";
const OWNER = "a".repeat(64);
test("buildRepositoryChannelBindingTemplate preserves repository metadata", () => {
const repository = {
id: `${OWNER}:desktop`,
dtag: "desktop",
name: "Desktop",
description: "Desktop app",
owner: OWNER,
createdAt: 1,
repoAddress: `30617:${OWNER}:desktop`,
eventContent: "Desktop app",
eventTags: [
["d", "desktop"],
["name", "Desktop"],
["x-custom", "preserve-me"],
],
};
const template = buildRepositoryChannelBindingTemplate({
channelId: "11111111-1111-4111-8111-111111111111",
ownerPubkey: OWNER,
repository,
});
assert.equal(template.content, "Desktop app");
assert.deepEqual(template.tags, [
["d", "desktop"],
["name", "Desktop"],
["x-custom", "preserve-me"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
]);
});
// ── buildProjectPatchTemplate: unknown-tag preservation ────────────────────
test("buildProjectPatchTemplate preserves unknown tags from the live head", () => {
const OWNER = "a".repeat(64);
const existingAddress = `30617:${OWNER}:desktop`;
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "ignored-by-nip-mp",
tags: [
["d", "platform"],
["name", "Platform"],
["description", "Multi-repo project"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
["future-metadata", "preserve-me"],
["alt", "extension tag that must survive round-trip"],
["a", existingAddress],
],
};
const newAddress = `30617:${OWNER}:mobile`;
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: [existingAddress, newAddress],
});
// Content must be preserved verbatim (NIP-MP §content).
assert.equal(template.content, "ignored-by-nip-mp");
// Non-`a` tags — including unknown ones — must survive in order.
const nonMemberTags = template.tags.filter((t) => t[0] !== "a");
assert.deepEqual(nonMemberTags, [
["d", "platform"],
["name", "Platform"],
["description", "Multi-repo project"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
["future-metadata", "preserve-me"],
["alt", "extension tag that must survive round-trip"],
]);
// New address must be added; sorted order maintained.
const memberTags = template.tags.filter((t) => t[0] === "a").map((t) => t[1]);
assert.deepEqual(memberTags.sort(), [existingAddress, newAddress].sort());
});
test("buildProjectPatchTemplate preserves relay hints on existing members", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const existingWithHint = `30617:${OTHER}:relay`;
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["a", existingWithHint, "wss://relay.example"],
],
};
const newAddress = `30617:${OWNER}:mobile`;
const template = buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: [existingWithHint, newAddress],
});
const existingTag = template.tags.find(
(t) => t[0] === "a" && t[1] === existingWithHint,
);
assert.ok(existingTag, "existing member tag must be present");
assert.equal(
existingTag[2],
"wss://relay.example",
"relay hint must be preserved",
);
});
test("buildProjectPatchTemplate rejects a non-owner caller", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [["d", "platform"]],
};
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead,
ownerPubkey: OTHER,
repositoryAddresses: [],
}),
/Only the project owner/,
);
});
test("buildProjectPatchTemplate rejects a repository address list exceeding 64 members", () => {
const OWNER = "a".repeat(64);
const liveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [["d", "wide"]],
};
const tooMany = Array.from(
{ length: 65 },
(_, i) => `30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`,
);
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead,
ownerPubkey: OWNER,
repositoryAddresses: tooMany,
}),
/64/,
);
});
// ── buildAddedRepositoryEventTemplatesFromHead: racing writer ───────────────
test("buildAddedRepositoryEventTemplatesFromHead detects a concurrent add via the live head", () => {
const OWNER = "a".repeat(64);
const existingAddress = `30617:${OWNER}:desktop`;
const newDtag = "mobile";
const newAddress = `30617:${OWNER}:${newDtag}`;
// Live head already contains the address (another session snuck it in).
const liveHeadWithRace = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 150,
content: "",
tags: [
["d", "platform"],
["a", existingAddress],
["a", newAddress], // concurrent add
],
};
assert.throws(
() =>
buildAddedRepositoryEventTemplatesFromHead({
accessChannelId: "11111111-1111-4111-8111-111111111111",
existingRepositoryAddresses: [existingAddress],
liveHead: liveHeadWithRace,
name: "Mobile",
ownerPubkey: OWNER,
}),
/already contains.*mobile.*another session/,
);
});
// ── validateProjectEventEnvelope: shared full-envelope validator ─────────────
// These tests pin the NIP-MP validation rules through the WRITE helper so that
// a nonconforming live head (e.g. from a permissive relay) is caught before
// Desktop signs and re-submits it.
test("validateProjectEventEnvelope accepts a valid minimal envelope", () => {
assert.doesNotThrow(() =>
validateProjectEventEnvelope([["d", "platform"]], ""),
);
});
test("validateProjectEventEnvelope rejects missing d tag", () => {
assert.throws(
() => validateProjectEventEnvelope([["name", "X"]], ""),
/NIP-MP.*'d'/,
);
});
test("validateProjectEventEnvelope rejects duplicate d tags", () => {
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "a"],
["d", "b"],
],
"",
),
/NIP-MP.*'d'/,
);
});
test("validateProjectEventEnvelope rejects duplicate name tags", () => {
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["name", "X"],
["name", "Y"],
],
"",
),
/NIP-MP.*duplicate.*'name'/,
);
});
test("validateProjectEventEnvelope rejects a name tag that exceeds 256 bytes", () => {
const longName = "x".repeat(257);
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["name", longName],
],
"",
),
/NIP-MP.*'name'.*256/,
);
});
test("validateProjectEventEnvelope rejects a description tag that exceeds 2048 bytes", () => {
const longDesc = "x".repeat(2049);
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["description", longDesc],
],
"",
),
/NIP-MP.*'description'.*2048/,
);
});
test("validateProjectEventEnvelope rejects more than 64 a-tags", () => {
const tags = [["d", "wide"]];
for (let i = 0; i < 65; i++) {
tags.push([
"a",
`30617:${"a".repeat(64)}:repo-${String(i).padStart(2, "0")}`,
]);
}
assert.throws(() => validateProjectEventEnvelope(tags, ""), /NIP-MP.*64/);
});
test("validateProjectEventEnvelope rejects a member address with uppercase owner hex", () => {
const upperAddr = `30617:${"A".repeat(64)}:desktop`;
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["a", upperAddr],
],
"",
),
/NIP-MP.*invalid.*address/,
);
});
test("validateProjectEventEnvelope rejects duplicate a-tag addresses", () => {
const addr = `30617:${"a".repeat(64)}:desktop`;
assert.throws(
() =>
validateProjectEventEnvelope(
[
["d", "platform"],
["a", addr],
["a", addr],
],
"",
),
/NIP-MP.*duplicate.*address/,
);
});
test("buildProjectPatchTemplate catches duplicate d in live head via full-envelope validation", () => {
// A relay that accepted a nonconforming event could serve a head with two d tags.
// buildProjectPatchTemplate must catch this before signing.
const badLiveHead = {
id: "e".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["d", "extra"],
],
};
assert.throws(
() =>
buildProjectPatchTemplate({
liveHead: badLiveHead,
ownerPubkey: OWNER,
repositoryAddresses: [],
}),
/NIP-MP.*'d'/,
);
});