Skip to content

Commit a892652

Browse files
committed
fix(#712/#711/#710/#709): wire auth tokens, mock IDs, limits API, settings backend
#712 — login route now echoes a session block in its JSON response when the backend returns a token, so the client can persist it to sessionStorage and attach Authorization: Bearer on wallet/API requests. Previously only the HttpOnly cookie was set server-side; client fetches went out unauthenticated. #711 — export MOCK_WALLET_IDS constants from src/mock-data/wallets.ts. WalletDetail.stories.tsx now references those constants instead of hardcoded strings, so a mock ID rename propagates from one place and stories never silently load a NotFound state. #710 — SpendingLimitsCard now passes Authorization: Bearer on every fetch to /api/spending-limits. The route itself now requires a Bearer token (401 otherwise) and has an explicit isMockFallbackAllowed() guard: default limits are returned in dev/CI, 503 backend_unavailable in production with no backend. #709 — new /api/settings route (GET + PATCH) proxies to MUX_BACKEND_URL/developers/me/settings, requires Bearer auth, returns a mock echo in dev and 503 in production with no backend. Settings page is rewritten to load from and save to /api/settings instead of localStorage; no project preferences ever touch the browser store.
1 parent ffa84e7 commit a892652

13 files changed

Lines changed: 1205 additions & 119 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Tests for #712: login route echoes session block so client can persist
3+
* the bearer token in sessionStorage and attach it to wallet requests.
4+
*/
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
// ── Helpers ─────────────────────────────────────────────────────────────────
8+
9+
/** Minimal Next.js config mock */
10+
vi.mock("@/lib/api/config", () => ({
11+
getApiBaseUrl: vi.fn(() => ""),
12+
getUpstreamAuthHeaders: vi.fn(() => ({})),
13+
isMockFallbackAllowed: vi.fn(() => true),
14+
}));
15+
16+
vi.mock("@/lib/auth/routeAccess", () => ({
17+
SESSION_TOKEN_COOKIE: "mux_auth_token",
18+
}));
19+
20+
// Import AFTER mocks
21+
import { getApiBaseUrl, isMockFallbackAllowed } from "@/lib/api/config";
22+
import { POST } from "@/app/api/auth/login/route";
23+
24+
function makeRequest(body: unknown) {
25+
return new Request("http://localhost/api/auth/login", {
26+
method: "POST",
27+
headers: { "content-type": "application/json" },
28+
body: JSON.stringify(body),
29+
});
30+
}
31+
32+
describe("/api/auth/login — #712 session block", () => {
33+
beforeEach(() => {
34+
vi.mocked(getApiBaseUrl).mockReturnValue("");
35+
vi.mocked(isMockFallbackAllowed).mockReturnValue(true);
36+
});
37+
38+
it("returns a session block in the mock fallback response", async () => {
39+
const res = await POST(makeRequest({ email: "dev@example.com", password: "password123" }));
40+
expect(res.status).toBe(200);
41+
const body = await res.json();
42+
expect(body.session).toBeDefined();
43+
expect(typeof body.session.accessToken).toBe("string");
44+
expect(body.session.accessToken.length).toBeGreaterThan(0);
45+
});
46+
47+
it("includes user in the mock fallback response alongside session", async () => {
48+
const res = await POST(makeRequest({ email: "alice@example.com", password: "pass123" }));
49+
const body = await res.json();
50+
expect(body.user).toBeDefined();
51+
expect(body.user.email).toBe("alice@example.com");
52+
expect(body.session).toBeDefined();
53+
});
54+
55+
it("returns 503 when no backend and production mode", async () => {
56+
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
57+
const res = await POST(makeRequest({ email: "dev@example.com", password: "pass" }));
58+
expect(res.status).toBe(503);
59+
const body = await res.json();
60+
expect(body.error).toBe("backend_unavailable");
61+
});
62+
63+
it("returns 400 for missing email", async () => {
64+
const res = await POST(makeRequest({ password: "pass123" }));
65+
expect(res.status).toBe(400);
66+
});
67+
68+
it("returns 400 for missing password", async () => {
69+
const res = await POST(makeRequest({ email: "dev@example.com" }));
70+
expect(res.status).toBe(400);
71+
});
72+
73+
it("proxies to backend and echoes session block when backend is configured", async () => {
74+
vi.mocked(getApiBaseUrl).mockReturnValue("https://api.example.com");
75+
76+
const mockFetch = vi.fn().mockResolvedValue({
77+
ok: true,
78+
status: 200,
79+
json: async () => ({
80+
user: { name: "Dev", email: "dev@example.com", role: "developer" },
81+
accessToken: "real-token-from-backend",
82+
}),
83+
});
84+
vi.stubGlobal("fetch", mockFetch);
85+
86+
const res = await POST(makeRequest({ email: "dev@example.com", password: "realpass" }));
87+
expect(res.status).toBe(200);
88+
const body = await res.json();
89+
// Session block should be synthesised from the backend's accessToken
90+
expect(body.session).toBeDefined();
91+
expect(body.session.accessToken).toBe("real-token-from-backend");
92+
93+
vi.unstubAllGlobals();
94+
});
95+
});

src/app/api/auth/login/route.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,34 @@ export async function POST(request: Request) {
9696
body: JSON.stringify({ email, password }),
9797
});
9898

99-
const data = await upstream.json().catch(() => ({}));
99+
const data = (await upstream.json().catch(() => ({}))) as Record<
100+
string,
101+
unknown
102+
>;
100103

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

105-
const response = NextResponse.json(data, { status: 200 });
108+
// Echo a `session` block so the client can persist the bearer token
109+
// in sessionStorage (via `src/lib/session.js`) and attach it as the
110+
// Authorization header on subsequent wallet/API requests (#712).
111+
// The token is ALSO written to the HttpOnly cookie below — both
112+
// transports are needed: the cookie for middleware-level route
113+
// protection, the sessionStorage copy for client-side fetch calls.
106114
const token = extractSessionToken(data);
115+
const responsePayload: Record<string, unknown> = { ...data };
116+
if (token && !responsePayload.session) {
117+
responsePayload.session = {
118+
accessToken: token,
119+
// Carry forward expiresIn from the backend response when present.
120+
...(typeof data.expiresIn === "number"
121+
? { expiresIn: data.expiresIn }
122+
: {}),
123+
};
124+
}
125+
126+
const response = NextResponse.json(responsePayload, { status: 200 });
107127
if (token) {
108128
setSessionCookie(response, token);
109129
}

src/app/api/settings/route.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Tests for #709: /api/settings must proxy to mux-backend and must not
3+
* serve mock data in production.
4+
*/
5+
import { beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
vi.mock("@/lib/api/config", () => ({
8+
getBackendApiBaseUrl: vi.fn(() => ""),
9+
getUpstreamAuthHeaders: vi.fn(() => ({})),
10+
isMockFallbackAllowed: vi.fn(() => true),
11+
}));
12+
13+
import {
14+
getBackendApiBaseUrl,
15+
isMockFallbackAllowed,
16+
} from "@/lib/api/config";
17+
import { GET, PATCH } from "@/app/api/settings/route";
18+
19+
function makeGetRequest(opts?: { auth?: string }) {
20+
const headers: Record<string, string> = {};
21+
if (opts?.auth !== undefined) headers.authorization = opts.auth;
22+
return new Request("http://localhost/api/settings", {
23+
method: "GET",
24+
headers,
25+
});
26+
}
27+
28+
function makePatchRequest(body: unknown, opts?: { auth?: string }) {
29+
const headers: Record<string, string> = {
30+
"content-type": "application/json",
31+
};
32+
if (opts?.auth !== undefined) headers.authorization = opts.auth;
33+
return new Request("http://localhost/api/settings", {
34+
method: "PATCH",
35+
headers,
36+
body: JSON.stringify(body),
37+
});
38+
}
39+
40+
const validSettings = {
41+
displayName: "Alice Dev",
42+
emailUpdates: true,
43+
compactWallets: false,
44+
};
45+
46+
describe("/api/settings — #709", () => {
47+
beforeEach(() => {
48+
vi.mocked(getBackendApiBaseUrl).mockReturnValue("");
49+
vi.mocked(isMockFallbackAllowed).mockReturnValue(true);
50+
});
51+
52+
// ── GET ──────────────────────────────────────────────────────────────────
53+
54+
describe("GET", () => {
55+
it("returns 401 when Authorization header is missing", async () => {
56+
const res = await GET(makeGetRequest());
57+
expect(res.status).toBe(401);
58+
const body = await res.json();
59+
expect(body.error).toBe("missing_auth");
60+
});
61+
62+
it("returns 401 when Authorization is not Bearer", async () => {
63+
const res = await GET(makeGetRequest({ auth: "Basic abc" }));
64+
expect(res.status).toBe(401);
65+
});
66+
67+
it("returns mock settings in non-production when no backend configured", async () => {
68+
const res = await GET(makeGetRequest({ auth: "Bearer mock-token" }));
69+
expect(res.status).toBe(200);
70+
const body = await res.json();
71+
expect(body.settings).toBeDefined();
72+
expect(typeof body.settings.emailUpdates).toBe("boolean");
73+
expect(typeof body.settings.compactWallets).toBe("boolean");
74+
});
75+
76+
it("returns 503 in production when no backend configured", async () => {
77+
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
78+
const res = await GET(makeGetRequest({ auth: "Bearer token" }));
79+
expect(res.status).toBe(503);
80+
const body = await res.json();
81+
expect(body.error).toBe("backend_unavailable");
82+
});
83+
84+
it("proxies to backend when MUX_BACKEND_URL is set", async () => {
85+
vi.mocked(getBackendApiBaseUrl).mockReturnValue(
86+
"https://backend.example.com",
87+
);
88+
const mockFetch = vi.fn().mockResolvedValue({
89+
ok: true,
90+
status: 200,
91+
json: async () => ({ settings: validSettings }),
92+
});
93+
vi.stubGlobal("fetch", mockFetch);
94+
95+
const res = await GET(makeGetRequest({ auth: "Bearer real-token" }));
96+
expect(res.status).toBe(200);
97+
expect(mockFetch).toHaveBeenCalledWith(
98+
"https://backend.example.com/developers/me/settings",
99+
expect.any(Object),
100+
);
101+
102+
vi.unstubAllGlobals();
103+
});
104+
});
105+
106+
// ── PATCH ─────────────────────────────────────────────────────────────────
107+
108+
describe("PATCH", () => {
109+
it("returns 401 when Authorization header is missing", async () => {
110+
const res = await PATCH(makePatchRequest(validSettings));
111+
expect(res.status).toBe(401);
112+
});
113+
114+
it("returns 400 for invalid body (missing displayName)", async () => {
115+
const res = await PATCH(
116+
makePatchRequest(
117+
{ emailUpdates: true, compactWallets: false },
118+
{ auth: "Bearer token" },
119+
),
120+
);
121+
expect(res.status).toBe(400);
122+
});
123+
124+
it("returns 400 when displayName is empty string", async () => {
125+
const res = await PATCH(
126+
makePatchRequest(
127+
{ ...validSettings, displayName: "" },
128+
{ auth: "Bearer token" },
129+
),
130+
);
131+
expect(res.status).toBe(400);
132+
});
133+
134+
it("returns 400 when emailUpdates is not boolean", async () => {
135+
const res = await PATCH(
136+
makePatchRequest(
137+
{ ...validSettings, emailUpdates: "yes" },
138+
{ auth: "Bearer token" },
139+
),
140+
);
141+
expect(res.status).toBe(400);
142+
});
143+
144+
it("returns mock echo in non-production with no backend", async () => {
145+
const res = await PATCH(
146+
makePatchRequest(validSettings, { auth: "Bearer mock-token" }),
147+
);
148+
expect(res.status).toBe(200);
149+
const body = await res.json();
150+
expect(body.settings.displayName).toBe(validSettings.displayName);
151+
expect(body.settings.emailUpdates).toBe(validSettings.emailUpdates);
152+
});
153+
154+
it("trims displayName whitespace in mock echo", async () => {
155+
const res = await PATCH(
156+
makePatchRequest(
157+
{ ...validSettings, displayName: " Alice " },
158+
{ auth: "Bearer mock-token" },
159+
),
160+
);
161+
expect(res.status).toBe(200);
162+
const body = await res.json();
163+
expect(body.settings.displayName).toBe("Alice");
164+
});
165+
166+
it("returns 503 in production with no backend", async () => {
167+
vi.mocked(isMockFallbackAllowed).mockReturnValue(false);
168+
const res = await PATCH(
169+
makePatchRequest(validSettings, { auth: "Bearer token" }),
170+
);
171+
expect(res.status).toBe(503);
172+
});
173+
174+
it("proxies to backend when MUX_BACKEND_URL is set", async () => {
175+
vi.mocked(getBackendApiBaseUrl).mockReturnValue(
176+
"https://backend.example.com",
177+
);
178+
const mockFetch = vi.fn().mockResolvedValue({
179+
ok: true,
180+
status: 200,
181+
json: async () => ({ settings: validSettings }),
182+
});
183+
vi.stubGlobal("fetch", mockFetch);
184+
185+
const res = await PATCH(
186+
makePatchRequest(validSettings, { auth: "Bearer real-token" }),
187+
);
188+
expect(res.status).toBe(200);
189+
expect(mockFetch).toHaveBeenCalledWith(
190+
"https://backend.example.com/developers/me/settings",
191+
expect.objectContaining({ method: "PATCH" }),
192+
);
193+
194+
vi.unstubAllGlobals();
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)