# ProductView Security Test Tracking

Internal developer checklist. Not for external documentation.

Tracks mandatory security test coverage across all four ProductView implementations before the component ships.  Every row must reach "implemented" on its target platform(s) before the ship gate is closed.

Profile-oriented follow-up now lives in
[`docs/product-view-profile-conformance-matrix.md`](../../docs/product-view-profile-conformance-matrix.md).
This file remains the implementation-level test tracker until the matrix is
fully collapsed into one profile-aware conformance spec.

Status key:
- Implemented — test exists and passes in CI
- Stub — test file and test name exist; body is `TODO` or `skip()`
- Requires platform runner — cannot run in unit-test mode; needs a real webview, browser, or device

---

## Platform runner requirements

| Platform | Runner | Notes |
|----------|--------|-------|
| Rust | `cargo test -p host-product-view` | Runs in CI on every push |
| Swift | Xcode Test (`XCTest`) | Requires macOS or iOS Simulator; run locally or on macOS CI runner |
| Kotlin | Android Instrumented Test (`@RunWith(AndroidJUnit4::class)`) | Requires Android emulator or device; run on Firebase Test Lab or a hosted emulator |
| JS (jsdom) | `npx vitest run tests/product-view-security.test.ts` | No browser required; add vitest + jsdom to devDependencies |
| JS (Playwright) | `npx playwright test tests/product-view-security.playwright.ts` | Requires `npx playwright install chromium` |

---

## Category A — Lockdown script: network API overrides

These tests verify that the lockdown script sets each targeted global to `undefined`.  On Rust they test the constant string; on native platforms they inject the script into a real webview and eval `typeof window.X`; on JS the jsdom tests evaluate the script against a mock window.

| Test ID | Description | Rust | Swift | Kotlin | JS (jsdom) |
|---------|-------------|------|-------|--------|------------|
| A-01 | `window.WebSocket` is overridden to `undefined` | Implemented (`test_lockdown_overrides_websocket`) | Stub | Stub | Implemented (`WebSocket is set to undefined after lockdown`) |
| A-02 | `window.RTCPeerConnection` is overridden to `undefined` | Implemented (`test_lockdown_overrides_rtc_peer_connection`) | Stub | Stub | Implemented (`RTCPeerConnection is set to undefined after lockdown`) |
| A-03 | `window.RTCSessionDescription` is overridden to `undefined` | Implemented (`test_lockdown_overrides_rtc_session_description`) | Stub | Stub | Implemented (`RTCSessionDescription is set to undefined after lockdown`) |
| A-04 | `window.RTCIceCandidate` is overridden to `undefined` | Implemented (`test_lockdown_overrides_rtc_ice_candidate`) | Stub | Stub | Implemented (`RTCIceCandidate is set to undefined after lockdown`) |
| A-05 | `window.RTCDataChannel` is overridden to `undefined` | Implemented (`test_lockdown_overrides_rtc_data_channel`) | Stub | Stub | Implemented (`RTCDataChannel is set to undefined after lockdown`) |
| A-06 | `window.EventSource` is overridden to `undefined` | Implemented (`test_lockdown_overrides_event_source`) | Stub | Stub | Implemented (`EventSource is set to undefined after lockdown`) |
| A-07 | `window.SharedWorker` is overridden to `undefined` | Implemented (`test_lockdown_overrides_shared_worker`) | Stub | Stub | Implemented (`SharedWorker is set to undefined after lockdown`) |
| A-08 | `window.BroadcastChannel` is overridden to `undefined` | Implemented (`test_lockdown_overrides_broadcast_channel`) | Stub | Stub | Implemented (`BroadcastChannel is set to undefined after lockdown`) |
| A-09 | Lockdown script is idempotent (safe to inject twice) | Requires platform runner | Stub | Stub | Implemented (`lockdown is idempotent`) |

---

## Category B — Content Security Policy directives

These tests verify that the CSP string used in the iframe srcdoc (JS platform) and the equivalent native-level policy (Swift WKContentRuleList, Android `shouldInterceptRequest`) contains all required directives.

| Test ID | Description | Rust | Swift | Kotlin | JS (jsdom) |
|---------|-------------|------|-------|--------|------------|
| B-01 | `default-src 'none'` is present | N/A | Stub | Stub | Implemented (`default-src is 'none'`) |
| B-02 | `connect-src 'none'` blocks fetch/XHR/WebSocket | Implemented (`test_csp_blocks_connect`) | Stub | Stub | Implemented (`connect-src is 'none'`) |
| B-03 | `frame-src 'none'` prevents nested iframes | Implemented (`test_csp_blocks_frames`) | N/A | N/A | Implemented (`frame-src is 'none'`) |
| B-04 | `font-src 'none'` blocks external font loading | Implemented (`test_csp_blocks_fonts`) | N/A | N/A | Implemented (`font-src is 'none'`) |
| B-05 | `media-src 'none'` blocks audio/video requests | Implemented (`test_csp_blocks_media`) | N/A | N/A | Implemented (`media-src is 'none'`) |
| B-06 | `object-src 'none'` blocks plugin execution | Implemented (`test_csp_blocks_objects`) | N/A | N/A | Implemented (`object-src is 'none'`) |
| B-07 | `form-action 'none'` prevents form exfiltration | Implemented (`test_csp_blocks_forms`) | N/A | N/A | Implemented (`form-action is 'none'`) |
| B-08 | `base-uri 'none'` prevents base-tag hijacking | Requires platform runner | N/A | N/A | Implemented (`base-uri is 'none'`) |
| B-09 | `script-src 'unsafe-inline'` allows bootstrap scripts | Implemented (`iframe_csp_allows_inline_scripts_and_wasm`) | N/A | N/A | Implemented |
| B-10 | `script-src 'wasm-unsafe-eval'` allows WASM execution | Implemented (`iframe_csp_allows_inline_scripts_and_wasm`) | N/A | N/A | Implemented |

Note: Swift and Kotlin enforce network blocking at the WKContentRuleList / `shouldInterceptRequest` layer rather than via a CSP meta tag.  The "N/A" cells indicate that the policy mechanism differs but the security property (test ID) still needs a native equivalent test on those platforms (stubs in the Swift/Kotlin test suites).

---

## Category C — Sandbox attribute / WKContentRuleList correctness

| Test ID | Description | Rust | Swift | Kotlin | JS (jsdom) |
|---------|-------------|------|-------|--------|------------|
| C-01 | `sandbox` attribute is exactly `allow-scripts` (no weaker tokens) | Implemented (`iframe_sandbox_attr_only_allows_scripts`) | N/A | N/A | Implemented (`sandbox attribute is exactly 'allow-scripts'`) |
| C-02 | `sandbox` does not include `allow-same-origin` | Requires platform runner | N/A | N/A | Implemented (part of C-01 test body) |
| C-03 | WKContentRuleList JSON is valid and non-empty | Implemented (`block_all_content_rule_json_is_valid_json`) | Stub | N/A | N/A |
| C-04 | WKContentRuleList trigger covers `https?://` and `wss?://` | Implemented (covered by `block_all_content_rule_json_is_valid_json` field check) | Stub | N/A | N/A |

---

## Category D — Network blocking (real browser/webview required)

These tests must execute inside a live webview instance.  Each one mounts a minimal product bundle, has the product script attempt the network operation, and asserts the operation fails or is blocked.

| Test ID | Description | Rust | Swift | Kotlin | JS (Playwright) |
|---------|-------------|------|-------|--------|-----------------|
| D-01 | `fetch()` to HTTPS is blocked | N/A | Stub | Stub | Stub |
| D-02 | `fetch()` to HTTP is blocked | N/A | Stub | Stub | Stub |
| D-03 | `XMLHttpRequest` is blocked | N/A | Stub | Stub | Stub |
| D-04 | `WebSocket` connection attempt throws (global undefined) | N/A | Stub | Stub | Stub |
| D-05 | `EventSource` construction throws (global undefined) | N/A | Stub | Stub | Stub |
| D-06 | `navigator.sendBeacon` is blocked | N/A | Stub | Stub | Stub |
| D-07 | `RTCPeerConnection` construction throws (global undefined) | N/A | Stub | Stub | Stub |

Platform implementation notes:
- **Swift**: use `XCTestExpectation` with a delegate mock; inject a product that calls the API and posts back the result via the bridge.  Assert the bridge receives an error payload, not a success payload.
- **Kotlin**: use `CountDownLatch` in an Instrumented Test.  The `ProductViewDelegate.onMessage()` callback delivers the result.  Assert the payload encodes a failure.
- **JS/Playwright**: use `page.evaluate()` to inject a script into the iframe context; use `page.on('console')` or `page.waitForFunction()` to observe the result.

---

## Category E — Scheme and navigation isolation

| Test ID | Description | Rust | Swift | Kotlin | JS (Playwright) |
|---------|-------------|------|-------|--------|-----------------|
| E-01 | `javascript:` navigation is cancelled | N/A | Stub | Stub | Stub |
| E-02 | `data:` navigation is cancelled | N/A | Stub | Stub | Stub |
| E-03 | External `https://` navigation surfaces to delegate, not WebView | N/A | Stub | Stub | Stub |
| E-04 | Internal `dotapp://` navigation is allowed | N/A | Stub | Stub | N/A |

Platform implementation notes:
- **Swift**: verify via `WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:)` mock.  The test implementation lives in `decidePolicyFor` in `ProductView.swift` — assert the delegate receives `didRequestNavigateTo` for external URLs and that `.cancel` is returned for non-`dotapp://` schemes.
- **Kotlin**: verify via `ProductWebViewClient.shouldOverrideUrlLoading()`.  The delegate mock should record the URL and assert it is reported, not followed.

---

## Category F — Cross-product isolation

| Test ID | Description | Rust | Swift | Kotlin | JS (Playwright) |
|---------|-------------|------|-------|--------|-----------------|
| F-01 | `SharedWorker` is `undefined` inside product iframe | N/A | Stub | Stub | Stub |
| F-02 | `BroadcastChannel` is `undefined` inside product iframe | N/A | Stub | Stub | Stub |
| F-03 | Two product iframes cannot share a `BroadcastChannel` name | N/A | Stub | Stub | Stub |
| F-04 | Non-persistent data store: localStorage is empty on reload | N/A | Stub | Stub | Stub |
| F-05 | Non-persistent data store: cookies are empty on reload | N/A | Stub | Stub | Stub |

Platform notes:
- **Swift F-04/F-05**: `WKWebViewConfiguration.websiteDataStore = .nonPersistent()` is already set in `buildWebView()`.  The test should call `loadAssets()` twice (with a fresh `WKWebView` instance) and assert that `localStorage.getItem()` returns `null` on the second load.
- **Kotlin F-04**: Android WebView `domStorageEnabled = true` does not disable persistence on its own.  A test should verify that data written to `localStorage` in one `ProductView` instance is not visible in a fresh instance for the same `productId`.  If persistence is detected, file a bug — the current implementation does not explicitly isolate localStorage.

---

## Category G — Permissions API

| Test ID | Description | Rust | Swift | Kotlin | JS (Playwright) |
|---------|-------------|------|-------|--------|-----------------|
| G-01 | Camera access is denied | N/A | Stub | Stub | Stub |
| G-02 | Microphone access is denied | N/A | Stub | Stub | Stub |
| G-03 | Geolocation is denied | N/A | Stub | Stub | Stub |
| G-04 | Service Worker registration fails | N/A | N/A | N/A | Stub |
| G-05 | Notifications permission is denied | N/A | Stub | Stub | Stub |

Platform notes:
- **Swift G-01/G-02**: `ProductView.swift` now installs `WKUIDelegate` and denies media capture requests. Add XCTest coverage proving camera/microphone requests are denied without prompting.
- **Kotlin G-01/G-02**: `ProductView.kt` now installs a `WebChromeClient` that denies `PermissionRequest`s. Add instrumented coverage proving camera/microphone requests are denied without prompting.
- **JS G-04**: `sandbox="allow-scripts"` without `allow-same-origin` makes the iframe's origin opaque, which prevents Service Worker registration.  The Playwright test should assert the promise rejects with a `SecurityError`.

---

## Category H — Bridge hardening

| Test ID | Description | Rust | Swift | Kotlin | JS (jsdom) |
|---------|-------------|------|-------|--------|------------|
| H-01 | Malformed (non-base64) message body is dropped without crash | N/A | Stub | Stub | Implemented (`message handler rejects malformed base64 gracefully`) |
| H-02 | Non-string message body is dropped without crash | N/A | Stub | Stub | Implemented (`message handler ignores non-object payloads`) |
| H-03 | Messages from wrong `event.source` are rejected | N/A | N/A | N/A | Implemented (`message handler ignores events with wrong source`) |
| H-04 | Messages with wrong `type` field are ignored | N/A | N/A | N/A | Implemented (`message handler ignores messages with wrong type field`) |
| H-05 | Large payload (5 MB) is handled without crash | N/A | Stub | Stub | Stub (Playwright) |
| H-06 | Empty payload (zero-length Uint8Array) is forwarded to delegate | N/A | Stub | Stub | Stub |
| H-07 | Rapid burst of 1000 messages does not deadlock | N/A | Stub | Stub | Stub |

Platform notes:
- **Swift H-01**: `MessageHandlerBridge.userContentController(_:didReceive:)` already guards against non-String body and invalid base64.  The test should inject JavaScript that calls `window.webkit.messageHandlers.hostApi.postMessage(42)` and assert the delegate is never called.
- **Kotlin H-01**: `HostBridge.postMessage()` now catches `Base64.decode()` failures and drops malformed payloads. Add an instrumented test that sends a non-base64 payload and asserts the host process does not crash and the delegate is not called.
- **Kotlin H-02**: `HostBridge.postMessage()` accepts only `String` because `@JavascriptInterface` methods are typed.  Non-string values passed from JS will cause a `WebView` type coercion; a test should verify the bridge does not throw.

---

## Category I — Asset path validation (Rust only)

These tests live in `crates/host-product-view/src/mime.rs` and verify that `is_safe_asset_path()` rejects all traversal and injection vectors.

| Test ID | Description | Rust | Swift | Kotlin | JS |
|---------|-------------|------|-------|--------|-----|
| I-01 | `..` traversal is rejected | Implemented (`traversal_dot_dot_is_rejected`) | N/A | N/A | N/A |
| I-02 | Leading `/` (UNIX absolute) is rejected | Implemented (`absolute_unix_path_is_rejected`) | N/A | N/A | N/A |
| I-03 | Leading `\` (Windows absolute) is rejected | Implemented (`absolute_windows_path_is_rejected`) | N/A | N/A | N/A |
| I-04 | Null byte in path is rejected | Implemented (`test_rejects_null_bytes_in_path`) | N/A | N/A | N/A |
| I-05 | Windows drive letter (colon) is rejected | Implemented (`test_rejects_windows_drive_letter`) | N/A | N/A | N/A |
| I-06 | Backslash traversal is rejected | Implemented (`test_rejects_backslash_traversal`) | N/A | N/A | N/A |
| I-07 | Nested paths without traversal are accepted | Implemented (`test_accepts_nested_paths`) | N/A | N/A | N/A |
| I-08 | Dotfiles are accepted | Implemented (`test_accepts_dotfiles`) | N/A | N/A | N/A |
| I-09 | Empty path is accepted (callers decide 404 vs. default doc) | Implemented (`empty_path_is_safe`) | N/A | N/A | N/A |

---

## Ship-gate summary

All tests in categories A–I must reach "Implemented" or have an explicit documented deferral before the ProductView component ships.

Current blocking gaps (must fix before ship):

1. **G-01/G-02 Swift** — unit coverage now proves the deny path and UI delegate installation; add WebKit runner coverage proving media-capture requests are denied without prompting.
2. **G-01/G-02 Kotlin** — in-repo instrumented tests now cover explicit permission denial; run and stabilize them on an emulator/device CI lane.
3. **H-01 Kotlin** — in-repo instrumented tests now cover malformed base64 drop behavior and the valid-message path; run and stabilize them on an emulator/device CI lane.
4. **D-01 through D-07 (all platforms)** — Network-blocking tests require a live webview runner and are currently stubs; must be implemented as part of the native platform test suites before CI can enforce them.
