mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The Apple Health pink chip rendered only inside the desktop `<Table>` cell. iOS users — the audience that actually consumes Apple Health data — land on the mobile card branch and saw no source indicator at all, risking duplicate hand-entries on top of synced rows. Add a compact badge inline with the timestamp on the mobile card so the distinction lands at every viewport. Component test asserts the `measurement-source-badge` testid appears twice (desktop + mobile) so the next layout refactor can't silently drop one branch. Also extends the `POST /api/devices` unit test mocks for the NotificationChannel auto-upsert added in the previous commit, plus a new test that pins the same-user apnsToken collision path returning 201 idempotently (the cross-user path keeps its 409). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
device: {
|
|
findUnique: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
notificationChannel: {
|
|
upsert: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/crypto", () => ({
|
|
encrypt: (s: string) => `enc(${s})`,
|
|
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
|
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: () => null })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import { POST } from "../route";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
|
|
const SESSION_OK = {
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
|
user: { id: "user-1", username: "marc", role: "USER" as const },
|
|
};
|
|
|
|
function req(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/devices", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
|
|
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
|
|
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
|
|
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
|
|
id: "ch-1",
|
|
} as never);
|
|
});
|
|
|
|
describe("POST /api/devices", () => {
|
|
it("returns 401 when unauthenticated", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(null);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 422 for missing token", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(req({ bundleId: "io.healthlog.app" }));
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("creates a new device row when token is unknown", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
locale: "de-DE",
|
|
appVersion: "1.0.0",
|
|
model: "iPhone15,2",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.device.create).toHaveBeenCalledTimes(1);
|
|
expect(prisma.device.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects cross-user re-registration with 409", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
|
id: "dev-1",
|
|
userId: "other-user",
|
|
token: "abcd1234efgh5678",
|
|
} as never);
|
|
const res = await POST(
|
|
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
|
);
|
|
expect(res.status).toBe(409);
|
|
expect(prisma.device.update).not.toHaveBeenCalled();
|
|
expect(prisma.device.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("updates in place when same user re-registers their own token", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
|
id: "dev-1",
|
|
userId: "user-1",
|
|
token: "abcd1234efgh5678",
|
|
} as never);
|
|
const res = await POST(
|
|
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.device.update).toHaveBeenCalledTimes(1);
|
|
expect(prisma.device.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 422 when only apnsToken is supplied without apnsEnvironment", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsToken: "deadbeef".repeat(8),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("returns 422 when only apnsEnvironment is supplied without apnsToken", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsEnvironment: "sandbox",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("returns 422 when apnsToken is not hex", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsToken: "not-hex-z",
|
|
apnsEnvironment: "sandbox",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("creates a Device row with the apnsToken + apnsEnvironment pair", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsToken: "deadbeef".repeat(8),
|
|
apnsEnvironment: "sandbox",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
expect(prisma.device.create).toHaveBeenCalledTimes(1);
|
|
const args = vi.mocked(prisma.device.create).mock.calls[0][0] as {
|
|
data: { apnsToken?: string; apnsEnvironment?: string };
|
|
};
|
|
expect(args.data.apnsToken).toBe("deadbeef".repeat(8));
|
|
expect(args.data.apnsEnvironment).toBe("sandbox");
|
|
});
|
|
|
|
it("rejects re-registration of an apnsToken owned by another user with 409", async () => {
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.device.findFirst).mockResolvedValue({
|
|
id: "other-dev",
|
|
userId: "other-user",
|
|
} as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsToken: "deadbeef".repeat(8),
|
|
apnsEnvironment: "sandbox",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(409);
|
|
expect(prisma.device.create).not.toHaveBeenCalled();
|
|
expect(prisma.device.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls through idempotently when the apnsToken already belongs to the same user", async () => {
|
|
// v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used
|
|
// to filter `NOT: { userId }` so it skipped same-user collisions
|
|
// entirely, letting `findMany({ apnsToken })` fan a single push
|
|
// out to two Device rows. The guard now reads `userId` and only
|
|
// rejects cross-user collisions; same-user collisions fall
|
|
// through to the legacy-token upsert path.
|
|
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
|
vi.mocked(prisma.device.findFirst).mockResolvedValue({
|
|
id: "self-dev",
|
|
userId: "user-1",
|
|
} as never);
|
|
const res = await POST(
|
|
req({
|
|
token: "abcd1234efgh5678",
|
|
bundleId: "io.healthlog.app",
|
|
apnsToken: "deadbeef".repeat(8),
|
|
apnsEnvironment: "sandbox",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
});
|
|
});
|