Files
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

157 lines
5.6 KiB
TypeScript
Raw Permalink 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);
},
})),
};
});
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);
});
});