mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new tab, which returned a raw JSON 400 for every brand-new account (Withings credentials are per-user BYO with no env fallback, and the wizard never collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical raw-JSON-on-no-creds pattern on their own /connect routes, while every other failure branch already redirected with a toast reason. All six now redirect to /settings/integrations?<provider>=error&reason=nocreds instead, and the wizard's Withings card checks the integrations-status envelope first: unless credentials are already configured, the CTA reads "Set up in Settings" and links straight to the credentials form rather than launching a doomed OAuth handshake. Also surfaces the BYO-developer-app requirement, which was invisible until a user was deep inside a Settings card. The source-picker step now says up front that device sources need a free developer app on the user's own account; each of the six provider cards gets a compact 3-step guide with the exact callback URL to register, shown until the user has saved their own credentials.
This commit is contained in:
1 parent
4f7420905a
commit
fb73c97125
27 files changed
+744
-73
No files matched your search
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* GET /api/fitbit/connect
|
||||
*
|
||||
* Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit
|
||||
* consent screen; rate-limit and no-credentials both redirect back to
|
||||
* `/settings/integrations?fitbit=error&reason=<tag>` rather than returning
|
||||
* JSON — a browser-navigation entry point should never surface an unstyled
|
||||
* JSON page.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { fitbitOAuthState: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fitbit/credentials", () => ({
|
||||
getUserFitbitCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fitbit/client", () => ({
|
||||
getAuthorizationUrl: vi.fn(
|
||||
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
|
||||
),
|
||||
generatePkcePair: vi.fn(() => ({
|
||||
verifier: "verifier-stub",
|
||||
challenge: "challenge-stub",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fitbit/oauth-state", () => ({
|
||||
FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state",
|
||||
FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
|
||||
mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ setError: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
|
||||
|
||||
function connectRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/fitbit/connect", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.fitbitOAuthState.create).mockReset();
|
||||
vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getUserFitbitCredentials).mockResolvedValue({
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/fitbit/connect", () => {
|
||||
it("redirects to the Fitbit authorize URL on the happy path", async () => {
|
||||
const response = await GET(connectRequest());
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain(
|
||||
"www.fitbit.com/oauth2/authorize",
|
||||
);
|
||||
expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("redirects to reason=rate_limited when the bucket is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("fitbit=error");
|
||||
expect(location).toContain("reason=rate_limited");
|
||||
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
|
||||
vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("fitbit=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
|
||||
* ledger rows for the full 10-min TTL window. Both the rate-limit and
|
||||
* create-failure paths redirect (not JSON) because the entry point is a browser
|
||||
* navigation — a 429 envelope would surface as a blank page.
|
||||
* navigation — a 429 envelope would surface as a blank page. The
|
||||
* no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
@@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const creds = await getUserFitbitCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
|
||||
400,
|
||||
annotate({ action: { name: "fitbit.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
|
||||
() => ({
|
||||
rateLimitMock: vi.fn(),
|
||||
getCredsMock: vi.fn(),
|
||||
mintMock: vi.fn(() => "signed-state"),
|
||||
authUrlMock: vi.fn(() => "https://cloud.ouraring.com/oauth/authorize?x=1"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
|
||||
vi.mock("@/lib/oura/client", () => ({ getAuthorizationUrl: authUrlMock }));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
}));
|
||||
vi.mock("@/lib/oauth/signed-state", () => ({
|
||||
OAUTH_STATE_TTL_MS: 600_000,
|
||||
mintSignedState: mintMock,
|
||||
oauthStateCookieName: () => "oura_oauth_state",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
|
||||
const run = GET as unknown as () => Promise<{
|
||||
status: number;
|
||||
headers: { get(k: string): string | null };
|
||||
cookies: { get(k: string): { value: string } | undefined };
|
||||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimitMock.mockResolvedValue({ allowed: true });
|
||||
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
describe("GET /api/oura/connect", () => {
|
||||
it("redirects to the consent screen and sets the state cookie", async () => {
|
||||
const res = await run();
|
||||
expect(res.headers.get("location")).toContain("cloud.ouraring.com");
|
||||
expect(res.cookies.get("oura_oauth_state")?.value).toBe("signed-state");
|
||||
});
|
||||
|
||||
it("redirects to an error when the bucket is exhausted", async () => {
|
||||
rateLimitMock.mockResolvedValue({ allowed: false });
|
||||
const res = await run();
|
||||
expect(res.headers.get("location")).toContain("reason=rate_limited");
|
||||
expect(getCredsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
|
||||
getCredsMock.mockResolvedValue(null);
|
||||
const res = await run();
|
||||
const location = res.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("oura=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -19,7 +18,10 @@ import { NextResponse } from "next/server";
|
||||
* BYO Oura client id/secret wins, falling back to the shared env app
|
||||
* (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the
|
||||
* stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is
|
||||
* the `state` URL param AND an httpOnly cookie. Rate-limited per user.
|
||||
* the `state` URL param AND an httpOnly cookie. Rate-limited per user. The
|
||||
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
|
||||
* JSON 400 — every other failure path here already redirects, and a
|
||||
* browser-navigation entry point should never surface an unstyled JSON page.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
@@ -45,7 +47,13 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const creds = await getOuraClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Oura integration is not configured on this server.", 400);
|
||||
annotate({ action: { name: "oura.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?oura=error&reason=nocreds",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("oura", user.id);
|
||||
|
||||
@@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
@@ -61,9 +58,12 @@ describe("GET /api/polar/connect", () => {
|
||||
expect(getCredsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when neither BYO nor env credentials resolve", async () => {
|
||||
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
|
||||
getCredsMock.mockResolvedValue(null);
|
||||
const res = (await run()) as unknown as { status: number };
|
||||
expect(res.status).toBe(400);
|
||||
const res = await run();
|
||||
const location = res.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("polar=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -22,7 +21,10 @@ import { NextResponse } from "next/server";
|
||||
* same token is the `state` URL param AND an httpOnly cookie, and the callback
|
||||
* enforces both signature validity and a byte-exact cookie match.
|
||||
*
|
||||
* Rate-limited per user so a session can't spam the consent redirect.
|
||||
* Rate-limited per user so a session can't spam the consent redirect. The
|
||||
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
|
||||
* JSON 400 — every other failure path here already redirects, and a
|
||||
* browser-navigation entry point should never surface an unstyled JSON page.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
@@ -48,7 +50,13 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const creds = await getPolarClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Polar integration is not configured on this server.", 400);
|
||||
annotate({ action: { name: "polar.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?polar=error&reason=nocreds",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("polar", user.id);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
|
||||
() => ({
|
||||
rateLimitMock: vi.fn(),
|
||||
getCredsMock: vi.fn(),
|
||||
mintMock: vi.fn(() => "signed-state"),
|
||||
authUrlMock: vi.fn(() => "https://www.strava.com/oauth/authorize?x=1"),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
|
||||
vi.mock("@/lib/strava/client", () => ({ getAuthorizationUrl: authUrlMock }));
|
||||
vi.mock("@/lib/strava/credentials", () => ({
|
||||
getStravaClientCredentials: getCredsMock,
|
||||
}));
|
||||
vi.mock("@/lib/oauth/signed-state", () => ({
|
||||
OAUTH_STATE_TTL_MS: 600_000,
|
||||
mintSignedState: mintMock,
|
||||
oauthStateCookieName: () => "strava_oauth_state",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
|
||||
const run = GET as unknown as () => Promise<{
|
||||
status: number;
|
||||
headers: { get(k: string): string | null };
|
||||
cookies: { get(k: string): { value: string } | undefined };
|
||||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
rateLimitMock.mockResolvedValue({ allowed: true });
|
||||
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
describe("GET /api/strava/connect", () => {
|
||||
it("redirects to the consent screen and sets the state cookie", async () => {
|
||||
const res = await run();
|
||||
expect(res.headers.get("location")).toContain("www.strava.com");
|
||||
expect(res.cookies.get("strava_oauth_state")?.value).toBe("signed-state");
|
||||
});
|
||||
|
||||
it("redirects to an error when the bucket is exhausted", async () => {
|
||||
rateLimitMock.mockResolvedValue({ allowed: false });
|
||||
const res = await run();
|
||||
expect(res.headers.get("location")).toContain("reason=rate_limited");
|
||||
expect(getCredsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => {
|
||||
getCredsMock.mockResolvedValue(null);
|
||||
const res = await run();
|
||||
const location = res.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("strava=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -19,7 +18,10 @@ import { NextResponse } from "next/server";
|
||||
* user's BYO Strava client id/secret wins, falling back to the shared env app
|
||||
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
|
||||
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
|
||||
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
|
||||
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user. The
|
||||
* no-credentials branch redirects too (v1.29.x) rather than returning a raw
|
||||
* JSON 400 — every other failure path here already redirects, and a
|
||||
* browser-navigation entry point should never surface an unstyled JSON page.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
@@ -45,9 +47,12 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const creds = await getStravaClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Strava integration is not configured on this server.",
|
||||
400,
|
||||
annotate({ action: { name: "strava.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=error&reason=nocreds",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserWhoopCredentials } from "@/lib/whoop/credentials";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const create = prisma.whoopOAuthState.create as ReturnType<typeof vi.fn>;
|
||||
@@ -133,6 +134,17 @@ describe("GET /api/whoop/connect", () => {
|
||||
expect(create.mock.calls[0][0].data.returnScheme).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
|
||||
vi.mocked(getUserWhoopCredentials).mockResolvedValueOnce(null);
|
||||
const res = await GET(makeReq("https://app.example/api/whoop/connect"));
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("whoop=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ticket + return_scheme combine", async () => {
|
||||
consume.mockResolvedValue({ userId: "ticket-user" });
|
||||
const res = await GET(
|
||||
|
||||
@@ -40,7 +40,10 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
|
||||
* ledger rows for the full 10-min TTL window. Both the rate-limit and
|
||||
* create-failure paths redirect (not JSON) because the cookie entry point is a
|
||||
* browser navigation — a 429 envelope would surface as a blank page.
|
||||
* browser navigation — a 429 envelope would surface as a blank page. The
|
||||
* no-credentials branch redirects too (v1.29.x), matching Withings — only the
|
||||
* ticket-invalid branch stays a typed 401 JSON response, since that path is
|
||||
* consumed by a native client, not a browser navigation.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
@@ -89,9 +92,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const creds = await getUserWhoopCredentials(userId);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Please configure your WHOOP Client ID and Client Secret in Settings first.",
|
||||
400,
|
||||
annotate({ action: { name: "whoop.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?whoop=error&reason=nocreds", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* redirects to
|
||||
* `/settings/integrations?withings=error&reason=connect`
|
||||
* instead of bubbling a 500.
|
||||
* 4. No credentials: redirects to
|
||||
* `/settings/integrations?withings=error&reason=nocreds` instead of
|
||||
* returning a raw JSON 400 (v1.29.x — the flagship onboarding CTA
|
||||
* dead-ended in an unstyled JSON tab for every brand-new account).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -132,4 +136,17 @@ describe("GET /api/withings/connect", () => {
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=connect");
|
||||
});
|
||||
|
||||
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
|
||||
vi.mocked(getUserWithingsCredentials).mockResolvedValueOnce(null);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=nocreds");
|
||||
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -32,6 +31,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
* instead of bubbling a 500. Both rate-limit and create-failure paths
|
||||
* redirect rather than returning JSON because the entry point is a
|
||||
* browser navigation — a 429 envelope would surface as a blank page.
|
||||
*
|
||||
* v1.29.x — the no-credentials branch redirects too, matching every
|
||||
* other failure path in this route. It used to return a raw JSON 400,
|
||||
* which every browser navigation into this route (onboarding's
|
||||
* "Connect Withings" tap included) surfaced as an unstyled JSON page —
|
||||
* the first "connect a device" moment for a brand-new self-hoster.
|
||||
*/
|
||||
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
@@ -58,9 +63,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Please configure your Withings Client ID and Client Secret in Settings first.",
|
||||
400,
|
||||
annotate({ action: { name: "withings.connect.no_credentials" } });
|
||||
return NextResponse.redirect(
|
||||
new URL("/settings/integrations?withings=error&reason=nocreds", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user