Files
buzz/desktop/src/features/projects/projectModels.test.mjs
Wes 9176e34405 fix(projects): allow owners to delete agent projects (#6533)
## Summary

- expose project deletion when the current user directly owns the
project or owns/manages its author agent
- publish a viewer-signed, project-only tombstone after fetching the
exact live head, then detect concurrent replacement
- honor relay-authorized owner-signed tombstones in desktop project read
models

## Testing

- pre-push hook: desktop Biome, TypeScript, 5,358 desktop tests, Rust
tests, Tauri checks, and file-size ratchet passed at `a36f7a4cf`
- `cargo check -p buzz-test-client --tests`
- relay E2E coverage added as ignored; not run against a live relay

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Signed-off-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-27 09:33:04 -06:00

768 lines
23 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
addRepositoryToProject,
buildProjectReadModels,
eventToExplicitProject,
eventToRepository,
selectProjectRepository,
} from "./projectModels.ts";
import { projectMatchesRouteId } from "./projectRoutes.ts";
const PROJECT_OWNER = "a".repeat(64);
const FRONTEND_OWNER = "b".repeat(64);
const BACKEND_OWNER = "c".repeat(64);
const RELAY_ORIGIN = "https://relay.example";
function repositoryEvent(owner, id, createdAt = 100) {
return {
id: `${id}-${createdAt}`,
kind: 30617,
pubkey: owner,
created_at: createdAt,
content: "",
tags: [
["d", id],
["name", id],
],
};
}
function projectEvent(repositoryTags, overrides = {}) {
return {
id: "project-event",
kind: 30621,
pubkey: PROJECT_OWNER,
created_at: 200,
content: "ignored by NIP-MP readers",
tags: [
["d", "sprout"],
["name", "Sprout"],
["description", "A multi-repository project"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
...repositoryTags,
],
...overrides,
};
}
test("eventToRepository preserves repository-scoped identity and clone data", () => {
const repository = eventToRepository(
repositoryEvent(FRONTEND_OWNER, "frontend"),
RELAY_ORIGIN,
);
assert.equal(repository.id, `${FRONTEND_OWNER}:frontend`);
assert.equal(repository.repoAddress, `30617:${FRONTEND_OWNER}:frontend`);
assert.deepEqual(repository.cloneUrls, [
`${RELAY_ORIGIN}/git/${FRONTEND_OWNER}/frontend`,
]);
});
test("buildProjectReadModels resolves repositories with a deterministic selection fallback", () => {
const frontendAddress = `30617:${PROJECT_OWNER}:frontend`;
const backendAddress = `30617:${PROJECT_OWNER}:backend`;
const projects = buildProjectReadModels({
projectEvents: [
projectEvent([
["a", frontendAddress],
["a", backendAddress, "wss://relay.example"],
]),
],
repositoryEvents: [
repositoryEvent(PROJECT_OWNER, "frontend"),
repositoryEvent(PROJECT_OWNER, "backend"),
],
relayOrigin: RELAY_ORIGIN,
});
assert.equal(projects.length, 1);
assert.equal(projects[0].id, `30621:${PROJECT_OWNER}:sprout`);
assert.equal(projects[0].projectAddress, `30621:${PROJECT_OWNER}:sprout`);
assert.equal(projects[0].primaryRepositoryAddress, backendAddress);
assert.deepEqual(
projects[0].repositories.map((repository) => repository.repoAddress),
[backendAddress, frontendAddress],
);
assert.equal(
projects[0].repositoryRelayHints[backendAddress],
"wss://relay.example",
);
assert.equal(
projects[0].projectChannelId,
"11111111-1111-4111-8111-111111111111",
);
assert.deepEqual(projects[0].relatedChannelIds, []);
});
test("buildProjectReadModels keeps extra related channel ids", () => {
const relatedA = "22222222-2222-4222-8222-222222222222";
const relatedB = "33333333-3333-4333-8333-333333333333";
const home = "11111111-1111-4111-8111-111111111111";
const projects = buildProjectReadModels({
projectEvents: [
projectEvent([
["buzz-related-channel", relatedA],
["buzz-related-channel", home],
["buzz-related-channel", relatedB],
["buzz-related-channel", relatedA],
]),
],
repositoryEvents: [],
relayOrigin: RELAY_ORIGIN,
});
assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]);
});
test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => {
const frontendAddress = `30617:${PROJECT_OWNER}:frontend`;
const projects = buildProjectReadModels({
projectEvents: [projectEvent([["a", frontendAddress]])],
repositoryEvents: [
repositoryEvent(PROJECT_OWNER, "frontend"),
repositoryEvent(BACKEND_OWNER, "backend"),
],
relayOrigin: RELAY_ORIGIN,
});
assert.equal(projects.length, 2);
assert.equal(projects[0].legacy, false);
assert.equal(projects[1].legacy, true);
assert.equal(
projects[1].primaryRepositoryAddress,
projects[1].projectAddress,
);
assert.equal(projects[1].repositories[0].dtag, "backend");
});
test("buildProjectReadModels does not let an unauthorized project hide a repository", () => {
const frontendAddress = `30617:${FRONTEND_OWNER}:frontend`;
const projects = buildProjectReadModels({
projectEvents: [projectEvent([["a", frontendAddress]])],
repositoryEvents: [repositoryEvent(FRONTEND_OWNER, "frontend")],
relayOrigin: RELAY_ORIGIN,
});
assert.equal(projects.length, 2);
assert.equal(projects.filter((project) => project.legacy).length, 1);
assert.equal(projects.filter((project) => !project.legacy).length, 1);
});
test("project and repository routes stay distinct when coordinates share a d tag", () => {
const projects = buildProjectReadModels({
projectEvents: [projectEvent([])],
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")],
relayOrigin: RELAY_ORIGIN,
});
const explicitProject = projects.find((project) => !project.legacy);
const implicitProject = projects.find((project) => project.legacy);
assert.notEqual(explicitProject.id, implicitProject.id);
assert.equal(
projectMatchesRouteId(explicitProject, explicitProject.projectAddress),
true,
);
assert.equal(
projectMatchesRouteId(explicitProject, implicitProject.projectAddress),
false,
);
});
test("addRepositoryToProject promotes a legacy repository route to a project coordinate", () => {
const [legacyProject] = buildProjectReadModels({
projectEvents: [],
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "sprout")],
relayOrigin: RELAY_ORIGIN,
});
const attachedRepository = eventToRepository(
repositoryEvent(PROJECT_OWNER, "mobile"),
RELAY_ORIGIN,
);
const updated = addRepositoryToProject(
legacyProject,
attachedRepository,
300,
);
assert.equal(updated.id, `30621:${PROJECT_OWNER}:sprout`);
assert.equal(updated.legacy, false);
assert.equal(updated.repositories.length, 2);
});
test("selectProjectRepository honors a request and falls back to primary", () => {
const frontendAddress = `30617:${PROJECT_OWNER}:frontend`;
const projects = buildProjectReadModels({
projectEvents: [
projectEvent([
["a", frontendAddress],
["a", `30617:${PROJECT_OWNER}:backend`],
]),
],
repositoryEvents: [
repositoryEvent(PROJECT_OWNER, "frontend"),
repositoryEvent(PROJECT_OWNER, "backend"),
],
relayOrigin: RELAY_ORIGIN,
});
assert.equal(
selectProjectRepository(projects[0], `${PROJECT_OWNER}:backend`)?.dtag,
"backend",
);
assert.equal(
selectProjectRepository(projects[0], "missing:repository")?.dtag,
"backend",
);
assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend");
});
test("buildProjectReadModels keeps the viewer's own unlisted project", () => {
const repoAddress = `30617:${PROJECT_OWNER}:secret`;
const unlisted = {
...projectEvent([["a", repoAddress]]),
tags: [
["d", "secret"],
["name", "Secret"],
["buzz-channel", "11111111-1111-4111-8111-111111111111"],
["buzz-visibility", "unlisted"],
["a", repoAddress],
],
};
const asStranger = buildProjectReadModels({
projectEvents: [unlisted],
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
relayOrigin: RELAY_ORIGIN,
});
const asOwner = buildProjectReadModels({
projectEvents: [unlisted],
repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")],
relayOrigin: RELAY_ORIGIN,
viewerPubkey: PROJECT_OWNER,
});
assert.equal(
asStranger.some((project) => project.dtag === "secret" && !project.legacy),
false,
);
assert.equal(
asStranger.some((project) => project.legacy && project.dtag === "secret"),
true,
);
assert.equal(asOwner.length, 1);
assert.equal(asOwner[0]?.legacy, false);
assert.equal(asOwner[0]?.dtag, "secret");
assert.equal(asOwner[0]?.visibility, "unlisted");
});
function coordinateParts(coordinate) {
const first = coordinate.indexOf(":");
const second = coordinate.indexOf(":", first + 1);
return {
kind: Number(coordinate.slice(0, first)),
owner: coordinate.slice(first + 1, second),
dtag: coordinate.slice(second + 1),
};
}
function sortedJson(values) {
return values
.map((value) => JSON.stringify(value))
.sort()
.map((value) => JSON.parse(value));
}
test("buildProjectReadModels conforms to the shared NIP-MP fold fixtures", () => {
const fixture = JSON.parse(
readFileSync(
new URL(
"../../../../docs/nips/NIP-MP.fold-fixtures.json",
import.meta.url,
),
"utf8",
),
);
for (const [caseIndex, fixtureCase] of fixture.cases.entries()) {
let eventIndex = caseIndex * 100;
const hiddenAddresses = new Set();
const repositoryEvents = fixtureCase.repositories.flatMap((repository) => {
if (repository.viewer_hidden) hiddenAddresses.add(repository.coordinate);
if (repository.state !== "live") return [];
const { dtag, owner } = coordinateParts(repository.coordinate);
return [
{
...repositoryEvent(
owner,
dtag,
repository.created_at ?? 1_000 - caseIndex,
),
id: (++eventIndex).toString(16).padStart(64, "0"),
tags: [
["d", dtag],
["name", dtag],
...(repository.maintainers?.length
? [["maintainers", ...repository.maintainers]]
: []),
],
},
];
});
const projectEvents = fixtureCase.projects.flatMap((project) => {
if (project.viewer_hidden) hiddenAddresses.add(project.coordinate);
if (project.state !== "live") return [];
const { dtag, owner } = coordinateParts(project.coordinate);
return [
{
id: (++eventIndex).toString(16).padStart(64, "0"),
kind: 30621,
pubkey: owner,
created_at: project.created_at ?? 900 - caseIndex,
content: "",
tags: [
["d", dtag],
["name", dtag],
...(project.visibility === "unlisted"
? [["buzz-visibility", "unlisted"]]
: []),
...project.members.map((member) => ["a", member]),
],
},
];
});
const projects = buildProjectReadModels({
projectEvents,
repositoryEvents,
relayOrigin: RELAY_ORIGIN,
hiddenAddresses,
});
const actualContainers = projects
.filter((project) => !project.legacy)
.map((project) => ({
project: project.projectAddress,
members: project.repositoryAddresses.flatMap((coordinate) => {
if (
project.repositories.some(
(repository) => repository.repoAddress === coordinate,
)
) {
return [{ coordinate, render: "resolved" }];
}
return project.unavailableRepositoryAddresses?.includes(coordinate)
? [{ coordinate, render: "unavailable" }]
: [];
}),
}));
const expectedContainers = fixtureCase.expect.containers.map(
(container) => ({
project: container.project,
members: sortedJson(container.members),
}),
);
assert.deepEqual(
sortedJson(
actualContainers.map((container) => ({
...container,
members: sortedJson(container.members),
})),
),
sortedJson(expectedContainers),
fixtureCase.name,
);
assert.deepEqual(
projects
.filter((project) => project.legacy)
.map((project) => project.projectAddress)
.sort(),
[...fixtureCase.expect.implicit_cards].sort(),
fixtureCase.name,
);
}
});
// ── NIP-MP ingest-fixture conformance (TypeScript parser) ──────────────────
//
// The shared NIP-MP.fixtures.json is the ingest oracle used by the relay
// validator and the Rust CLI builder. The TypeScript read model must agree
// on every accept/reject case so that relay and desktop never diverge about
// which signed project heads are valid.
test("buildProjectReadModels conforms to the shared NIP-MP ingest fixtures", () => {
const fixtures = JSON.parse(
readFileSync(
new URL("../../../../docs/nips/NIP-MP.fixtures.json", import.meta.url),
"utf8",
),
);
const SIGNER = "a".repeat(64);
for (const fixtureCase of fixtures.cases) {
const event = {
id: "e".repeat(64),
kind: fixtureCase.template.kind,
pubkey: SIGNER,
created_at: 1_000,
content: fixtureCase.template.content,
tags: fixtureCase.template.tags,
};
// Use eventToExplicitProject directly — buildProjectReadModels only returns
// listed projects, so an unlisted-but-valid envelope (e.g. valid_unlisted)
// would be filtered out before we could observe it. The ingest oracle only
// cares whether the envelope parses successfully, not whether it reaches
// the rendered collection; eventToExplicitProject is the correct gate.
const parsed = eventToExplicitProject(event, new Map(), new Map());
const accepted = parsed !== null;
if (fixtureCase.expect === "accept") {
assert.equal(
accepted,
true,
`${fixtureCase.name}: expected accept, got reject`,
);
} else {
assert.equal(
accepted,
false,
`${fixtureCase.name}: expected reject, got accept`,
);
}
}
});
// ── NIP-09 tombstone deletion ───────────────────────────────────────────────
test("buildProjectReadModels applies kind:5 tombstones to project events", () => {
const owner = "a".repeat(64);
const projectAddress = `30621:${owner}:platform`;
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: owner,
created_at: 100,
content: "",
tags: [
["d", "platform"],
["name", "Platform"],
],
};
const deletionEvent = {
id: "d".repeat(64),
kind: 5,
pubkey: owner,
created_at: 200,
content: "",
tags: [["a", projectAddress]],
};
const withDeletion = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
deletionEvents: [deletionEvent],
});
assert.equal(
withDeletion.filter((p) => !p.legacy).length,
0,
"deleted project should not appear",
);
const withoutDeletion = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
});
assert.equal(
withoutDeletion.filter((p) => !p.legacy).length,
1,
"project without deletion should appear",
);
});
test("buildProjectReadModels applies kind:5 tombstones to repository events", () => {
const owner = "b".repeat(64);
const repoAddress = `30617:${owner}:relay`;
const repoEvent = {
id: "r".repeat(64),
kind: 30617,
pubkey: owner,
created_at: 100,
content: "",
tags: [
["d", "relay"],
["name", "relay"],
],
};
const deletionEvent = {
id: "d".repeat(64),
kind: 5,
pubkey: owner,
created_at: 200,
content: "",
tags: [["a", repoAddress]],
};
const withDeletion = buildProjectReadModels({
projectEvents: [],
repositoryEvents: [repoEvent],
deletionEvents: [deletionEvent],
});
assert.equal(withDeletion.length, 0, "deleted repository should not appear");
});
test("buildProjectReadModels ignores a tombstone that predates the live head", () => {
const owner = "a".repeat(64);
const projectAddress = `30621:${owner}:platform`;
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: owner,
created_at: 200,
content: "",
tags: [["d", "platform"]],
};
// Deletion is at t=100, but the live head is at t=200 — should not apply.
const staleDeletion = {
id: "d".repeat(64),
kind: 5,
pubkey: owner,
created_at: 100,
content: "",
tags: [["a", projectAddress]],
};
const projects = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
deletionEvents: [staleDeletion],
});
assert.equal(
projects.filter((p) => !p.legacy).length,
1,
"head newer than tombstone must survive",
);
});
test("buildProjectReadModels applies a relay-authorized owner tombstone", () => {
const owner = "a".repeat(64);
const agentOwner = "b".repeat(64);
const projectAddress = `30621:${owner}:platform`;
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: owner,
created_at: 100,
content: "",
tags: [["d", "platform"]],
};
const ownerDeletion = {
id: "d".repeat(64),
kind: 5,
pubkey: agentOwner,
created_at: 200,
content: "",
tags: [["a", projectAddress]],
};
const projects = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
deletionEvents: [ownerDeletion],
});
assert.equal(
projects.filter((p) => !p.legacy).length,
0,
"relay-authorized owner tombstone should delete the agent project",
);
});
// ── Route contract ──────────────────────────────────────────────────────────
test("projectMatchesRouteId resolves a 30617 coordinate to the containing project", () => {
const projectOwner = "a".repeat(64);
const repoOwner = "c".repeat(64);
const repoAddress = `30617:${repoOwner}:relay`;
const projects = buildProjectReadModels({
projectEvents: [
{
id: "project-event",
kind: 30621,
pubkey: projectOwner,
created_at: 200,
content: "",
tags: [
["d", "platform"],
["a", repoAddress],
],
},
],
repositoryEvents: [
{
id: "repo-event",
kind: 30617,
pubkey: repoOwner,
created_at: 100,
content: "",
tags: [
["d", "relay"],
["name", "relay"],
["maintainers", projectOwner],
],
},
],
});
const explicitProject = projects.find((p) => !p.legacy);
assert.ok(explicitProject, "explicit project must be present");
// Entity link navigates with the 30617 coordinate (repo dtag ≠ project dtag).
assert.equal(
projectMatchesRouteId(explicitProject, repoAddress),
true,
"30617 route must resolve to the containing project",
);
// Legacy project (implicit card for an unclaimed repo) must NOT match the
// explicit project's 30621 coordinate.
assert.equal(
projectMatchesRouteId(explicitProject, `30617:${projectOwner}:platform`),
false,
"unrelated 30617 address must not match",
);
});
// ── 30617 route selects the correct repository (finding 4 regression gate) ──
//
// When `projectId` is a `30617:<owner>:<d>` coordinate (emitted by entity
// links in #4695), `selectProjectRepository` must resolve to the repository
// whose `id` is `<owner>:<d>` — not to the project's primary repository.
// This test verifies the DI contract that `ProjectDetailScreen` uses to derive
// `routeRepositoryId` from `projectId`.
test("selectProjectRepository resolves a non-primary repository when repositoryId is derived from a 30617 projectId", () => {
const OWNER = "a".repeat(64);
const OTHER = "b".repeat(64);
const primaryRepo = {
id: `${OWNER}:buzz`,
dtag: "buzz",
name: "Buzz",
repoAddress: `30617:${OWNER}:buzz`,
owner: OWNER,
cloneUrls: [],
webUrl: null,
description: "",
contributors: [],
createdAt: 100,
status: "active",
defaultBranch: "main",
};
const nonPrimaryRepo = {
id: `${OTHER}:relay-tools`,
dtag: "relay-tools",
name: "Relay Tools",
repoAddress: `30617:${OTHER}:relay-tools`,
owner: OTHER,
cloneUrls: [],
webUrl: null,
description: "",
contributors: [],
createdAt: 80,
status: "active",
defaultBranch: "main",
};
const project = {
id: `30621:${OWNER}:buzz`,
dtag: "buzz",
name: "Buzz",
description: "",
owner: OWNER,
createdAt: 100,
projectChannelId: null,
status: "active",
projectAddress: `30621:${OWNER}:buzz`,
primaryRepositoryAddress: primaryRepo.repoAddress,
repositoryAddresses: [primaryRepo.repoAddress, nonPrimaryRepo.repoAddress],
repositories: [primaryRepo, nonPrimaryRepo],
unavailableRepositoryAddresses: [],
visibility: "listed",
legacy: false,
};
// Without a repositoryId: falls back to primary.
assert.equal(
selectProjectRepository(project, undefined)?.id,
primaryRepo.id,
"no repositoryId must yield the primary repository",
);
// With a repositoryId derived from the 30617 coordinate (as ProjectDetailScreen does):
// "30617:<owner>:<d>".slice("30617:".length) === "<owner>:<d>" === Repository.id
const routeRepositoryId = nonPrimaryRepo.repoAddress.slice("30617:".length);
assert.equal(
routeRepositoryId,
nonPrimaryRepo.id,
"routeRepositoryId derivation must equal Repository.id",
);
assert.equal(
selectProjectRepository(project, routeRepositoryId)?.id,
nonPrimaryRepo.id,
"derived repositoryId from 30617 coordinate must resolve to non-primary repository",
);
assert.notEqual(
selectProjectRepository(project, routeRepositoryId)?.id,
primaryRepo.id,
"non-primary linked repo must NOT fall back to primary",
);
});
// ── Tombstone exhaustive enumeration gate (finding 3) ───────────────────────
//
// Verifies that `buildProjectReadModels` applies ALL deletion events in the
// supplied array — not just the first 2000 — proving the exhaustive-enumeration
// path (via `fetchProjectEventsExhaustively` in `fetchProjects`) makes a
// difference. The prior code capped kind:5 at 2000; this test ensures deletion
// 2001+ is honoured when the exhaustive path is used.
test("buildProjectReadModels applies deletion beyond the 2000-event boundary", () => {
const OWNER = "a".repeat(64);
// The project we want to verify is deleted (it's beyond event 2000).
const targetDtag = "beyond-limit";
const targetAddress = `30621:${OWNER}:${targetDtag}`;
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 100,
content: "",
tags: [["d", targetDtag]],
};
// Build 2001 deletion events; the target is the last one.
const deletionEvents = Array.from({ length: 2001 }, (_, i) => ({
id: String(i).padStart(64, "0"),
kind: 5,
pubkey: OWNER,
created_at: 200,
content: "",
tags: [["a", i < 2000 ? `30621:${OWNER}:filler-${i}` : targetAddress]],
}));
const projects = buildProjectReadModels({
projectEvents: [projectEvent],
repositoryEvents: [],
deletionEvents,
relayOrigin: null,
});
assert.equal(
projects.length,
0,
"project at deletion event 2001+ must be suppressed when all tombstones are applied",
);
});