Skip to content
Merged
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
89 changes: 89 additions & 0 deletions src/__tests__/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
NOTIFICATION_STATUS_SKIPPED_OPTED_OUT,
} from "@/lib/notifications/events";
import { resolveSignatureRecipients } from "@/lib/notifications/recipients";
import {
maskAddress,
summarizeSignableSignatureContext,
summarizeTransactionSignatureContext,
} from "@/lib/notifications/signatureContext";
import { renderSignatureRequiredEmail } from "@/lib/notifications/templates/signatureRequired";

describe("notification recipient resolution", () => {
Expand Down Expand Up @@ -113,13 +118,97 @@ describe("notification email templates", () => {
totalSigners: 3,
actionUrl: "https://example.com/sign?x=<bad>",
preferencesUrl: "https://example.com/preferences",
signatureContext: {
summary: "Send 50 ADA to addr_tes...3te2",
details: [{ label: "Outputs", value: "2 total outputs" }],
},
});

expect(email.subject).toBe("Signature required: <Vault>");
expect(email.html).toContain("&lt;Vault&gt;");
expect(email.html).toContain("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;");
expect(email.html).not.toContain("<script>alert");
expect(email.text).toContain("A transaction in <Vault> is waiting");
expect(email.text).toContain("What needs signing: Send 50 ADA");
expect(email.html).toContain("What needs signing");
expect(email.html.indexOf("Description")).toBeLessThan(
email.html.indexOf("What needs signing"),
);
expect(email.text.indexOf("Description:")).toBeLessThan(
email.text.indexOf("What needs signing:"),
);
expect(email.text).toContain("Review and sign:");
});

it("truncates long descriptions in html and text bodies", () => {
const longDescription = "Governance ".repeat(40).trim();
const email = renderSignatureRequiredEmail({
walletName: "Vault",
resourceType: "transaction",
description: longDescription,
signedCount: 0,
requiredCount: 2,
totalSigners: 3,
actionUrl: "https://example.com/sign",
preferencesUrl: "https://example.com/preferences",
});

expect(email.html).toContain("Description");
expect(email.html).toContain("...");
expect(email.html).not.toContain(longDescription);
expect(email.text).toContain("Description:");
expect(email.text).toContain("...");
expect(email.text).not.toContain(longDescription);
});
});

describe("notification signature context summaries", () => {
it("summarizes transfer outputs without revealing full recipient addresses", () => {
const fullAddress = "addr_test1qpy7y6u20jv7ky7p4vte2";
const summary = summarizeTransactionSignatureContext({
outputs: [
{
address: fullAddress,
amount: [{ unit: "lovelace", quantity: "50000000" }],
},
],
});

expect(summary?.summary).toBe(`Send 50 ADA to ${maskAddress(fullAddress)}`);
expect(summary?.summary).not.toContain(fullAddress);
});

it("summarizes governance proxy vote metadata", () => {
const summary = summarizeTransactionSignatureContext({
proxyBot: {
kind: "proxyVote",
votes: [
{
proposalId:
"0123456789abcdef0123456789abcdef0123456789abcdef01234567#0",
voteKind: "Yes",
},
],
},
});

expect(summary?.summary).toContain("Governance Yes vote");
expect(summary?.summary).toContain("01234567");
expect(summary?.summary).toContain("...67#0");
});

it("summarizes signable governance payloads by method", () => {
expect(
summarizeSignableSignatureContext({
method: "ekklesia-vote",
description: "Hydra Budget Vote",
}),
).toEqual({ summary: "Governance vote package" });
});

it("falls back to governance descriptions when transaction metadata is generic", () => {
expect(summarizeTransactionSignatureContext("{}", "Vote: Yes - Treasury")).toEqual({
summary: "Governance action: Vote: Yes - Treasury",
});
});
});
163 changes: 163 additions & 0 deletions src/__tests__/proposalMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, jest } from "@jest/globals";
import {
createProposalMetadataFallback,
fetchProposalMetadataWithFallback,
getAnchorUrls,
normalizeProposalMetadata,
} from "@/lib/governance/proposalMetadata";

const proposal = {
tx_hash: "tx-proposal",
cert_index: 0,
governance_type: "info_action",
};

afterEach(() => {
jest.restoreAllMocks();
});

describe("proposal metadata helpers", () => {
it("normalizes usable Blockfrost metadata without extra fetching", async () => {
const provider = {
get: jest.fn(async () => ({
tx_hash: "tx-proposal",
cert_index: 0,
hash: "hash",
url: "https://example.com/metadata.json",
bytes: "123",
json_metadata: {
body: {
title: "Proposal title",
abstract: "Proposal abstract",
motivation: "Motivation",
rationale: "Rationale",
references: [{ "@type": "Other", label: "Spec", uri: "https://example.com" }],
},
authors: [{ name: "Ada" }],
},
})),
};

const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });

expect(provider.get).toHaveBeenCalledTimes(1);
expect(metadata).toMatchObject({
tx_hash: "tx-proposal",
cert_index: 0,
governance_type: "info_action",
hash: "hash",
json_metadata: {
body: {
title: "Proposal title",
abstract: "Proposal abstract",
motivation: "Motivation",
rationale: "Rationale",
},
authors: [{ name: "Ada" }],
},
});
});

it("hydrates metadata from a regular anchor URL", async () => {
const provider = {
get: jest.fn(async () => ({
tx_hash: "tx-proposal",
cert_index: 0,
url: "https://example.com/anchor.json",
})),
};
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
body: {
title: "Anchor title",
abstract: "Anchor abstract",
},
authors: [{ name: "Anchor author" }],
}),
{ status: 200 },
),
);

const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });

expect(fetchSpy).toHaveBeenCalledWith(
"https://example.com/anchor.json",
expect.objectContaining({ method: "GET" }),
);
expect(metadata?.json_metadata.body.title).toBe("Anchor title");
expect(metadata?.json_metadata.authors).toEqual([{ name: "Anchor author" }]);
});

it("tries IPFS gateway fallbacks until one returns usable JSON", async () => {
expect(getAnchorUrls("ipfs://cid/path.json")).toEqual([
"https://ipfs.io/ipfs/cid/path.json",
"https://cloudflare-ipfs.com/ipfs/cid/path.json",
"https://dweb.link/ipfs/cid/path.json",
]);

const provider = {
get: jest.fn(async () => ({
tx_hash: "tx-proposal",
cert_index: 0,
url: "ipfs://cid/path.json",
})),
};
const fetchSpy = jest
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("not found", { status: 504 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ title: "Wrapped anchor title" }), {
status: 200,
}),
);

const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(metadata?.json_metadata.body.title).toBe("Wrapped anchor title");
});

it("returns fallback metadata without changing the ProposalMetadata shape", () => {
const metadata = createProposalMetadataFallback(proposal);

expect(metadata).toEqual({
tx_hash: "tx-proposal",
cert_index: 0,
governance_type: "info_action",
hash: "",
url: "",
bytes: "",
json_metadata: {
body: {
title: "Metadata could not be loaded.",
abstract: "tx-proposal#0",
motivation: "",
rationale: "",
references: [],
},
authors: [],
},
});
});

it("normalizes missing or unusable metadata fields into safe defaults", () => {
const metadata = normalizeProposalMetadata({ json_metadata: { body: null } }, proposal);

expect(metadata).toMatchObject({
tx_hash: "tx-proposal",
cert_index: 0,
governance_type: "info_action",
json_metadata: {
body: {
title: "Metadata could not be loaded.",
abstract: "tx-proposal#0",
motivation: "",
rationale: "",
references: [],
},
authors: [],
},
});
});
});
Loading
Loading