diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e997639..ab163a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,50 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi + host-wasm: + name: '@parity/truapi-host-wasm' + runs-on: ubuntu-latest + needs: codegen + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install workspace deps + build @parity/truapi + run: | + npm ci --ignore-scripts + npm run build --prefix js/packages/truapi + + - name: Install wasm-pack + run: cargo install wasm-pack --locked + + - name: Build WASM + run: npm run build:wasm --prefix js/packages/truapi-host-wasm + + - name: Test + run: bun test + working-directory: js/packages/truapi-host-wasm + playground: name: Playground (build + lint) runs-on: ubuntu-latest @@ -279,7 +323,7 @@ jobs: name: CI Status if: always() runs-on: ubuntu-latest - needs: [rust, licenses, codegen, ts-client, playground, explorer, e2e] + needs: [rust, licenses, codegen, ts-client, host-wasm, playground, explorer, e2e] steps: - name: Check all jobs run: | @@ -288,6 +332,7 @@ jobs: "${{ needs.licenses.result }}" "${{ needs.codegen.result }}" "${{ needs.ts-client.result }}" + "${{ needs.host-wasm.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" "${{ needs.e2e.result }}" diff --git a/js/packages/truapi-host-wasm/package.json b/js/packages/truapi-host-wasm/package.json index c9125f87..7c120844 100644 --- a/js/packages/truapi-host-wasm/package.json +++ b/js/packages/truapi-host-wasm/package.json @@ -39,11 +39,11 @@ "test": "bun test" }, "dependencies": { - "@parity/truapi": "file:../truapi" + "@parity/truapi": "file:../truapi", + "neverthrow": "^8.2.0" }, "devDependencies": { "@types/bun": "^1.3.0", - "neverthrow": "^8.2.0", "typescript": "^5.7" } } diff --git a/js/packages/truapi-host-wasm/src/web/create-mock-host.test.ts b/js/packages/truapi-host-wasm/src/web/create-mock-host.test.ts new file mode 100644 index 00000000..5bcc97af --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-mock-host.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import type { CoreStorageKey } from "../generated/host-callbacks.js"; +import { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; +import { createWebWorkerProvider } from "./index.js"; + +describe("createMockHost callbacks", () => { + it("product storage round-trips and is namespaced from core", async () => { + const { callbacks } = createMockHost(); + await callbacks.write("k", new Uint8Array([1, 2, 3])); + expect(await callbacks.read("k")).toEqual(new Uint8Array([1, 2, 3])); + // A product key never collides with a core slot. + expect(await callbacks.readCoreStorage({ tag: "AuthSession" })).toBeUndefined(); + await callbacks.clear("k"); + expect(await callbacks.read("k")).toBeUndefined(); + }); + + it("core storage round-trips per slot", async () => { + const { callbacks } = createMockHost(); + const key: CoreStorageKey = { + tag: "PermissionAuthorization", + value: { storageKey: "cam" }, + }; + await callbacks.writeCoreStorage(key, new Uint8Array([9])); + expect(await callbacks.readCoreStorage(key)).toEqual(new Uint8Array([9])); + await callbacks.clearCoreStorage(key); + expect(await callbacks.readCoreStorage(key)).toBeUndefined(); + }); + + it("permissions follow per-capability policy", async () => { + const { callbacks } = createMockHost({ + devicePermissions: "allow-all", + remotePermissions: "deny-all", + }); + expect((await callbacks.devicePermission("Notifications")).granted).toBe(true); + expect((await callbacks.remotePermission({ permission: { tag: "WebRtc" } })).granted).toBe( + false, + ); + }); + + it("feature support and theme reflect config", async () => { + const { callbacks } = createMockHost({ featureSupported: false, theme: "Light" }); + expect( + ( + await callbacks.featureSupported({ + tag: "Chain", + value: { genesisHash: "0x00" }, + }) + ).supported, + ).toBe(false); + const theme = await callbacks.subscribeTheme()[Symbol.asyncIterator]().next(); + expect(theme.value).toEqual(ok("Light")); + }); + + it("records navigations and assigns monotonic notification ids", async () => { + const host = createMockHost(); + await host.callbacks.navigateTo("https://a"); + await host.callbacks.navigateTo("https://b"); + expect(host.navigations()).toEqual(["https://a", "https://b"]); + + const first = await host.callbacks.pushNotification({ text: "one" }); + const second = await host.callbacks.pushNotification({ text: "two" }); + expect([first.id, second.id]).toEqual([0, 1]); + expect(host.pushedNotifications().length).toBe(2); + }); + + it("confirms per config and records chain sends", async () => { + const denied = createMockHost({ confirmUserActions: false }); + expect( + await denied.callbacks.confirmUserAction?.({ + tag: "ResourceAllocation", + value: { resources: [] }, + }), + ).toBe(false); + + const host = createMockHost(); + const conn = await host.callbacks.connect(new Uint8Array(32)); + conn.send("rpc-1"); + expect(host.sentRpc()).toEqual(["rpc-1"]); + }); + + it("replays scripted chain frames", async () => { + const host = createMockHost({ chainResponses: ["f1", "f2"] }); + const conn = await host.callbacks.connect(new Uint8Array(32)); + const frames: string[] = []; + for await (const frame of conn.responses()) { + frames.push(frame); + } + expect(frames).toEqual(["f1", "f2"]); + }); + + it("records confirmations and cancelled notifications", async () => { + const host = createMockHost(); + await host.callbacks.confirmUserAction?.({ + tag: "ResourceAllocation", + value: { resources: [] }, + }); + expect(host.confirmations()).toEqual(["ResourceAllocation"]); + + const { id } = await host.callbacks.pushNotification({ text: "x" }); + await host.callbacks.cancelNotification?.(id); + expect(host.cancelledNotifications()).toEqual([id]); + }); + + it("chainClosed ends the response stream immediately", async () => { + const host = createMockHost({ chainClosed: true }); + const conn = await host.callbacks.connect(new Uint8Array(32)); + const first = await conn.responses()[Symbol.asyncIterator]().next(); + expect(first.done).toBe(true); + }); + + it("preimage submit then lookup round-trips", async () => { + const { callbacks } = createMockHost(); + const key = await callbacks.submitPreimage?.(new Uint8Array([4, 5, 6])); + expect(key).toBeDefined(); + const found = await callbacks.lookupPreimage(key!)[Symbol.asyncIterator]().next(); + expect(found.value).toEqual(ok(new Uint8Array([4, 5, 6]))); + }); +}); + +/** Minimal `Worker` stand-in: records posted messages and lets the test drive + * the `message` event by hand, so the provider initializes without real WASM. */ +class FakeWorker { + listeners = new Map void>>(); + messages: Record[] = []; + + addEventListener(name: string, fn: (event: unknown) => void) { + const set = this.listeners.get(name) ?? new Set(); + set.add(fn); + this.listeners.set(name, set); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: Record) { + this.messages.push(message); + } + + terminate() {} + + emit(message: Record) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } +} + +describe("createMockHost with createWebWorkerProvider", () => { + it("initializes a worker provider with the mock callbacks (no real WASM)", async () => { + const worker = new FakeWorker(); + const host = createMockHost(); + const providerPromise = createWebWorkerProvider( + worker as unknown as Worker, + host.callbacks, + { runtimeConfig: mockRuntimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + + const provider = await providerPromise; + expect(provider).toBeDefined(); + const init = worker.messages.find((message) => message.kind === "init"); + expect(init).toBeDefined(); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/web/create-mock-host.ts b/js/packages/truapi-host-wasm/src/web/create-mock-host.ts new file mode 100644 index 00000000..0e56534d --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-mock-host.ts @@ -0,0 +1,275 @@ +// A deterministic, in-memory mock host. `createMockHost` returns a complete +// `HostCallbacks` set (the JS sibling of `truapi-platform`'s `MockPlatform`) +// plus recordings for assertions. Hand `host.callbacks` to +// `createWebWorkerProvider` (or `createIframeHost`) to run the real +// truapi-server WASM core against a mocked OS seam: storage is in-memory, +// permissions answer from a fixed policy, navigation/notifications are +// recorded, and the chain connection is silent (or replays canned frames). +// +// Signing and login require a paired wallet answering over the statement-store +// channel; the default silent chain records outbound requests and never +// answers, so those flows park. Everything else (storage, permissions, +// features, theme, navigation, notifications, preimage) works without a wallet. + +import { ok } from "neverthrow"; + +import type { + GenericError, + HostPushNotificationRequest, + Result, + ThemeVariant, +} from "@parity/truapi"; + +import type { + AuthState, + CoreStorageKey, + HostCallbacks, + JsonRpcConnection, +} from "../generated/host-callbacks.js"; +import type { HostCoreRuntimeConfig } from "../runtime.js"; + +/** How the mock answers a permission prompt for one capability. */ +export type PermissionPolicy = "allow-all" | "deny-all"; + +/** Behavior knobs for {@link createMockHost}. */ +export interface MockHostConfig { + /** Answer for `devicePermission`. Default `"allow-all"`. */ + devicePermissions?: PermissionPolicy; + /** Answer for `remotePermission`. Default `"allow-all"`. */ + remotePermissions?: PermissionPolicy; + /** Whether `featureSupported` reports support. Default `true`. */ + featureSupported?: boolean; + /** Theme emitted by `subscribeTheme`. Default `"Dark"`. */ + theme?: ThemeVariant; + /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ + confirmUserActions?: boolean; + /** + * JSON-RPC response frames the chain connection replays, in order. Empty + * (the default) means a silent connection: it records outbound requests and + * never answers, so chain-dependent flows park. + */ + chainResponses?: string[]; + /** + * When `true`, the chain response stream ends immediately instead of parking, + * so disconnect/timeout paths can be asserted (fail-fast). Ignored when + * `chainResponses` is non-empty. + */ + chainClosed?: boolean; +} + +/** A mock host: the callbacks to wire into a provider, plus assertion oracles. */ +export interface MockHost { + /** Hand this to `createWebWorkerProvider` / `createIframeHost`. */ + callbacks: HostCallbacks; + /** URLs the core asked the host to open, in order. */ + navigations(): string[]; + /** Notifications the core asked the host to show, in order. */ + pushedNotifications(): HostPushNotificationRequest[]; + /** Raw JSON-RPC the core sent over the chain connection, in order. */ + sentRpc(): string[]; + /** Auth-state transitions the core emitted, in order. */ + authStates(): AuthState[]; + /** Confirmation kinds the core requested (review `tag`s), in order. */ + confirmations(): string[]; + /** Notification ids the core asked the host to cancel, in order. */ + cancelledNotifications(): number[]; +} + +/** Deterministic 8-byte key for a preimage value (FNV-1a), so `submitPreimage` + * then `lookupPreimage` round-trips without using the full value as its key. */ +function preimageKey(value: Uint8Array): Uint8Array { + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + const mask = 0xffffffffffffffffn; + for (const byte of value) { + hash = ((hash ^ BigInt(byte)) * prime) & mask; + } + const key = new Uint8Array(8); + for (let i = 0; i < 8; i++) { + key[i] = Number((hash >> BigInt(8 * i)) & 0xffn); + } + return key; +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Build an in-memory mock host. The returned `callbacks` implement every + * `HostCallbacks` capability; the accessor methods expose what the core did. + */ +export function createMockHost(config: MockHostConfig = {}): MockHost { + const { + devicePermissions = "allow-all", + remotePermissions = "allow-all", + featureSupported = true, + theme = "Dark", + confirmUserActions = true, + chainResponses = [], + chainClosed = false, + } = config; + + const storage = new Map(); + const preimages = new Map(); + const navigations: string[] = []; + const pushedNotifications: HostPushNotificationRequest[] = []; + const sentRpc: string[] = []; + const authStates: AuthState[] = []; + const confirmations: string[] = []; + const cancelledNotifications: number[] = []; + let nextNotificationId = 0; + + // Product keys are namespaced from core slots so neither can shadow the other. + // This in-JS key scheme is internal and independent from the Rust MockPlatform's + // (state never crosses the boundary), so the two need not match byte-for-byte. + const productKey = (key: string): string => `product:${key}`; + const coreKey = (key: CoreStorageKey): string => + key.tag === "PermissionAuthorization" + ? `core:permission:${key.value.storageKey}` + : `core:${key.tag}`; + const granted = (policy: PermissionPolicy): boolean => policy === "allow-all"; + + const callbacks: HostCallbacks = { + // ProductStorage + async read(key) { + return storage.get(productKey(key)); + }, + async write(key, value) { + storage.set(productKey(key), value); + }, + async clear(key) { + storage.delete(productKey(key)); + }, + + // CoreStorage + async readCoreStorage(key) { + return storage.get(coreKey(key)); + }, + async writeCoreStorage(key, value) { + storage.set(coreKey(key), value); + }, + async clearCoreStorage(key) { + storage.delete(coreKey(key)); + }, + + // Navigation + async navigateTo(url) { + navigations.push(url); + }, + + // Notifications + async pushNotification(notification) { + pushedNotifications.push(notification); + return { id: nextNotificationId++ }; + }, + async cancelNotification(id) { + cancelledNotifications.push(id); + }, + + // Permissions + async devicePermission() { + return { granted: granted(devicePermissions) }; + }, + async remotePermission() { + return { granted: granted(remotePermissions) }; + }, + + // Features + async featureSupported() { + return { supported: featureSupported }; + }, + + // ChainProvider + async connect(): Promise { + return { + send(request) { + sentRpc.push(request); + }, + async *responses(): AsyncGenerator { + for (const frame of chainResponses) { + yield frame; + } + if (chainResponses.length === 0 && !chainClosed) { + // Silent: never yields, so chain-dependent flows park. `chainClosed` + // instead ends the stream here for fail-fast disconnect tests. + await new Promise(() => {}); + } + }, + }; + }, + + // AuthPresenter + authStateChanged(state) { + authStates.push(state); + }, + + // UserConfirmation + async confirmUserAction(review) { + confirmations.push(review.tag); + return confirmUserActions; + }, + + // ThemeHost + async *subscribeTheme(): AsyncGenerator< + Result + > { + yield ok(theme); + // A live subscription never ends: emit the current theme, then stay open. + await new Promise(() => {}); + }, + + // PreimageHost + async submitPreimage(value) { + const key = preimageKey(value); + preimages.set(hex(key), value); + return key; + }, + async *lookupPreimage( + key, + ): AsyncGenerator> { + yield ok(preimages.get(hex(key))); + // Stay open for future updates (none, in the mock). + await new Promise(() => {}); + }, + }; + + return { + callbacks, + navigations: () => [...navigations], + pushedNotifications: () => [...pushedNotifications], + sentRpc: () => [...sentRpc], + authStates: () => [...authStates], + confirmations: () => [...confirmations], + cancelledNotifications: () => [...cancelledNotifications], + }; +} + +/** + * A default {@link HostCoreRuntimeConfig} for a mock host. Override any field; + * the genesis hash and product id are placeholders suitable for tests. + */ +export function mockRuntimeConfig( + overrides: Partial = {}, +): HostCoreRuntimeConfig { + return { + productId: "mock.product", + host: { + name: "Mock Host", + icon: "https://example.invalid/mock.png", + version: "0.0.0", + }, + platform: { + type: "node", + version: "0", + }, + people: { + genesisHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} diff --git a/js/packages/truapi-host-wasm/src/web/index.ts b/js/packages/truapi-host-wasm/src/web/index.ts index 430c7098..d8282ceb 100644 --- a/js/packages/truapi-host-wasm/src/web/index.ts +++ b/js/packages/truapi-host-wasm/src/web/index.ts @@ -5,3 +5,5 @@ export type { WebWorkerHostCallbacks, } from "./create-worker-host-runtime.js"; export { createWebWorkerProvider } from "./create-worker-host-runtime.js"; +export { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; +export type { MockHost, MockHostConfig, PermissionPolicy } from "./create-mock-host.js"; diff --git a/js/packages/truapi-host-wasm/src/web/wasm-bridge.test.ts b/js/packages/truapi-host-wasm/src/web/wasm-bridge.test.ts new file mode 100644 index 00000000..bd081b6a --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/wasm-bridge.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; + +import { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; + +// Drives the REAL truapi-server WASM core against createMockHost's callbacks — +// headless, no browser, no worker — to prove the JS↔SCALE↔WASM callback bridge. +// Requires the built WASM artifact (`npm run build:wasm` / `make wasm`); skipped +// when it is absent so a plain `bun test` on a fresh checkout stays green. The +// `host-wasm` CI job builds the WASM and runs this suite. +const wasmUrl = new URL("../../dist/wasm/web/truapi_server_bg.wasm", import.meta.url); +const glueUrl = new URL("../../dist/wasm/web/truapi_server.js", import.meta.url); +const built = existsSync(wasmUrl); + +const suite = built ? describe : describe.skip; + +suite("real WASM core ↔ createMockHost bridge", () => { + it("the core invokes createMockHost callbacks across the JS↔SCALE↔WASM boundary", async () => { + const { initSync, WasmHostCore } = await import(glueUrl.href); + const { createWasmRawCallbacks } = await import("../generated/host-callbacks-adapter.js"); + initSync({ module: readFileSync(wasmUrl) }); + + const mock = createMockHost(); + const invoked: string[] = []; + const readCoreStorage = mock.callbacks.readCoreStorage.bind(mock.callbacks); + mock.callbacks.readCoreStorage = async (key) => { + invoked.push(`readCoreStorage:${key.tag}`); + return readCoreStorage(key); + }; + + const raw = createWasmRawCallbacks(mock.callbacks); + // The core emits response frames through `emitFrame`; the worker sets it + // outside the generated adapter, so the harness supplies it too. + (raw as unknown as { emitFrame: (bytes: Uint8Array) => void }).emitFrame = () => {}; + new WasmHostCore(raw, mockRuntimeConfig()); + // The real core reads its auth session on startup, which crosses the bridge + // into the mock's readCoreStorage with a SCALE-decoded CoreStorageKey. + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(invoked.some((c) => c.startsWith("readCoreStorage:"))).toBe(true); + }); +}); diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index dea02704..12039475 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true description = "Platform capability traits for TrUAPI host implementations" license = "MIT" +[features] +# In-memory `MockPlatform` for tests and host simulators. +mock = [] + [dependencies] truapi = { path = "../truapi" } async-trait = "0.1" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 566c076f..48ccfab0 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -26,6 +26,10 @@ use truapi::latest::{ }; use url::Url; +/// In-memory mock platform for tests and host simulators (enable the `mock` feature). +#[cfg(feature = "mock")] +pub mod mock; + /// Static runtime configuration supplied by the embedding host before the /// core handles product-scoped calls. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs new file mode 100644 index 00000000..26a76391 --- /dev/null +++ b/rust/crates/truapi-platform/src/mock.rs @@ -0,0 +1,868 @@ +//! In-memory mock [`Platform`](crate::Platform) for tests and host simulators. +//! +//! `MockPlatform` implements every capability trait with deterministic, +//! configurable behavior and no OS, device, or network dependency: storage is +//! an in-memory map, permission prompts answer from a fixed per-capability +//! policy (no UI), navigation and notifications are recorded, and chain access +//! returns a configurable connection. Because the protocol logic lives in +//! `truapi-server`, a `MockPlatform` wired into the core yields a faithful host +//! whose only mocked surface is the OS-primitive seam. +//! +//! Behavior is a [`MockConfig`] read on every call: per-capability permission +//! policy, feature support, theme, confirmation answer, [`ChainBehavior`], and +//! [`MockFaults`] error injection. Recordings (`navigations`, +//! `pushed_notifications`, `confirmations`, `auth_states`, `sent_rpc`, …) are +//! the test oracles. +//! +//! Signing and login require a paired wallet answering over the statement-store +//! channel. With [`ChainBehavior::Silent`] the chain connection records +//! outbound requests and never answers, so those flows park; use +//! [`ChainBehavior::Scripted`] to feed canned response frames. + +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::stream::{self, BoxStream}; + +use truapi::v01; + +use crate::async_trait; +use crate::{ + AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, + JsonRpcConnection, Navigation, Notifications, Permissions, PreimageHost, ProductStorage, + ThemeHost, UserConfirmation, UserConfirmationReview, +}; + +/// How the mock answers a permission prompt for one capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PermissionPolicy { + /// Grant without prompting. + #[default] + AllowAll, + /// Deny. + DenyAll, +} + +impl PermissionPolicy { + fn granted(self) -> bool { + matches!(self, PermissionPolicy::AllowAll) + } +} + +/// The kind of action the core asked the host to confirm. Recorded on every +/// `confirm_user_action` so tests can assert what the core tried to do +/// (e.g. that a sign-payload review fired) even when the chain parks afterward. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmKind { + /// [`UserConfirmationReview::SignPayload`]. + SignPayload, + /// [`UserConfirmationReview::SignRaw`]. + SignRaw, + /// [`UserConfirmationReview::CreateTransaction`]. + CreateTransaction, + /// [`UserConfirmationReview::AccountAlias`]. + AccountAlias, + /// [`UserConfirmationReview::ResourceAllocation`]. + ResourceAllocation, + /// [`UserConfirmationReview::PreimageSubmit`]. + PreimageSubmit, +} + +impl ConfirmKind { + fn of(review: &UserConfirmationReview) -> Self { + match review { + UserConfirmationReview::SignPayload(_) => ConfirmKind::SignPayload, + UserConfirmationReview::SignRaw(_) => ConfirmKind::SignRaw, + UserConfirmationReview::CreateTransaction(_) => ConfirmKind::CreateTransaction, + UserConfirmationReview::AccountAlias(_) => ConfirmKind::AccountAlias, + UserConfirmationReview::ResourceAllocation(_) => ConfirmKind::ResourceAllocation, + UserConfirmationReview::PreimageSubmit(_) => ConfirmKind::PreimageSubmit, + } + } +} + +/// How the mock's chain connection behaves. +#[derive(Debug, Clone, Default)] +pub enum ChainBehavior { + /// Record outbound requests, never answer. Chain-dependent flows (login, + /// signing, statement store) park rather than complete, so drive any test + /// that reaches them under a timeout; use [`ChainBehavior::Closed`] to make + /// a disconnect observable instead. + #[default] + Silent, + /// Record outbound requests and replay these response frames in order, + /// then end the stream. + Scripted(Vec), + /// Record outbound requests; the response stream ends immediately, so + /// disconnect/timeout paths can be asserted (fail-fast) rather than parked. + Closed, + /// `connect` fails with this reason. + ConnectError(String), +} + +/// Optional error injection. When a field is `Some`, the matching host call +/// returns that error instead of succeeding, exercising the core's +/// error-handling paths. +#[derive(Debug, Clone, Default)] +pub struct MockFaults { + /// Product and core storage reads/writes/clears fail with this reason. + pub storage_error: Option, + /// `navigate_to` fails with this reason. + pub navigate_error: Option, + /// `push_notification` fails with this reason. + pub notification_error: Option, + /// `submit_preimage` fails with this reason. + pub preimage_submit_error: Option, +} + +/// Behavior knobs for [`MockPlatform`], read on every call. +#[derive(Debug, Clone)] +pub struct MockConfig { + /// Answer for `device_permission`. + pub device_permissions: PermissionPolicy, + /// Answer for `remote_permission`. + pub remote_permissions: PermissionPolicy, + /// Whether `feature_supported` reports support. + pub feature_supported: bool, + /// Theme emitted by `subscribe_theme`. + pub theme: v01::ThemeVariant, + /// Whether `confirm_user_action` confirms reviewed actions. + pub confirm_user_actions: bool, + /// Chain connection behavior. + pub chain: ChainBehavior, + /// Error injection. + pub faults: MockFaults, +} + +impl Default for MockConfig { + fn default() -> Self { + Self { + device_permissions: PermissionPolicy::AllowAll, + remote_permissions: PermissionPolicy::AllowAll, + feature_supported: true, + theme: v01::ThemeVariant::Dark, + confirm_user_actions: true, + chain: ChainBehavior::Silent, + faults: MockFaults::default(), + } + } +} + +/// In-memory mock host platform. Cheap to `clone`; clones share recordings and +/// storage (state is `Arc`ed), so a recording made through one clone is visible +/// through another. +#[derive(Clone)] +pub struct MockPlatform { + config: Arc, + storage: Arc>>>, + preimages: Arc, Vec>>>, + navigations: Arc>>, + notifications: Arc>>, + cancelled_notifications: Arc>>, + confirmations: Arc>>, + auth_states: Arc>>, + sent_rpc: Arc>>, + next_notification_id: Arc, +} + +impl Default for MockPlatform { + fn default() -> Self { + Self::new() + } +} + +impl MockPlatform { + /// Build a mock platform with default behavior (allow-all permissions, + /// feature support on, dark theme, auto-confirm, silent chain, no faults). + pub fn new() -> Self { + Self::with_config(MockConfig::default()) + } + + /// Build a mock platform with explicit behavior. + pub fn with_config(config: MockConfig) -> Self { + Self { + config: Arc::new(config), + storage: Arc::new(Mutex::new(HashMap::new())), + preimages: Arc::new(Mutex::new(HashMap::new())), + navigations: Arc::new(Mutex::new(Vec::new())), + notifications: Arc::new(Mutex::new(Vec::new())), + cancelled_notifications: Arc::new(Mutex::new(Vec::new())), + confirmations: Arc::new(Mutex::new(Vec::new())), + auth_states: Arc::new(Mutex::new(Vec::new())), + sent_rpc: Arc::new(Mutex::new(Vec::new())), + next_notification_id: Arc::new(AtomicU32::new(0)), + } + } + + /// URLs the core asked the host to open, in order. + pub fn navigations(&self) -> Vec { + self.navigations + .lock() + .expect("navigations poisoned") + .clone() + } + + /// Notifications the core asked the host to show, in order. + pub fn pushed_notifications(&self) -> Vec { + self.notifications + .lock() + .expect("notifications poisoned") + .clone() + } + + /// Notification ids the core asked the host to cancel, in order. + pub fn cancelled_notifications(&self) -> Vec { + self.cancelled_notifications + .lock() + .expect("cancellations poisoned") + .clone() + } + + /// Confirmation kinds the core requested, in order. + pub fn confirmations(&self) -> Vec { + self.confirmations + .lock() + .expect("confirmations poisoned") + .clone() + } + + /// Auth state transitions the core emitted, in order. + pub fn auth_states(&self) -> Vec { + self.auth_states + .lock() + .expect("auth states poisoned") + .clone() + } + + /// Raw JSON-RPC requests the core sent over the chain connection. + pub fn sent_rpc(&self) -> Vec { + self.sent_rpc.lock().expect("sent rpc poisoned").clone() + } +} + +/// Product keys are namespaced from core slots so neither can shadow the other. +fn product_key(key: &str) -> String { + format!("product:{key}") +} + +/// Stable string key for a typed core-storage slot. +fn core_key(key: &CoreStorageKey) -> String { + match key { + CoreStorageKey::AuthSession => "core:auth-session".to_string(), + CoreStorageKey::PairingDeviceIdentity => "core:pairing-device-identity".to_string(), + CoreStorageKey::PermissionAuthorization { + product_id, + request, + } => format!("core:permission:{product_id}:{request:?}"), + } +} + +/// Deterministic short key for a preimage value, so `submit` then `lookup` +/// round-trips without storing the full value as its own key. +fn preimage_key(value: &[u8]) -> Vec { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish().to_le_bytes().to_vec() +} + +#[async_trait] +impl ProductStorage for MockPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + Ok(self + .storage + .lock() + .expect("storage poisoned") + .get(&product_key(&key)) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .insert(product_key(&key), value); + Ok(()) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .remove(&product_key(&key)); + Ok(()) + } +} + +#[async_trait] +impl CoreStorage for MockPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::GenericError { + reason: reason.clone(), + }); + } + Ok(self + .storage + .lock() + .expect("storage poisoned") + .get(&core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::GenericError { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .insert(core_key(&key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(v01::GenericError { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .remove(&core_key(&key)); + Ok(()) + } +} + +#[async_trait] +impl Navigation for MockPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + if let Some(reason) = &self.config.faults.navigate_error { + return Err(v01::HostNavigateToError::Unknown { + reason: reason.clone(), + }); + } + self.navigations + .lock() + .expect("navigations poisoned") + .push(url); + Ok(()) + } +} + +#[async_trait] +impl Notifications for MockPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + if let Some(reason) = &self.config.faults.notification_error { + return Err(v01::GenericError { + reason: reason.clone(), + }); + } + self.notifications + .lock() + .expect("notifications poisoned") + .push(notification); + let id = self.next_notification_id.fetch_add(1, Ordering::SeqCst); + Ok(v01::HostPushNotificationResponse { id }) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + self.cancelled_notifications + .lock() + .expect("cancellations poisoned") + .push(id); + Ok(()) + } +} + +#[async_trait] +impl Permissions for MockPlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { + granted: self.config.device_permissions.granted(), + }) + } + + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { + granted: self.config.remote_permissions.granted(), + }) + } +} + +#[async_trait] +impl Features for MockPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { + supported: self.config.feature_supported, + }) + } +} + +/// A configurable chain connection: records outbound requests, and either +/// stays silent (`responses` `None`) or replays canned frames. +struct MockConnection { + sent: Arc>>, + responses: Option>, +} + +impl JsonRpcConnection for MockConnection { + fn send(&self, request: String) { + self.sent.lock().expect("sent rpc poisoned").push(request); + } + + fn responses(&self) -> BoxStream<'static, String> { + match &self.responses { + None => Box::pin(stream::pending()), + Some(frames) => Box::pin(stream::iter(frames.clone())), + } + } + + fn close(&self) {} +} + +#[async_trait] +impl ChainProvider for MockPlatform { + async fn connect( + &self, + _genesis_hash: Vec, + ) -> Result, v01::GenericError> { + match &self.config.chain { + ChainBehavior::ConnectError(reason) => Err(v01::GenericError { + reason: reason.clone(), + }), + ChainBehavior::Silent => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: None, + })), + ChainBehavior::Scripted(frames) => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: Some(frames.clone()), + })), + ChainBehavior::Closed => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: Some(Vec::new()), + })), + } + } +} + +impl AuthPresenter for MockPlatform { + fn auth_state_changed(&self, state: AuthState) { + self.auth_states + .lock() + .expect("auth states poisoned") + .push(state); + } +} + +#[async_trait] +impl UserConfirmation for MockPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + self.confirmations + .lock() + .expect("confirmations poisoned") + .push(ConfirmKind::of(&review)); + Ok(self.config.confirm_user_actions) + } +} + +impl ThemeHost for MockPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + let theme = self.config.theme; + // Emit the current theme, then stay open (a live subscription never + // ends), matching the real host contract. + Box::pin( + stream::once(async move { Ok::(theme) }).chain( + stream::pending::>(), + ), + ) + } +} + +#[async_trait] +impl PreimageHost for MockPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + if let Some(reason) = &self.config.faults.preimage_submit_error { + return Err(v01::PreimageSubmitError::Unknown { + reason: reason.clone(), + }); + } + let key = preimage_key(&value); + self.preimages + .lock() + .expect("preimages poisoned") + .insert(key.clone(), value); + Ok(key) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + let found = self + .preimages + .lock() + .expect("preimages poisoned") + .get(&key) + .cloned(); + // Emit the current value/miss, then stay open — a live subscription that + // never ends, matching `subscribe_theme`, the trait doc, and the JS mock. + Box::pin( + stream::once(async move { Ok(found) }) + .chain(stream::pending::>, v01::GenericError>>()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + use futures::executor::block_on; + + fn resource_review() -> UserConfirmationReview { + UserConfirmationReview::ResourceAllocation(v01::HostRequestResourceAllocationRequest { + resources: vec![], + }) + } + + #[test] + fn implements_platform() { + fn assert_platform(_: &P) {} + assert_platform(&MockPlatform::new()); + } + + #[test] + fn product_storage_round_trips_and_is_namespaced() { + let p = MockPlatform::new(); + block_on(p.write("k".into(), vec![1, 2, 3])).unwrap(); + assert_eq!(block_on(p.read("k".into())).unwrap(), Some(vec![1, 2, 3])); + // A product key never collides with a core slot. + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + None + ); + block_on(p.clear("k".into())).unwrap(); + assert_eq!(block_on(p.read("k".into())).unwrap(), None); + } + + #[test] + fn core_storage_round_trips() { + let p = MockPlatform::new(); + block_on(p.write_core_storage(CoreStorageKey::AuthSession, vec![7])).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + Some(vec![7]) + ); + block_on(p.clear_core_storage(CoreStorageKey::AuthSession)).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + None + ); + } + + #[test] + fn core_and_product_keys_do_not_collide() { + let p = MockPlatform::new(); + block_on(p.write_core_storage(CoreStorageKey::AuthSession, vec![1])).unwrap(); + // Reading the same logical name as a product key must miss the core slot. + assert_eq!(block_on(p.read("auth-session".into())).unwrap(), None); + assert_eq!(block_on(p.read("core:auth-session".into())).unwrap(), None); + // ...and a product key must not be visible through core storage. + block_on(p.write("x".into(), vec![2])).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::PairingDeviceIdentity)).unwrap(), + None + ); + } + + #[test] + fn permissions_deny_all_denies_device_and_remote() { + let p = MockPlatform::with_config(MockConfig { + device_permissions: PermissionPolicy::DenyAll, + remote_permissions: PermissionPolicy::DenyAll, + ..Default::default() + }); + assert!( + !block_on(p.device_permission(v01::HostDevicePermissionRequest::Notifications)) + .unwrap() + .granted + ); + assert!( + !block_on(p.remote_permission(v01::RemotePermissionRequest { + permission: v01::RemotePermission::WebRtc + })) + .unwrap() + .granted + ); + } + + #[test] + fn permissions_split_allows_device_denies_remote() { + let p = MockPlatform::with_config(MockConfig { + device_permissions: PermissionPolicy::AllowAll, + remote_permissions: PermissionPolicy::DenyAll, + ..Default::default() + }); + assert!( + block_on(p.device_permission(v01::HostDevicePermissionRequest::Notifications)) + .unwrap() + .granted + ); + assert!( + !block_on(p.remote_permission(v01::RemotePermissionRequest { + permission: v01::RemotePermission::WebRtc + })) + .unwrap() + .granted + ); + } + + #[test] + fn navigation_records_and_can_error() { + let p = MockPlatform::new(); + block_on(p.navigate_to("a".into())).unwrap(); + block_on(p.navigate_to("b".into())).unwrap(); + assert_eq!(p.navigations(), vec!["a".to_string(), "b".to_string()]); + + let p2 = MockPlatform::with_config(MockConfig { + faults: MockFaults { + navigate_error: Some("blocked".into()), + ..Default::default() + }, + ..Default::default() + }); + assert!(block_on(p2.navigate_to("c".into())).is_err()); + } + + #[test] + fn notifications_record_order_with_unique_ids() { + let p = MockPlatform::new(); + let make = |text: &str| v01::HostPushNotificationRequest { + text: text.to_string(), + deeplink: None, + scheduled_at: None, + }; + let id0 = block_on(p.push_notification(make("one"))).unwrap().id; + let id1 = block_on(p.push_notification(make("two"))).unwrap().id; + assert_eq!((id0, id1), (0, 1)); + assert_eq!(p.pushed_notifications().len(), 2); + block_on(p.cancel_notification(id1)).unwrap(); + assert_eq!(p.cancelled_notifications(), vec![1u32]); + } + + #[test] + fn notification_error_injected() { + let p = MockPlatform::with_config(MockConfig { + faults: MockFaults { + notification_error: Some("denied".into()), + ..Default::default() + }, + ..Default::default() + }); + let request = v01::HostPushNotificationRequest { + text: "x".into(), + deeplink: None, + scheduled_at: None, + }; + assert!(block_on(p.push_notification(request)).is_err()); + } + + #[test] + fn storage_error_injected() { + let p = MockPlatform::with_config(MockConfig { + faults: MockFaults { + storage_error: Some("disk".into()), + ..Default::default() + }, + ..Default::default() + }); + assert!(block_on(p.read("k".into())).is_err()); + assert!(block_on(p.read_core_storage(CoreStorageKey::AuthSession)).is_err()); + } + + #[test] + fn confirm_records_kind_and_answers() { + let p = MockPlatform::new(); + assert!(block_on(p.confirm_user_action(resource_review())).unwrap()); + assert_eq!(p.confirmations(), vec![ConfirmKind::ResourceAllocation]); + + let p2 = MockPlatform::with_config(MockConfig { + confirm_user_actions: false, + ..Default::default() + }); + assert!(!block_on(p2.confirm_user_action(resource_review())).unwrap()); + } + + #[test] + fn feature_supported_reflects_config() { + let p = MockPlatform::with_config(MockConfig { + feature_supported: false, + ..Default::default() + }); + let response = block_on( + p.feature_supported(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0; 32], + }), + ) + .unwrap(); + assert!(!response.supported); + } + + #[test] + fn theme_emits_configured_variant_then_stays_open() { + let p = MockPlatform::new(); + let mut stream = p.subscribe_theme(); + assert_eq!( + block_on(stream.next()).unwrap().unwrap(), + v01::ThemeVariant::Dark + ); + // A live subscription does not end after the current value. + assert!(stream.next().now_or_never().is_none()); + } + + #[test] + fn theme_emits_configured_light() { + let p = MockPlatform::with_config(MockConfig { + theme: v01::ThemeVariant::Light, + ..Default::default() + }); + assert_eq!( + block_on(p.subscribe_theme().next()).unwrap().unwrap(), + v01::ThemeVariant::Light + ); + } + + #[test] + fn preimage_submit_then_lookup_round_trips() { + let p = MockPlatform::new(); + let key = block_on(p.submit_preimage(vec![1, 2, 3])).unwrap(); + let found = block_on(p.lookup_preimage(key).next()).unwrap().unwrap(); + assert_eq!(found, Some(vec![1, 2, 3])); + // An unknown key misses. + let miss = block_on(p.lookup_preimage(vec![9, 9, 9, 9, 9, 9, 9, 9]).next()) + .unwrap() + .unwrap(); + assert_eq!(miss, None); + } + + #[test] + fn preimage_submit_error_injected() { + let p = MockPlatform::with_config(MockConfig { + faults: MockFaults { + preimage_submit_error: Some("nope".into()), + ..Default::default() + }, + ..Default::default() + }); + assert!(block_on(p.submit_preimage(vec![1])).is_err()); + } + + #[test] + fn chain_silent_records_sends_and_parks() { + let p = MockPlatform::new(); + let conn = block_on(p.connect(vec![0u8; 32])).unwrap(); + conn.send("req-1".to_string()); + assert_eq!(p.sent_rpc(), vec!["req-1".to_string()]); + // Silent: the response stream never yields (parks rather than ends). + assert!(conn.responses().next().now_or_never().is_none()); + } + + #[test] + fn chain_scripted_replays_frames() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::Scripted(vec!["frame-1".into(), "frame-2".into()]), + ..Default::default() + }); + let conn = block_on(p.connect(vec![0u8; 32])).unwrap(); + let frames: Vec = block_on(conn.responses().collect()); + assert_eq!(frames, vec!["frame-1".to_string(), "frame-2".to_string()]); + } + + #[test] + fn chain_closed_ends_stream_immediately() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::Closed, + ..Default::default() + }); + let conn = block_on(p.connect(vec![0u8; 32])).unwrap(); + // Closed ends at once (None), so disconnect paths fail fast instead of + // parking like Silent. + assert!(block_on(conn.responses().next()).is_none()); + } + + #[test] + fn chain_connect_error() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::ConnectError("offline".into()), + ..Default::default() + }); + assert!(block_on(p.connect(vec![0u8; 32])).is_err()); + } + + #[test] + fn auth_states_record_in_order() { + let p = MockPlatform::new(); + p.auth_state_changed(AuthState::Disconnected); + p.auth_state_changed(AuthState::Pairing { + deeplink: "dl".into(), + }); + assert_eq!(p.auth_states().len(), 2); + } + + #[test] + fn clone_shares_recordings() { + let p = MockPlatform::new(); + let clone = p.clone(); + block_on(clone.navigate_to("z".into())).unwrap(); + assert_eq!(p.navigations(), vec!["z".to_string()]); + } +} diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 6cecb5f6..e6e40b81 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -43,6 +43,11 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["r [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } +[dev-dependencies] +# Test-only: the canonical config-driven mock platform. Kept in dev-dependencies +# so the `mock` feature never enters the default (production WASM) build. +truapi-platform = { path = "../truapi-platform", features = ["mock"] } + [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" subxt-rpcs = { version = "0.50.1", default-features = false, features = ["web"] } diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs index 16a4c373..d51e01e3 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -346,6 +346,52 @@ mod tests { assert_eq!(response.payload.value, vec![0x00, 0x00, 0x01]); } + /// The canonical config-driven `MockPlatform` drives the real core + /// end-to-end: a configured answer flows through the production dispatcher + /// and out the wire, proving the mock is faithful by construction rather + /// than merely trait-complete. + #[test] + fn from_mock_platform_dispatches_configured_feature_supported() { + use truapi_platform::mock::{MockConfig, MockPlatform}; + + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let encoded = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + } + .encode(); + + let dispatch = |platform: MockPlatform| { + let core = TrUApiCore::from_platform_with_config( + Arc::new(platform), + runtime_config("dotli.dot"), + test_spawner(), + ); + let response_bytes = core + .receive_from_product(&encoded) + .expect("dispatcher should emit a response"); + let response = + ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.payload.id, ids.response_id); + response.payload.value + }; + + // Default mock supports the feature: [Ok 0x00][V1 0x00][supported=1]. + assert_eq!(dispatch(MockPlatform::new()), vec![0x00, 0x00, 0x01]); + // A configured "unsupported" answer flows through the same dispatcher. + let unsupported = MockPlatform::with_config(MockConfig { + feature_supported: false, + ..Default::default() + }); + assert_eq!(dispatch(unsupported), vec![0x00, 0x00, 0x00]); + } + /// Drive a request frame through `TrUApiCore::receive_from_product`, /// decode the response envelope, and return its payload bytes (without /// the wrapping ProtocolMessage). Shared by the runtime-delegation @@ -376,6 +422,144 @@ mod tests { ) } + fn make_mock_core(config: truapi_platform::mock::MockConfig) -> TrUApiCore { + TrUApiCore::from_platform_with_config( + Arc::new(truapi_platform::mock::MockPlatform::with_config(config)), + runtime_config("dotli.dot"), + test_spawner(), + ) + } + + /// MockPlatform product storage round-trips through the real dispatcher: + /// a value written over the wire reads back, then misses after clear. + /// Proves the seam works end-to-end, not just in the mock's own unit tests. + #[test] + fn from_mock_platform_storage_round_trips_through_core() { + let core = make_mock_core(truapi_platform::mock::MockConfig::default()); + + let write = HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { + key: "k".into(), + value: vec![1, 2, 3], + }); + // Ok 0x00, V1 0x00. + assert_eq!( + run_request(&core, "local_storage_write", write.encode()), + vec![0x00, 0x00] + ); + + let read = + HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key: "k".into() }); + // Ok 0x00, V1 0x00, Some 0x01, compact-len(3) 0x0c, bytes. + assert_eq!( + run_request(&core, "local_storage_read", read.encode()), + vec![0x00, 0x00, 0x01, 0x0c, 1, 2, 3] + ); + + let clear = + HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key: "k".into() }); + assert_eq!( + run_request(&core, "local_storage_clear", clear.encode()), + vec![0x00, 0x00] + ); + + // After clear the read misses: Ok 0x00, V1 0x00, None 0x00. + let read_again = + HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key: "k".into() }); + assert_eq!( + run_request(&core, "local_storage_read", read_again.encode()), + vec![0x00, 0x00, 0x00] + ); + } + + /// The mock's per-capability permission policy surfaces through the real + /// permission service and wire: a `DenyAll` device policy yields + /// `granted: false`, an `AllowAll` policy yields `granted: true`. Closes the + /// "allow-all hiding a denied path" gap. + #[test] + fn from_mock_platform_device_permission_policy_through_core() { + use truapi::versioned::permissions::HostDevicePermissionRequest; + use truapi_platform::mock::{MockConfig, PermissionPolicy}; + + let request = + || HostDevicePermissionRequest::V1(v01::HostDevicePermissionRequest::Camera).encode(); + + // AllowAll (default): Ok 0x00, V1 0x00, granted=1. + let allow = make_mock_core(MockConfig::default()); + assert_eq!( + run_request(&allow, "permissions_request_device_permission", request()), + vec![0x00, 0x00, 0x01] + ); + + // DenyAll: granted=0. + let deny = make_mock_core(MockConfig { + device_permissions: PermissionPolicy::DenyAll, + ..Default::default() + }); + assert_eq!( + run_request(&deny, "permissions_request_device_permission", request()), + vec![0x00, 0x00, 0x00] + ); + } + + /// Preimage submit flows through the core's confirm gate to the platform: + /// the default mock auto-confirms (Ok envelope), and a `confirm = false` + /// mock is rejected by the core before reaching the platform (Err envelope). + #[test] + fn from_mock_platform_preimage_submit_through_core() { + use truapi::versioned::preimage::RemotePreimageSubmitRequest; + use truapi_platform::mock::MockConfig; + + // Versioned envelope layout is [version_index, result_discriminant, ..]. + // For a V1 response the version index is 0, so the Ok/Err distinction + // lives at byte index 1: 0x00 = Ok, 0x01 = Err. + + // Default mock auto-confirms: submit succeeds (V1 index 0x00, Ok 0x00). + let confirmed = make_mock_core(MockConfig::default()); + let ok_payload = run_request( + &confirmed, + "preimage_submit", + RemotePreimageSubmitRequest::V1(vec![1, 2, 3]).encode(), + ); + assert_eq!(ok_payload.first(), Some(&0x00)); + assert_eq!(ok_payload.get(1), Some(&0x00)); + + // confirm_user_actions = false: the core rejects before the platform + // (V1 index 0x00, Err 0x01). + let rejected = make_mock_core(MockConfig { + confirm_user_actions: false, + ..Default::default() + }); + let err_payload = run_request( + &rejected, + "preimage_submit", + RemotePreimageSubmitRequest::V1(vec![1, 2, 3]).encode(), + ); + assert_eq!(err_payload.first(), Some(&0x00)); + assert_eq!(err_payload.get(1), Some(&0x01)); + } + + /// A MockPlatform storage fault surfaces through the real core as a wire + /// `Err` envelope — proving fault injection propagates through the dispatcher. + #[test] + fn from_mock_platform_storage_fault_surfaces_through_core() { + use truapi_platform::mock::{MockConfig, MockFaults}; + + let core = make_mock_core(MockConfig { + faults: MockFaults { + storage_error: Some("disk full".into()), + ..Default::default() + }, + ..Default::default() + }); + let read = + HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key: "k".into() }); + // Versioned wire layout is [version_index=0x00 (V1)][result_index][inner]; + // the Err discriminant is byte 1, not byte 0 (byte 0 is the V1 version index). + let payload = run_request(&core, "local_storage_read", read.encode()); + assert_eq!(payload.first(), Some(&0x00)); // V1 version index + assert_eq!(payload.get(1), Some(&0x01)); // Result::Err discriminant + } + #[test] fn local_storage_read_round_trips_none() { let core = make_core();