Ctrl K
ComunidadGuías…

test/shared/apple-wallet.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, unknown>[];
  secondaryFields: Record<string, unknown>[];
  auxiliaryFields: Record<string, unknown>[];
  backFields: Record<string, unknown>[];
};

const makePassData = (overrides: Partial<PassData> = {}): 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<string, string>[];
      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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => f.key === "date",
        ),
      ).toBeUndefined();
      expect(pass.relevantDate).toBeUndefined();

      expect(
        ticket.secondaryFields.find(
          (f: Record<string, unknown>) => f.key === "location",
        ),
      ).toBeUndefined();

      expect(
        ticket.auxiliaryFields.find(
          (f: Record<string, unknown>) => f.key === "qty",
        ),
      ).toBeUndefined();

      expect(
        ticket.auxiliaryFields.find(
          (f: Record<string, unknown>) => 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<string, unknown>) => f.key === "qty",
      );
      expect(qtyField).toBeDefined();
      expect(qtyField!.value).toBe(2);

      const priceField = ticket.auxiliaryFields.find(
        (f: Record<string, unknown>) => f.key === "price",
      );
      expect(priceField).toBeDefined();
      expect(priceField!.value).toBe(25);
      expect(priceField!.currencyCode).toBe("EUR");

      const bookingField = ticket.auxiliaryFields.find(
        (f: Record<string, unknown>) => 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");
    });
  });
});