mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(auth-check-user): four-branch discovery endpoint for iOS onboarding
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:
- not_found no account
- passkey_only passkey present, no password
- email_fallback password present (passkey optional)
- exists neither credential (recovery path)
The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.
Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
This commit is contained in:
1 parent
cb8f74e4fa
commit
99a801f993
3 files changed
+212
No files matched your search
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* v1.4.41 W-IOS-COORD SB-7 follow-up — pin the four-branch discovery
|
||||
* contract for `POST /api/auth/check-user`. The iOS onboarding screen
|
||||
* branches on `branch` to decide which credential form to render; any
|
||||
* regression that drops a branch silently makes the wrong form appear.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/auth/check-user/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
interface CheckUserBody {
|
||||
data: {
|
||||
branch: "not_found" | "passkey_only" | "email_fallback" | "exists";
|
||||
hasPasskey: boolean;
|
||||
hasPassword: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown) {
|
||||
return new Request("http://localhost/api/auth/check-user", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as unknown as Parameters<typeof POST>[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/auth/check-user", () => {
|
||||
it("returns `not_found` when no account matches the identifier", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
const res = await POST(makeRequest({ identifier: "ghost@example.com" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.branch).toBe("not_found");
|
||||
expect(body.data.hasPasskey).toBe(false);
|
||||
expect(body.data.hasPassword).toBe(false);
|
||||
});
|
||||
|
||||
it("returns `passkey_only` when the user has passkeys and no password", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "u1",
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 2 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("passkey_only");
|
||||
expect(body.data.hasPasskey).toBe(true);
|
||||
expect(body.data.hasPassword).toBe(false);
|
||||
});
|
||||
|
||||
it("returns `email_fallback` when the user has a password (regardless of passkey count)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "u2",
|
||||
passwordHash: "argon2id$…",
|
||||
_count: { passkeys: 1 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("email_fallback");
|
||||
expect(body.data.hasPasskey).toBe(true);
|
||||
expect(body.data.hasPassword).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `exists` when the user has neither passkey nor password (recovery path)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue({
|
||||
id: "u3",
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 0 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("exists");
|
||||
expect(body.data.hasPasskey).toBe(false);
|
||||
expect(body.data.hasPassword).toBe(false);
|
||||
});
|
||||
|
||||
it("422s when identifier is missing or blank", async () => {
|
||||
const res = await POST(makeRequest({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `POST /api/auth/check-user` — four-branch discovery for iOS onboarding.
|
||||
*
|
||||
* v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs
|
||||
* to know, given a typed identifier (username or email), what the next
|
||||
* UX step should be:
|
||||
*
|
||||
* - `not_found` — no account exists; show the sign-up screen.
|
||||
* - `passkey_only` — account exists, has at least one Passkey, no
|
||||
* password hash. Show "Sign in with Passkey".
|
||||
* - `email_fallback` — account exists, has a password hash (with or
|
||||
* without a Passkey). Show password field plus
|
||||
* a "Use Passkey" affordance when applicable.
|
||||
* - `exists` — account exists with no usable credential
|
||||
* (neither passkey nor password). Treat as
|
||||
* recovery path; show "Reset access" hint.
|
||||
*
|
||||
* The route is intentionally narrow:
|
||||
*
|
||||
* - Accepts `{ identifier: string }` (either an email or a username).
|
||||
* - Returns `{ branch, hasPasskey, hasPassword }` — booleans are
|
||||
* included so the iOS client can render a "or sign in with Passkey"
|
||||
* button alongside the password field without a second round-trip.
|
||||
* - Never leaks PII. The response shape is the same regardless of
|
||||
* whether the identifier matched (callers learn account-existence
|
||||
* either way — that is the explicit contract iOS needs). The
|
||||
* handler does NOT echo the identifier back.
|
||||
* - No rate-limit middleware added here; the higher-level edge limit
|
||||
* on `/api/auth/*` covers brute-force enumeration concerns, and the
|
||||
* route is functionally equivalent in information leak to the
|
||||
* existing `/api/auth/passkey/login-options` request that already
|
||||
* accepts an identifier.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const bodySchema = z.object({
|
||||
identifier: z.string().trim().min(1).max(254),
|
||||
});
|
||||
|
||||
export type CheckUserBranch =
|
||||
| "not_found"
|
||||
| "passkey_only"
|
||||
| "email_fallback"
|
||||
| "exists";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("identifier required", 422);
|
||||
}
|
||||
|
||||
const identifier = parsed.data.identifier.toLowerCase();
|
||||
|
||||
// Match on either username or email — both are unique in the schema.
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: identifier }, { email: identifier }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
passwordHash: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
|
||||
return apiSuccess({
|
||||
branch: "not_found" satisfies CheckUserBranch,
|
||||
hasPasskey: false,
|
||||
hasPassword: false,
|
||||
});
|
||||
}
|
||||
|
||||
const hasPasskey = user._count.passkeys > 0;
|
||||
const hasPassword = Boolean(user.passwordHash);
|
||||
|
||||
let branch: CheckUserBranch;
|
||||
if (hasPasskey && !hasPassword) branch = "passkey_only";
|
||||
else if (hasPassword) branch = "email_fallback";
|
||||
else branch = "exists";
|
||||
|
||||
annotate({ action: { name: "auth.check-user" }, meta: { branch } });
|
||||
return apiSuccess({ branch, hasPasskey, hasPassword });
|
||||
});
|
||||
File renamed without changes.
Reference in new issue
Block a user