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
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@

import { describe, it, expect, vi, beforeEach } from "vitest";

const { mockGetSession, mockGetGoogleAccessToken } = vi.hoisted(() => ({
const { mockGetSession, mockGetGoogleAccessToken, mockGetEnv } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockGetGoogleAccessToken: vi.fn(),
mockGetEnv: vi.fn(),
}));

vi.mock("@/lib/env", () => ({
getEnv: mockGetEnv,
}));

vi.mock("@/lib/auth", () => ({
Expand All @@ -35,6 +40,10 @@ describe("GET /api/auth/health", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
mockGetEnv.mockReturnValue({
AUTH_MODE: "service_account",
BETTER_AUTH_SECRET: "mock-secret",
});
});

it("returns 200 { ok: true } when token acquisition succeeds", async () => {
Expand All @@ -51,7 +60,6 @@ describe("GET /api/auth/health", () => {
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("no_credentials");
expect(body.error.source).toBe("admin-sdk");
});
Expand All @@ -62,4 +70,30 @@ describe("GET /api/auth/health", () => {
const res = await GET();
expect(res.status).toBe(401);
});

describe("user_oauth mode", () => {
beforeEach(() => {
mockGetEnv.mockReturnValue({
AUTH_MODE: "user_oauth",
BETTER_AUTH_SECRET: "mock-secret",
GOOGLE_CLIENT_ID: "123-abc.apps.googleusercontent.com",
GOOGLE_CLIENT_SECRET: "secret",
});
});

it("returns 200 when both session and Google token are valid", async () => {
mockGetGoogleAccessToken.mockResolvedValue("mock-oauth-token");
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
});

it("returns 401 unauthenticated when Google token is expired/missing", async () => {
mockGetGoogleAccessToken.mockResolvedValue(undefined);
const res = await GET();
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error.code).toBe("unauthenticated");
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const { mockGetEnv } = vi.hoisted(() => ({
mockGetEnv: vi.fn(),
}));

vi.mock("@/lib/env", () => ({
getEnv: mockGetEnv,
}));

import { GET } from "@/app/api/auth/auto-session/route";

describe("GET /api/auth/auto-session", () => {
const originalFetch = global.fetch;

beforeEach(() => {
vi.clearAllMocks();
mockGetEnv.mockReturnValue({
AUTH_MODE: "service_account",
BETTER_AUTH_SECRET: "mock-secret",
BETTER_AUTH_URL: "http://localhost:3000",
});
});

afterEach(() => {
global.fetch = originalFetch;
});

it("returns 404 when AUTH_MODE is not service_account", async () => {
mockGetEnv.mockReturnValue({
AUTH_MODE: "user_oauth",
});

const req = new Request("http://localhost:3000/api/auth/auto-session");
const res = await GET(req);
expect(res.status).toBe(404);
expect(await res.text()).toBe("Not found");
});

it("redirects to /dashboard and sets cookies on successful anonymous sign-in", async () => {
const mockFetchResponse = {
ok: true,
headers: {
getSetCookie: () => ["session_token=valid-token; Path=/"],
},
};
global.fetch = vi.fn().mockResolvedValue(mockFetchResponse);

const req = new Request("http://localhost:3000/api/auth/auto-session");
const res = await GET(req);

expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("http://localhost:3000/dashboard");
expect(res.headers.getSetCookie()).toEqual(["session_token=valid-token; Path=/"]);
});

it("returns 503 HTML page when fetch to anonymous sign-in fails", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network connection lost"));

const req = new Request("http://localhost:3000/api/auth/auto-session");
const res = await GET(req);

expect(res.status).toBe(503);
expect(res.headers.get("content-type")).toMatch(/text\/html/);
const body = await res.text();
expect(body).toContain("Service Account Session Failed");
expect(body).toContain("Fetch failed: Network connection lost");
});

it("returns error HTML page when anonymous sign-in returns non-200", async () => {
const mockFetchResponse = {
ok: false,
status: 500,
text: async () => "Internal Database Error",
headers: {
getSetCookie: () => [],
},
};
global.fetch = vi.fn().mockResolvedValue(mockFetchResponse);

const req = new Request("http://localhost:3000/api/auth/auto-session");
const res = await GET(req);

expect(res.status).toBe(500);
expect(res.headers.get("content-type")).toMatch(/text\/html/);
const body = await res.text();
expect(body).toContain("Service Account Session Failed");
expect(body).toContain("API returned 500: Internal Database Error");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ describe("toAuthError", () => {
const err = new Error("Invalid Customer Id");
const result = toAuthError(err, "admin-sdk");
expect(result?.code).toBe("invalid_customer_id");
expect(result?.remedy).toContain("Ensure the Customer ID parameter passed to the tool is correct");
expect(result?.remedy).toContain(
"Ensure the Customer ID parameter passed to the tool is correct",
);
expect(result?.remedy).not.toContain("/sa-setup");
});
});
Expand Down
38 changes: 31 additions & 7 deletions mcp-examples/pocket-cep/src/__tests__/unit/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const { mockGetSession, mockCookies, mockGetEnv } = vi.hoisted(() => ({
const { mockGetSession, mockCookies, mockGetGoogleAccessToken, mockGetEnv } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockCookies: {
getAll: vi.fn(() => [] as { name: string; value: string }[]),
delete: vi.fn(),
},
mockGetEnv: vi.fn(() => ({
AUTH_MODE: "service_account" as "service_account" | "user_oauth",
BETTER_AUTH_SECRET: "mock-secret",
})),
mockGetGoogleAccessToken: vi.fn(),
mockGetEnv: vi.fn(
() =>
({
AUTH_MODE: "service_account" as "service_account" | "user_oauth",
BETTER_AUTH_SECRET: "mock-secret",
}) as Record<string, unknown>,
),
}));

vi.mock("next/headers", () => ({
Expand All @@ -29,6 +33,10 @@ vi.mock("@/lib/env", () => ({
getEnv: mockGetEnv,
}));

vi.mock("@/lib/access-token", () => ({
getGoogleAccessToken: mockGetGoogleAccessToken,
}));

import { requireSession } from "@/lib/session";

describe("requireSession", () => {
Expand Down Expand Up @@ -71,17 +79,20 @@ describe("requireSession", () => {
expect(mockCookies.delete).not.toHaveBeenCalled();
});

describe("in user_oauth mode", () => {
describe("user_oauth mode", () => {
beforeEach(() => {
mockGetEnv.mockReturnValue({
AUTH_MODE: "user_oauth",
BETTER_AUTH_SECRET: "mock-secret",
GOOGLE_CLIENT_ID: "123-abc.apps.googleusercontent.com",
GOOGLE_CLIENT_SECRET: "secret",
});
});

it("returns session when getSession succeeds with a normal user email", async () => {
it("returns session when session is valid and Google token is valid", async () => {
const mockSession = { user: { id: "u1", email: "admin@company.com" } };
mockGetSession.mockResolvedValue(mockSession);
mockGetGoogleAccessToken.mockResolvedValue("valid-google-token");

const session = await requireSession();
expect(session).toBe(mockSession);
Expand All @@ -99,5 +110,18 @@ describe("requireSession", () => {
expect(session).toBeNull();
expect(mockCookies.delete).toHaveBeenCalledWith("better-auth.session_token");
});

it("clears cookies and returns null when session is valid but Google token is expired/missing", async () => {
const mockSession = { user: { id: "u1", email: "admin@company.com" } };
mockGetSession.mockResolvedValue(mockSession);
mockGetGoogleAccessToken.mockResolvedValue(undefined); // expired
mockCookies.getAll.mockReturnValue([
{ name: "better-auth.session_token", value: "stale-val" },
]);

const session = await requireSession();
expect(session).toBeNull();
expect(mockCookies.delete).toHaveBeenCalledWith("better-auth.session_token");
});
});
});
15 changes: 12 additions & 3 deletions mcp-examples/pocket-cep/src/app/api/auth/auto-session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { NextResponse } from "next/server";
import { getEnv } from "@/lib/env";
import { renderSaSessionErrorHtml } from "@/lib/env-error-page";

/**
* Creates an anonymous session and redirects to the dashboard.
Expand Down Expand Up @@ -48,18 +49,26 @@ export async function GET(request: Request) {
body: "{}",
signal: AbortSignal.timeout(5000),
});
} catch {
} catch (err) {
if (acceptJson) {
return NextResponse.json({ error: "session_unavailable" }, { status: 503 });
}
return NextResponse.redirect(new URL("/?error=session_unavailable", base));
const errMsg = err instanceof Error ? err.message : String(err);
return new NextResponse(renderSaSessionErrorHtml(`Fetch failed: ${errMsg}`), {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

if (!response.ok) {
if (acceptJson) {
return NextResponse.json({ error: "session_failed" }, { status: 401 });
}
return NextResponse.redirect(new URL("/?error=session_failed", base));
const body = await response.text().catch(() => "Unknown error");
return new NextResponse(renderSaSessionErrorHtml(`API returned ${response.status}: ${body}`), {
status: response.status,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}

const cookies = response.headers.getSetCookie();
Expand Down
3 changes: 2 additions & 1 deletion mcp-examples/pocket-cep/src/app/api/auth/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ import { getGoogleAccessToken } from "@/lib/access-token";
import { AuthError, isAuthError } from "@/lib/auth-errors";
import { requireSession } from "@/lib/session";
import { getErrorMessage } from "@/lib/errors";
import { unauthenticatedResponse } from "@/lib/api-response";

/**
* Probes Google credentials by requesting a fresh access token. Returns 200
* if Google issues one and 401 with the structured payload if it refuses.
*/
export async function GET() {
if (!(await requireSession())) {
return NextResponse.json({ ok: false, error: "Not authenticated" }, { status: 401 });
return unauthenticatedResponse();
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { ReactNode } from "react";
import type { AuthErrorPayload } from "@/lib/auth-errors";
import { AUTH_ERROR_EVENT } from "@/lib/auth-aware-fetch";
import { useMode } from "./mode-provider";
import { authClient } from "@/lib/auth-client";

/**
* Value exposed by the AuthHealthContext. `clear()` drops the current
Expand Down Expand Up @@ -80,9 +81,11 @@ export function AuthHealthProvider({ children }: { children: ReactNode }) {
} else {
console.warn(
"AuthHealthProvider: Stale session detected in User OAuth mode. " +
"Redirecting to login page.",
"Logging out and redirecting to login page.",
);
window.location.href = "/";
authClient.signOut().finally(() => {
window.location.href = "/";
});
}
} else if (detail && typeof detail.code === "string") {
setError(detail);
Expand Down
6 changes: 5 additions & 1 deletion mcp-examples/pocket-cep/src/lib/activity-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ export async function getActivitySafe(days: number = DEFAULT_ACTIVITY_DAYS): Pro
* Pulls and groups Chrome audit events for the given caller, scoped to
* `days` of history. Pagination stops at {@link ACTIVITY_MAX_EVENTS}.
*/
async function fetchActivity(tokenToUse: string, days: number, impersonatedUser?: string): Promise<ActivityMap> {
async function fetchActivity(
tokenToUse: string,
days: number,
impersonatedUser?: string,
): Promise<ActivityMap> {
const requestHeaders = await buildGoogleApiHeaders(tokenToUse);

const baseUrl = new URL(
Expand Down
20 changes: 20 additions & 0 deletions mcp-examples/pocket-cep/src/lib/env-error-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,23 @@ export function renderMcpUnreachableHtml(url: string): string {
footerHint: "Once the MCP server is running, refresh this page.",
});
}

/**
* Builds the error page when the Service Account anonymous session fails to establish.
* Prevents redirect loops in service_account mode when the BetterAuth backend is failing.
*/
export function renderSaSessionErrorHtml(error: string): string {
return renderSetupBlockedHtml({
pageTitle: "Pocket CEP — Service Account Session Failed",
heading: "Service Account Session Failed",
lede: "Pocket CEP could not establish an anonymous session for Service Account mode.",
failuresHeading: "Error Details",
failures: [{ code: "session_failed", message: error }],
primaryAction: {
command: "npm run doctor",
description:
"<strong>Run diagnostic checks.</strong> This will verify your Service Account credentials and connection to Google APIs.",
},
footerHint: "Check your console logs and try refreshing this page.",
});
}
Loading