Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
/**
|
|
* x402 Enforcer Tests
|
|
*
|
|
* Tests the x402 payment enforcement logic:
|
|
* - createEnforcer() creates a valid enforcer
|
|
* - enforce() returns 402 when no payment header is present
|
|
* - enforce() verifies and settles valid payments
|
|
* - enforce() returns 402 for invalid payments
|
|
* - hasPayment() checks for payment headers
|
|
* - applyHeaders() sets response headers
|
|
* - botOnly mode skips enforcement for humans
|
|
* - Price normalization ($ prefix stripping)
|
|
* - Error when no price is configured
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
import type { X402Config, X402Enforcer } from "../src/types.js";
|
|
|
|
// Mock instances
|
|
const mockBuildPaymentRequirements = vi.fn();
|
|
const mockCreatePaymentRequiredResponse = vi.fn();
|
|
const mockFindMatchingRequirements = vi.fn();
|
|
const mockVerifyPayment = vi.fn();
|
|
const mockSettlePayment = vi.fn();
|
|
const mockInitialize = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const mockResourceServer = {
|
|
buildPaymentRequirements: mockBuildPaymentRequirements,
|
|
createPaymentRequiredResponse: mockCreatePaymentRequiredResponse,
|
|
findMatchingRequirements: mockFindMatchingRequirements,
|
|
verifyPayment: mockVerifyPayment,
|
|
settlePayment: mockSettlePayment,
|
|
initialize: mockInitialize,
|
|
register: vi.fn().mockReturnThis(),
|
|
};
|
|
|
|
const mockEncodePaymentRequiredHeader = vi.fn().mockReturnValue("encoded-payment-required");
|
|
const mockDecodePaymentSignatureHeader = vi.fn();
|
|
const mockEncodePaymentResponseHeader = vi.fn().mockReturnValue("encoded-payment-response");
|
|
|
|
vi.mock("@x402/core/server", () => ({
|
|
HTTPFacilitatorClient: vi.fn(),
|
|
x402ResourceServer: vi.fn().mockImplementation(function () {
|
|
return mockResourceServer;
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@x402/core/http", () => ({
|
|
encodePaymentRequiredHeader: (...args: unknown[]) => mockEncodePaymentRequiredHeader(...args),
|
|
decodePaymentSignatureHeader: (...args: unknown[]) => mockDecodePaymentSignatureHeader(...args),
|
|
encodePaymentResponseHeader: (...args: unknown[]) => mockEncodePaymentResponseHeader(...args),
|
|
}));
|
|
|
|
vi.mock("@x402/evm/exact/server", () => ({
|
|
ExactEvmScheme: vi.fn(),
|
|
}));
|
|
|
|
const defaultConfig: X402Config = {
|
|
payTo: "0xTestWallet",
|
|
network: "eip155:8453",
|
|
defaultPrice: "$0.01",
|
|
facilitatorUrl: "https://test-facilitator.example.com",
|
|
};
|
|
|
|
function makeRequest(
|
|
url: string,
|
|
headers?: Record<string, string>,
|
|
cf?: { botManagement?: { score?: number } },
|
|
): Request {
|
|
const req = new Request(url, { headers });
|
|
if (cf) {
|
|
(req as unknown as { cf: typeof cf }).cf = cf;
|
|
}
|
|
return req;
|
|
}
|
|
|
|
/** Re-import the enforcer module to reset the cached singleton */
|
|
async function freshEnforcer(config: X402Config): Promise<X402Enforcer> {
|
|
const mod = await import("../src/enforcer.js");
|
|
return mod.createEnforcer(config);
|
|
}
|
|
|
|
describe("createEnforcer()", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
mockBuildPaymentRequirements.mockReset();
|
|
mockCreatePaymentRequiredResponse.mockReset();
|
|
mockFindMatchingRequirements.mockReset();
|
|
mockVerifyPayment.mockReset();
|
|
mockSettlePayment.mockReset();
|
|
mockInitialize.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("enforce()", () => {
|
|
it("returns 402 when no payment header is present", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article");
|
|
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
const mockPaymentRequired = {
|
|
x402Version: 2,
|
|
accepts: mockRequirements,
|
|
resource: { url: "/premium-article" },
|
|
};
|
|
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
const response = result as Response;
|
|
expect(response.status).toBe(402);
|
|
expect(response.headers.get("PAYMENT-REQUIRED")).toBe("encoded-payment-required");
|
|
expect(response.headers.get("Content-Type")).toBe("application/json");
|
|
|
|
const body = await response.json();
|
|
expect(body.x402Version).toBe(2);
|
|
});
|
|
|
|
it("returns EnforceResult when payment is valid", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article", {
|
|
"payment-signature": "valid-payment-sig",
|
|
});
|
|
|
|
const mockPayload = { x402Version: 2, payload: {} };
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
|
|
|
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
|
mockVerifyPayment.mockResolvedValue({
|
|
isValid: true,
|
|
payer: "0xPayerWallet",
|
|
});
|
|
mockSettlePayment.mockResolvedValue({
|
|
success: true,
|
|
transaction: "0xTxHash",
|
|
network: "eip155:8453",
|
|
payer: "0xPayerWallet",
|
|
});
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).not.toBeInstanceOf(Response);
|
|
const enforceResult = result as {
|
|
paid: boolean;
|
|
skipped: boolean;
|
|
payer?: string;
|
|
responseHeaders: Record<string, string>;
|
|
};
|
|
expect(enforceResult.paid).toBe(true);
|
|
expect(enforceResult.skipped).toBe(false);
|
|
expect(enforceResult.payer).toBe("0xPayerWallet");
|
|
expect(enforceResult.responseHeaders["PAYMENT-RESPONSE"]).toBe("encoded-payment-response");
|
|
});
|
|
|
|
it("returns 402 when payment verification fails", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article", {
|
|
"payment-signature": "invalid-payment-sig",
|
|
});
|
|
|
|
const mockPayload = { x402Version: 2, payload: {} };
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
|
const mockPaymentRequired = {
|
|
x402Version: 2,
|
|
error: "insufficient_balance",
|
|
accepts: mockRequirements,
|
|
};
|
|
|
|
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
|
mockVerifyPayment.mockResolvedValue({
|
|
isValid: false,
|
|
invalidReason: "insufficient_balance",
|
|
});
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).status).toBe(402);
|
|
});
|
|
|
|
it("returns 402 when payment doesn't match requirements", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article", {
|
|
"payment-signature": "mismatched-payment-sig",
|
|
});
|
|
|
|
const mockPayload = { x402Version: 2, payload: {} };
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
const mockPaymentRequired = {
|
|
x402Version: 2,
|
|
error: "Payment does not match accepted requirements",
|
|
accepts: mockRequirements,
|
|
};
|
|
|
|
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockFindMatchingRequirements.mockReturnValue(undefined);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue(mockPaymentRequired);
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).status).toBe(402);
|
|
});
|
|
|
|
it("throws when no price is configured", async () => {
|
|
const enforcer = await freshEnforcer({
|
|
payTo: "0xTestWallet",
|
|
network: "eip155:8453",
|
|
});
|
|
const request = makeRequest("https://example.com/premium-article");
|
|
|
|
await expect(enforcer.enforce(request)).rejects.toThrow("No price specified");
|
|
});
|
|
|
|
it("allows overriding price per-request", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article");
|
|
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
|
x402Version: 2,
|
|
accepts: mockRequirements,
|
|
});
|
|
|
|
await enforcer.enforce(request, { price: "$0.50" });
|
|
|
|
expect(mockBuildPaymentRequirements).toHaveBeenCalledWith(
|
|
expect.objectContaining({ price: "0.50" }),
|
|
);
|
|
});
|
|
|
|
it("allows overriding payTo per-request", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article");
|
|
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
|
x402Version: 2,
|
|
accepts: mockRequirements,
|
|
});
|
|
|
|
await enforcer.enforce(request, { payTo: "0xOverrideWallet" });
|
|
|
|
expect(mockBuildPaymentRequirements).toHaveBeenCalledWith(
|
|
expect.objectContaining({ payTo: "0xOverrideWallet" }),
|
|
);
|
|
});
|
|
|
|
it("reads PAYMENT-SIGNATURE header (case-insensitive)", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/premium-article", {
|
|
"PAYMENT-SIGNATURE": "valid-payment-sig",
|
|
});
|
|
|
|
const mockPayload = { x402Version: 2, payload: {} };
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
const mockMatchingReqs = { scheme: "exact", network: "eip155:8453" };
|
|
|
|
mockDecodePaymentSignatureHeader.mockReturnValue(mockPayload);
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockFindMatchingRequirements.mockReturnValue(mockMatchingReqs);
|
|
mockVerifyPayment.mockResolvedValue({ isValid: true, payer: "0xPayer" });
|
|
mockSettlePayment.mockResolvedValue({
|
|
success: true,
|
|
transaction: "0xTx",
|
|
network: "eip155:8453",
|
|
});
|
|
|
|
const result = await enforcer.enforce(request);
|
|
expect(result).not.toBeInstanceOf(Response);
|
|
});
|
|
});
|
|
|
|
describe("botOnly mode", () => {
|
|
it("skips enforcement for humans (high bot score)", async () => {
|
|
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
|
const request = makeRequest(
|
|
"https://example.com/article",
|
|
{},
|
|
{ botManagement: { score: 90 } },
|
|
);
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).not.toBeInstanceOf(Response);
|
|
const enforceResult = result as { paid: boolean; skipped: boolean };
|
|
expect(enforceResult.paid).toBe(false);
|
|
expect(enforceResult.skipped).toBe(true);
|
|
});
|
|
|
|
it("enforces for bots (low bot score)", async () => {
|
|
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
|
const request = makeRequest(
|
|
"https://example.com/article",
|
|
{},
|
|
{ botManagement: { score: 5 } },
|
|
);
|
|
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
|
x402Version: 2,
|
|
accepts: mockRequirements,
|
|
});
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).toBeInstanceOf(Response);
|
|
expect((result as Response).status).toBe(402);
|
|
});
|
|
|
|
it("treats missing cf data as human (skips enforcement)", async () => {
|
|
const enforcer = await freshEnforcer({ ...defaultConfig, botOnly: true });
|
|
const request = makeRequest("https://example.com/article");
|
|
|
|
const result = await enforcer.enforce(request);
|
|
|
|
expect(result).not.toBeInstanceOf(Response);
|
|
const enforceResult = result as { skipped: boolean };
|
|
expect(enforceResult.skipped).toBe(true);
|
|
});
|
|
|
|
it("respects custom botScoreThreshold", async () => {
|
|
const enforcer = await freshEnforcer({
|
|
...defaultConfig,
|
|
botOnly: true,
|
|
botScoreThreshold: 50,
|
|
});
|
|
// Score 40 < threshold 50 -> bot -> enforce
|
|
const request = makeRequest(
|
|
"https://example.com/article",
|
|
{},
|
|
{ botManagement: { score: 40 } },
|
|
);
|
|
|
|
const mockRequirements = [{ scheme: "exact", network: "eip155:8453" }];
|
|
mockBuildPaymentRequirements.mockResolvedValue(mockRequirements);
|
|
mockCreatePaymentRequiredResponse.mockResolvedValue({
|
|
x402Version: 2,
|
|
accepts: mockRequirements,
|
|
});
|
|
|
|
const result = await enforcer.enforce(request);
|
|
expect(result).toBeInstanceOf(Response);
|
|
});
|
|
});
|
|
|
|
describe("applyHeaders()", () => {
|
|
it("sets response headers from EnforceResult", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const mockResponse = { headers: new Headers() };
|
|
|
|
enforcer.applyHeaders(
|
|
{
|
|
paid: true,
|
|
skipped: false,
|
|
responseHeaders: {
|
|
"PAYMENT-RESPONSE": "encoded-response",
|
|
"X-Custom": "value",
|
|
},
|
|
},
|
|
mockResponse,
|
|
);
|
|
|
|
expect(mockResponse.headers.get("PAYMENT-RESPONSE")).toBe("encoded-response");
|
|
expect(mockResponse.headers.get("X-Custom")).toBe("value");
|
|
});
|
|
|
|
it("is a no-op when there are no response headers", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const mockResponse = { headers: new Headers() };
|
|
|
|
enforcer.applyHeaders({ paid: false, skipped: true, responseHeaders: {} }, mockResponse);
|
|
|
|
expect([...mockResponse.headers.entries()]).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("hasPayment()", () => {
|
|
it("returns true when payment-signature header is present", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/article", {
|
|
"payment-signature": "some-sig",
|
|
});
|
|
expect(enforcer.hasPayment(request)).toBe(true);
|
|
});
|
|
|
|
it("returns true when PAYMENT-SIGNATURE header is present (uppercase)", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/article", {
|
|
"PAYMENT-SIGNATURE": "some-sig",
|
|
});
|
|
expect(enforcer.hasPayment(request)).toBe(true);
|
|
});
|
|
|
|
it("returns false when no payment header is present", async () => {
|
|
const enforcer = await freshEnforcer(defaultConfig);
|
|
const request = makeRequest("https://example.com/article");
|
|
expect(enforcer.hasPayment(request)).toBe(false);
|
|
});
|
|
});
|
|
});
|