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
21 changes: 21 additions & 0 deletions app/api/sandbox/reconnect/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { GET } from "@/app/api/sandbox/reconnect/route";
import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler";

vi.mock("@/lib/sandbox/getSandboxReconnectHandler", () => ({
getSandboxReconnectHandler: vi.fn(async () => NextResponse.json({ ok: true }, { status: 200 })),
}));

describe("GET /api/sandbox/reconnect route shell", () => {
it("delegates to getSandboxReconnectHandler", async () => {
const req = new NextRequest("http://localhost/api/sandbox/reconnect?sessionId=s", {
method: "GET",
});
const res = await GET(req);

expect(getSandboxReconnectHandler).toHaveBeenCalledWith(req);
expect(res.status).toBe(200);
});
});
30 changes: 30 additions & 0 deletions app/api/sandbox/reconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(),
});
}

/**
* `GET /api/sandbox/reconnect?sessionId=...` — live runtime probe to
* decide whether the sandbox bound to a session is still reachable.
*
* @param request - The incoming request.
* @returns A NextResponse with `{ status, hasSnapshot, expiresAt?, lifecycle }` on 200, or an error.
*/
export async function GET(request: NextRequest) {
return getSandboxReconnectHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
177 changes: 177 additions & 0 deletions lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { getSandboxReconnectHandler } from "@/lib/sandbox/getSandboxReconnectHandler";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { connectSandbox } from "@/lib/sandbox/factory";
import { updateSession } from "@/lib/supabase/sessions/updateSession";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));
vi.mock("@/lib/sandbox/factory", () => ({
connectSandbox: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/updateSession", () => ({
updateSession: vi.fn(),
}));

const ACCOUNT_ID = "acc-1";
const RUNTIME_STATE = { type: "vercel", sandboxName: "session-sess-1" };

const baseRow = {
id: "sess-1",
account_id: ACCOUNT_ID,
sandbox_state: null as unknown,
lifecycle_state: null as string | null,
lifecycle_version: 0,
sandbox_expires_at: null as string | null,
hibernate_after: null as string | null,
last_activity_at: null as string | null,
snapshot_url: null as string | null,
};

function makeReq(query = "?sessionId=sess-1"): NextRequest {
return new NextRequest(`http://localhost/api/sandbox/reconnect${query}`, { method: "GET" });
}

function fakeAliveSandbox(expiresAt = Date.now() + 1_800_000) {
return {
expiresAt,
exec: vi.fn(async () => ({
success: true,
exitCode: 0,
stdout: "/workspace",
stderr: "",
truncated: false,
})),
};
}

describe("getSandboxReconnectHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_ID,
orgId: null,
authToken: "k",
});
vi.mocked(updateSession).mockResolvedValue({} as never);
});

it("returns the auth response unchanged when auth fails", async () => {
const fail = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValueOnce(fail);

const res = await getSandboxReconnectHandler(makeReq());

expect(res).toBe(fail);
});

it("returns 400 when sessionId is missing", async () => {
const res = await getSandboxReconnectHandler(makeReq(""));
expect(res.status).toBe(400);
});

it("returns 404 when no session exists with the given id", async () => {
vi.mocked(selectSessions).mockResolvedValue([]);

const res = await getSandboxReconnectHandler(makeReq());
expect(res.status).toBe(404);
});

it("returns 403 when the session is not owned by the authenticated account", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, account_id: "someone-else" } as never,
]);

const res = await getSandboxReconnectHandler(makeReq());
expect(res.status).toBe(403);
});

it("returns status='no_sandbox' when sandbox_state has no runtime metadata", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: { type: "vercel" } } as never,
]);

const res = await getSandboxReconnectHandler(makeReq());

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("no_sandbox");
expect(body.expiresAt).toBeUndefined();
expect(connectSandbox).not.toHaveBeenCalled();
});

it("sets hasSnapshot=true when snapshot_url is set on a no_sandbox session", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: null, snapshot_url: "snap://x" } as never,
]);

const res = await getSandboxReconnectHandler(makeReq());

const body = await res.json();
expect(body.status).toBe("no_sandbox");
expect(body.hasSnapshot).toBe(true);
});

it("returns status='connected' with expiresAt when the runtime probe succeeds", async () => {
const expiresAt = Date.parse("2099-01-01T00:00:00.000Z");
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE } as never,
]);
vi.mocked(connectSandbox).mockResolvedValueOnce(fakeAliveSandbox(expiresAt) as never);

const res = await getSandboxReconnectHandler(makeReq());

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("connected");
expect(body.expiresAt).toBe(expiresAt);
});

it("returns status='expired' and clears runtime state when the probe throws", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never,
]);
vi.mocked(connectSandbox).mockRejectedValueOnce(new Error("sandbox not found"));

const res = await getSandboxReconnectHandler(makeReq());

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("expired");
expect(body.expiresAt).toBeUndefined();
expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
sandbox_state: null,
lifecycle_state: "hibernated",
}),
);
});

it("includes the lifecycle envelope on every 200 response", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: { type: "vercel" } } as never,
]);

const res = await getSandboxReconnectHandler(makeReq());

const body = await res.json();
expect(body.lifecycle).toMatchObject({
serverTime: expect.any(Number),
state: null,
lastActivityAt: null,
hibernateAfter: null,
sandboxExpiresAt: null,
});
});
});
53 changes: 53 additions & 0 deletions lib/sandbox/__tests__/noSandboxResponse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from "vitest";
import { NextResponse } from "next/server";
import { noSandboxResponse } from "@/lib/sandbox/noSandboxResponse";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));

const baseRow = {
id: "sess-1",
account_id: "acc-1",
sandbox_state: null,
lifecycle_state: null,
lifecycle_version: 0,
sandbox_expires_at: null,
hibernate_after: null,
last_activity_at: null,
snapshot_url: null,
};

describe("noSandboxResponse", () => {
it("returns a 200 NextResponse with status='no_sandbox'", async () => {
const res = noSandboxResponse(baseRow as never);

expect(res).toBeInstanceOf(NextResponse);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("no_sandbox");
});

it("derives hasSnapshot from snapshot_url presence", async () => {
const withSnap = await noSandboxResponse({
...baseRow,
snapshot_url: "snap://x",
} as never).json();
const without = await noSandboxResponse(baseRow as never).json();

expect(withSnap.hasSnapshot).toBe(true);
expect(without.hasSnapshot).toBe(false);
});

it("includes the lifecycle envelope projected from the row", async () => {
const body = await noSandboxResponse(baseRow as never).json();

expect(body.lifecycle).toMatchObject({
serverTime: expect.any(Number),
state: null,
lastActivityAt: null,
hibernateAfter: null,
sandboxExpiresAt: null,
});
});
});
81 changes: 81 additions & 0 deletions lib/sandbox/__tests__/validateSandboxReconnectRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";

import { validateSandboxReconnectRequest } from "@/lib/sandbox/validateSandboxReconnectRequest";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));

const ACCOUNT_ID = "acc-1";
const baseRow = { id: "sess-1", account_id: ACCOUNT_ID } as never;

function makeReq(query = "?sessionId=sess-1"): NextRequest {
return new NextRequest(`http://localhost/api/sandbox/reconnect${query}`, { method: "GET" });
}

describe("validateSandboxReconnectRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: ACCOUNT_ID,
orgId: null,
authToken: "k",
});
});

it("returns the auth response unchanged when auth fails", async () => {
const fail = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValueOnce(fail);

const result = await validateSandboxReconnectRequest(makeReq());

expect(result).toBe(fail);
});

it("returns 400 when sessionId is missing from the query", async () => {
const result = await validateSandboxReconnectRequest(makeReq(""));

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(400);
});

it("returns 404 when no session exists with the given id", async () => {
vi.mocked(selectSessions).mockResolvedValue([]);

const result = await validateSandboxReconnectRequest(makeReq());

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(404);
});

it("returns 403 when the session is not owned by the authenticated account", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, account_id: "someone-else" } as never,
]);

const result = await validateSandboxReconnectRequest(makeReq());

expect(result).toBeInstanceOf(NextResponse);
expect((result as NextResponse).status).toBe(403);
});

it("returns the validated row + auth on the happy path", async () => {
vi.mocked(selectSessions).mockResolvedValue([baseRow]);

const result = await validateSandboxReconnectRequest(makeReq());

expect(result).not.toBeInstanceOf(NextResponse);
if (result instanceof NextResponse) return;
expect(result.row.id).toBe("sess-1");
expect(result.auth.accountId).toBe(ACCOUNT_ID);
});
});
Loading
Loading