import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { unzipSync } from "fflate"; import { isValidRsaPrivateKey } from "#crypto/rsa-private-key.ts"; import { isValidAppleCertificate } from "#shared/apple-wallet/certificate.ts"; import { buildPkpass, generatePassJson, type PassData, padAuthToken, type SigningCredentials, sha1Hex, trimAuthToken, } from "#shared/apple-wallet.ts"; import { WALLET_ICONS } from "#shared/wallet-icons.ts"; import { generateTestCerts } from "#test-utils/crypto.ts"; /** Type for eventTicket field groups in pass.json */ type TicketFields = { primaryFields: Record[]; secondaryFields: Record[]; auxiliaryFields: Record[]; backFields: Record[]; }; const makePassData = (overrides: Partial = {}): PassData => ({ attendeeDate: null, checkinUrl: "https://example.com/checkin/ABC123", currencyCode: "GBP", description: "Ticket for Summer Concert", listingDate: "2026-06-15T19:00:00Z", listingLocation: "Town Hall", listingName: "Summer Concert", organizationName: "Test Platform", pricePaid: 0, quantity: 1, serialNumber: "ABC123", webServiceURL: "https://example.com", ...overrides, }); describe("apple-wallet", () => { // Certs are cached in generateTestCerts — no per-test RSA keygen const creds: SigningCredentials = generateTestCerts(); describe("buildPkpass integration", () => { /** Helper to extract and parse pass.json from a pkpass ZIP */ const extractPassJson = (pkpass: Uint8Array) => JSON.parse(new TextDecoder().decode(unzipSync(pkpass)["pass.json"]!)); test("includes all required top-level fields and barcode", async () => { const pass = extractPassJson(await buildPkpass(makePassData(), creds)); expect(pass.formatVersion).toBe(1); expect(pass.passTypeIdentifier).toBe("pass.com.test.tickets"); expect(pass.teamIdentifier).toBe("TESTTEAM01"); expect(pass.serialNumber).toBe("ABC123"); expect(pass.organizationName).toBe("Test Platform"); expect(pass.description).toBe("Ticket for Summer Concert"); const barcodes = pass.barcodes as Record[]; expect(barcodes).toHaveLength(1); expect(barcodes[0]!.format).toBe("PKBarcodeFormatQR"); expect(barcodes[0]!.message).toBe("https://example.com/checkin/ABC123"); expect(barcodes[0]!.messageEncoding).toBe("iso-8859-1"); const ticket = pass.eventTicket as TicketFields; expect(pass.listingTicket).toBeUndefined(); expect(ticket.primaryFields).toEqual([ { key: "listing", label: "LISTING", value: "Summer Concert" }, ]); const dateField = ticket.secondaryFields.find( (f: Record) => f.key === "date", ); expect(dateField).toEqual({ dateStyle: "PKDateStyleMedium", key: "date", label: "DATE", timeStyle: "PKDateStyleShort", value: "2026-06-15T19:00:00Z", }); const locationField = ticket.secondaryFields.find( (f: Record) => f.key === "location", ); expect(locationField).toBeDefined(); expect(locationField!.value).toBe("Town Hall"); expect(pass.relevantDate).toBe("2026-06-15T19:00:00Z"); expect(pass.foregroundColor).toBe("rgb(0, 0, 0)"); expect(pass.backgroundColor).toBe("rgb(255, 255, 255)"); expect(pass.labelColor).toBe("rgb(100, 100, 100)"); expect(pass.webServiceURL).toBe("https://example.com"); expect(pass.authenticationToken).toBe("ABC123----------"); expect( (pass.authenticationToken as string).length, ).toBeGreaterThanOrEqual(16); }); test("omits date, location, qty, and price when data is empty or default", async () => { const pass = extractPassJson( await buildPkpass( makePassData({ listingDate: "", listingLocation: "", pricePaid: 0, quantity: 1, }), creds, ), ); const ticket = pass.eventTicket as TicketFields; expect( ticket.secondaryFields.find( (f: Record) => f.key === "date", ), ).toBeUndefined(); expect(pass.relevantDate).toBeUndefined(); expect( ticket.secondaryFields.find( (f: Record) => f.key === "location", ), ).toBeUndefined(); expect( ticket.auxiliaryFields.find( (f: Record) => f.key === "qty", ), ).toBeUndefined(); expect( ticket.auxiliaryFields.find( (f: Record) => f.key === "price", ), ).toBeUndefined(); }); test("includes quantity, price, and booking date when present", async () => { const pass = extractPassJson( await buildPkpass( makePassData({ attendeeDate: "2026-06-15", currencyCode: "EUR", pricePaid: 2500, quantity: 2, }), creds, ), ); const ticket = pass.eventTicket as TicketFields; const qtyField = ticket.auxiliaryFields.find( (f: Record) => f.key === "qty", ); expect(qtyField).toBeDefined(); expect(qtyField!.value).toBe(2); const priceField = ticket.auxiliaryFields.find( (f: Record) => f.key === "price", ); expect(priceField).toBeDefined(); expect(priceField!.value).toBe(25); expect(priceField!.currencyCode).toBe("EUR"); const bookingField = ticket.auxiliaryFields.find( (f: Record) => f.key === "booking-date", ); expect(bookingField).toBeDefined(); expect(bookingField!.value).toBe("2026-06-15"); }); test("uses custom colors when provided", async () => { const pass = extractPassJson( await buildPkpass( makePassData({ backgroundColor: "rgb(0, 0, 255)", foregroundColor: "rgb(255, 0, 0)", labelColor: "rgb(0, 255, 0)", }), creds, ), ); expect(pass.foregroundColor).toBe("rgb(255, 0, 0)"); expect(pass.backgroundColor).toBe("rgb(0, 0, 255)"); expect(pass.labelColor).toBe("rgb(0, 255, 0)"); }); test("uses default colors when optional values are empty", () => { const pass = generatePassJson( makePassData({ backgroundColor: "", foregroundColor: "", labelColor: "", }), creds, ); expect(pass.backgroundColor).toBe("rgb(255, 255, 255)"); expect(pass.foregroundColor).toBe("rgb(0, 0, 0)"); expect(pass.labelColor).toBe("rgb(100, 100, 100)"); }); test("includes the smallest positive price", () => { const pass = generatePassJson(makePassData({ pricePaid: 1 }), creds); const ticket = pass.eventTicket as TicketFields; expect(ticket.auxiliaryFields).toContainEqual({ currencyCode: "GBP", key: "price", label: "PRICE", value: 0.01, }); }); }); describe("padAuthToken / trimAuthToken", () => { test("pads short tokens to 16 characters", () => { expect(padAuthToken("ABC123")).toBe("ABC123----------"); expect(padAuthToken("ABC123")).toHaveLength(16); }); test("pads 10-char ticket tokens to 16 characters", () => { expect(padAuthToken("803357EE59")).toBe("803357EE59------"); expect(padAuthToken("803357EE59")).toHaveLength(16); }); test("does not pad tokens already at 16 characters", () => { const long = "ABCDEF1234567890"; expect(padAuthToken(long)).toBe(long); }); test("trimAuthToken reverses padAuthToken", () => { expect(trimAuthToken(padAuthToken("ABC123"))).toBe("ABC123"); expect(trimAuthToken(padAuthToken("803357EE59"))).toBe("803357EE59"); }); test("trimAuthToken handles unpadded tokens", () => { expect(trimAuthToken("ABCDEF1234567890")).toBe("ABCDEF1234567890"); }); }); describe("buildPkpass", () => { test("produces a valid ZIP with pass.json, icons, and manifest hashes", async () => { const data = makePassData(); const pkpass = await buildPkpass(data, creds); expect(pkpass).toBeInstanceOf(Uint8Array); expect(pkpass.length).toBeGreaterThan(0); const files = unzipSync(pkpass); expect(files["pass.json"]).toBeDefined(); expect(files["icon.png"]).toBeDefined(); expect(files["icon@2x.png"]).toBeDefined(); expect(files["icon@3x.png"]).toBeDefined(); expect(files["manifest.json"]).toBeDefined(); expect(files.signature).toBeDefined(); // pass.json matches generatePassJson const passJson = JSON.parse( new TextDecoder().decode(files["pass.json"]!), ); const expected = generatePassJson(data, creds); expect(passJson).toEqual(expected); // manifest SHA-1 hashes are correct for all content files const manifest = JSON.parse( new TextDecoder().decode(files["manifest.json"]!), ); expect(manifest["pass.json"]).toBe(await sha1Hex(files["pass.json"]!)); expect(manifest["icon.png"]).toBe(await sha1Hex(files["icon.png"]!)); expect(manifest["icon@2x.png"]).toBe( await sha1Hex(files["icon@2x.png"]!), ); expect(manifest["icon@3x.png"]).toBe( await sha1Hex(files["icon@3x.png"]!), ); }); test("produces different pkpass for different serial numbers", async () => { const [a, b] = await Promise.all([ buildPkpass(makePassData({ serialNumber: "AAA" }), creds), buildPkpass(makePassData({ serialNumber: "BBB" }), creds), ]); const aJson = JSON.parse( new TextDecoder().decode(unzipSync(a)["pass.json"]!), ); const bJson = JSON.parse( new TextDecoder().decode(unzipSync(b)["pass.json"]!), ); expect(aJson.serialNumber).toBe("AAA"); expect(bJson.serialNumber).toBe("BBB"); }); }); describe("WALLET_ICONS", () => { test("contains all three required icon sizes", () => { expect(WALLET_ICONS["icon.png"]).toBeInstanceOf(Uint8Array); expect(WALLET_ICONS["icon@2x.png"]).toBeInstanceOf(Uint8Array); expect(WALLET_ICONS["icon@3x.png"]).toBeInstanceOf(Uint8Array); }); test("each icon is a valid PNG", () => { for (const icon of Object.values(WALLET_ICONS)) { // PNG signature: 0x89 P N G \r \n 0x1a \n expect(icon[0]).toBe(137); expect(icon[1]).toBe(80); expect(icon[2]).toBe(78); expect(icon[3]).toBe(71); } }); }); describe("isValidAppleCertificate", () => { test("returns true for a valid PEM certificate", async () => { expect(await isValidAppleCertificate(creds.signingCert)).toBe(true); }); test("returns false for a private key PEM", async () => { expect(await isValidAppleCertificate(creds.signingKey)).toBe(false); }); test("returns false for garbage input", async () => { expect(await isValidAppleCertificate("not a certificate")).toBe(false); }); }); describe("isValidRsaPrivateKey", () => { test("returns true for a valid PEM private key", async () => { expect(await isValidRsaPrivateKey(creds.signingKey)).toBe(true); }); test("returns false for a certificate PEM", async () => { expect(await isValidRsaPrivateKey(creds.signingCert)).toBe(false); }); test("returns false for garbage input", async () => { expect(await isValidRsaPrivateKey("not a key")).toBe(false); }); }); describe("currency-aware price formatting", () => { test("converts price using currency decimal places for JPY (0 decimals)", () => { const pass = generatePassJson( makePassData({ currencyCode: "JPY", pricePaid: 1000 }), creds, ); const ticket = pass.eventTicket as TicketFields; const priceField = ticket.auxiliaryFields.find((f) => f.key === "price"); expect(priceField!.value).toBe(1000); expect(priceField!.currencyCode).toBe("JPY"); }); test("converts price using currency decimal places for GBP (2 decimals)", () => { const pass = generatePassJson( makePassData({ currencyCode: "GBP", pricePaid: 2500 }), creds, ); const ticket = pass.eventTicket as TicketFields; const priceField = ticket.auxiliaryFields.find((f) => f.key === "price"); expect(priceField!.value).toBe(25); expect(priceField!.currencyCode).toBe("GBP"); }); }); });