Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
"vitest": "^2.1.8",
"yaml": "^2.6.1"
}
}
65 changes: 65 additions & 0 deletions apps/api/src/__tests__/e2e.docker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterAll, describe, expect, it } from "vitest";
import { loadConfig } from "../config";
import { DockerSessionRuntime } from "../docker-session-runtime";

// Opt-in end-to-end test against a REAL Docker engine. Skipped unless
// AIRLOCK_E2E=1, so normal CI never runs it. It drives the full lifecycle:
// ping -> pull -> create -> list -> extend -> stop
//
// Run it with a reachable engine and (optionally) a pre-pulled image:
// AIRLOCK_E2E=1 AIRLOCK_IMAGE_CHROMIUM=kasmweb/chromium:1.18.0 \
// bun run --filter @airlock/api test
//
// The first run pulls the (large) Kasm image, so the create test gets a
// generous timeout. Point AIRLOCK_IMAGE_CHROMIUM at a pre-pulled tag to skip
// the download.
const e2eEnabled = process.env.AIRLOCK_E2E === "1";

describe.runIf(e2eEnabled)("DockerSessionRuntime e2e (real engine)", () => {
const config = loadConfig({
...process.env,
// Keep sessions short; the test stops them explicitly anyway.
AIRLOCK_DEFAULT_TTL_SECONDS: "60",
// Isolation network adds engine state; leave it on to exercise the path.
AIRLOCK_NETWORK_ISOLATION: process.env.AIRLOCK_NETWORK_ISOLATION ?? "true"
} as NodeJS.ProcessEnv);
const runtime = new DockerSessionRuntime({ config });
let createdSessionId: string | null = null;

afterAll(async () => {
if (createdSessionId) {
await runtime.stopSession(createdSessionId).catch(() => undefined);
}
});

it("reaches the engine", async () => {
expect(await runtime.ping()).toBe(true);
});

it("creates, lists, extends, and stops a session", async () => {
const session = await runtime.createSession({
browser: "chromium",
targetUrl: "https://example.com",
ttlSeconds: 60
});
createdSessionId = session.sessionId;

expect(session.sessionId).toMatch(/[0-9a-f-]{36}/);
expect(session.browserUrl).toMatch(/^https:\/\//);
expect(session.vncPassword.length).toBeGreaterThan(0);

const listed = await runtime.listSessions();
expect(listed.some((s) => s.sessionId === session.sessionId)).toBe(true);

const extended = await runtime.extendSession(session.sessionId, 120);
expect(extended).not.toBeNull();
expect(new Date(extended!.expiresAt).getTime()).toBeGreaterThan(
new Date(session.expiresAt).getTime()
);

expect(await runtime.stopSession(session.sessionId)).toBe(true);
createdSessionId = null;
const afterStop = await runtime.getSession(session.sessionId);
expect(afterStop).toBeNull();
}, 300_000);
});
61 changes: 61 additions & 0 deletions apps/api/src/__tests__/openapi-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { BROWSER_KINDS, TTL_MAX_SECONDS, TTL_MIN_SECONDS } from "@airlock/shared";
import { parse } from "yaml";
import { describe, expect, it } from "vitest";

// Guards docs/openapi.yaml against silent drift from the code's actual
// contracts. It is not a full generator, but it pins the dynamic surfaces
// (browser catalog, TTL bounds, route table) that are easiest to forget.
const specPath = path.resolve(process.cwd(), "../../docs/openapi.yaml");
const spec = parse(readFileSync(specPath, "utf8")) as {
paths: Record<string, Record<string, unknown>>;
components: {
schemas: {
BrowserKind: { enum: string[] };
CreateSessionRequest: { properties: { ttlSeconds: { minimum: number; maximum: number } } };
ExtendSessionRequest: { properties: { ttlSeconds: { minimum: number; maximum: number } } };
};
};
};

describe("openapi.yaml stays in sync with the code", () => {
it("lists exactly the supported browser kinds", () => {
expect([...spec.components.schemas.BrowserKind.enum].sort()).toEqual([...BROWSER_KINDS].sort());
});

it("uses the shared TTL bounds on the create and extend bodies", () => {
for (const schema of ["CreateSessionRequest", "ExtendSessionRequest"] as const) {
const ttl = spec.components.schemas[schema].properties.ttlSeconds;
expect(ttl.minimum).toBe(TTL_MIN_SECONDS);
expect(ttl.maximum).toBe(TTL_MAX_SECONDS);
}
});

it("documents every implemented route", () => {
const expectedPaths = [
"/healthz",
"/health",
"/readyz",
"/metrics",
"/api/meta",
"/api/sessions",
"/api/sessions/{sessionId}",
"/api/images/pull",
"/api/internal/prune",
"/s/{sessionId}"
];
for (const route of expectedPaths) {
expect(spec.paths).toHaveProperty([route]);
}
});

it("documents the methods on the session collection and item paths", () => {
expect(Object.keys(spec.paths["/api/sessions"])).toEqual(
expect.arrayContaining(["get", "post"])
);
expect(Object.keys(spec.paths["/api/sessions/{sessionId}"])).toEqual(
expect.arrayContaining(["get", "patch", "delete"])
);
});
});
98 changes: 98 additions & 0 deletions apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the client factory but keep the real AirlockApiError for instanceof.
vi.mock("./lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/api")>();
return { ...actual, createAirlockClient: vi.fn() };
});

import { App } from "./App";
import { AirlockApiError, AirlockClient, createAirlockClient } from "./lib/api";

const meta = {
browsers: ["chromium", "firefox"],
defaultBrowser: "chromium",
defaultTtlSeconds: 1800,
ttlMinSeconds: 60,
ttlMaxSeconds: 86400
};

const makeClient = (overrides: Partial<AirlockClient> = {}): AirlockClient => ({
getMeta: vi.fn().mockResolvedValue(meta),
listSessions: vi.fn().mockResolvedValue([]),
createSession: vi.fn(),
extendSession: vi.fn(),
stopSession: vi.fn(),
pullImages: vi.fn().mockResolvedValue([]),
...overrides
});

const useClient = (client: AirlockClient): void => {
vi.mocked(createAirlockClient).mockReturnValue(client);
};

beforeEach(() => {
// jsdom in this config may not expose localStorage; token-storage tolerates
// that, so the test just guards the cleanup.
try {
window.localStorage?.clear();
} catch {
// ignore
}
vi.mocked(createAirlockClient).mockReset();
});

describe("App", () => {
it("shows the dashboard when the token is accepted", async () => {
useClient(makeClient());
render(<App />);
expect(await screen.findByText("Launch a disposable browser")).toBeTruthy();
expect(screen.getByText("Active sessions")).toBeTruthy();
});

it("shows the login screen on a 401", async () => {
useClient(
makeClient({
getMeta: vi.fn().mockRejectedValue(new AirlockApiError("Unauthorized.", 401))
})
);
render(<App />);
expect(await screen.findByRole("button", { name: "Connect" })).toBeTruthy();
});

it("launches a session through the client", async () => {
const createSession = vi.fn().mockResolvedValue({
sessionId: "new-1",
browser: "chromium",
targetUrl: "https://example.com",
browserUrl: "https://localhost:32792",
sessionUrl: "http://localhost:8787/s/new-1",
vncPassword: "pw",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString()
});
useClient(makeClient({ createSession }));
render(<App />);

fireEvent.change(await screen.findByLabelText("URL to open"), {
target: { value: "https://example.com" }
});
fireEvent.click(screen.getByRole("button", { name: "Launch session" }));

await waitFor(() =>
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ targetUrl: "https://example.com" })
)
);
});

it("pulls images from the warm action", async () => {
const pullImages = vi.fn().mockResolvedValue([{ image: "kasmweb/chromium:1.18.0", ok: true }]);
useClient(makeClient({ pullImages }));
render(<App />);

fireEvent.click(await screen.findByRole("button", { name: "Pre-pull browser images" }));
await waitFor(() => expect(pullImages).toHaveBeenCalled());
});
});
98 changes: 98 additions & 0 deletions apps/web/src/components/SessionList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SessionResponse } from "../lib/api";
import { SessionList } from "./SessionList";

const session = (overrides: Partial<SessionResponse> = {}): SessionResponse => ({
sessionId: "s-1",
browser: "chromium",
targetUrl: "https://example.com",
browserUrl: "https://localhost:32792",
sessionUrl: "http://localhost:8787/s/s-1",
vncPassword: "pw",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 600_000).toISOString(),
...overrides
});

const noop = () => undefined;

describe("SessionList", () => {
it("shows the empty state with no sessions", () => {
render(
<SessionList
sessions={[]}
terminatingId={null}
extendingId={null}
onOpen={noop}
onExtend={noop}
onTerminate={noop}
/>
);
expect(screen.getByText(/No active sessions/i)).toBeTruthy();
});

it("fires open, extend, and terminate handlers with the session id", () => {
const onOpen = vi.fn();
const onExtend = vi.fn();
const onTerminate = vi.fn();
render(
<SessionList
sessions={[session()]}
terminatingId={null}
extendingId={null}
onOpen={onOpen}
onExtend={onExtend}
onTerminate={onTerminate}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Open" }));
fireEvent.click(screen.getByRole("button", { name: "+15m" }));
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));

expect(onOpen).toHaveBeenCalledWith("s-1");
expect(onExtend).toHaveBeenCalledWith("s-1", 900);
expect(onTerminate).toHaveBeenCalledWith("s-1");
});

it("reflects in-progress states on the buttons", () => {
render(
<SessionList
sessions={[session()]}
terminatingId="s-1"
extendingId="s-1"
onOpen={noop}
onExtend={noop}
onTerminate={noop}
/>
);
expect((screen.getByRole("button", { name: "Ending…" }) as HTMLButtonElement).disabled).toBe(
true
);
expect((screen.getByRole("button", { name: "Extending…" }) as HTMLButtonElement).disabled).toBe(
true
);
});

it("copies the share link to the clipboard", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
render(
<SessionList
sessions={[session()]}
terminatingId={null}
extendingId={null}
onOpen={noop}
onExtend={noop}
onTerminate={noop}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Copy link" }));
expect(writeText).toHaveBeenCalledWith("http://localhost:8787/s/s-1");
// Await the async re-render to "Copied" so the state update is wrapped; the
// pending reset timer is cleared on unmount.
await screen.findByRole("button", { name: "Copied" });
});
});
8 changes: 6 additions & 2 deletions apps/web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { SessionResponse } from "../lib/api";
import { formatTimeRemaining } from "../lib/time";

Expand Down Expand Up @@ -73,12 +73,16 @@ export const SessionList = ({

const CopyLinkButton = ({ url }: { url: string }): JSX.Element => {
const [copied, setCopied] = useState(false);
const resetTimer = useRef<number | undefined>(undefined);

useEffect(() => () => window.clearTimeout(resetTimer.current), []);

const copy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
window.clearTimeout(resetTimer.current);
resetTimer.current = window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard access can be denied; leave the label unchanged.
}
Expand Down
Loading