Files
paseo/packages/app/src/runtime/daemon-start-service.test.ts
T
Mohamed Boudra e1c769c018 Make password-protected daemons work across local and remote connections (#5393)
* feat(auth): authenticate daemon clients through hello

Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release.

Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows.

* fix(auth): preserve Hub bootstrap and local credential expectations

Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports.

Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change.

* fix(pairing): retain passwords across edits to the same target

Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target.

* test(auth): align local credential checks across platforms

Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them.

* feat(app): move host password into a settings row with a modal

The host page rendered an always-visible password form above Name on every
host. Replace it with a Password row under a Security section below
Appearance; tapping it opens a modal with Save and, when a password is saved,
Clear. The connection-error callout keeps its guidance and gains a Set
password button that opens the same modal. Drop the unused
pairing.hostPassword body/reopen/saveError strings.

* chore: remove committed QA screenshots

QA evidence stays out of the repository; the PR body describes it in words.

* feat(app): keep the host password out of the host page

The saved password is connection data, entered only when a host is added. A
Password setting on the host page reads as the daemon's own password, so the
row, modal, and callout button are gone. A rejected host shows the daemon's
reason with guidance to remove the host and add it again with the password.
useHostMutations no longer exposes setHostPassword; relay imports still set it
inside the runtime.

* feat(app): show host connection errors in an error alert

HostConnectionError renders the existing Alert with the connection error as
its title. Auth rejections add the re-add guidance as the description; other
errors show the title only. The Overview and Connections pages share it.

* style(app): soften alert borders to half-opacity accent

* feat(app): size alerts on the button scale

Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to
the button's in control-geometry: one font size for title and description, the
button icon size centered on the first text line, button padding and radius,
and a one-line alert as tall as the button of the same size. Consumers pick
sm among settings rows, cards, panels, and modals, and md when the alert is
the whole page or sheet state.

* style(app): roomier, rounder alerts with a tight title block

Alert sm padding returns to the original 12/16 and every size is at least
borderRadius.xl. Title and description sit a small gap apart with no line
height override. The icon slot carries a zero-width text strut in the alert's
font so the icon centers on the first line for titled, title-only, and
description-only alerts.

* refactor(app): align the alert icon with a centered lead row

Drop the zero-width strut. The icon and the alert's first text (title, else
description) share one row centered on each other; the description and actions
sit below, indented by the icon slot width plus gap so they start at the lead
text's left edge.

* refactor(app): drop the unused Alert icon override
2026-09-26 19:53:43 +02:00

371 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service";
import type { HostRuntimeStore } from "./host-runtime";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { defaultHostAppearance } from "@/hosts/appearance";
import type { HostProfile } from "@/types/host-connection";
interface RecordedUpsert {
listenAddress: string;
serverId: string;
hostname: string | null;
}
function createFakeStore(hosts: HostProfile[] = []): {
store: Pick<HostRuntimeStore, "getHosts" | "upsertConnectionFromListen">;
upserts: RecordedUpsert[];
} {
const upserts: RecordedUpsert[] = [];
const store = {
getHosts: () => hosts,
upsertConnectionFromListen: async (input: RecordedUpsert) => {
upserts.push(input);
return {} as Awaited<ReturnType<HostRuntimeStore["upsertConnectionFromListen"]>>;
},
};
return { store, upserts };
}
function makeStatus(overrides: Partial<DesktopDaemonStatus> = {}): DesktopDaemonStatus {
return {
serverId: "srv_desktop",
status: "running",
listen: "127.0.0.1:6767",
hostname: "desktop",
pid: 1234,
home: "/home",
version: "0.0.0",
desktopManaged: true,
ownedByDesktop: true,
startedAt: "2026-01-01T00:00:00.000Z",
error: null,
...overrides,
};
}
function makeRelayOnlyHost(serverId: string): HostProfile {
return {
serverId,
label: "Relay host",
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [
{
id: "relay:relay.example.com",
type: "relay",
relayEndpoint: "relay.example.com",
daemonPublicKeyB64: "public-key",
},
],
preferredConnectionId: "relay:relay.example.com",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
describe("DaemonStartService", () => {
it("upserts the connection on a successful daemon start", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus(),
});
const result = await service.start();
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
]);
expect(service.getLastError()).toBeNull();
expect(service.isRunning()).toBe(false);
});
it("reports lastError after a missing listen address and clears running state when done", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ listen: null }),
});
const result = await service.start();
expect(result).toEqual({
ok: false,
error: "Desktop daemon did not return a listen address.",
});
expect(service.getLastError()).toBe("Desktop daemon did not return a listen address.");
expect(service.isRunning()).toBe(false);
expect(fake.upserts).toEqual([]);
});
it("reports lastError when the daemon does not return a server id", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ serverId: "" }),
});
const result = await service.start();
expect(result).toEqual({ ok: false, error: "Desktop daemon did not return a server id." });
expect(service.getLastError()).toBe("Desktop daemon did not return a server id.");
expect(fake.upserts).toEqual([]);
});
it("reports lastError when the listen address is unsupported", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ listen: "???" }),
});
const result = await service.start();
expect(result.ok).toBe(false);
expect(service.getLastError()).toContain("unsupported listen address");
expect(fake.upserts).toEqual([]);
});
it("reports lastError when the underlying start call throws", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
throw new Error("ipc broke");
},
});
const result = await service.start();
expect(result).toEqual({ ok: false, error: "ipc broke" });
expect(service.getLastError()).toBe("ipc broke");
});
it("clears lastError on retry entry and reports null after subsequent success", async () => {
const fake = createFakeStore();
const startMock = vi
.fn<() => Promise<DesktopDaemonStatus>>()
.mockRejectedValueOnce(new Error("ipc broke"))
.mockResolvedValueOnce(makeStatus());
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: () => startMock(),
});
const failure = await service.start();
expect(failure.ok).toBe(false);
expect(service.getLastError()).toBe("ipc broke");
const success = await service.start();
expect(success).toEqual({ ok: true });
expect(service.getLastError()).toBeNull();
});
it("notifies subscribers when isRunning toggles between calls", async () => {
const fake = createFakeStore();
let resolveStart: ((value: DesktopDaemonStatus) => void) | undefined;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: () =>
new Promise<DesktopDaemonStatus>((resolve) => {
resolveStart = resolve;
}),
});
const runningSnapshots: boolean[] = [];
service.subscribe(() => {
runningSnapshots.push(service.isRunning());
});
const startPromise = service.start();
expect(service.isRunning()).toBe(true);
expect(runningSnapshots).toEqual([true]);
resolveStart?.(makeStatus());
await startPromise;
expect(service.isRunning()).toBe(false);
expect(runningSnapshots).toEqual([true, false]);
});
it("stays running while deciding whether the managed daemon should start", async () => {
const fake = createFakeStore();
let resolveCondition: ((value: boolean) => void) | undefined;
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const startPromise = service.startIfEnabled({
shouldStart: () =>
new Promise<boolean>((resolve) => {
resolveCondition = resolve;
}),
});
expect(service.isRunning()).toBe(true);
expect(daemonStartCount).toBe(0);
resolveCondition?.(true);
await startPromise;
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(1);
});
it("finishes without starting the daemon when management is disabled", async () => {
const fake = createFakeStore();
let daemonStartCount = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => {
daemonStartCount += 1;
return makeStatus();
},
});
const result = await service.startIfEnabled({ shouldStart: false });
expect(result).toEqual({ ok: true });
expect(service.isRunning()).toBe(false);
expect(daemonStartCount).toBe(0);
});
it("clears the error and notifies subscribers when retry begins", async () => {
const fake = createFakeStore();
const startMock = vi
.fn<() => Promise<DesktopDaemonStatus>>()
.mockRejectedValueOnce(new Error("ipc broke"))
.mockResolvedValueOnce(makeStatus());
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: () => startMock(),
});
await service.start();
expect(service.getLastError()).toBe("ipc broke");
const errorSnapshots: Array<string | null> = [];
service.subscribe(() => {
errorSnapshots.push(service.getLastError());
});
await service.start();
expect(errorSnapshots[0]).toBeNull();
expect(service.getLastError()).toBeNull();
});
it("surfaces settings errors through the daemon startup state", async () => {
const fake = createFakeStore();
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus(),
});
const result = await service.startIfEnabled({
shouldStart: async () => {
throw new Error("settings file unreadable");
},
});
expect(result).toEqual({
ok: false,
error: "Failed to evaluate desktop daemon settings: settings file unreadable",
});
expect(service.getLastError()).toBe(
"Failed to evaluate desktop daemon settings: settings file unreadable",
);
expect(service.isRunning()).toBe(false);
});
it("stops notifying after a subscriber unsubscribes", async () => {
const fake = createFakeStore();
let notifications = 0;
const service = new DaemonStartService({
store: fake.store,
startDesktopDaemon: async () => makeStatus({ listen: null }),
});
const unsubscribe = service.subscribe(() => {
notifications += 1;
});
await service.start();
const countAfterFirst = notifications;
expect(countAfterFirst).toBeGreaterThan(0);
unsubscribe();
await service.start();
expect(notifications).toBe(countAfterFirst);
});
});
describe("upsertDesktopDaemonConnection", () => {
it("upserts a valid desktop daemon status", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus());
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([
{ listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" },
]);
});
it("does not add localhost when desktop bootstrap finds its server id already registered", async () => {
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus());
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([]);
});
it("keeps an already registered host without waiting for a listen address", async () => {
const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]);
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
expect(result).toEqual({ ok: true });
expect(fake.upserts).toEqual([]);
});
it("rejects a missing listen address without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null }));
expect(result).toEqual({
ok: false,
error: "Desktop daemon did not return a listen address.",
});
expect(fake.upserts).toEqual([]);
});
it("rejects a missing server id without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ serverId: "" }));
expect(result).toEqual({
ok: false,
error: "Desktop daemon did not return a server id.",
});
expect(fake.upserts).toEqual([]);
});
it("rejects an unsupported listen address without upserting", async () => {
const fake = createFakeStore();
const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: "???" }));
expect(result.ok).toBe(false);
expect(result.ok ? "" : result.error).toContain("unsupported listen address");
expect(fake.upserts).toEqual([]);
});
});