Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 115 additions & 3 deletions crates/buzz-test-client/tests/e2e_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

use std::time::Duration;

use buzz_sdk::nip_oa;
use buzz_test_client::BuzzTestClient;
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};

Expand Down Expand Up @@ -92,19 +93,43 @@ fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event {
/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the
/// relay takes the coordinate-delete path rather than the event-id path.
/// `created_at` defaults to now when `None`.
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex());
fn coordinate_delete_for_author(
signer: &Keys,
author: &Keys,
kind: u16,
d_tag: &str,
created_at: Option<u64>,
) -> nostr::Event {
let coord = format!("{kind}:{}:{d_tag}", author.public_key().to_hex());
let builder =
EventBuilder::new(Kind::Custom(5), "")
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]);
match created_at {
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
None => builder,
}
.sign_with_keys(keys)
.sign_with_keys(signer)
.unwrap()
}

fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
coordinate_delete_for_author(keys, keys, kind, d_tag, created_at)
}

async fn connect_agent_with_owner(agent: &Keys, owner: &Keys) -> BuzzTestClient {
let tag_json = nip_oa::compute_auth_tag(owner, &agent.public_key(), "kind=9")
.expect("compute NIP-OA auth tag");
let auth_tag = nip_oa::parse_auth_tag(&tag_json).expect("parse NIP-OA auth tag");
let mut client = BuzzTestClient::connect_unauthenticated(&relay_url())
.await
.expect("connect agent unauthenticated");
client
.authenticate_with_nip_oa(agent, &auth_tag)
.await
.expect("authenticate agent with NIP-OA owner");
client
}

fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter {
Filter::new()
.kind(Kind::Custom(kind))
Expand Down Expand Up @@ -345,6 +370,93 @@ async fn test_project_tombstone_deletes_coordinate_and_spares_members() {
client.disconnect().await.expect("disconnect");
}

/// NIP-OA extends NIP-09 coordinate ownership: a human owner may delete an
/// agent-authored project, while an unrelated signer must be rejected without
/// changing the live project head.
#[tokio::test]
#[ignore]
async fn test_agent_owner_can_delete_agent_project_but_third_party_cannot() {
let agent = Keys::generate();
let owner = Keys::generate();
let third_party = Keys::generate();
let project_d = unique("agent-owned-project");

let mut agent_client = connect_agent_with_owner(&agent, &owner).await;
let ok = agent_client
.send_event(project_event(
&agent,
&project_d,
"Agent project",
&[],
None,
))
.await
.expect("send agent project");
assert!(ok.accepted, "relay rejected agent project: {}", ok.message);

let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party)
.await
.expect("connect third party");
let ok = third_party_client
.send_event(coordinate_delete_for_author(
&third_party,
&agent,
PROJECT_KIND,
&project_d,
None,
))
.await
.expect("send third-party tombstone");
assert!(
!ok.accepted,
"unrelated signer deleted an agent-owned project"
);
let still_live = query(
&mut third_party_client,
"agent-owner-third-party-rejected",
addressable_filter(PROJECT_KIND, &agent, &project_d),
)
.await;
assert_eq!(
still_live.len(),
1,
"rejected tombstone changed project state"
);

let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner)
.await
.expect("connect owner");
let ok = owner_client
.send_event(coordinate_delete_for_author(
&owner,
&agent,
PROJECT_KIND,
&project_d,
None,
))
.await
.expect("send owner tombstone");
assert!(
ok.accepted,
"relay rejected owner deletion of agent project: {}",
ok.message
);
let deleted = query(
&mut owner_client,
"agent-owner-deleted",
addressable_filter(PROJECT_KIND, &agent, &project_d),
)
.await;
assert!(deleted.is_empty(), "owner tombstone left project live");

agent_client.disconnect().await.expect("disconnect agent");
third_party_client
.disconnect()
.await
.expect("disconnect third party");
owner_client.disconnect().await.expect("disconnect owner");
}

/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own
/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or
/// replayed by a third party — must therefore retire V1 only and leave the newer
Expand Down
140 changes: 140 additions & 0 deletions desktop/src/features/projects/deleteProject.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";

import { deleteProject } from "./projectDeletion.ts";

const OWNER = "a".repeat(64);
const VIEWER = "b".repeat(64);
const PROJECT_ADDRESS = `30621:${OWNER}:platform`;
const project = {
createdAt: 50,
description: "",
dtag: "platform",
id: PROJECT_ADDRESS,
legacy: false,
name: "Platform",
owner: OWNER,
primaryRepositoryAddress: `30617:${OWNER}:repo`,
projectAddress: PROJECT_ADDRESS,
projectChannelId: null,
repositories: [],
repositoryAddresses: [`30617:${OWNER}:repo`],
status: "active",
};

function event(overrides = {}) {
return {
id: "1".repeat(64),
kind: 30621,
pubkey: OWNER,
created_at: 75,
content: "",
tags: [["d", "platform"]],
...overrides,
};
}

test("deleteProject lets the relay authorize an agent owner and tombstones only the project", async () => {
const calls = [];
await deleteProject(project, {
fetchEvents: async (filter) => {
calls.push(["fetch", filter]);
return calls.filter(([type]) => type === "fetch").length === 1
? [event()]
: [];
},
nowSeconds: () => 74,
signEvent: async (template) => {
calls.push(["sign", template]);
return event({
pubkey: VIEWER,
kind: template.kind,
content: template.content,
created_at: template.createdAt,
tags: template.tags,
});
},
publishEvent: async (signed) => {
calls.push(["publish", signed]);
},
});

assert.deepEqual(calls[0][1], {
kinds: [30621],
authors: [OWNER],
"#d": ["platform"],
limit: 1,
});
assert.deepEqual(calls[1][1].tags, [["a", PROJECT_ADDRESS]]);
assert.equal(calls[1][1].createdAt, 76);
assert.equal(calls[2][1].pubkey, VIEWER);
assert.deepEqual(calls[3][1], calls[0][1]);
assert.equal(calls[1][1].content, "Delete project Platform");
});

test("deleteProject builds a one-coordinate tombstone", async () => {
const calls = [];
await deleteProject(project, {
fetchEvents: async () => (calls.length === 0 ? [event()] : []),
signEvent: async (template) => {
calls.push(template);
return event({
kind: template.kind,
content: template.content,
created_at: template.createdAt,
tags: template.tags,
});
},
publishEvent: async () => {},
});

assert.equal(calls[0].kind, 5);
assert.deepEqual(calls[0].tags, [["a", PROJECT_ADDRESS]]);
});

test("deleteProject fails closed when the live project head is missing", async () => {
await assert.rejects(
deleteProject(project, { fetchEvents: async () => [] }),
/Could not find this project on the relay/,
);
});

test("deleteProject reports a concurrent replacement that survives", async () => {
let fetchCount = 0;
await assert.rejects(
deleteProject(project, {
fetchEvents: async () => {
fetchCount += 1;
return [event({ created_at: fetchCount === 1 ? 75 : 77 })];
},
nowSeconds: () => 74,
signEvent: async (template) =>
event({
pubkey: VIEWER,
kind: template.kind,
created_at: template.createdAt,
tags: template.tags,
}),
publishEvent: async () => {},
}),
/updated while it was being deleted/,
);
});

test("deleteProject reports uncertain outcome when publish acknowledgement is lost", async () => {
await assert.rejects(
deleteProject(project, {
fetchEvents: async () => [event()],
signEvent: async (template) =>
event({
kind: template.kind,
created_at: template.createdAt,
tags: template.tags,
}),
publishEvent: async (_event, timeoutMessage) => {
throw new Error(timeoutMessage);
},
}),
/Could not confirm whether the project was deleted\. Projects were refreshed\./,
);
});
38 changes: 7 additions & 31 deletions desktop/src/features/projects/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
listProjectLocalRepositories,
} from "@/shared/api/projectGit";
import {
KIND_DELETION,
KIND_GIT_ISSUE,
KIND_GIT_PATCH,
KIND_GIT_PR_UPDATE,
Expand Down Expand Up @@ -59,6 +58,10 @@ import {
projectPullRequestEventsToPullRequests,
} from "./projectPullRequests.mjs";
import { fetchProjectsWorkItems } from "./projectWorkItems";
import {
projectDeletionMutationOptions,
projectsQueryKey,
} from "./projectDeletionMutation";
import {
eventToRepository,
type Project,
Expand All @@ -71,6 +74,8 @@ import {
} from "./projectEnumeration";
import { projectMatchesRouteId } from "./projectRoutes";

export { projectsQueryKey };

export type {
Project,
ProjectIssue,
Expand Down Expand Up @@ -621,27 +626,6 @@ async function fetchProjectActivitySummaries(
);
}

async function deleteProject(project: Project): Promise<void> {
const identity = await getIdentity();
if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) {
throw new Error("Only the project owner can delete this project.");
}

const event = await signRelayEvent({
kind: KIND_DELETION,
content: `Delete project ${project.name}`,
tags: [["a", project.projectAddress]],
});

await relayClient.publishEvent(
event,
"Timed out deleting project.",
"Failed to delete project.",
);
}

export const projectsQueryKey = ["projects"] as const;

/**
* Freshness windows for the Projects surface. Every local write path
* invalidates its keys explicitly (issue/PR mutations, project creation,
Expand Down Expand Up @@ -984,13 +968,5 @@ export function useProjectActivitySummariesQuery(projects: Project[]) {
export function useDeleteProjectMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: deleteProject,
onSuccess: (_data, project) => {
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.filter((item) => item.id !== project.id),
);
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
});
return useMutation(projectDeletionMutationOptions(queryClient));
}
Loading
Loading