Files
HealthLog/src/__tests__/integration-callback-url-guard.test.ts
T
Marc-André Bombeck 05d3736d75 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.
2026-09-01 21:00:12 +02:00

57 lines
2.5 KiB
TypeScript

/**
* Structural guard: no client module on the Settings → Integrations surface
* may read `process.env.NEXT_PUBLIC_APP_URL`.
*
* Why this is a tripwire and not a behavioural test. Next.js inlines every
* `NEXT_PUBLIC_*` reference in a `"use client"` module at BUILD time. The
* published image is built without `NEXT_PUBLIC_APP_URL` (the Dockerfile
* passes only the version / build-sha / built-at args, and `.env` files are
* not in the build context), so any such read compiles to `undefined` for
* every self-hoster, no matter what their runtime env says. A unit test with
* the variable set in `process.env` cannot see that: under Vitest the read is
* live, so the code passes while the shipped bundle is inert. v1.38.0's
* callback-origin notice shipped exactly that way.
*
* The callback URL therefore comes from the server (`getIntegrationCallbackUrls`
* in `src/lib/integrations/callback-urls.ts`) and travels down as a prop. This
* guard fails the moment a client file on the surface reaches for the env var
* again.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SURFACE = join(process.cwd(), "src/components/settings/integrations");
const CLIENT_DIRECTIVE = /^\s*["']use client["']\s*;?/m;
const APP_URL_READ = /process\.env\.NEXT_PUBLIC_APP_URL/;
function surfaceFiles(): string[] {
return walkSourceFiles(SURFACE, { floor: 15 })
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
describe("Settings → Integrations client modules never read NEXT_PUBLIC_APP_URL", () => {
it('every "use client" file on the surface is free of the env read', () => {
const files = surfaceFiles();
const clientFiles = files.filter((rel) =>
CLIENT_DIRECTIVE.test(readFileSync(join(SURFACE, rel), "utf8")),
);
// The walk must have found the surface, and the surface must still be
// client-rendered — an empty set would pass on a tree the guard never read.
expect(files.length).toBeGreaterThanOrEqual(15);
expect(clientFiles.length).toBeGreaterThanOrEqual(10);
const offenders = clientFiles.filter((rel) =>
APP_URL_READ.test(readFileSync(join(SURFACE, rel), "utf8")),
);
expect(
offenders,
`NEXT_PUBLIC_APP_URL is inlined at build time in client bundles and is empty in the published image; pass the server-derived callback URL as a prop instead: ${offenders.join(", ")}`,
).toEqual([]);
});
});