Files
buzz/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts
Logan Johnson 2226b6f95b fix(desktop): npub identity controls across profile, agents, and workflows (#7489)
🤖
## Summary

Building on #7488's npub foundation, this PR finishes the identity
display change for the controls where you actually manage people and
keys: profile, settings, agent access, and workflows. Everywhere in
these surfaces, an identity key shows — and copies — as its canonical
npub (npub is the human-readable encoding of a Nostr public key: the
compact `npub1j57...fjmv` form where space is tight, the full npub where
the whole key matters), and accepts npub as input.

After this change:

- Profile panel: the public-key row and the managed-by / declared-owner
copies show the full npub. If a key can't be encoded, you see
"Unavailable" with no copy button — never a raw or partial key.
- Settings: the identity card shows and copies the npub. The
hosted-communities account identity derives from the bound key
(`pubkey_hex`) — the same authority as the mismatch gate and hosted
operations — so the display can never disagree with what the app acts
on; an unusable hex falls back to a neutral label instead of rendering
the unverified server npub. The connected claim and a community's
Connect action require that same usable bound key to match the local one
— with no usable binding the card cannot claim connected or start
Connect, while the community list, linking, and delete/rebind recovery
stay available.
- Hosted create/onboarding: the account and device identity rows in the
create flow and owner onboarding derive from the same authoritative
fields (bound key / local key), with the same neutral fallback;
readiness requires a usable bound key that matches the local one.
- Respond-to allowlist (controls who may respond to an agent): entries
can be typed or pasted as hex or npub; both spellings of the same key
are recognized as one entry and dedupe. Search results, chips, and
remove buttons use the compact npub.
- Workflow key fields: to/from keys display as npubs in the form and
save back as canonical hex. Templates like `{{trigger.author}}`, roles,
and free text pass through untouched; placeholders accept both
spellings.
- Recipient and agent dialogs: the verify popover is npub-only (the
raw-hex line is gone); denied-membership screens never show a raw key.
- The Rust-side truncated display name (used for native surfaces) shows
the same compact npub, so those surfaces match the web UI.

Internal representation is unchanged: keys are still stored, sent, and
matched as canonical 64-character hex — npub is a display and input
spelling, normalized to hex at the boundary, so existing data and
integrations keep working. Bound-key usability and comparison use one
normalized form (trimmed, lowercased, 64 hex characters; npub rejected),
so padded or mixed-case spellings of the same key match. Display names,
private keys, and event IDs are untouched.

## Details

- `respondToAllowlist` / `RespondToField`: npub entries normalize to
canonical hex; cross-form dedupe; compact npub in rows and chips;
direct-add accepts npub and stores canonical hex.
- `workflowFormTypes` / `WorkflowStepCard`: hex → npub for display, npub
→ canonical hex on save; templates, roles, and free text pass through in
both directions (roundtrip-tested).
- `UserProfilePanelFields`, `ProfileSettingsCard`,
`HostedCommunitiesSettingsCard`, `MembershipDenied`,
`SelectedRecipientChip`, `AddAgentToChannelDialog`: npub display and
copy; invalid keys → "Unavailable" with no copy; hosted identity rows
derive from the bound `pubkey_hex` (create/onboarding rows from the
bound and local keys), never the unverified server npub;
connected/readiness/Connect gates use the same usable-bound-key
predicate, and the settings Connect invocation callback re-checks it
before starting.
- `src-tauri/src/commands/identity.rs`: `truncated_display_name`
compacts to the first 8 + last 4 characters of the npub (above a 12-char
threshold), mirroring `truncateNpub`.
- e2e: profile key rows and clipboard polls assert npub forms and
raw-hex suppression; the display-screenshots spec pins the npub-only
popover; hosted specs drive the real settings card, create flow, and
onboarding rows through their real providers, and the unlinked/npub-only
identity cases assert no connected claim and no Connect action.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Stack: builds on #7488 (shared npub foundation), now merged; this PR
is rebased onto main and stands on its own.

### Testing

At head `303c90ffa` (base: main `bfc38485`; 24 files, +1125/−146):

- Focused unit suites (respondToAllowlist, workflowFormTypes,
hostedCommunityApi bound-key helpers) green; mutation-checked — dropping
allowlist canonicalization fails the dedupe case, and dropping bound-key
normalization fails the npub-in-hex and padded same-key cases.
- Full desktop unit suite 6,477/6,477, `desktop-typecheck`,
`desktop-check` (formatting fixed narrowly with `biome check --write` on
the touched files only), and a fresh E2E build at the current head; the
add-community + hosted-communities-settings specs 18/18 and onboarding
integration 69/69 on a fresh dedicated port, with focused new-case runs
4+4 covering padded same-key (ready, Connect kept — no false rebind) and
npub-in-hex (neutral label, recovery, no Connect) across the settings
card, create flow, and first-community onboarding, plus the
unlinked-account settings regression asserting Connect cannot occur.
- `cargo fmt`/clippy (both feature sets) and `cargo test identity` (71
pass) passed at the earlier full-change head; since then, the only
production changes in this PR's delta are the hosted identity display
authority and its fail-closed bound-key gating/normalization above
(base-side fixes carry #7488's receipts) — every other change is
test-only.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:08:01 -04:00

314 lines
11 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { npubEncode } from "nostr-tools/nip19";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
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);
/**
* Install the default hosted-communities fixture and open its settings
* section. `builderlabIdentity` overrides the bound account identity so a
* spec can drive independent — even contradictory — `pubkey_hex`/`npub`
* fields; the mock bridge passes both through verbatim, like the native
* command.
*/
async function openHostedCommunitiesSettings(
page: Page,
builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null,
) {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
builderlabIdentity: builderlabIdentity ?? {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
},
builderlabCommunities: [
{
id: "active-community",
name: "E2E Test",
normalized_host: "localhost:3000",
},
{
id: "other-community",
name: "Design studio",
normalized_host: "design-studio.communities.buzz.xyz",
},
],
});
await page.goto("/");
await openSettings(page, "hosted-communities");
}
test.beforeEach(async ({ page }) => {
await openHostedCommunitiesSettings(page);
});
test("identity: mismatch rows follow pubkey_hex, never the hosted npub or raw hex", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: "f".repeat(64),
npub: npubEncode(OTHER_HEX),
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
const settingsView = page.getByTestId("settings-view");
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText(npubEncode("f".repeat(64)));
await expect(
page
.getByText("This device", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
// The independently valid but contradictory hosted npub, and the raw hex
// it would stand in for, must never render.
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
await expect(settingsView.getByText("f".repeat(64))).toHaveCount(0);
});
test("identity: connected row follows pubkey_hex when the hosted npub encodes another key", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
npub: npubEncode(OTHER_HEX),
});
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
await expect(
page.getByTestId("settings-view").getByText(npubEncode(OTHER_HEX)),
).toHaveCount(0);
});
test("identity: unusable bound hex renders the neutral label, not the hosted npub or raw hex", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
// Valid hex alphabet, wrong length — unusable as an identity key.
pubkey_hex: "f".repeat(63),
npub: npubEncode(OTHER_HEX),
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
const settingsView = page.getByTestId("settings-view");
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText("Unavailable");
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
await expect(settingsView.getByText("f".repeat(63))).toHaveCount(0);
});
test("identity: consistent hosted identity renders its canonical npub", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
pubkey_hex: DEFAULT_MOCK_PUBKEY,
npub: npubEncode(DEFAULT_MOCK_PUBKEY),
});
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
});
test("identity: unlinked account offers linking, never a connected claim", async ({
page,
}) => {
await installMockBridge(page, {
builderlabAuth: {
email: "owner@example.com",
expiresAt: "2099-01-01T00:00:00Z",
},
// No identity object at all: the account has not linked a Buzz key.
builderlabIdentity: null,
builderlabCommunities: [
{
id: "active-community",
name: "E2E Test",
normalized_host: "localhost:3000",
},
],
});
await page.goto("/");
await openSettings(page, "hosted-communities");
await expect(
page.getByText("Link this account to your Buzz identity"),
).toBeVisible();
await expect(page.getByText("Buzz identity connected")).toHaveCount(0);
// The seeded owned community still lists — every affordance that does
// not act on the binding stays available — but Connect is an action on
// the binding and cannot occur without a usable bound key: no row
// affordance, and no onboarding it could start.
await expect(page.getByTestId("hosted-community-row")).toHaveCount(1);
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
// Creation stays unavailable: there is no authoritative key to bind the
// new community to.
await expect(page.getByLabel("Community address")).toBeDisabled();
await expect(
page.getByRole("button", { name: "Create and connect", exact: true }),
).toBeDisabled();
});
test("identity: padded or mixed-case bound hex is the same key, not a mismatch", async ({
page,
}) => {
await openHostedCommunitiesSettings(page, {
// The same key this device signs with, padded and uppercased. It
// normalizes to the local key on both sides of the comparison — never
// a mismatch demanding delete/rebind of an identity the device already
// holds.
pubkey_hex: ` ${DEFAULT_MOCK_PUBKEY.toUpperCase()} `,
});
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toHaveCount(0);
const connectedNpub = page
.getByText("Buzz identity connected")
.locator("span.font-mono");
await expect(connectedNpub).toHaveText(npubEncode(DEFAULT_MOCK_PUBKEY));
// A binding that is the same key after normalization keeps its Connect
// affordance — recovery is reserved for a binding that actually differs.
await expect(
page.getByRole("button", { name: "Connect", exact: true }).first(),
).toBeVisible();
});
/**
* Identity payloads whose authoritative `pubkey_hex` cannot act as a key.
* Each is an identity *object* — so presence alone must never read as a
* connected, ready account — yet none carries a key the app can use.
*/
const UNUSABLE_BOUND_KEY_PAYLOADS: Array<
[label: string, payload: { npub?: string; pubkey_hex?: string }]
> = [
["missing key", {}],
["non-hex key", { pubkey_hex: "zz".repeat(32) }],
["npub-only key", { npub: npubEncode(OTHER_HEX) }],
// A checksum-valid npub stored in the hex field itself is not a hex
// key: it must fail closed like any other unusable spelling, never
// display as the account's authoritative key, and never read as a
// usable binding when the local comparison is skipped.
["npub stored in the hex field", { pubkey_hex: npubEncode(OTHER_HEX) }],
];
for (const [label, payload] of UNUSABLE_BOUND_KEY_PAYLOADS) {
test(`identity: ${label} is recovery, never a connected account or actions`, async ({
page,
}) => {
await openHostedCommunitiesSettings(page, payload);
const settingsView = page.getByTestId("settings-view");
// No connected claim anywhere on the surface, despite the identity
// object being present.
await expect(page.getByText("Buzz identity connected")).toHaveCount(0);
// The mismatch recovery block owns the identity panel instead.
await expect(
page.getByText("This account is connected to a different Buzz identity"),
).toBeVisible();
await expect(
page
.getByText("Account uses", { exact: true })
.locator("xpath=following-sibling::dd[1]"),
).toHaveText("Unavailable");
// The unverified server-sent npub spelling never renders.
await expect(settingsView.getByText(npubEncode(OTHER_HEX))).toHaveCount(0);
// Connect stays unavailable for every owned community.
await expect(
page.getByRole("button", { name: "Connect", exact: true }),
).toHaveCount(0);
// Creation stays unavailable: no key the new community would bind to.
await expect(page.getByLabel("Community address")).toBeDisabled();
await expect(
page.getByRole("button", { name: "Create and connect", exact: true }),
).toBeDisabled();
});
}
test("capture: community icon picker sits beside its hosted community", async ({
page,
}) => {
const activeRow = page
.getByTestId("hosted-community-row")
.filter({ hasText: "E2E Test" });
const otherRow = page
.getByTestId("hosted-community-row")
.filter({ hasText: "Design studio" });
await expect(activeRow.getByTestId("community-icon-settings")).toBeVisible();
await expect(otherRow.getByTestId("community-icon-settings")).toHaveCount(0);
const iconDataUrl = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" rx="28" fill="#ff56c3"/><text x="64" y="80" text-anchor="middle" font-size="48">😅</text></svg>',
)}`;
await activeRow.getByLabel("Add community icon").click();
const picker = page.getByRole("group", { name: "Community icon picker" });
await expect(picker).toBeVisible();
await expect(page.getByRole("tab", { name: "Image" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Emoji" })).toBeVisible();
await page.getByPlaceholder("Paste a URL").fill(iconDataUrl);
await page.getByRole("button", { name: "Apply" }).click();
const icon = activeRow.getByRole("img", { name: /community icon$/i });
await expect(icon).toBeVisible();
const maskImage = await activeRow
.getByTestId("community-icon-mask")
.evaluate((element) => getComputedStyle(element).webkitMaskImage);
expect(maskImage).toContain("radial-gradient");
await expect(page.getByTestId("community-icon-save")).toHaveCount(0);
await expect
.poll(() =>
page.evaluate(() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.some(
(event) =>
event.kind === 9033 &&
event.tags.some(
(tag) => tag[0] === "icon" && tag[1]?.startsWith("data:image/"),
),
),
),
)
.toBe(true);
const iconBox = await activeRow
.getByTestId("community-icon-settings")
.boundingBox();
const nameBox = await activeRow
.getByText("E2E Test", { exact: true })
.boundingBox();
expect(iconBox).not.toBeNull();
expect(nameBox).not.toBeNull();
expect(iconBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
nameBox?.x ?? Number.NEGATIVE_INFINITY,
);
await waitForAnimations(page);
await activeRow.screenshot({
path: `${OUTDIR}/01-community-icon-row.png`,
});
});