From c802c1baca86a6d448995e493b88da4c105878dc Mon Sep 17 00:00:00 2001 From: Daniel Kronovet Date: Fri, 20 Feb 2026 23:28:47 -0500 Subject: [PATCH] test: add SessionProvider unit and WASM integration tests Add focused unit tests for SessionProvider's core logic (validatePoliciesSubset, ingestSessionFromRedirect, constructor validation, disconnect selective removal). Add WASM integration tests exercising real binary with toWasmPolicies round-trip, policy ordering stability, and address normalization. Both test suites pass with all existing tests (67 Jest + 15 WASM). Includes test:wasm npm script for standalone Node.js WASM testing. Co-Authored-By: Claude Haiku 4.5 --- packages/controller/package.json | 1 + .../src/__tests__/session-provider.test.ts | 474 +++++++++++++++++ .../src/__tests__/wasm-integration.test.mjs | 490 ++++++++++++++++++ 3 files changed, 965 insertions(+) create mode 100644 packages/controller/src/__tests__/session-provider.test.ts create mode 100644 packages/controller/src/__tests__/wasm-integration.test.mjs diff --git a/packages/controller/package.json b/packages/controller/package.json index 223830130f..5d5097b85a 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -18,6 +18,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "test": "jest", + "test:wasm": "node --experimental-wasm-modules --test src/__tests__/wasm-integration.test.mjs", "version": "pnpm pkg get version" }, "exports": { diff --git a/packages/controller/src/__tests__/session-provider.test.ts b/packages/controller/src/__tests__/session-provider.test.ts new file mode 100644 index 0000000000..a36ad3e1de --- /dev/null +++ b/packages/controller/src/__tests__/session-provider.test.ts @@ -0,0 +1,474 @@ +// Mock starknet partially — stark.randomAddress is non-configurable, so jest.spyOn fails. +const MOCK_PK = + "0x01234567890abcdef01234567890abcdef01234567890abcdef01234567890ab"; + +jest.mock("starknet", () => { + const actual = jest.requireActual("starknet"); + return { + ...actual, + stark: { + ...actual.stark, + randomAddress: jest.fn(() => MOCK_PK), + }, + }; +}); + +jest.mock("@cartridge/controller-wasm", () => ({ + signerToGuid: jest.fn(() => "0xmockguid"), + subscribeCreateSession: jest.fn(), +})); + +jest.mock("@cartridge/presets", () => ({ + loadConfig: jest.fn(), +})); + +jest.mock("../session/account", () => ({ + __esModule: true, + default: jest.fn().mockImplementation((_provider: any, opts: any) => ({ + address: opts.address, + execute: jest.fn(), + })), +})); + +jest.mock("../provider", () => { + function MockBaseProvider(this: any) { + this.account = undefined; + this.subscriptions = []; + this.emitAccountsChanged = jest.fn(); + this.emitNetworkChanged = jest.fn(); + } + MockBaseProvider.prototype.safeProbe = function () { + return Promise.resolve(this.account); + }; + return { __esModule: true, default: MockBaseProvider }; +}); + +import { ec, stark } from "starknet"; +import { signerToGuid } from "@cartridge/controller-wasm"; +import SessionProvider from "../session/provider"; +import { ParsedSessionPolicies } from "../policies"; + +const mockRandomAddress = stark.randomAddress as jest.Mock; +const mockSignerToGuid = signerToGuid as jest.Mock; + +// --- Fixtures --- + +const MOCK_PRIVATE_KEY = MOCK_PK; +const MOCK_PUBLIC_KEY = ec.starkCurve.getStarkKey(MOCK_PRIVATE_KEY); +const MOCK_SESSION_KEY_GUID = "0xmockguid"; +const MOCK_RPC = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9"; +const MOCK_CHAIN_ID = "0x534e5f4d41494e"; +const MOCK_REDIRECT = "https://game.example.com/callback"; + +const VALID_SESSION = { + username: "testuser", + address: "0xabc", + ownerGuid: "0xowner", + expiresAt: String(Math.floor(Date.now() / 1000) + 3600), + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid: MOCK_SESSION_KEY_GUID, +}; + +const VALID_POLICIES: ParsedSessionPolicies = { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, +}; + +// --- localStorage helper --- + +type MockStorage = { [key: string]: string }; + +function createMockLocalStorage() { + const store: MockStorage = {}; + + return { + get length() { + return Object.keys(store).length; + }, + clear: jest.fn(() => { + Object.keys(store).forEach((key) => delete store[key]); + }), + getItem: jest.fn((key: string) => (key in store ? store[key] : null)), + setItem: jest.fn((key: string, value: string) => { + store[key] = String(value); + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + key: jest.fn((index: number) => { + const keys = Object.keys(store); + return keys[index] || null; + }), + _store: store, + } as Storage & { _store: MockStorage }; +} + +function setupWindowMocks() { + (global as any).window = { + open: jest.fn(), + location: { search: "", pathname: "/", hash: "" }, + history: { replaceState: jest.fn() }, + }; + (global as any).document = { title: "test" }; + (global as any).atob = (str: string) => Buffer.from(str, "base64").toString(); + (global as any).btoa = (str: string) => Buffer.from(str).toString("base64"); +} + +describe("SessionProvider", () => { + const originalLocalStorage = (global as any).localStorage; + const originalWindow = (global as any).window; + const originalDocument = (global as any).document; + let mockLocalStorage: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + mockRandomAddress.mockReturnValue(MOCK_PRIVATE_KEY); + mockSignerToGuid.mockReturnValue(MOCK_SESSION_KEY_GUID); + + mockLocalStorage = createMockLocalStorage(); + (global as any).localStorage = mockLocalStorage; + setupWindowMocks(); + }); + + afterEach(() => { + (global as any).localStorage = originalLocalStorage; + (global as any).window = originalWindow; + (global as any).document = originalDocument; + }); + + function createProvider( + overrides: Partial<{ + rpc: string; + chainId: string; + policies: any; + preset: string; + shouldOverridePresetPolicies: boolean; + redirectUrl: string; + keychainUrl: string; + apiUrl: string; + signupOptions: any; + }> = {}, + ) { + return new SessionProvider({ + rpc: MOCK_RPC, + chainId: MOCK_CHAIN_ID, + policies: { + contracts: { "0x1": { methods: [{ entrypoint: "attack" }] } }, + }, + redirectUrl: MOCK_REDIRECT, + ...overrides, + }); + } + + // ========================================== + // Constructor + // ========================================== + + describe("constructor", () => { + it("throws when neither policies nor preset provided", () => { + expect( + () => + new SessionProvider({ + rpc: MOCK_RPC, + chainId: MOCK_CHAIN_ID, + redirectUrl: MOCK_REDIRECT, + } as any), + ).toThrow("Either `policies` or `preset` must be provided"); + }); + }); + + // ========================================== + // validatePoliciesSubset + // ========================================== + + describe("validatePoliciesSubset", () => { + function callValidate( + provider: any, + newPolicies: ParsedSessionPolicies, + existingPolicies: ParsedSessionPolicies, + ): boolean { + return provider.validatePoliciesSubset(newPolicies, existingPolicies); + } + + it("returns true when new policies are a subset of stored policies", () => { + const provider = createProvider(); + const result = callValidate(provider, VALID_POLICIES, { + verified: false, + contracts: { + "0x1": { + methods: [ + { entrypoint: "attack", authorized: true }, + { entrypoint: "defend", authorized: true }, + ], + }, + }, + }); + expect(result).toBe(true); + }); + + it("returns false when new policies contain unknown address", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x999": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + VALID_POLICIES, + ); + expect(result).toBe(false); + }); + + it("returns false when new policies contain unknown method", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "unknown_method", authorized: true }], + }, + }, + }, + VALID_POLICIES, + ); + expect(result).toBe(false); + }); + + it("returns false when stored method has authorized: false", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + { + verified: false, + contracts: { + "0x1": { + methods: [{ entrypoint: "attack", authorized: false }], + }, + }, + }, + ); + expect(result).toBe(false); + }); + + it("returns true for matching message policies", () => { + const provider = createProvider(); + const messagePolicies: ParsedSessionPolicies = { + verified: false, + messages: [ + { + domain: { + name: "TestDomain", + version: "1", + chainId: MOCK_CHAIN_ID, + }, + types: { + StarknetDomain: [ + { name: "name", type: "shortstring" }, + { name: "version", type: "shortstring" }, + { name: "chainId", type: "shortstring" }, + ], + Message: [{ name: "content", type: "felt" }], + }, + primaryType: "Message", + authorized: true, + }, + ], + }; + + const result = callValidate(provider, messagePolicies, messagePolicies); + expect(result).toBe(true); + }); + + it("returns false when message is missing from stored policies", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + messages: [ + { + domain: { + name: "TestDomain", + version: "1", + chainId: MOCK_CHAIN_ID, + }, + types: { + StarknetDomain: [ + { name: "name", type: "shortstring" }, + { name: "version", type: "shortstring" }, + { name: "chainId", type: "shortstring" }, + ], + Message: [{ name: "content", type: "felt" }], + }, + primaryType: "Message", + authorized: true, + }, + ], + }, + { verified: false }, + ); + expect(result).toBe(false); + }); + + it("returns true when new policies have no contracts or messages", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { verified: false }, + VALID_POLICIES, + ); + expect(result).toBe(true); + }); + + it("returns false when new has contracts but stored has none", () => { + const provider = createProvider(); + const result = callValidate(provider, VALID_POLICIES, { + verified: false, + }); + expect(result).toBe(false); + }); + + it("does exact address matching (no normalization)", () => { + const provider = createProvider(); + const result = callValidate( + provider, + { + verified: false, + contracts: { + "0xABC": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + { + verified: false, + contracts: { + "0xabc": { + methods: [{ entrypoint: "attack", authorized: true }], + }, + }, + }, + ); + // Different casing = different key = not found + expect(result).toBe(false); + }); + }); + + // ========================================== + // ingestSessionFromRedirect + // ========================================== + + describe("ingestSessionFromRedirect", () => { + it("decodes valid base64 session and stores to localStorage", () => { + const provider = createProvider(); + const encoded = (global as any).btoa(JSON.stringify(VALID_SESSION)); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + + expect(result).toBeDefined(); + expect(result.username).toBe("testuser"); + expect(result.address).toBe("0xabc"); + + const stored = JSON.parse(mockLocalStorage.getItem("session")!); + expect(stored.username).toBe("testuser"); + }); + + it("pads base64 correctly when padding is stripped", () => { + const provider = createProvider(); + const encoded = (global as any) + .btoa(JSON.stringify(VALID_SESSION)) + .replace(/=+$/, ""); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + expect(result).toBeDefined(); + expect(result.username).toBe("testuser"); + }); + + it("returns undefined for malformed payloads without throwing", () => { + const provider = createProvider(); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + const result = (provider as any).ingestSessionFromRedirect( + "not-valid-base64!!!", + ); + expect(result).toBeUndefined(); + + consoleSpy.mockRestore(); + }); + + it("returns undefined when required fields are missing", () => { + const provider = createProvider(); + const incomplete = { username: "test" }; + const encoded = (global as any).btoa(JSON.stringify(incomplete)); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + expect(result).toBeUndefined(); + }); + + it("fills default guardianKeyGuid and metadataHash", () => { + const provider = createProvider(); + const sessionWithoutDefaults = { + username: "testuser", + address: "0xabc", + ownerGuid: "0xowner", + expiresAt: String(Math.floor(Date.now() / 1000) + 3600), + }; + const encoded = (global as any).btoa( + JSON.stringify(sessionWithoutDefaults), + ); + + const result = (provider as any).ingestSessionFromRedirect(encoded); + + expect(result.guardianKeyGuid).toBe("0x0"); + expect(result.metadataHash).toBe("0x0"); + }); + }); + + // ========================================== + // disconnect + // ========================================== + + describe("disconnect", () => { + it("removes only session keys from localStorage", async () => { + (global as any).window.open = jest.fn(() => null); + + const provider = createProvider(); + mockLocalStorage.setItem("session", JSON.stringify(VALID_SESSION)); + mockLocalStorage.setItem( + "sessionPolicies", + JSON.stringify(VALID_POLICIES), + ); + mockLocalStorage.setItem("lastUsedConnector", "controller_session"); + mockLocalStorage.setItem("unrelatedKey", "keep"); + + await provider.disconnect(); + + expect(mockLocalStorage.getItem("sessionSigner")).toBeNull(); + expect(mockLocalStorage.getItem("session")).toBeNull(); + expect(mockLocalStorage.getItem("sessionPolicies")).toBeNull(); + expect(mockLocalStorage.getItem("lastUsedConnector")).toBeNull(); + expect(mockLocalStorage.getItem("unrelatedKey")).toBe("keep"); + }); + }); +}); diff --git a/packages/controller/src/__tests__/wasm-integration.test.mjs b/packages/controller/src/__tests__/wasm-integration.test.mjs new file mode 100644 index 0000000000..d98f398375 --- /dev/null +++ b/packages/controller/src/__tests__/wasm-integration.test.mjs @@ -0,0 +1,490 @@ +/** + * WASM Integration Tests + * + * These tests exercise the real @cartridge/controller-wasm binary in Node.js + * to verify that policy conversion and session construction work end-to-end. + * + * Run with: node --experimental-wasm-modules --test packages/controller/src/__tests__/wasm-integration.test.mjs + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Resolve the WASM package via import.meta.resolve, then dynamic import. +// This handles pnpm's .pnpm store symlinks correctly. +const sessionWasmPath = import.meta.resolve( + "@cartridge/controller-wasm/session", +); +const { CartridgeSessionAccount, signerToGuid } = await import(sessionWasmPath); + +// --- Helpers --- + +/** Generate a valid 252-bit felt hex string from a seed */ +function felt(seed) { + return "0x" + seed.toString(16).padStart(64, "0"); +} + +/** Import starknet.js for getChecksumAddress and hash utilities */ +const starknet = await import("starknet"); +const { ec, stark, hash, getChecksumAddress, addAddressPadding } = starknet; + +/** Reproduce toWasmPolicies logic from src/utils.ts for test verification */ +function toWasmPolicies(parsedPolicies) { + const contractPolicies = Object.entries(parsedPolicies.contracts ?? {}) + .sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase())) + .flatMap(([target, { methods }]) => + [...methods] + .sort((a, b) => a.entrypoint.localeCompare(b.entrypoint)) + .map((m) => { + if (m.entrypoint === "approve" && m.spender && m.amount) { + return { + target: getChecksumAddress(target), + spender: m.spender, + amount: String(m.amount), + }; + } + return { + target: getChecksumAddress(target), + method: hash.getSelectorFromName(m.entrypoint), + authorized: !!m.authorized, + }; + }), + ); + + return contractPolicies; +} + +// --- Fixtures --- + +const MOCK_RPC = "https://api.cartridge.gg/x/starknet/mainnet/rpc/v0_9"; +const MOCK_CHAIN_ID = "0x534e5f4d41494e"; // SN_MAIN +const MOCK_PRIVATE_KEY = stark.randomAddress(); +const MOCK_ADDRESS = felt(0xabc123); +const MOCK_OWNER_GUID = felt(0xdef456); + +function makeSession(policies) { + const guid = signerToGuid({ starknet: { privateKey: MOCK_PRIVATE_KEY } }); + return { + expiresAt: Math.floor(Date.now() / 1000) + 3600, + policies, + guardianKeyGuid: "0x0", + metadataHash: "0x0", + sessionKeyGuid: guid, + }; +} + +// --- Tests --- + +describe("WASM: signerToGuid", () => { + it("returns a hex string for starknet signer", () => { + const guid = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + assert.ok(typeof guid === "string", "guid should be a string"); + assert.ok(guid.startsWith("0x"), "guid should start with 0x"); + assert.ok(guid.length > 2, "guid should be non-empty"); + }); + + it("is deterministic for the same key", () => { + const guid1 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + const guid2 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + assert.equal(guid1, guid2); + }); + + it("produces different guids for different keys", () => { + const guid1 = signerToGuid({ + starknet: { privateKey: MOCK_PRIVATE_KEY }, + }); + const guid2 = signerToGuid({ + starknet: { privateKey: stark.randomAddress() }, + }); + assert.notEqual(guid1, guid2); + }); +}); + +describe("WASM: CartridgeSessionAccount.newAsRegistered", () => { + it("constructs with a single CallPolicy", () => { + const policies = [ + { + target: getChecksumAddress(MOCK_ADDRESS), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct, "should return a session account"); + assert.equal(typeof acct.execute, "function"); + assert.equal(typeof acct.executeFromOutside, "function"); + acct.free(); + }); + + it("constructs with multiple CallPolicies across contracts", () => { + const addr1 = felt(0x111); + const addr2 = felt(0x222); + const policies = [ + { + target: getChecksumAddress(addr1), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(addr1), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + { + target: getChecksumAddress(addr2), + method: hash.getSelectorFromName("transfer"), + authorized: true, + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with an ApprovalPolicy", () => { + const tokenAddr = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; + const spenderAddr = felt(0x123456); + const policies = [ + { + target: getChecksumAddress(tokenAddr), + spender: getChecksumAddress(spenderAddr), + amount: "1000000000000000000", + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with mixed CallPolicy and ApprovalPolicy", () => { + const policies = [ + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x222)), + spender: getChecksumAddress(felt(0x333)), + amount: "500", + }, + ]; + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("constructs with empty policies array", () => { + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession([]), + ); + assert.ok(acct); + acct.free(); + }); +}); + +describe("WASM: toWasmPolicies round-trip", () => { + it("accepts output from toWasmPolicies with single contract", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [ + { entrypoint: "attack", authorized: true }, + { entrypoint: "defend", authorized: true }, + ], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("accepts output from toWasmPolicies with multiple contracts", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [{ entrypoint: "attack", authorized: true }], + }, + [felt(0x222)]: { + methods: [ + { entrypoint: "transfer", authorized: true }, + { entrypoint: "approve", authorized: true }, + ], + }, + [felt(0xaaa)]: { + methods: [{ entrypoint: "mint", authorized: true }], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + assert.ok(policies.length === 4, `expected 4 policies, got ${policies.length}`); + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); + + it("accepts output from toWasmPolicies with ApprovalPolicy", () => { + const parsed = { + verified: false, + contracts: { + [felt(0x111)]: { + methods: [ + { + entrypoint: "approve", + spender: getChecksumAddress(felt(0x999)), + amount: "1000000", + authorized: true, + }, + ], + }, + }, + }; + + const policies = toWasmPolicies(parsed); + // Should produce an ApprovalPolicy (has spender+amount, no method) + assert.ok(policies[0].spender, "should have spender field"); + assert.ok(policies[0].amount, "should have amount field"); + assert.ok(!policies[0].method, "should not have method field"); + + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies), + ); + assert.ok(acct); + acct.free(); + }); +}); + +describe("WASM: address normalization consistency", () => { + it("produces same session with differently-cased addresses", () => { + const addrLower = "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"; + const addrUpper = "0x049D36570D4E46F48E99674BD3FCC84644DDD6B96F7C741B1562B82F9E004DC7"; + const addrChecksum = getChecksumAddress(addrLower); + + // Both should normalize to the same checksum address + assert.equal(getChecksumAddress(addrLower), getChecksumAddress(addrUpper)); + + const makePolicies = (addr) => [ + { + target: getChecksumAddress(addr), + method: hash.getSelectorFromName("transfer"), + authorized: true, + }, + ]; + + // Both should construct without errors + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(addrLower)), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(addrUpper)), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); + + it("handles zero-padded vs short addresses", () => { + const shortAddr = "0x1"; + const paddedAddr = addAddressPadding(shortAddr); + + const makePolicies = (addr) => [ + { + target: getChecksumAddress(addr), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(shortAddr)), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(makePolicies(paddedAddr)), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); +}); + +describe("WASM: policy ordering stability", () => { + it("same policies in different insertion order produce valid sessions", () => { + const policies1 = [ + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x222)), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + ]; + + const policies2 = [ + { + target: getChecksumAddress(felt(0x222)), + method: hash.getSelectorFromName("defend"), + authorized: true, + }, + { + target: getChecksumAddress(felt(0x111)), + method: hash.getSelectorFromName("attack"), + authorized: true, + }, + ]; + + // Both orderings should be accepted + const acct1 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies1), + ); + const acct2 = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policies2), + ); + + assert.ok(acct1); + assert.ok(acct2); + acct1.free(); + acct2.free(); + }); + + it("toWasmPolicies canonical sort is stable across input orderings", () => { + const parsedA = { + verified: false, + contracts: { + [felt(0x222)]: { methods: [{ entrypoint: "defend", authorized: true }] }, + [felt(0x111)]: { methods: [{ entrypoint: "attack", authorized: true }] }, + }, + }; + + const parsedB = { + verified: false, + contracts: { + [felt(0x111)]: { methods: [{ entrypoint: "attack", authorized: true }] }, + [felt(0x222)]: { methods: [{ entrypoint: "defend", authorized: true }] }, + }, + }; + + const policiesA = toWasmPolicies(parsedA); + const policiesB = toWasmPolicies(parsedB); + + // Canonical sort should produce identical output + assert.deepEqual(policiesA, policiesB); + + // Both should be accepted by WASM + const acct = CartridgeSessionAccount.newAsRegistered( + MOCK_RPC, + MOCK_PRIVATE_KEY, + MOCK_ADDRESS, + MOCK_OWNER_GUID, + MOCK_CHAIN_ID, + makeSession(policiesA), + ); + assert.ok(acct); + acct.free(); + }); +});