Files
HealthLog/tests/integration/user-avatar.test.ts
T
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00

165 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.5.5 — self-hosted avatar storage end-to-end guard.
*
* Confirms that the upload + read + delete cycle round-trips the raw
* image bytes through Postgres without truncation, encoding drift,
* or owner-scope leaks:
*
* 1. POST /api/user/avatar with a valid PNG returns 201 + the
* `avatarUrl` the /me payload would return.
* 2. GET /api/user/avatar/{userId} for the same session echoes the
* bytes verbatim with the persisted Content-Type.
* 3. GET /api/user/avatar/{otherUserId} from the first session
* returns 403 — the owner-scope gate covers cross-user reads.
* 4. DELETE /api/user/avatar wipes the columns and the subsequent
* GET returns 404.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==",
"base64",
);
async function seedSessionUser(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
timezone: "Europe/Berlin",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
return { user, session };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("self-hosted avatar — end-to-end", () => {
it("round-trips the bytes through Postgres without drift", async () => {
const { user } = await seedSessionUser("avatar-owner");
cookieJar.set(
"healthlog_session",
(await getPrismaClient().session.findFirst({
where: { userId: user.id },
}))!.id,
);
const { POST, DELETE } = await import("@/app/api/user/avatar/route");
const { GET } = await import("@/app/api/user/avatar/[id]/route");
// Narrow the apiHandler signature for the test fixtures (see
// tests/integration/timezone-per-user.test.ts for the precedent).
type RouteCtx = { params: Promise<{ id: string }> };
const post = POST as (r: Request) => Promise<Response>;
const del = DELETE as (r: Request) => Promise<Response>;
const get = GET as (r: Request, ctx: RouteCtx) => Promise<Response>;
// ── POST: upload the 1×1 PNG ──────────────────────────────
const fd = new FormData();
fd.append(
"file",
new Blob([PNG_1X1], { type: "image/png" }),
"avatar.png",
);
const uploadReq = new Request("http://localhost/api/user/avatar", {
method: "POST",
body: fd,
});
const uploadRes = await post(uploadReq);
expect(uploadRes.status).toBe(201);
const uploadEnv = (await uploadRes.json()) as {
data: { avatarUrl: string; contentType: string };
};
expect(uploadEnv.data.contentType).toBe("image/png");
expect(uploadEnv.data.avatarUrl).toMatch(
new RegExp(`^/api/user/avatar/${user.id}\\?v=\\d+$`),
);
// ── GET (owner): bytes round-trip exactly ────────────────
const getRes = await get(
new Request(`http://localhost/api/user/avatar/${user.id}`),
{ params: Promise.resolve({ id: user.id }) },
);
expect(getRes.status).toBe(200);
expect(getRes.headers.get("content-type")).toBe("image/png");
const echoed = Buffer.from(await getRes.arrayBuffer());
expect(echoed.equals(PNG_1X1)).toBe(true);
// ── GET (cross-user): 403 ────────────────────────────────
const { user: other } = await seedSessionUser("other-user");
const crossRes = await get(
new Request(`http://localhost/api/user/avatar/${other.id}`),
{ params: Promise.resolve({ id: other.id }) },
);
expect(crossRes.status).toBe(403);
// ── DELETE: clears the row ───────────────────────────────
const delRes = await del(
new Request("http://localhost/api/user/avatar", { method: "DELETE" }),
);
expect(delRes.status).toBe(204);
const fresh = await getPrismaClient().user.findUnique({
where: { id: user.id },
select: {
avatarBytes: true,
avatarContentType: true,
avatarUpdatedAt: true,
},
});
expect(fresh?.avatarBytes).toBeNull();
expect(fresh?.avatarContentType).toBeNull();
expect(fresh?.avatarUpdatedAt).toBeNull();
// ── GET (after delete): 404 ──────────────────────────────
const post404 = await get(
new Request(`http://localhost/api/user/avatar/${user.id}`),
{ params: Promise.resolve({ id: user.id }) },
);
expect(post404.status).toBe(404);
});
});