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
95 changes: 95 additions & 0 deletions src/app/api/auth/login/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Tests for #712: login route echoes session block so client can persist
* the bearer token in sessionStorage and attach it to wallet requests.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";

// ── Helpers ─────────────────────────────────────────────────────────────────

/** Minimal Next.js config mock */
vi.mock("@/lib/api/config", () => ({
getApiBaseUrl: vi.fn(() => ""),
getUpstreamAuthHeaders: vi.fn(() => ({})),
isMockFallbackAllowed: vi.fn(() => true),
}));

vi.mock("@/lib/auth/routeAccess", () => ({
SESSION_TOKEN_COOKIE: "mux_auth_token",
}));

// Import AFTER mocks
import { getApiBaseUrl, isMockFallbackAllowed } from "@/lib/api/config";
import { POST } from "@/app/api/auth/login/route";

function makeRequest(body: unknown) {
return new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}

describe("/api/auth/login — #712 session block", () => {
beforeEach(() => {
vi.mocked(getApiBaseUrl).mockReturnValue("");
vi.mocked(isMockFallbackAllowed).mockReturnValue(true);
});

it("returns a session block in the mock fallback response", async () => {
const res = await POST(makeRequest({ email: "dev@example.com", password: "password123" }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.session).toBeDefined();
expect(typeof body.session.accessToken).toBe("string");
expect(body.session.accessToken.length).toBeGreaterThan(0);
});

it("includes user in the mock fallback response alongside session", async () => {
const res = await POST(makeRequest({ email: "alice@example.com", password: "pass123" }));
const body = await res.json();
expect(body.user).toBeDefined();
expect(body.user.email).toBe("alice@example.com");
expect(body.session).toBeDefined();
});

it("returns 503 when no backend and production mode", async () => {
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
const res = await POST(makeRequest({ email: "dev@example.com", password: "pass" }));
expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toBe("backend_unavailable");
});

it("returns 400 for missing email", async () => {
const res = await POST(makeRequest({ password: "pass123" }));
expect(res.status).toBe(400);
});

it("returns 400 for missing password", async () => {
const res = await POST(makeRequest({ email: "dev@example.com" }));
expect(res.status).toBe(400);
});

it("proxies to backend and echoes session block when backend is configured", async () => {
vi.mocked(getApiBaseUrl).mockReturnValue("https://api.example.com");

const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
user: { name: "Dev", email: "dev@example.com", role: "developer" },
accessToken: "real-token-from-backend",
}),
});
vi.stubGlobal("fetch", mockFetch);

const res = await POST(makeRequest({ email: "dev@example.com", password: "realpass" }));
expect(res.status).toBe(200);
const body = await res.json();
// Session block should be synthesised from the backend's accessToken
expect(body.session).toBeDefined();
expect(body.session.accessToken).toBe("real-token-from-backend");

vi.unstubAllGlobals();
});
});
24 changes: 22 additions & 2 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,34 @@ export async function POST(request: Request) {
body: JSON.stringify({ email, password }),
});

const data = await upstream.json().catch(() => ({}));
const data = (await upstream.json().catch(() => ({}))) as Record<
string,
unknown
>;

if (!upstream.ok) {
return NextResponse.json(data, { status: upstream.status });
}

const response = NextResponse.json(data, { status: 200 });
// Echo a `session` block so the client can persist the bearer token
// in sessionStorage (via `src/lib/session.js`) and attach it as the
// Authorization header on subsequent wallet/API requests (#712).
// The token is ALSO written to the HttpOnly cookie below — both
// transports are needed: the cookie for middleware-level route
// protection, the sessionStorage copy for client-side fetch calls.
const token = extractSessionToken(data);
const responsePayload: Record<string, unknown> = { ...data };
if (token && !responsePayload.session) {
responsePayload.session = {
accessToken: token,
// Carry forward expiresIn from the backend response when present.
...(typeof data.expiresIn === "number"
? { expiresIn: data.expiresIn }
: {}),
};
}

const response = NextResponse.json(responsePayload, { status: 200 });
if (token) {
setSessionCookie(response, token);
}
Expand Down
197 changes: 197 additions & 0 deletions src/app/api/settings/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* Tests for #709: /api/settings must proxy to mux-backend and must not
* serve mock data in production.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/api/config", () => ({
getBackendApiBaseUrl: vi.fn(() => ""),
getUpstreamAuthHeaders: vi.fn(() => ({})),
isMockFallbackAllowed: vi.fn(() => true),
}));

import {
getBackendApiBaseUrl,
isMockFallbackAllowed,
} from "@/lib/api/config";
import { GET, PATCH } from "@/app/api/settings/route";

function makeGetRequest(opts?: { auth?: string }) {
const headers: Record<string, string> = {};
if (opts?.auth !== undefined) headers.authorization = opts.auth;
return new Request("http://localhost/api/settings", {
method: "GET",
headers,
});
}

function makePatchRequest(body: unknown, opts?: { auth?: string }) {
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (opts?.auth !== undefined) headers.authorization = opts.auth;
return new Request("http://localhost/api/settings", {
method: "PATCH",
headers,
body: JSON.stringify(body),
});
}

const validSettings = {
displayName: "Alice Dev",
emailUpdates: true,
compactWallets: false,
};

describe("/api/settings — #709", () => {
beforeEach(() => {
vi.mocked(getBackendApiBaseUrl).mockReturnValue("");
vi.mocked(isMockFallbackAllowed).mockReturnValue(true);
});

// ── GET ──────────────────────────────────────────────────────────────────

describe("GET", () => {
it("returns 401 when Authorization header is missing", async () => {
const res = await GET(makeGetRequest());
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("missing_auth");
});

it("returns 401 when Authorization is not Bearer", async () => {
const res = await GET(makeGetRequest({ auth: "Basic abc" }));
expect(res.status).toBe(401);
});

it("returns mock settings in non-production when no backend configured", async () => {
const res = await GET(makeGetRequest({ auth: "Bearer mock-token" }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.settings).toBeDefined();
expect(typeof body.settings.emailUpdates).toBe("boolean");
expect(typeof body.settings.compactWallets).toBe("boolean");
});

it("returns 503 in production when no backend configured", async () => {
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
const res = await GET(makeGetRequest({ auth: "Bearer token" }));
expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toBe("backend_unavailable");
});

it("proxies to backend when MUX_BACKEND_URL is set", async () => {
vi.mocked(getBackendApiBaseUrl).mockReturnValue(
"https://backend.example.com",
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ settings: validSettings }),
});
vi.stubGlobal("fetch", mockFetch);

const res = await GET(makeGetRequest({ auth: "Bearer real-token" }));
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
"https://backend.example.com/developers/me/settings",
expect.any(Object),
);

vi.unstubAllGlobals();
});
});

// ── PATCH ─────────────────────────────────────────────────────────────────

describe("PATCH", () => {
it("returns 401 when Authorization header is missing", async () => {
const res = await PATCH(makePatchRequest(validSettings));
expect(res.status).toBe(401);
});

it("returns 400 for invalid body (missing displayName)", async () => {
const res = await PATCH(
makePatchRequest(
{ emailUpdates: true, compactWallets: false },
{ auth: "Bearer token" },
),
);
expect(res.status).toBe(400);
});

it("returns 400 when displayName is empty string", async () => {
const res = await PATCH(
makePatchRequest(
{ ...validSettings, displayName: "" },
{ auth: "Bearer token" },
),
);
expect(res.status).toBe(400);
});

it("returns 400 when emailUpdates is not boolean", async () => {
const res = await PATCH(
makePatchRequest(
{ ...validSettings, emailUpdates: "yes" },
{ auth: "Bearer token" },
),
);
expect(res.status).toBe(400);
});

it("returns mock echo in non-production with no backend", async () => {
const res = await PATCH(
makePatchRequest(validSettings, { auth: "Bearer mock-token" }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.settings.displayName).toBe(validSettings.displayName);
expect(body.settings.emailUpdates).toBe(validSettings.emailUpdates);
});

it("trims displayName whitespace in mock echo", async () => {
const res = await PATCH(
makePatchRequest(
{ ...validSettings, displayName: " Alice " },
{ auth: "Bearer mock-token" },
),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.settings.displayName).toBe("Alice");
});

it("returns 503 in production with no backend", async () => {
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
const res = await PATCH(
makePatchRequest(validSettings, { auth: "Bearer token" }),
);
expect(res.status).toBe(503);
});

it("proxies to backend when MUX_BACKEND_URL is set", async () => {
vi.mocked(getBackendApiBaseUrl).mockReturnValue(
"https://backend.example.com",
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ settings: validSettings }),
});
vi.stubGlobal("fetch", mockFetch);

const res = await PATCH(
makePatchRequest(validSettings, { auth: "Bearer real-token" }),
);
expect(res.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
"https://backend.example.com/developers/me/settings",
expect.objectContaining({ method: "PATCH" }),
);

vi.unstubAllGlobals();
});
});
});
Loading