mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.
The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.
Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
This commit is contained in:
1 parent
97b1603cb9
commit
05d3736d75
25 files changed
+516
-57
No files matched your search
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `<CallbackMismatchNotice>` renders from the callback URL it is HANDED, not
|
||||
* from anything it reads itself. The origin the browser is on comes through
|
||||
* `useSyncExternalStore`, whose server snapshot is deliberately `null` (the
|
||||
* server cannot know the browser origin, and a notice that flips at hydration
|
||||
* would flash). A static render therefore never shows it; to exercise the
|
||||
* client path here the hook is replaced with its client snapshot and a bare
|
||||
* `window.location.origin` is provided.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react")>();
|
||||
return {
|
||||
...actual,
|
||||
useSyncExternalStore: <T,>(
|
||||
_subscribe: (onChange: () => void) => () => void,
|
||||
getSnapshot: () => T,
|
||||
): T => getSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CallbackMismatchNotice } from "../setup-guide-link";
|
||||
|
||||
const BROWSER_ORIGIN = "https://health.example";
|
||||
|
||||
function render(callbackUrl: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CallbackMismatchNotice provider="WHOOP" callbackUrl={callbackUrl} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<CallbackMismatchNotice>", () => {
|
||||
const priorWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
location: { origin: BROWSER_ORIGIN },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (priorWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = priorWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the warning when the configured callback origin differs from the browser origin", () => {
|
||||
const html = render("https://edge.example/api/whoop/callback");
|
||||
expect(html).toContain("WHOOP");
|
||||
expect(html).toContain("https://edge.example");
|
||||
expect(html).toContain(BROWSER_ORIGIN);
|
||||
expect(html).toContain("differs from this app address");
|
||||
});
|
||||
|
||||
it("renders nothing when the callback origin matches the browser origin", () => {
|
||||
expect(render(`${BROWSER_ORIGIN}/api/whoop/callback`)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when the server could not produce a callback URL", () => {
|
||||
expect(render(null)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing for a callback URL that is not absolute", () => {
|
||||
// The pre-fix client bundle produced exactly this shape and the notice
|
||||
// stayed silent; it still must, rather than throwing out of the card.
|
||||
expect(render("/api/whoop/callback")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -84,10 +84,20 @@ function fullEnvelope() {
|
||||
};
|
||||
}
|
||||
|
||||
const CALLBACK_URLS = {
|
||||
withings: "https://app.example/api/withings/callback",
|
||||
whoop: "https://app.example/api/whoop/callback",
|
||||
fitbit: "https://app.example/api/fitbit/callback",
|
||||
"google-health": "https://app.example/api/google-health/callback",
|
||||
polar: "https://app.example/api/polar/callback",
|
||||
oura: "https://app.example/api/oura/callback",
|
||||
strava: "https://app.example/api/strava/callback",
|
||||
};
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ConnectionsPanel />
|
||||
<ConnectionsPanel callbackUrls={CALLBACK_URLS} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ function render({
|
||||
dataHref="/insights/sleep"
|
||||
credentials={credentials}
|
||||
viewModel={viewModel}
|
||||
callbackUrl="https://app.example/api/polar/callback"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -162,7 +163,6 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
|
||||
|
||||
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
|
||||
it("shows the callback URL guide before the user has BYO credentials", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
const html = render({
|
||||
credentials: true,
|
||||
viewModel: {
|
||||
|
||||
@@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WhoopCard } from "../whoop-card";
|
||||
import type { IntegrationStatusViewModel } from "../shared";
|
||||
|
||||
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
function render(
|
||||
viewModel?: Partial<IntegrationStatusViewModel>,
|
||||
callbackUrl: string | null = "https://app.example/api/whoop/callback",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<WhoopCard
|
||||
viewModel={viewModel as IntegrationStatusViewModel | undefined}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -27,13 +31,19 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
|
||||
describe("<WhoopCard> redirect-URI mini-guide", () => {
|
||||
it("shows the callback URL guide before credentials are configured", () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
|
||||
const html = render({ connected: false, configured: false });
|
||||
expect(html).toContain('data-testid="whoop-redirect-guide"');
|
||||
expect(html).toContain('data-testid="whoop-redirect-uri"');
|
||||
expect(html).toContain("https://app.example/api/whoop/callback");
|
||||
});
|
||||
|
||||
it("falls back to the fixed path when the server has no redirect URI", () => {
|
||||
const html = render({ connected: false, configured: false }, null);
|
||||
expect(html).toContain('data-testid="whoop-redirect-uri"');
|
||||
expect(html).toContain("/api/whoop/callback");
|
||||
expect(html).not.toContain("undefined/api/whoop/callback");
|
||||
});
|
||||
|
||||
it("hides the guide once credentials are configured", () => {
|
||||
const html = render({ connected: false, configured: true });
|
||||
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
|
||||
|
||||
Reference in new issue
Block a user