mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
fix(desktop): guard hosted deletion across identities
Signed-off-by: Codex <noreply@openai.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -63,11 +63,11 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () =
|
||||
target.setItem("buzz:hosted-community-delete-pending:v1", bytes);
|
||||
assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), {
|
||||
owned: envelope,
|
||||
blockedByAnotherAccount: false,
|
||||
blockingOwnerPubkey: null,
|
||||
});
|
||||
assert.deepEqual(pendingCommunityDeletionForAccount("b".repeat(64), target), {
|
||||
owned: null,
|
||||
blockedByAnotherAccount: true,
|
||||
blockingOwnerPubkey: "a".repeat(64),
|
||||
});
|
||||
assert.equal(
|
||||
target.getItem("buzz:hosted-community-delete-pending:v1"),
|
||||
@@ -82,7 +82,7 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () =
|
||||
);
|
||||
assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), {
|
||||
owned: envelope,
|
||||
blockedByAnotherAccount: false,
|
||||
blockingOwnerPubkey: null,
|
||||
});
|
||||
assert.deepEqual(publicDeletionRequest(envelope), {
|
||||
community_id: envelope.community_id,
|
||||
|
||||
@@ -161,11 +161,11 @@ export function pendingCommunityDeletionForAccount(
|
||||
storage: StorageLike = defaultStorage(),
|
||||
): {
|
||||
owned: PendingCommunityDeletion | null;
|
||||
blockedByAnotherAccount: boolean;
|
||||
blockingOwnerPubkey: string | null;
|
||||
} {
|
||||
const stored = loadPendingCommunityDeletion(storage);
|
||||
if (!stored || !ownerPubkey) {
|
||||
return { owned: null, blockedByAnotherAccount: false };
|
||||
return { owned: null, blockingOwnerPubkey: null };
|
||||
}
|
||||
if (
|
||||
pendingCommunityDeletionMatchesAccount(
|
||||
@@ -174,9 +174,9 @@ export function pendingCommunityDeletionForAccount(
|
||||
BUILDERLAB_BACKEND_ORIGIN,
|
||||
)
|
||||
) {
|
||||
return { owned: stored, blockedByAnotherAccount: false };
|
||||
return { owned: stored, blockingOwnerPubkey: null };
|
||||
}
|
||||
return { owned: null, blockedByAnotherAccount: true };
|
||||
return { owned: null, blockingOwnerPubkey: stored.bound_owner_pubkey };
|
||||
}
|
||||
|
||||
export function publicDeletionRequest(
|
||||
|
||||
@@ -121,6 +121,15 @@ test("shared unknown errors stay neutral on non-deletion surfaces when deletion
|
||||
});
|
||||
|
||||
test("limit_reached uses the server quota when available and retains the fallback", () => {
|
||||
assert.equal(
|
||||
hostedCommunityErrorMessage(
|
||||
{ code: "limit_reached" },
|
||||
undefined,
|
||||
"fallback",
|
||||
0,
|
||||
),
|
||||
"You can't create more communities right now.",
|
||||
);
|
||||
assert.equal(
|
||||
hostedCommunityErrorMessage(
|
||||
{ code: "limit_reached" },
|
||||
|
||||
@@ -100,7 +100,10 @@ export function hostedCommunityErrorMessage(
|
||||
missing_mapping: "Connect your Buzz identity before creating a community.",
|
||||
invalid_name: "Use lowercase letters, numbers, and hyphens.",
|
||||
taken: "That Buzz address is already taken.",
|
||||
limit_reached: `You've reached the limit of ${displayedLimit} hosted communities.`,
|
||||
limit_reached:
|
||||
quotaLimit === 0
|
||||
? "You can't create more communities right now."
|
||||
: `You've reached the limit of ${displayedLimit} hosted communities.`,
|
||||
relay_unavailable: "Community provisioning is temporarily unavailable.",
|
||||
identity_already_bound:
|
||||
"This Builderlab account is connected to another Buzz identity.",
|
||||
|
||||
@@ -89,8 +89,9 @@ export function HostedCommunitiesSettingsCard() {
|
||||
} | null>(null);
|
||||
const [pendingDeletion, setPendingDeletion] =
|
||||
React.useState<PendingCommunityDeletion | null>(null);
|
||||
const [blockedByAnotherAccount, setBlockedByAnotherAccount] =
|
||||
React.useState(false);
|
||||
const [blockingOwnerPubkey, setBlockingOwnerPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const hiddenCommunityIds = React.useRef(new Set<string>());
|
||||
const recoveryAccount = React.useRef<string | null>(null);
|
||||
const deleteInFlight = React.useRef<symbol | null>(null);
|
||||
@@ -144,7 +145,7 @@ export function HostedCommunitiesSettingsCard() {
|
||||
adoptAccountOwner(nextOwner);
|
||||
const deletionView = pendingCommunityDeletionForAccount(nextOwner);
|
||||
setPendingDeletion(deletionView.owned);
|
||||
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
|
||||
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
|
||||
setIdentity(identityResponse.identity ?? null);
|
||||
const nextCommunities = (communitiesResponse.communities ?? []).filter(
|
||||
(community) =>
|
||||
@@ -524,9 +525,9 @@ export function HostedCommunitiesSettingsCard() {
|
||||
|
||||
const startCommunityDeletion = (community: HostedCommunity) => {
|
||||
const deletionView = pendingCommunityDeletionForAccount(boundHex);
|
||||
if (deletionView.owned || deletionView.blockedByAnotherAccount) {
|
||||
if (deletionView.owned || deletionView.blockingOwnerPubkey) {
|
||||
setPendingDeletion(deletionView.owned);
|
||||
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
|
||||
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -558,7 +559,7 @@ export function HostedCommunitiesSettingsCard() {
|
||||
return;
|
||||
}
|
||||
setPendingDeletion(envelope);
|
||||
setBlockedByAnotherAccount(false);
|
||||
setBlockingOwnerPubkey(null);
|
||||
void runDeletion("Starting deletion…", envelope, async (generation) => {
|
||||
try {
|
||||
await invokeDeletion(envelope, "initial", generation);
|
||||
@@ -639,7 +640,7 @@ export function HostedCommunitiesSettingsCard() {
|
||||
recoveryAccount.current = accountKey;
|
||||
const deletionView = pendingCommunityDeletionForAccount(boundHex);
|
||||
setPendingDeletion(deletionView.owned);
|
||||
setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount);
|
||||
setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey);
|
||||
}, [auth, boundHex, loading]);
|
||||
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
@@ -790,17 +791,24 @@ export function HostedCommunitiesSettingsCard() {
|
||||
Check deletion status
|
||||
</Button>
|
||||
</div>
|
||||
{!deletionCapability ? (
|
||||
<p>
|
||||
Community deletion is unavailable right now, so this request
|
||||
can't be checked. It stays saved on this device.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{auth && boundHex && blockedByAnotherAccount ? (
|
||||
{auth && boundHex && deletionCapability && blockingOwnerPubkey ? (
|
||||
<div
|
||||
className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-sm"
|
||||
aria-live="polite"
|
||||
>
|
||||
Another account has a pending deletion on this device. Sign in with
|
||||
that account to check deletion status before starting another deletion
|
||||
here.
|
||||
A deletion request from {safeNpub(blockingOwnerPubkey)} is still
|
||||
pending on this device. Switch to that Buzz identity and use Check
|
||||
deletion status before starting another deletion here. If you no
|
||||
longer have that identity, contact support.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -980,7 +988,7 @@ export function HostedCommunitiesSettingsCard() {
|
||||
busy || pendingDeletion?.community_id === community.id
|
||||
}
|
||||
deletionPending={
|
||||
pendingDeletion !== null || blockedByAnotherAccount
|
||||
pendingDeletion !== null || blockingOwnerPubkey !== null
|
||||
}
|
||||
canDelete={
|
||||
deletionCapability &&
|
||||
|
||||
@@ -9,6 +9,19 @@ const OUTDIR = "test-results/hosted-communities";
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
/** A second valid identity key, used only as a contradictory hosted npub. */
|
||||
const OTHER_HEX = "b".repeat(64);
|
||||
const PENDING_KEY = "buzz:hosted-community-delete-pending:v1";
|
||||
const OTHER_PENDING = {
|
||||
community_id: "44444444-4444-4444-8444-444444444444",
|
||||
host: "private-other.communities.buzz.xyz",
|
||||
request_id: "55555555-5555-4555-8555-555555555555",
|
||||
acknowledgement_version: 1,
|
||||
bound_owner_pubkey: "a".repeat(64),
|
||||
backend_origin: "https://app.builderlab.xyz",
|
||||
};
|
||||
const OTHER_PENDING_BYTES = JSON.stringify(OTHER_PENDING);
|
||||
const BLOCKED_COPY = `A deletion request from ${npubEncode(OTHER_PENDING.bound_owner_pubkey)} is still pending on this device. Switch to that Buzz identity and use Check deletion status before starting another deletion here. If you no longer have that identity, contact support.`;
|
||||
const CAPABILITY_OFF_COPY =
|
||||
"Community deletion is unavailable right now, so this request can't be checked. It stays saved on this device.";
|
||||
const DELETION_COMMUNITIES: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -157,6 +170,101 @@ async function deletionPayloads(page: Page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function seedOtherOwnerPending(page: Page) {
|
||||
await page.evaluate(
|
||||
({ key, bytes }) => window.localStorage.setItem(key, bytes),
|
||||
{ key: PENDING_KEY, bytes: OTHER_PENDING_BYTES },
|
||||
);
|
||||
}
|
||||
|
||||
test("another Buzz identity's pending slot disables archived Delete without revealing its target", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDeletionFixture(page, { capability: true });
|
||||
await seedOtherOwnerPending(page);
|
||||
await page.getByRole("button", { name: "Refresh" }).click();
|
||||
|
||||
const notice = page.getByText(BLOCKED_COPY, { exact: true });
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).not.toContainText(OTHER_PENDING.host);
|
||||
await expect(notice).not.toContainText(OTHER_PENDING.community_id);
|
||||
await expect(notice).not.toContainText(OTHER_PENDING.request_id);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("hosted-community-row")
|
||||
.filter({ hasText: "Archived team" })
|
||||
.getByRole("button", { name: "Delete", exact: true }),
|
||||
).toBeDisabled();
|
||||
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
|
||||
expect(await deletionPayloads(page)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("an occupied slot discovered during confirmation never sends or overwrites", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDeletionFixture(page, { capability: true });
|
||||
const archived = page
|
||||
.getByTestId("hosted-community-row")
|
||||
.filter({ hasText: "Archived team" });
|
||||
await archived.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
await page
|
||||
.getByLabel(
|
||||
"Type the exact host to continue: Exact-Host.communities.buzz.xyz",
|
||||
)
|
||||
.fill("Exact-Host.communities.buzz.xyz");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await seedOtherOwnerPending(page);
|
||||
await page
|
||||
.getByRole("button", { name: "Delete community permanently" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText(BLOCKED_COPY, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Could not safely save the pending deletion request/),
|
||||
).toHaveCount(0);
|
||||
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
|
||||
expect(await deletionPayloads(page)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("another identity's blocked notice is hidden when deletion capability is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDeletionFixture(page, { capability: false });
|
||||
await seedOtherOwnerPending(page);
|
||||
await page.getByRole("button", { name: "Refresh" }).click();
|
||||
|
||||
await expect(page.getByText(BLOCKED_COPY, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/pending deletion on this device/i)).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES);
|
||||
expect(await deletionPayloads(page)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("own pending request explains why Check is disabled when deletion capability is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDeletionFixture(page, { capability: false });
|
||||
const ownedBytes = JSON.stringify({
|
||||
...OTHER_PENDING,
|
||||
bound_owner_pubkey: DEFAULT_MOCK_PUBKEY,
|
||||
});
|
||||
await page.evaluate(
|
||||
({ key, bytes }) => window.localStorage.setItem(key, bytes),
|
||||
{ key: PENDING_KEY, bytes: ownedBytes },
|
||||
);
|
||||
await page.getByRole("button", { name: "Refresh" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(CAPABILITY_OFF_COPY, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Check deletion status" }),
|
||||
).toBeDisabled();
|
||||
expect(await storedDeletionBytes(page)).toBe(ownedBytes);
|
||||
expect(await deletionPayloads(page)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("reopen sends nothing and Check resends the same saved request once", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -389,7 +497,9 @@ test("A-B-A owner switch retains exact bytes and replays only under A", async ({
|
||||
page.getByRole("button", { name: "Check deletion status" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/another account has a pending deletion/i),
|
||||
page.getByText(
|
||||
/A deletion request from npub1.* is still pending on this device/,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => storedDeletionBytes(page)).toBe(bytes);
|
||||
expect(await deletionPayloads(page)).toHaveLength(1);
|
||||
|
||||
Reference in New Issue
Block a user