| 1 | // Run: tsx src/__tests__/remote-store.test.ts |
| 2 | // |
| 3 | // Tests for the remote store's status/fingerprint reconciliation and the |
| 4 | // bridge mock's remote:* event fan-out. |
| 5 | |
| 6 | import { RemoteConnectionTimeoutError, useRemoteStore, waitForRemoteConnection } from "../store/remote"; |
| 7 | import { onRemoteStatus, __emitMockRemote } from "../lib/bridge"; |
| 8 | import { isRemoteHostKeyMismatch, remoteConnectionErrorSummaryKey } from "../lib/remoteErrors"; |
| 9 | import type { RemoteConnectionStatus } from "../lib/types"; |
| 10 | |
| 11 | let passed = 0; |
| 12 | let failed = 0; |
| 13 | |
| 14 | function eq(a: unknown, b: unknown, label: string) { |
| 15 | if (JSON.stringify(a) === JSON.stringify(b)) { |
| 16 | process.stdout.write(` PASS ${label}\n`); |
| 17 | passed += 1; |
| 18 | } else { |
| 19 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 20 | failed += 1; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | function reset() { |
| 25 | useRemoteStore.setState({ hosts: [], statuses: {}, pendingFingerprint: null, pendingSecretPrompt: null, statusPopoverRequest: null }); |
| 26 | } |
| 27 | |
| 28 | useRemoteStore.getState().setHosts([ |
| 29 | { id: "box", label: "box", host: "box.test", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "/srv/app", serveInstall: "auto", useSSHConfig: false }, |
| 30 | ]); |
| 31 | eq(useRemoteStore.getState().hosts[0]?.defaultWorkspace, "/srv/app", "configured hosts hydrate persistent UI state"); |
| 32 | |
| 33 | // pending_hostkey status sets the pending fingerprint that drives the dialog. |
| 34 | reset(); |
| 35 | useRemoteStore.getState().applyStatus({ |
| 36 | hostId: "box", |
| 37 | state: "pending_hostkey", |
| 38 | fingerprint: { hostId: "box", address: "1.2.3.4:22", keyType: "ssh-ed25519", sha256: "AAAA" }, |
| 39 | }); |
| 40 | eq(useRemoteStore.getState().pendingFingerprint?.sha256, "AAAA", "pending_hostkey sets fingerprint"); |
| 41 | |
| 42 | // A stale dialog completion must not clear a newer fingerprint. |
| 43 | const oldFingerprint = useRemoteStore.getState().pendingFingerprint!; |
| 44 | useRemoteStore.getState().applyStatus({ |
| 45 | hostId: "other", |
| 46 | state: "pending_hostkey", |
| 47 | fingerprint: { hostId: "other", address: "2.3.4.5:22", keyType: "ssh-ed25519", sha256: "BBBB" }, |
| 48 | }); |
| 49 | useRemoteStore.getState().clearPendingFingerprint(oldFingerprint); |
| 50 | eq(useRemoteStore.getState().pendingFingerprint?.sha256, "BBBB", "stale dialog cannot clear newer fingerprint"); |
| 51 | |
| 52 | // A subsequent non-pending status for the same host clears the fingerprint. |
| 53 | useRemoteStore.getState().applyStatus({ hostId: "other", state: "connected" }); |
| 54 | eq(useRemoteStore.getState().pendingFingerprint, null, "resolution clears fingerprint"); |
| 55 | eq(useRemoteStore.getState().statuses["other"]?.state, "connected", "status recorded"); |
| 56 | |
| 57 | // Interactive credentials expose metadata to the UI, never the secret value. |
| 58 | reset(); |
| 59 | useRemoteStore.getState().applyStatus({ |
| 60 | hostId: "box", |
| 61 | state: "pending_secret", |
| 62 | secretPrompt: { promptId: "prompt-1", hostId: "box", host: "dev@box.test", kind: "password" }, |
| 63 | }); |
| 64 | eq(useRemoteStore.getState().pendingSecretPrompt?.kind, "password", "pending_secret opens the one-shot credential dialog"); |
| 65 | eq(JSON.stringify(useRemoteStore.getState().statuses.box).includes("secret-value"), false, "status contains no credential plaintext"); |
| 66 | const oldSecretPrompt = useRemoteStore.getState().pendingSecretPrompt!; |
| 67 | useRemoteStore.getState().applyStatus({ |
| 68 | hostId: "other", |
| 69 | state: "pending_secret", |
| 70 | secretPrompt: { promptId: "prompt-2", hostId: "other", host: "other.test", kind: "passphrase" }, |
| 71 | }); |
| 72 | useRemoteStore.getState().clearPendingSecretPrompt(oldSecretPrompt); |
| 73 | eq(useRemoteStore.getState().pendingSecretPrompt?.hostId, "other", "stale credential dialog cannot clear a newer prompt"); |
| 74 | const firstIdentityPrompt = { |
| 75 | promptId: "prompt-3", hostId: "other", host: "other.test", kind: "passphrase" as const, identity: "id_first", |
| 76 | }; |
| 77 | useRemoteStore.getState().applyStatus({ hostId: "other", state: "pending_secret", secretPrompt: firstIdentityPrompt }); |
| 78 | useRemoteStore.getState().applyStatus({ |
| 79 | hostId: "other", |
| 80 | state: "pending_secret", |
| 81 | secretPrompt: { promptId: "prompt-4", hostId: "other", host: "other.test", kind: "passphrase", identity: "id_second" }, |
| 82 | }); |
| 83 | useRemoteStore.getState().clearPendingSecretPrompt(firstIdentityPrompt); |
| 84 | eq(useRemoteStore.getState().pendingSecretPrompt?.identity, "id_second", "one key prompt cannot clear the next key prompt"); |
| 85 | const oldSameMetadataPrompt = useRemoteStore.getState().pendingSecretPrompt!; |
| 86 | useRemoteStore.getState().applyStatus({ |
| 87 | hostId: "other", |
| 88 | state: "pending_secret", |
| 89 | secretPrompt: { promptId: "prompt-5", hostId: "other", host: "other.test", kind: "passphrase", identity: "id_second" }, |
| 90 | }); |
| 91 | useRemoteStore.getState().clearPendingSecretPrompt(oldSameMetadataPrompt); |
| 92 | eq(useRemoteStore.getState().pendingSecretPrompt?.promptId, "prompt-5", "opaque prompt ID protects sequential prompts with identical metadata"); |
| 93 | useRemoteStore.getState().applyStatus({ hostId: "other", state: "connecting" }); |
| 94 | eq(useRemoteStore.getState().pendingSecretPrompt, null, "credential resolution clears the prompt"); |
| 95 | |
| 96 | // setStatuses replaces the whole map (mount hydration). |
| 97 | useRemoteStore.getState().setStatuses([ |
| 98 | { hostId: "a", state: "connected" }, |
| 99 | { hostId: "b", state: "reconnecting", attempt: 2 }, |
| 100 | ]); |
| 101 | eq(Object.keys(useRemoteStore.getState().statuses).sort(), ["a", "b"], "setStatuses hydrates"); |
| 102 | eq(useRemoteStore.getState().statuses["b"]?.attempt, 2, "attempt preserved"); |
| 103 | |
| 104 | // Late hydration fills missing hosts without overwriting a newer live event. |
| 105 | useRemoteStore.getState().applyStatus({ hostId: "live", state: "connected" }); |
| 106 | useRemoteStore.getState().hydrateStatuses([ |
| 107 | { hostId: "live", state: "connecting" }, |
| 108 | { hostId: "snapshot-only", state: "connected" }, |
| 109 | ]); |
| 110 | eq(useRemoteStore.getState().statuses["live"]?.state, "connected", "hydration preserves newer live status"); |
| 111 | eq(useRemoteStore.getState().statuses["snapshot-only"]?.state, "connected", "hydration fills missing status"); |
| 112 | |
| 113 | useRemoteStore.getState().requestStatusPopover("box"); |
| 114 | const firstReveal = useRemoteStore.getState().statusPopoverRequest!; |
| 115 | eq(firstReveal.hostId, "box", "connection failures can request the anchored status popover"); |
| 116 | useRemoteStore.getState().requestStatusPopover("box"); |
| 117 | const secondReveal = useRemoteStore.getState().statusPopoverRequest!; |
| 118 | eq(secondReveal.nonce > firstReveal.nonce, true, "repeated failures create a fresh popover request"); |
| 119 | useRemoteStore.getState().clearStatusPopoverRequest(firstReveal); |
| 120 | eq(useRemoteStore.getState().statusPopoverRequest?.nonce, secondReveal.nonce, "stale popover completion cannot clear a newer request"); |
| 121 | useRemoteStore.getState().clearStatusPopoverRequest(secondReveal); |
| 122 | eq(useRemoteStore.getState().statusPopoverRequest, null, "matching popover request is consumed"); |
| 123 | |
| 124 | const mismatchStatus: RemoteConnectionStatus = { |
| 125 | hostId: "box", |
| 126 | state: "stopped", |
| 127 | error: "raw path-bearing backend error", |
| 128 | errorDetails: { |
| 129 | code: "host_key_mismatch", |
| 130 | presentedSha256: "SHA256:new", |
| 131 | knownHostRecords: [{ path: "/home/dev/.ssh/known_hosts", line: 7 }], |
| 132 | }, |
| 133 | }; |
| 134 | eq(isRemoteHostKeyMismatch(mismatchStatus), true, "structured mismatch is recognized without parsing raw error text"); |
| 135 | eq(remoteConnectionErrorSummaryKey(mismatchStatus), "remote.error.summary.host_key_mismatch", "mismatch uses the localized safe summary"); |
| 136 | eq( |
| 137 | remoteConnectionErrorSummaryKey({ hostId: "legacy", state: "degraded", error: "forward attach failed" }), |
| 138 | "remote.error.summary.degraded", |
| 139 | "legacy degraded status falls back to the warning summary", |
| 140 | ); |
| 141 | |
| 142 | const connectionReady = waitForRemoteConnection("waiting", 1_000); |
| 143 | useRemoteStore.getState().applyStatus({ hostId: "waiting", state: "connected" }); |
| 144 | await connectionReady; |
| 145 | eq(useRemoteStore.getState().statuses["waiting"]?.state, "connected", "connection waiter resolves on live connected status"); |
| 146 | |
| 147 | useRemoteStore.getState().applyStatus({ hostId: "failed", state: "stopped", error: "handshake failed" }); |
| 148 | let failedConnection = ""; |
| 149 | try { |
| 150 | await waitForRemoteConnection("failed", 1_000); |
| 151 | } catch (err) { |
| 152 | failedConnection = err instanceof Error ? err.message : String(err); |
| 153 | } |
| 154 | eq(failedConnection, "handshake failed", "connection waiter rejects the host error without waiting for timeout"); |
| 155 | |
| 156 | useRemoteStore.getState().applyStatus({ hostId: "timeout", state: "connecting" }); |
| 157 | let timeoutError: unknown; |
| 158 | try { |
| 159 | await waitForRemoteConnection("timeout", 1); |
| 160 | } catch (err) { |
| 161 | timeoutError = err; |
| 162 | } |
| 163 | eq(timeoutError instanceof RemoteConnectionTimeoutError, true, "connection waiter exposes a typed timeout for recovery UI"); |
| 164 | eq(useRemoteStore.getState().statuses.timeout?.state, "connecting", "connection timeout does not forge a backend terminal state"); |
| 165 | |
| 166 | // The bridge mock fan-out delivers remote:status to subscribers. |
| 167 | (function testMockFanout() { |
| 168 | if (typeof window === "undefined") { |
| 169 | (globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis; |
| 170 | } |
| 171 | let received: RemoteConnectionStatus | null = null; |
| 172 | const off = onRemoteStatus((s) => { |
| 173 | received = s; |
| 174 | }); |
| 175 | __emitMockRemote("status", { hostId: "z", state: "connected" }); |
| 176 | eq(received !== null && (received as RemoteConnectionStatus).hostId, "z", "mock fan-out delivers status"); |
| 177 | off(); |
| 178 | received = null; |
| 179 | __emitMockRemote("status", { hostId: "y", state: "connected" }); |
| 180 | eq(received, null, "unsubscribe stops delivery"); |
| 181 | })(); |
| 182 | |
| 183 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 184 | if (failed > 0) process.exit(1); |
| 185 |