Files
paseo/packages/app/src/hosts/pair-scan-model.test.ts
T
Mohamed Boudra 364e6456e0 Add confirmation for new hosts (#5753)
* Add confirmation for new hosts

Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted
link) now asks the user to confirm before the host is added or connected.
The prompt shows the host id, its key fingerprint, and the relay. Cancel
leaves no new host and no connection.

The host runtime owns the check: `upsertConnectionFromOfferUrl` and
`probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before
doing anything. `HostTrustPromptModal`, mounted at the app root, registers
the confirmer and renders the prompt with the shared confirm sheet. Without
a registered confirmer, pairing links are declined.

Adds a test that an offer link with the confirmation declined adds no host
and attempts no connection.

* Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.

* Move host confirmation into the host runtime

The host runtime now owns the pending host confirmation and the decision to
ask. The sheet reads the pending confirmation and sends the answer; nothing
registers into a module variable.

A pairing flow is a LinkPairing the runtime hands out, so the password retry
for a confirmed link doesn't ask again without views creating or resetting
approval objects. Cancel is a result instead of an error. Pairing waits for
saved hosts to load before deciding whether to ask.

* Stop QR scanning after an attempt ends

The scan screen now keeps its workflow in a small model. When the user
cancels the host confirmation, closes the password prompt, or the attempt
fails, scanning stops until they tap Scan QR code again. Before, the code
still in front of the camera started a new attempt on the next frame.

A host confirmation answer is now tied to the request it answers, so a tap
on one sheet never answers a newer one. Relay hosts can no longer be added
through HostMutations without the confirmation, the add-host modal drops
the reset its per-open mount made redundant, and the fingerprint comment
says what the shortened form shows.
2026-09-30 18:11:23 +01:00

117 lines
3.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime";
import { openPairScan, type PairScanModel } from "./pair-scan-model";
const LINK = "https://app.paseo.sh/#offer=abc";
const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) };
/** The host runtime's side of a scan: each import resolves with the next queued outcome. */
function createScanner(outcomes: Array<ConnectionLinkImport | Error>) {
const imports: string[] = [];
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: async (link) => {
imports.push(link);
const outcome = outcomes.shift();
if (!outcome) throw new Error("No outcome queued for this import");
if (outcome instanceof Error) throw outcome;
return outcome;
},
onConnected: (serverId) => connected.push(serverId),
});
return { scan, imports, connected };
}
async function settled(scan: PairScanModel): Promise<void> {
await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing"));
}
describe("openPairScan", () => {
it("stops scanning after Cancel until the user scans again", async () => {
const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]);
scan.scan(LINK);
await settled(scan);
// The code is still in front of the camera.
scan.scan(LINK);
scan.scan(LINK);
await Promise.resolve();
expect(imports).toEqual([LINK]);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
scan.scanAgain();
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK, LINK]);
});
it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => {
const { scan, imports, connected } = createScanner([
{ status: "connected", serverId: "srv_scan" },
]);
scan.scan("https://example.com/not-a-pairing-link");
expect(scan.getState()).toEqual({ status: "scanning" });
scan.scan(LINK);
scan.scan(LINK);
await settled(scan);
expect(imports).toEqual([LINK]);
expect(connected).toEqual(["srv_scan"]);
scan.scan(LINK);
expect(imports).toEqual([LINK]);
});
it("ends the attempt when the password prompt closes", async () => {
const { scan, imports } = createScanner([
{ status: "password_required", link: LINK, pairing: PAIRING },
]);
scan.scan(LINK);
await settled(scan);
expect(scan.getState()).toEqual({
status: "passwordRequired",
passwordRequired: { link: LINK, pairing: PAIRING },
});
scan.scan(LINK);
scan.closePassword();
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: null });
expect(imports).toEqual([LINK]);
});
it("stops with the error when an attempt fails", async () => {
const { scan, imports } = createScanner([new Error("Server disconnected")]);
scan.scan(LINK);
await settled(scan);
scan.scan(LINK);
expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" });
expect(imports).toEqual([LINK]);
});
it("does not navigate for an attempt that finishes after the screen closed", async () => {
let finish: (outcome: ConnectionLinkImport) => void = () => {};
const connected: string[] = [];
const scan = openPairScan({
importConnectionLink: () =>
new Promise((resolve) => {
finish = resolve;
}),
onConnected: (serverId) => connected.push(serverId),
});
scan.scan(LINK);
scan.close();
finish({ status: "connected", serverId: "srv_scan" });
await Promise.resolve();
expect(connected).toEqual([]);
});
});