Skip to content
Open
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
80 changes: 80 additions & 0 deletions apps/web/__tests__/api/auth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/(auth)/callback/route";

const mocks = vi.hoisted(() => ({
exchange: vi.fn(),
getUser: vi.fn(),
profile: vi.fn(),
}));

vi.mock("@/lib/supabase/server", () => ({
createClient: () => ({ auth: { exchangeCodeForSession: mocks.exchange, getUser: mocks.getUser } }),
}));
vi.mock("@/lib/supabase/service", () => ({
getServiceClient: () => ({
from: () => ({ select: () => ({ eq: () => ({ single: mocks.profile }) }) }),
}),
}));
vi.mock("@/lib/analytics/server", () => ({
captureServerActivationEvent: vi.fn(),
identifyServerActivationUser: vi.fn(),
}));

function callback(next?: string, withCode = true) {
const url = new URL("https://straude.com/callback");
if (withCode) url.searchParams.set("code", "oauth-code");
if (next !== undefined) url.searchParams.set("next", next);
return GET(new Request(url));
}

describe("auth callback return path", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.exchange.mockResolvedValue({ error: null });
mocks.getUser.mockResolvedValue({ data: { user: { id: "user-1" } } });
mocks.profile.mockResolvedValue({ data: { username: "developer", onboarding_completed: true } });
});

it.each([false, true])("returns a user with onboarding_completed=%s to explicit CLI approval", async (completed) => {
mocks.profile.mockResolvedValue({ data: { username: "developer", onboarding_completed: completed } });
const next = "/cli/verify?code=ABCD1234&verify_secret=secret%2Bwith%2Fsymbols%3D";
const response = await callback(next);
expect(response.headers.get("location")).toBe(`https://straude.com${next}`);
expect(mocks.exchange).toHaveBeenCalledWith("oauth-code");
});

it("sends ordinary fresh signups to onboarding", async () => {
mocks.profile.mockResolvedValue({ data: { username: null, onboarding_completed: false } });
expect((await callback()).headers.get("location")).toBe("https://straude.com/onboarding");
});

it("sends ordinary returning users to feed", async () => {
expect((await callback()).headers.get("location")).toBe("https://straude.com/feed");
});

it("honors an explicit onboarding recovery destination", async () => {
expect((await callback("/onboarding")).headers.get("location")).toBe("https://straude.com/onboarding");
});

it.each([
"https://evil.example", "//evil.example", "/\\evil.example", "/%5cevil.example",
"/%2fevil.example", "/\tevil.example", "/%0a/evil.example", "/%0d/evil.example",
"/%00evil.example", "/%7fevil.example", "javascript:alert(1)", "/%broken",
])("rejects an unsafe destination: %s", async (next) => {
expect((await callback(next)).headers.get("location")).toBe("https://straude.com/feed");
});

it.each([true, false])("preserves the CLI return path when auth fails (withCode=%s)", async (withCode) => {
mocks.exchange.mockResolvedValue({ error: { message: "Expired code" } });
const next = "/cli/verify?code=ABCD1234&verify_secret=secret";
const destination = new URL((await callback(next, withCode)).headers.get("location")!);
expect(destination.pathname).toBe("/login");
expect(destination.searchParams.get("error")).toBe("auth");
expect(destination.searchParams.get("next")).toBe(next);
});

it("does not forward an unsafe destination after an auth failure", async () => {
mocks.exchange.mockResolvedValue({ error: { message: "Expired code" } });
expect((await callback("//evil.example")).headers.get("location")).toBe("https://straude.com/login?error=auth");
});
});
75 changes: 75 additions & 0 deletions apps/web/__tests__/components/AuthReturnPath.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "@/app/(auth)/login/page";
import SignupPage from "@/app/(auth)/signup/page";

const mocks = vi.hoisted(() => ({
params: new URLSearchParams(),
otp: vi.fn(),
oauth: vi.fn(),
}));

vi.mock("next/navigation", () => ({ useSearchParams: () => mocks.params }));
vi.mock("@/lib/supabase/client", () => ({
createClient: () => ({ auth: { signInWithOtp: mocks.otp, signInWithOAuth: mocks.oauth } }),
}));
vi.mock("@/lib/analytics/client", () => ({ trackActivationEvent: vi.fn() }));

const returnTo = "/cli/verify?code=ABCD1234&verify_secret=secret%2Bwith%2Fsymbols%3D";

describe.each([
{ name: "login", Page: LoginPage, link: "Sign up", otherPath: "/signup" },
{ name: "signup", Page: SignupPage, link: "Log in", otherPath: "/login" },
])("$name return path", ({ Page, link, otherPath }) => {
beforeEach(() => {
vi.clearAllMocks();
mocks.params = new URLSearchParams({ next: returnTo });
mocks.otp.mockResolvedValue({ error: null });
mocks.oauth.mockResolvedValue({ error: null });
});
afterEach(cleanup);

it("preserves the complete CLI request in the magic link callback", async () => {
render(<Page />);
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "dev@example.com" } });
fireEvent.click(screen.getByRole("button", { name: "Send magic link" }));
await waitFor(() => expect(mocks.otp).toHaveBeenCalledOnce());
const callback = new URL(mocks.otp.mock.calls[0][0].options.emailRedirectTo);
expect(callback.origin).toBe(window.location.origin);
expect(callback.pathname).toBe("/callback");
expect(callback.searchParams.get("next")).toBe(returnTo);
await screen.findByText("Check your email");
});

it("preserves the complete CLI request in the GitHub callback", () => {
render(<Page />);
fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" }));
expect(mocks.oauth).toHaveBeenCalledOnce();
expect(mocks.oauth.mock.calls[0][0].provider).toBe("github");
const callback = new URL(mocks.oauth.mock.calls[0][0].options.redirectTo);
expect(callback.pathname).toBe("/callback");
expect(callback.searchParams.get("next")).toBe(returnTo);
});

it("keeps the CLI request when switching between login and signup", () => {
render(<Page />);
const destination = new URL(screen.getByRole("link", { name: link }).getAttribute("href")!, window.location.origin);
expect(destination.pathname).toBe(otherPath);
expect(destination.searchParams.get("next")).toBe(returnTo);
});

it("supports returning to onboarding after session recovery", () => {
mocks.params = new URLSearchParams({ next: "/onboarding" });
render(<Page />);
fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" }));
expect(new URL(mocks.oauth.mock.calls[0][0].options.redirectTo).searchParams.get("next")).toBe("/onboarding");
});

it.each([null, "https://evil.example", "/\\evil.example", "//evil.example"])("omits an absent or unsafe return path: %s", (next) => {
mocks.params = new URLSearchParams(next ? { next } : {});
render(<Page />);
expect(screen.getByRole("link", { name: link })).toHaveAttribute("href", otherPath);
fireEvent.click(screen.getByRole("button", { name: "Continue with GitHub" }));
expect(mocks.oauth.mock.calls[0][0].options.redirectTo).toBe(`${window.location.origin}/callback`);
});
});
200 changes: 200 additions & 0 deletions apps/web/__tests__/components/OnboardingPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import OnboardingPage from "@/app/(onboarding)/onboarding/page";

const { push, track } = vi.hoisted(() => ({ push: vi.fn(), track: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
vi.mock("@/lib/analytics/client", () => ({ trackActivationEvent: track }));

const usage = {
has_data: true,
has_usage: true,
cost_usd: 12.34,
total_tokens: 12_000,
session_count: 3,
top_model: "gpt-5.4",
latest_usage_id: "usage-1",
latest_usage_date: "2026-09-03",
latest_post_url: "/post/post-1",
};
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const getStatus = vi.fn();
const completeSetup = vi.fn();
const copy = vi.fn();
let fetchMock: ReturnType<typeof vi.fn>;

async function flush() {
await act(async () => {});
}

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
getStatus.mockReset().mockImplementation(() => Promise.resolve(response({ has_data: false })));
completeSetup.mockReset().mockImplementation(() => Promise.resolve(response({ username: "oscar" })));
copy.mockReset().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: copy } });
fetchMock = vi.fn((url: string, options?: RequestInit) => {
if (url === "/api/usage/status") return getStatus(options);
if (options?.method === "PATCH") return completeSetup(options);
return Promise.resolve(response({ username: "oscar" }));
});
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});

describe("first-sync onboarding", () => {
it("shows the command immediately without requiring a profile save", async () => {
render(<OnboardingPage />);
expect(screen.getByRole("textbox", { name: "Sync command" })).toHaveValue("npx straude@latest");
expect(screen.queryByRole("textbox", { name: /username/i })).not.toBeInTheDocument();
expect(screen.queryByText("Ready to sync")).not.toBeInTheDocument();
await flush();
expect(completeSetup).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => url.includes("check-username"))).toBe(false);
});

it("activates only after confirmed usage and a successful completion save", async () => {
let resolveCompletion!: (value: Response) => void;
completeSetup.mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveCompletion = resolve; }));
getStatus.mockResolvedValueOnce(response({ has_data: false })).mockResolvedValueOnce(response(usage));
render(<OnboardingPage />);
await flush();
fireEvent.click(screen.getByRole("button", { name: "Copy sync command" }));
await flush();
await act(async () => { await vi.advanceTimersByTimeAsync(4000); });
expect(screen.getByText("Finishing setup…")).toBeInTheDocument();
expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything());
expect(screen.queryByText("Your first sync is complete")).not.toBeInTheDocument();
expect(JSON.parse(completeSetup.mock.calls[0][0].body)).toEqual({
onboarding_completed: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
await act(async () => { resolveCompletion(response({})); });
expect(screen.getByText("Your first sync is complete")).toBeInTheDocument();
expect(screen.getByText("$12.34")).toBeInTheDocument();
expect(screen.getByText("12k")).toBeInTheDocument();
expect(screen.getByText("gpt-5.4")).toBeInTheDocument();
expect(screen.getByText("2026-09-03")).toBeInTheDocument();
expect(screen.queryByText("Sessions")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /add a handle/ })).toHaveAttribute("href", "/settings");
expect(track).toHaveBeenCalledWith("activation_completed", expect.objectContaining({
has_existing_usage: false,
session_count: 3,
"$insert_id": "activation_completed:usage-1",
}));
fireEvent.click(screen.getByRole("button", { name: "View your profile" }));
expect(push).toHaveBeenCalledWith("/u/oscar");
await act(async () => { await vi.advanceTimersByTimeAsync(12_000); });
expect(getStatus).toHaveBeenCalledTimes(2);
expect(completeSetup).toHaveBeenCalledTimes(1);
});

it.each(["network", "http"])("recovers from a %s status failure", async (failure) => {
if (failure === "network") getStatus.mockRejectedValueOnce(new Error("offline"));
else getStatus.mockResolvedValueOnce(response({}, 503));
getStatus.mockResolvedValueOnce(response(usage));
render(<OnboardingPage />);
await flush();
expect(screen.getByRole("alert")).toHaveTextContent("could not check your usage");
expect(completeSetup).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
await flush();
expect(screen.getByText("Your first sync is complete")).toBeInTheDocument();
});

it.each(["network", "http"])("retries completion after a %s failure without claiming activation", async (failure) => {
getStatus.mockResolvedValueOnce(response(usage));
if (failure === "network") completeSetup.mockRejectedValueOnce(new Error("offline"));
else completeSetup.mockResolvedValueOnce(response({ error: "Unable to verify first sync" }, 500));
render(<OnboardingPage />);
await flush();
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything());
expect(screen.queryByText("Your first sync is complete")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry setup" }));
await flush();
expect(screen.getByText("Your first sync is complete")).toBeInTheDocument();
expect(getStatus).toHaveBeenCalledTimes(1);
expect(completeSetup).toHaveBeenCalledTimes(2);
expect(track.mock.calls.filter(([event]) => event === "activation_completed")).toHaveLength(1);
});

it("offers sign-in recovery with an onboarding return path", async () => {
getStatus.mockResolvedValueOnce(response({}, 401));
render(<OnboardingPage />);
await flush();
expect(screen.getByRole("link", { name: "Sign in again" })).toHaveAttribute("href", "/login?next=%2Fonboarding");
expect(completeSetup).not.toHaveBeenCalled();
});

it("keeps the command selectable when clipboard access is refused", async () => {
copy.mockRejectedValueOnce(new Error("denied"));
render(<OnboardingPage />);
fireEvent.click(screen.getByRole("button", { name: "Copy sync command" }));
await flush();
expect(screen.getByText(/copy it manually/)).toBeInTheDocument();
const input = screen.getByRole("textbox", { name: "Sync command" }) as HTMLInputElement;
fireEvent.focus(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe("npx straude@latest".length);
expect(track).not.toHaveBeenCalledWith("sync_command_copied", expect.anything());
});

it("does not activate on explore or act on an in-flight check after unmount", async () => {
let resolveStatus!: (value: Response) => void;
getStatus.mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveStatus = resolve; }));
const { unmount } = render(<OnboardingPage />);
fireEvent.click(screen.getByRole("button", { name: "Explore without syncing" }));
expect(push).toHaveBeenCalledWith("/feed");
unmount();
expect(getStatus.mock.calls[0][0].signal.aborted).toBe(true);
await act(async () => { resolveStatus(response(usage)); });
expect(completeSetup).not.toHaveBeenCalled();
expect(track).not.toHaveBeenCalledWith("activation_completed", expect.anything());
});

it("does not overlap slow status requests", async () => {
let resolveStatus!: (value: Response) => void;
getStatus.mockImplementationOnce(() => new Promise<Response>((resolve) => { resolveStatus = resolve; }));
render(<OnboardingPage />);
await act(async () => { await vi.advanceTimersByTimeAsync(12_000); });
expect(getStatus).toHaveBeenCalledTimes(1);
await act(async () => { resolveStatus(response({ has_data: false })); });
await act(async () => { await vi.advanceTimersByTimeAsync(4000); });
expect(getStatus).toHaveBeenCalledTimes(2);
});

it("turns a stalled request into a recoverable error after fifteen seconds", async () => {
getStatus.mockImplementationOnce((options: RequestInit) => new Promise<Response>((_resolve, reject) => {
options.signal?.addEventListener("abort", () => reject(new Error("request aborted")));
}));
render(<OnboardingPage />);
await act(async () => { await vi.advanceTimersByTimeAsync(15_000); });
expect(screen.getByRole("alert")).toHaveTextContent("could not check your usage");
expect(getStatus.mock.calls[0][0].signal.aborted).toBe(true);
expect(completeSetup).not.toHaveBeenCalled();
getStatus.mockResolvedValueOnce(response(usage));
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
await flush();
expect(screen.getByText("Your first sync is complete")).toBeInTheDocument();
});

it("stops waiting after five minutes and can resume checks", async () => {
render(<OnboardingPage />);
await act(async () => { await vi.advanceTimersByTimeAsync(300_000); });
expect(screen.getByRole("alert")).toHaveTextContent("No usage received yet");
const previousChecks = getStatus.mock.calls.length;
await act(async () => { await vi.advanceTimersByTimeAsync(8000); });
expect(getStatus).toHaveBeenCalledTimes(previousChecks);
getStatus.mockResolvedValueOnce(response(usage));
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
await flush();
expect(screen.getByText("Your first sync is complete")).toBeInTheDocument();
});
});
11 changes: 6 additions & 5 deletions apps/web/app/(auth)/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { safeAuthNext } from "@/lib/supabase/redirect";
import { after } from "@/lib/utils/after";
import { ACTIVATION_ANONYMOUS_COOKIE, deriveActivationState, getCookieValue } from "@/lib/analytics/activation";
import { captureServerActivationEvent, identifyServerActivationUser } from "@/lib/analytics/server";
Expand All @@ -9,10 +10,8 @@ export async function GET(request: Request) {
const { searchParams, origin: requestOrigin } = new URL(request.url);
const origin = requestOrigin;
const code = searchParams.get("code");
const rawNext = searchParams.get("next") ?? "/feed";
// Prevent open redirect: only allow relative paths starting with /
const next =
rawNext.startsWith("/") && !rawNext.startsWith("//") ? rawNext : "/feed";
const returnTo = safeAuthNext(searchParams.get("next"));
const next = returnTo ?? "/feed";

if (code) {
const supabase = await createClient();
Expand Down Expand Up @@ -84,5 +83,7 @@ export async function GET(request: Request) {
}
}

return NextResponse.redirect(`${origin}/login?error=auth`);
const retryParams = new URLSearchParams({ error: "auth" });
if (returnTo) retryParams.set("next", returnTo);
return NextResponse.redirect(`${origin}/login?${retryParams}`);
}
Loading
Loading