Skip to content
49 changes: 49 additions & 0 deletions apps/web/src/authBootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { createBrowserHistory } from "@tanstack/react-router";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import { HttpClientError, HttpClientRequest } from "effect/http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { installEnvironmentHttpTest } from "../test/environmentHttpTest";
Expand Down Expand Up @@ -165,6 +166,54 @@ describe("resolveInitialServerAuthGateState", () => {
expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
});

it("retries the desktop bootstrap while the backend is still unreachable", async () => {
const nextSession = sequence(
unauthenticatedSession(DESKTOP_AUTH),
authenticatedSession(DESKTOP_AUTH),
);
const testApi = await installAuthApi({
session: nextSession,
browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])),
});
// What FetchHttpClient yields when nothing listens on the backend port.
const connectionRefused = new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: HttpClientRequest.get("http://localhost:3773/api/auth/session"),
cause: new TypeError("Failed to fetch"),
}),
});
let unreachableAttempts = 1;
__setPrimaryHttpRunnerForTests((effect) => {
if (unreachableAttempts > 0) {
unreachableAttempts -= 1;
return Promise.reject(connectionRefused);
}
return testApi.run(effect);
});

const testWindow = installTestBrowser("http://localhost/");
testWindow.desktopBridge = {
getLocalEnvironmentBootstraps: () => [
{
id: "primary",
label: "Local",
httpBaseUrl: "http://localhost:3773",
wsBaseUrl: "ws://localhost:3773",
bootstrapToken: "desktop-bootstrap-token",
},
],
} as unknown as DesktopBridge;

const { resolveInitialServerAuthGateState } = await import("./environments/primary");

vi.useFakeTimers({ toFake: ["setTimeout", "Date"] });
await Promise.all([
expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "authenticated" }),
vi.advanceTimersByTimeAsync(1_000),
]);
expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]);
});

it("takes a pairing token from the location hash and strips it immediately", async () => {
const testWindow = installTestBrowser("http://localhost/#token=pairing-token");
const { takePairingTokenFromUrl } = await import("./environments/primary");
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/environments/primary/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ function waitForBootstrapRetry(delayMs: number): Promise<void> {

function isTransientBootstrapError(error: unknown): boolean {
if (isPrimaryEnvironmentRequestError(error)) {
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || isTransportError(error.cause);
}

if (error instanceof TypeError) {
Expand All @@ -301,6 +301,10 @@ function isTransientBootstrapError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}

function isTransportError(error: unknown): boolean {
return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError";
}

async function bootstrapServerAuth(urlCredential: string | null): Promise<ServerAuthGateState> {
const currentSession = await fetchSessionState();
if (currentSession.authenticated && !urlCredential) {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/test/environmentHttpTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { HttpApiTest } from "effect/http-api";
import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";

import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient";
import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime";
import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "../src/lib/runtime";

type BrowserSessionHandler = (
payload: AuthBrowserSessionRequest,
Expand Down Expand Up @@ -125,10 +125,12 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc
);

const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client));
__setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect));
const run: PrimaryHttpEffectRunner = (effect) => runtime.runPromise(effect);
__setPrimaryHttpRunnerForTests(run);

return {
calls,
run,
async dispose() {
__setPrimaryHttpRunnerForTests();
await runtime.dispose();
Expand Down
Loading