mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(api,ops): transactional adopt, admin gate without a flash, and a digest path to deploys
The self-context adopt runs read-modify-write inside one transaction, the admin shell renders nothing until the role is confirmed, and the json-body helper merges Headers instances correctly. The compose image reference accepts a pinned digest through an env var, and a promote script resolves the published digest, verifies its signature and hands the operator the exact pinning call — the documented path away from a moving latest tag.
This commit is contained in:
1 parent
599b7e8c99
commit
f13aa684f9
12 files changed
+876
-79
No files matched your search
+17
-10
@@ -4,15 +4,22 @@ services:
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# Tracks the GHCR `:latest` multi-arch image. `pull_policy: always`
|
||||
# is the load-bearing line — without it, Docker keeps a stale
|
||||
# `:latest` digest cached on the host and skips the registry round-
|
||||
# trip on `compose up`, which is what kept Coolify re-deploying the
|
||||
# previous image after every tag push. With pull_policy=always the
|
||||
# compose-up always re-checks the registry digest and pulls when
|
||||
# GHCR has a fresher manifest. The small bandwidth cost on every
|
||||
# `compose up` is the price of digest-correctness.
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Tracks the GHCR `:latest` multi-arch image by default.
|
||||
# `pull_policy: always` is the load-bearing line — without it,
|
||||
# Docker keeps a stale `:latest` digest cached on the host and
|
||||
# skips the registry round-trip on `compose up`, which is what kept
|
||||
# Coolify re-deploying the previous image after every tag push.
|
||||
# With pull_policy=always the compose-up always re-checks the
|
||||
# registry digest and pulls when GHCR has a fresher manifest.
|
||||
#
|
||||
# HEALTHLOG_IMAGE_REF pins the image deterministically without
|
||||
# editing this file: set it in the compose `.env` (or the Coolify
|
||||
# env vars) to `:vX.Y.Z` for a tag or `@sha256:<digest>` for an
|
||||
# immutable digest reference (see docs/ops/deploy.md, "Digest
|
||||
# pinning"; scripts/promote-digest.ts resolves + verifies the
|
||||
# digest after a release). Unset, everything behaves exactly as
|
||||
# before — `:latest` plus pull_policy.
|
||||
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
pull_policy: always
|
||||
container_name: healthlog-app
|
||||
# The app runs as the unprivileged `nextjs` user and never needs a
|
||||
@@ -108,7 +115,7 @@ services:
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
|
||||
+63
-10
@@ -121,19 +121,72 @@ image's only immutable identity, and it is what the signature actually
|
||||
covers. For any deploy where supply-chain integrity matters more than
|
||||
convenience:
|
||||
|
||||
1. Resolve and verify the digest once:
|
||||
The compose file carries the hook for this: the `app` service image is
|
||||
`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the
|
||||
day-to-day `:latest` + `pull_policy: always` flow stays the documented
|
||||
default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose
|
||||
`.env` (or the deployment platform's env vars) to switch the reference
|
||||
without touching the file:
|
||||
|
||||
```sh
|
||||
cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \
|
||||
| jq -r '.[0].critical.image."docker-manifest-digest"'
|
||||
```
|
||||
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
|
||||
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
|
||||
|
||||
2. Pin the compose `image:` (or the Coolify image reference) to
|
||||
`ghcr.io/mbombeck/healthlog@sha256:<digest>` instead of the tag.
|
||||
### Promote a release to a pinned digest
|
||||
|
||||
The day-to-day `:latest` + `pull_policy: always` flow stays the
|
||||
documented default for self-hosters; digest pinning is the recommended
|
||||
hardening for operators with a threat model that includes the registry.
|
||||
`scripts/promote-digest.ts` automates the whole promotion after
|
||||
`docker-publish.yml` has pushed a tag:
|
||||
|
||||
```sh
|
||||
pnpm dlx tsx scripts/promote-digest.ts v1.16.5
|
||||
```
|
||||
|
||||
It resolves the multi-arch index digest from the GHCR registry API
|
||||
(anonymous pull token, no docker daemon), verifies the keyless cosign
|
||||
signature against the workflow identity (fails closed — no cosign, no
|
||||
promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…`
|
||||
value.
|
||||
|
||||
For a Coolify-managed deployment it can also apply the pin. Two facts
|
||||
about Coolify shape the mechanism (verified against the Coolify
|
||||
OpenAPI spec and a live instance):
|
||||
|
||||
- For a git-based "Docker Compose" application, Coolify re-reads the
|
||||
compose file from the repository checkout on every deploy.
|
||||
`docker_compose_raw` is its stored copy of that file, and the
|
||||
application-update API (`PATCH /api/v1/applications/{uuid}`) does not
|
||||
accept the field — editing the stored compose is neither possible
|
||||
via API nor durable.
|
||||
- Application **env vars** are durable, exposed via the documented
|
||||
`/api/v1/applications/{uuid}/envs` endpoints, and interpolated into
|
||||
the compose at deploy time.
|
||||
|
||||
So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With
|
||||
`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the
|
||||
environment the script creates/updates the var itself (add
|
||||
`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it
|
||||
prints the equivalent curl commands:
|
||||
|
||||
```sh
|
||||
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
|
||||
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
||||
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
|
||||
|
||||
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
After the deploy settles, confirm `/api/version` reports the released
|
||||
`version` + `buildSha` on every target (see above).
|
||||
|
||||
**Rollback:** re-run the script with the previous tag (or PATCH the env
|
||||
var back to the previous digest / to empty for `:latest`) and redeploy.
|
||||
Digest pinning trades the automatic `:latest` pickup for determinism —
|
||||
a pinned instance only moves when the var moves, so the promotion step
|
||||
becomes part of the release routine, not an optional extra.
|
||||
|
||||
Digest pinning is the recommended hardening for operators with a
|
||||
threat model that includes the registry; `:latest` remains the
|
||||
low-friction default.
|
||||
|
||||
## Deploy-status webhook: replay protection
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* promote-digest.ts — pin a released image to its immutable digest.
|
||||
*
|
||||
* After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
|
||||
* script turns the mutable tag into a deterministic deploy reference:
|
||||
*
|
||||
* 1. Resolves the multi-arch index digest for the tag straight from
|
||||
* the GHCR registry API (anonymous pull token — the image is
|
||||
* public; no docker daemon needed).
|
||||
* 2. Verifies the keyless cosign signature the publish workflow
|
||||
* attached to that digest (GitHub Actions OIDC identity). Fails
|
||||
* closed: no cosign binary or a failed verification aborts the
|
||||
* promotion.
|
||||
* 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
|
||||
* file interpolates into `image:` — and, when a Coolify API token
|
||||
* is present in the environment, writes it to the application's
|
||||
* env vars via the documented `/envs` endpoint. Without a token
|
||||
* it prints the exact curl commands instead of guessing.
|
||||
*
|
||||
* Why an env var and not the compose file itself: for a git-based
|
||||
* "Docker Compose" application Coolify re-reads the compose file from
|
||||
* the repository checkout on every deploy (`docker_compose_raw` is its
|
||||
* stored copy of that file and is not part of the application-update
|
||||
* API surface), so editing the stored compose is not durable. The
|
||||
* application env vars ARE durable and are interpolated into the
|
||||
* compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
|
||||
* resolves to the pinned digest when the var is set and to `:latest`
|
||||
* when it is not.
|
||||
*
|
||||
* Usage (one-shot, not a recurring job):
|
||||
* pnpm dlx tsx scripts/promote-digest.ts v1.16.5
|
||||
*
|
||||
* Env (all optional — without them the script prints commands only):
|
||||
* COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
|
||||
* COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
|
||||
* COOLIFY_APP_UUID UUID of the HealthLog application resource
|
||||
* PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
|
||||
*
|
||||
* See docs/ops/deploy.md ("Digest pinning") for the full runbook,
|
||||
* including rollback (re-point the var at the previous digest).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const IMAGE_REPO = "mbombeck/healthlog";
|
||||
const IMAGE = `ghcr.io/${IMAGE_REPO}`;
|
||||
const ENV_KEY = "HEALTHLOG_IMAGE_REF";
|
||||
const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
|
||||
const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
|
||||
|
||||
// The OCI index media types a multi-arch publish can produce. GHCR
|
||||
// answers the manifest request with the digest of whichever it stores;
|
||||
// accepting both keeps the script working across buildx versions.
|
||||
const INDEX_ACCEPT = [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
].join(", ");
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`\nERROR: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveDigest(tag: string): Promise<string> {
|
||||
// Anonymous pull token — the package is public, so no credentials.
|
||||
const tokenRes = await fetch(
|
||||
`https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
|
||||
);
|
||||
if (!tokenRes.ok) {
|
||||
fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
|
||||
}
|
||||
const { token } = (await tokenRes.json()) as { token?: string };
|
||||
if (!token) fail("GHCR token response carried no token");
|
||||
|
||||
// HEAD is enough — the digest rides the Docker-Content-Digest header.
|
||||
const manifestRes = await fetch(
|
||||
`https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
|
||||
{
|
||||
method: "HEAD",
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
|
||||
},
|
||||
);
|
||||
if (!manifestRes.ok) {
|
||||
fail(
|
||||
`manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
|
||||
);
|
||||
}
|
||||
const digest = manifestRes.headers.get("docker-content-digest");
|
||||
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function verifyCosign(digest: string): void {
|
||||
const ref = `${IMAGE}@${digest}`;
|
||||
try {
|
||||
execFileSync(
|
||||
"cosign",
|
||||
[
|
||||
"verify",
|
||||
"--certificate-oidc-issuer",
|
||||
COSIGN_ISSUER,
|
||||
"--certificate-identity-regexp",
|
||||
COSIGN_IDENTITY_RE,
|
||||
ref,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||
if (e.code === "ENOENT") {
|
||||
fail(
|
||||
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
|
||||
);
|
||||
}
|
||||
fail(
|
||||
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
|
||||
);
|
||||
}
|
||||
console.log(`cosign signature verified for ${ref}`);
|
||||
}
|
||||
|
||||
interface CoolifyEnvRow {
|
||||
uuid: string;
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
async function coolifyRequest(
|
||||
base: string,
|
||||
token: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCoolifyEnv(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
|
||||
if (!list.ok) {
|
||||
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
|
||||
}
|
||||
const rows = (await list.json()) as CoolifyEnvRow[];
|
||||
const existing = Array.isArray(rows)
|
||||
? rows.find((r) => r.key === ENV_KEY)
|
||||
: undefined;
|
||||
|
||||
const method = existing ? "PATCH" : "POST";
|
||||
const res = await coolifyRequest(
|
||||
base,
|
||||
token,
|
||||
`/applications/${appUuid}/envs`,
|
||||
{ method, body: JSON.stringify({ key: ENV_KEY, value }) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(`Coolify env ${method} failed: HTTP ${res.status}`);
|
||||
}
|
||||
console.log(
|
||||
`Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function triggerDeploy(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
): Promise<void> {
|
||||
const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
|
||||
if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
|
||||
console.log("Deploy triggered. Verify /api/version after it settles.");
|
||||
}
|
||||
|
||||
function printManualCommands(value: string): void {
|
||||
console.log(`
|
||||
No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
|
||||
token and application UUID; the UUID is in the Coolify URL of the app):
|
||||
|
||||
# set / update the pin (POST creates, PATCH updates an existing key)
|
||||
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
|
||||
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
|
||||
-d '{"key":"${ENV_KEY}","value":"${value}"}'
|
||||
|
||||
# redeploy with the pinned digest
|
||||
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
|
||||
-H "Authorization: Bearer <token>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tag = process.argv[2];
|
||||
if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
|
||||
fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
|
||||
}
|
||||
|
||||
console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
|
||||
const digest = await resolveDigest(tag);
|
||||
console.log(`digest: ${digest}`);
|
||||
|
||||
verifyCosign(digest);
|
||||
|
||||
const value = `@${digest}`;
|
||||
console.log(`\n${ENV_KEY}=${value}`);
|
||||
|
||||
const token = process.env.COOLIFY_API_TOKEN;
|
||||
const base = process.env.COOLIFY_API_URL;
|
||||
const appUuid = process.env.COOLIFY_APP_UUID;
|
||||
|
||||
if (token && base && appUuid) {
|
||||
await updateCoolifyEnv(base, token, appUuid, value);
|
||||
if (process.env.PROMOTE_DEPLOY === "1") {
|
||||
await triggerDeploy(base, token, appUuid);
|
||||
} else {
|
||||
console.log(
|
||||
"Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (token || base || appUuid) {
|
||||
console.log(
|
||||
"Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
|
||||
);
|
||||
}
|
||||
printManualCommands(value);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* POST /api/coach/about-me/adopt — locks the read-modify-write.
|
||||
*
|
||||
* The original handler read the self-context through the global client,
|
||||
* computed the appended value, then upserted: two concurrent adoptions
|
||||
* could read the same base value and one append silently lost. The
|
||||
* suite pins the transactional shape — empty upsert → `FOR UPDATE` row
|
||||
* lock → read through the SAME `tx` client → single-column update —
|
||||
* so a refactor can't quietly fall back to the racy read.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
const tx = {
|
||||
userHealthProfile: {
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: (s: string) => Buffer.from(s),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextForUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
getClientIp: () => "127.0.0.1",
|
||||
returnAllZodIssues: (_e: unknown, status: number) => ({
|
||||
data: null,
|
||||
error: "validation",
|
||||
status,
|
||||
}),
|
||||
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
|
||||
|
||||
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
|
||||
function adoptRequest(body: { question: string; answer: string }): Request {
|
||||
return new Request("http://localhost/api/coach/about-me/adopt", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
type Envelope = { data: unknown; error: string | null; status: number };
|
||||
const post = POST as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
const emptyCtx = {
|
||||
aboutMe: null,
|
||||
conditions: null,
|
||||
allergies: null,
|
||||
coachFocus: null,
|
||||
};
|
||||
|
||||
describe("POST /api/coach/about-me/adopt", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" });
|
||||
tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() });
|
||||
tx.$queryRaw.mockResolvedValue([{ id: "p1" }]);
|
||||
});
|
||||
|
||||
it("runs the read-modify-write inside one transaction with a row lock", async () => {
|
||||
getCtx.mockResolvedValue(emptyCtx);
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ adopted: true, field: "coachFocus" });
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
// Row pinned, then locked FOR UPDATE before the read.
|
||||
expect(tx.userHealthProfile.upsert).toHaveBeenCalledWith({
|
||||
where: { userId: "u1" },
|
||||
create: { userId: "u1" },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
// The read goes through the SAME tx client, not the global one.
|
||||
expect(getCtx).toHaveBeenCalledWith("u1", tx);
|
||||
// Exactly one encrypted column written.
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]);
|
||||
});
|
||||
|
||||
it("dedupes inside the locked section without writing", async () => {
|
||||
getCtx.mockResolvedValue({ ...emptyCtx, coachFocus: "Evening walks" });
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "evening walks" }),
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({
|
||||
adopted: false,
|
||||
field: "coachFocus",
|
||||
reason: "duplicate",
|
||||
});
|
||||
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to an existing value read under the lock", async () => {
|
||||
getCtx.mockResolvedValue({ ...emptyCtx, allergies: "pollen" });
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "Any allergies?", answer: "penicillin" }),
|
||||
);
|
||||
|
||||
expect(res.data).toEqual({ adopted: true, field: "allergies" });
|
||||
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
|
||||
expect(Buffer.from(data.allergiesEncrypted).toString()).toBe(
|
||||
"pollen\npenicillin",
|
||||
);
|
||||
});
|
||||
|
||||
it("422s when the overflow target aboutMe is full, still without a write", async () => {
|
||||
getCtx.mockResolvedValue({
|
||||
...emptyCtx,
|
||||
coachFocus: "x".repeat(499),
|
||||
aboutMe: "y".repeat(3999),
|
||||
});
|
||||
|
||||
const res = await post(
|
||||
adoptRequest({ question: "What matters?", answer: "z".repeat(10) }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -113,56 +113,82 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
}
|
||||
|
||||
const { question, answer } = parsed.data;
|
||||
const ctx = await getSelfContextForUser(user.id);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
const normAnswer = normalise(answer);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
const targetExisting = ctx[field];
|
||||
if (
|
||||
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
|
||||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
|
||||
) {
|
||||
// Read-modify-write under a row lock: two concurrent adoptions used
|
||||
// to read the same base value and one append silently lost (or the
|
||||
// dedupe missed the other's in-flight write). The empty upsert pins
|
||||
// the row, `FOR UPDATE` serialises every adoption for this user, and
|
||||
// the read happens through the same `tx` so it sees the locked state.
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
await tx.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
await tx.$queryRaw`
|
||||
SELECT id FROM user_health_profiles
|
||||
WHERE user_id = ${user.id}
|
||||
FOR UPDATE
|
||||
`;
|
||||
const ctx = await getSelfContextForUser(user.id, tx);
|
||||
|
||||
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
|
||||
|
||||
// Dedupe BEFORE any write: an answer already present in the target
|
||||
// field or in the free-text aboutMe never stacks a second copy.
|
||||
const targetExisting = ctx[field];
|
||||
if (
|
||||
(targetExisting && normalise(targetExisting).includes(normAnswer)) ||
|
||||
(ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer))
|
||||
) {
|
||||
return { kind: "duplicate" as const, field };
|
||||
}
|
||||
|
||||
// Append on its own line; a structured field at its cap overflows
|
||||
// into the free-text aboutMe (4000 chars) rather than failing the
|
||||
// adoption outright.
|
||||
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
|
||||
field = "aboutMe";
|
||||
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
|
||||
return { kind: "full" as const, field };
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field assembly (no mass assignment): exactly one
|
||||
// encrypted column is written per adoption.
|
||||
const column = (
|
||||
{
|
||||
conditions: "conditionsEncrypted",
|
||||
allergies: "allergiesEncrypted",
|
||||
coachFocus: "coachFocusEncrypted",
|
||||
aboutMe: "aboutMeEncrypted",
|
||||
} as const
|
||||
)[field];
|
||||
|
||||
await tx.userHealthProfile.update({
|
||||
where: { userId: user.id },
|
||||
data: { [column]: encryptToBytes(nextValue) },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
return { kind: "adopted" as const, field };
|
||||
});
|
||||
|
||||
const { field } = outcome;
|
||||
if (outcome.kind === "duplicate") {
|
||||
annotate({
|
||||
action: { name: "coach.about_me.adopt_deduped" },
|
||||
meta: { field },
|
||||
});
|
||||
return apiSuccess({ adopted: false, field, reason: "duplicate" });
|
||||
}
|
||||
|
||||
// Append on its own line; a structured field at its cap overflows
|
||||
// into the free-text aboutMe (4000 chars) rather than failing the
|
||||
// adoption outright.
|
||||
let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) {
|
||||
field = "aboutMe";
|
||||
nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer;
|
||||
if (nextValue.length > ABOUT_ME_MAX_CHARS) {
|
||||
return apiError("Self-context is full", 422);
|
||||
}
|
||||
if (outcome.kind === "full") {
|
||||
return apiError("Self-context is full", 422);
|
||||
}
|
||||
|
||||
// Field-by-field assembly (no mass assignment): exactly one encrypted
|
||||
// column is written per adoption.
|
||||
const column = (
|
||||
{
|
||||
conditions: "conditionsEncrypted",
|
||||
allergies: "allergiesEncrypted",
|
||||
coachFocus: "coachFocusEncrypted",
|
||||
aboutMe: "aboutMeEncrypted",
|
||||
} as const
|
||||
)[field];
|
||||
const payload = { [column]: encryptToBytes(nextValue) };
|
||||
|
||||
await prisma.userHealthProfile.upsert({
|
||||
where: { userId: user.id },
|
||||
create: { userId: user.id, ...payload },
|
||||
update: payload,
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
|
||||
await auditLog("coach.about_me.adopted", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
|
||||
@@ -21,6 +21,17 @@ vi.mock("next/navigation", () => ({
|
||||
usePathname: () => mockPathnameRef.value,
|
||||
}));
|
||||
|
||||
// The shell gates its own frame on the confirmed ADMIN role — a
|
||||
// non-admin (or a still-loading auth state) must not see the section
|
||||
// nav while AuthShell's redirect effect is catching up.
|
||||
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null };
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: mockUserRef.value }),
|
||||
}));
|
||||
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -31,8 +42,11 @@ import { ADMIN_SECTIONS, AdminShell } from "../admin-shell";
|
||||
function renderShell(props: {
|
||||
active?: (typeof ADMIN_SECTIONS)[number]["slug"];
|
||||
pathname?: string;
|
||||
user?: { role: string; username: string } | null;
|
||||
}) {
|
||||
mockPathnameRef.value = props.pathname ?? "/admin/system-status";
|
||||
mockUserRef.value =
|
||||
props.user === undefined ? { role: "ADMIN", username: "op" } : props.user;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AdminShell active={props.active}>
|
||||
@@ -133,6 +147,22 @@ describe("<AdminShell>", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders neither frame nor children until the role is confirmed ADMIN", () => {
|
||||
// Non-admin: no section nav, no children — the frame must not
|
||||
// flash while AuthShell's redirect effect moves the user away.
|
||||
const asUser = renderShell({
|
||||
active: "system-status",
|
||||
user: { role: "USER", username: "u" },
|
||||
});
|
||||
expect(asUser).toBe("");
|
||||
// Auth still in flight (user === null): same blank render.
|
||||
const loading = renderShell({ active: "system-status", user: null });
|
||||
expect(loading).toBe("");
|
||||
// Admin: frame + children render.
|
||||
const asAdmin = renderShell({ active: "system-status" });
|
||||
expect(asAdmin).toContain("section body");
|
||||
});
|
||||
|
||||
it("does NOT mark any section active on `/admin` overview", () => {
|
||||
const html = renderShell({ pathname: "/admin" });
|
||||
// Overview-link only — no section gets aria-current.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* v1.16.5 — admin invite section: multi-use surface contracts.
|
||||
*
|
||||
* SSR smoke tests per project convention (`renderToStaticMarkup` +
|
||||
* seeded QueryClient; no `@testing-library/react`). Pinned:
|
||||
*
|
||||
* 1. The table renders `uses / maxUses` for a multi-use invite.
|
||||
* 2. uses == maxUses surfaces the "Aufgebraucht" (exhausted) chip.
|
||||
* 3. Multiple redemptions render the "+N weitere" popover trigger;
|
||||
* a single redemption renders inline without one.
|
||||
* 4. `clampMaxUses` pins the create-dialog field to the 1–50 range
|
||||
* (the same clamp the submit path sends).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
InviteTokensSection,
|
||||
clampMaxUses,
|
||||
} from "@/components/admin/invite-tokens-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString();
|
||||
const PAST = new Date(Date.now() - 86_400_000).toISOString();
|
||||
|
||||
interface SeedInvite {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
usedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
uses: number;
|
||||
maxUses: number;
|
||||
creator: { id: string; username: string } | null;
|
||||
consumer: { id: string; username: string } | null;
|
||||
redemptions: Array<{
|
||||
id: string;
|
||||
redeemedAt: string;
|
||||
user: { id: string; username: string; email: string | null } | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function makeInvite(overrides: Partial<SeedInvite>): SeedInvite {
|
||||
return {
|
||||
id: "inv-1",
|
||||
createdAt: PAST,
|
||||
expiresAt: FUTURE,
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
uses: 0,
|
||||
maxUses: 1,
|
||||
creator: { id: "admin-1", username: "operator" },
|
||||
consumer: null,
|
||||
redemptions: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(invites: SeedInvite[]): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.adminInvites(), invites);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<QueryClientProvider client={client}>
|
||||
<InviteTokensSection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("InviteTokensSection (multi-use)", () => {
|
||||
it("renders uses / maxUses for a partially used multi-use invite", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 2,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("/ 5");
|
||||
expect(html).toContain("invite-status-active");
|
||||
});
|
||||
|
||||
it("surfaces the exhausted chip when uses reaches maxUses", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 3,
|
||||
maxUses: 3,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u3", username: "gamma", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("invite-status-exhausted");
|
||||
expect(html).toContain("Aufgebraucht");
|
||||
});
|
||||
|
||||
it("multiple redemptions render the popover trigger with the +N summary", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 3,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u2", username: "beta", email: null },
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u3", username: "gamma", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("admin-invite-redemptions-trigger");
|
||||
// de: "{username} + {count} weitere" — newest redemption leads.
|
||||
expect(html).toContain("alpha + 2 weitere");
|
||||
});
|
||||
|
||||
it("a single redemption renders inline without a popover trigger", () => {
|
||||
const html = render([
|
||||
makeInvite({
|
||||
uses: 1,
|
||||
maxUses: 5,
|
||||
redemptions: [
|
||||
{
|
||||
id: "r1",
|
||||
redeemedAt: PAST,
|
||||
user: { id: "u1", username: "alpha", email: null },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("alpha");
|
||||
expect(html).not.toContain("admin-invite-redemptions-trigger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampMaxUses", () => {
|
||||
it("passes the 1–50 range through", () => {
|
||||
expect(clampMaxUses("1")).toBe(1);
|
||||
expect(clampMaxUses("25")).toBe(25);
|
||||
expect(clampMaxUses("50")).toBe(50);
|
||||
});
|
||||
|
||||
it("clamps above the cap to 50", () => {
|
||||
expect(clampMaxUses("51")).toBe(50);
|
||||
expect(clampMaxUses("9999")).toBe(50);
|
||||
});
|
||||
|
||||
it("falls back to 1 for zero, negatives, and garbage", () => {
|
||||
expect(clampMaxUses("0")).toBe(1);
|
||||
expect(clampMaxUses("-3")).toBe(1);
|
||||
expect(clampMaxUses("")).toBe(1);
|
||||
expect(clampMaxUses("abc")).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates decimals to the integer part", () => {
|
||||
expect(clampMaxUses("2.9")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { scrollBehaviorForUser } from "@/lib/motion";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
|
||||
@@ -172,6 +173,7 @@ function deriveActiveSlug(
|
||||
export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
|
||||
@@ -199,6 +201,13 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
// The section bodies are already role-gated, but the shell frame
|
||||
// itself (the full section nav in two layouts) used to paint for a
|
||||
// non-admin during the frames between auth resolving and AuthShell's
|
||||
// redirect effect replacing the route. Render nothing — frame AND
|
||||
// children — until the role is confirmed ADMIN.
|
||||
if (!user || user.role !== "ADMIN") return null;
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
|
||||
@@ -123,6 +123,18 @@ export function deriveInviteStatus(
|
||||
const TTL_CHOICES = [7, 14, 30] as const;
|
||||
const MAX_USES_CAP = 50;
|
||||
|
||||
/**
|
||||
* Clamp the raw max-uses field to the mintable range (integer 1–50).
|
||||
* Shared by the submit path and the blur echo so the admin always sees
|
||||
* exactly what the POST will carry. Exported for unit tests.
|
||||
*/
|
||||
export function clampMaxUses(raw: string): number {
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 1
|
||||
? Math.min(parsed, MAX_USES_CAP)
|
||||
: 1;
|
||||
}
|
||||
|
||||
/** Whole days (ceil) from `now` until `iso`; negative when past. */
|
||||
function daysUntil(iso: string, now: Date): number {
|
||||
const ms = new Date(iso).getTime() - now.getTime();
|
||||
@@ -253,12 +265,7 @@ export function InviteTokensSection() {
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const parsedMax = Number.parseInt(maxUses, 10);
|
||||
const safeMax =
|
||||
Number.isFinite(parsedMax) && parsedMax >= 1
|
||||
? Math.min(parsedMax, MAX_USES_CAP)
|
||||
: 1;
|
||||
create.mutate({ expiresInDays: ttlDays, maxUses: safeMax });
|
||||
create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -591,8 +598,14 @@ export function InviteTokensSection() {
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
@@ -753,7 +766,12 @@ function RedemptionsCell({
|
||||
count: invite.redemptions.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the
|
||||
list height so the popover never outgrows the viewport. */}
|
||||
<ul
|
||||
className="mt-2 max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
data-testid="admin-invite-redemptions-list"
|
||||
>
|
||||
{invite.redemptions.map((entry) => (
|
||||
<li key={entry.id} className="text-sm leading-tight">
|
||||
<div>
|
||||
|
||||
@@ -61,12 +61,17 @@ function decryptOrNull(payload: Uint8Array | null): string | null {
|
||||
/**
|
||||
* Load + decrypt the caller's structured self-context. Every field is
|
||||
* independently fail-closed; a row that never existed yields all-null.
|
||||
*
|
||||
* `db` lets callers inside a transaction (the adopt endpoint's locked
|
||||
* read-modify-write) read through their `tx` client; default is the
|
||||
* global client.
|
||||
*/
|
||||
export async function getSelfContextForUser(
|
||||
userId: string,
|
||||
db: Pick<typeof prisma, "userHealthProfile"> = prisma,
|
||||
): Promise<SelfContext> {
|
||||
try {
|
||||
const row = await prisma.userHealthProfile.findUnique({
|
||||
const row = await db.userHealthProfile.findUnique({
|
||||
where: { userId },
|
||||
select: {
|
||||
aboutMeEncrypted: true,
|
||||
|
||||
@@ -192,11 +192,13 @@ describe("verb helpers", () => {
|
||||
|
||||
await helper("/api/foo", { a: 1 });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("/api/foo");
|
||||
expect(init.method).toBe(method);
|
||||
expect(init.body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect(new Headers(init.headers).get("Content-Type")).toBe(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits body and Content-Type when no body is given", async () => {
|
||||
@@ -212,10 +214,44 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiPost("/api/foo", { a: 1 }, { headers: { "X-Req": "1" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Req": "1" },
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
});
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
expect(headers.get("X-Req")).toBe("1");
|
||||
expect(init.body).toBe(JSON.stringify({ a: 1 }));
|
||||
});
|
||||
|
||||
it("keeps caller headers passed as a Headers instance (no spread loss)", async () => {
|
||||
// A `Headers` instance has no enumerable own properties, so the
|
||||
// previous `{ ...init.headers }` merge silently dropped every
|
||||
// caller header for this `HeadersInit` shape.
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
await apiPost(
|
||||
"/api/foo",
|
||||
{ a: 1 },
|
||||
{ headers: new Headers({ "X-Req": "1", "Idempotency-Key": "k1" }) },
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("X-Req")).toBe("1");
|
||||
expect(headers.get("Idempotency-Key")).toBe("k1");
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("respects a caller-supplied Content-Type override", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
|
||||
|
||||
await apiPost(
|
||||
"/api/foo",
|
||||
{ a: 1 },
|
||||
{ headers: new Headers({ "Content-Type": "application/json-patch+json" }) },
|
||||
);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(new Headers(init.headers).get("Content-Type")).toBe(
|
||||
"application/json-patch+json",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -141,7 +141,15 @@ function withJsonBody(
|
||||
): RequestInit {
|
||||
const merged: RequestInit = { ...init, method };
|
||||
if (body !== undefined) {
|
||||
merged.headers = { "Content-Type": "application/json", ...init?.headers };
|
||||
// `HeadersInit` is a union — a plain object spreads fine, but a
|
||||
// `Headers` instance (or entries array) has no enumerable own
|
||||
// properties, so `{ ...init.headers }` silently dropped every
|
||||
// caller header. `new Headers(...)` normalises all three shapes.
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
merged.headers = headers;
|
||||
merged.body = JSON.stringify(body);
|
||||
}
|
||||
return merged;
|
||||
|
||||
Reference in new issue
Block a user