import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { safeNextPath } from "@/lib/auth/config"; import { parseSessionCookieValue } from "@/lib/auth/session"; import { createPlatformOrganization, createPlatformUser, updatePlatformOrganization, updatePlatformUser } from "@/lib/server/account-store"; import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local"; import type { PlatformOrganization, PlatformUserRecord } from "@/lib/types"; import * as passwordRoute from "@/app/api/auth/password/route"; type PasswordLoginFixture = { version: 1; path: string; method: "POST"; localSessionTtlSeconds: number; inputCases: Array<{ name: string; body: Record; expectedRedirect: string; }>; safeNextCases: Array<{ input: string | null; expected: string }>; success: { topLevelKeys: string[]; publicUserKeys: string[]; forbiddenSerializedKeys: string[]; }; errors: Record<"invalidInput" | "invalidCredentials" | "disabledAccount" | "disabledOrganization" | "lockedAccount" | "rateLimited" | "unconfigured", { status: number; body: { error: string }; }>; rateLimit: { attemptsPerIp: number; windowSeconds: number }; cookieFixture: string; }; type SessionCookieFixture = { cookie: { chunkNames: string[]; attributes: { httpOnly: boolean; sameSite: string; path: string }; clear: { maxAgeSeconds: number }; }; }; const fixtureUrl = new URL("../contracts/auth/password-login-v1.json", import.meta.url); const authEnvironmentKeys = [ "NODE_ENV", "ZHINIAN_DATA_DIR", "ZHINIAN_DATA_BACKEND", "ZHINIAN_AUTH_REQUIRED", "ZHINIAN_AUTH_DISABLED", "ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET", "ZHINIAN_AUTH_COOKIE_SECURE", "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL" ] as const; const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]])); const sessionSecret = "password-contract-session-secret-with-enough-entropy"; let runtimeDir = ""; let organization: PlatformOrganization; let user: PlatformUserRecord; async function loadFixture(): Promise { return JSON.parse(await readFile(fixtureUrl, "utf8")) as PasswordLoginFixture; } async function loadCookieFixture(relativePath: string): Promise { return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture; } function login(body: unknown, ip = "192.0.2.10", url = "http://127.0.0.1/api/auth/password") { return passwordRoute.POST(new Request(url, { method: "POST", headers: { "content-type": "application/json", "x-forwarded-for": ip }, body: typeof body === "string" ? body : JSON.stringify(body) })); } function setCookieLines(response: Response): string[] { const headers = response.headers as Headers & { getSetCookie?: () => string[] }; return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""]; } function unsignedCookiePayload(cookieValue: string): Record { const [payload] = cookieValue.split("."); return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64url").toString("utf8")) as Record; } beforeEach(async () => { runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-password-contract-")); vi.stubEnv("NODE_ENV", "test"); vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir); vi.stubEnv("ZHINIAN_DATA_BACKEND", "local"); vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1"); vi.stubEnv("ZHINIAN_AUTH_DISABLED", ""); vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", sessionSecret); vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "false"); resetLocalAuthRateLimitForTests(); organization = await createPlatformOrganization("契约测试组织"); user = await createPlatformUser({ phone: "13800138000", displayName: "契约测试用户", password: "TestPass123", role: "user", organizationId: organization.id }); }); afterEach(async () => { resetLocalAuthRateLimitForTests(); vi.unstubAllEnvs(); for (const key of authEnvironmentKeys) { const original = originalEnvironment.get(key); if (original === undefined) Reflect.deleteProperty(process.env, key); else Reflect.set(process.env, key, original); } await rm(runtimeDir, { recursive: true, force: true }); }); describe("password login HTTP v1 cross-language contract", () => { it("freezes route identity, input aliases, trimming, ignored authMode, and safe redirects", async () => { const fixture = await loadFixture(); expect({ version: fixture.version, path: fixture.path, method: fixture.method }).toEqual({ version: 1, path: "/api/auth/password", method: "POST" }); expect(Object.keys(passwordRoute).sort()).toEqual(["POST", "dynamic", "runtime"]); expect(passwordRoute.runtime).toBe("nodejs"); expect(passwordRoute.dynamic).toBe("force-dynamic"); for (const testCase of fixture.safeNextCases) { expect(safeNextPath(testCase.input), testCase.input ?? "null").toBe(testCase.expected); } for (const [index, testCase] of fixture.inputCases.entries()) { const response = await login(testCase.body, `192.0.2.${20 + index}`); expect(response.status, testCase.name).toBe(200); const body = await response.json(); expect(body.redirectTo, testCase.name).toBe(testCase.expectedRedirect); expect(body.authMode, testCase.name).toBe("user"); } }); it("returns the exact public body and a parseable one-day session without sensitive fields", async () => { const fixture = await loadFixture(); const response = await login({ phone: user.phone, password: "TestPass123", next: "/assets?tab=mine#recent" }); const body = await response.json(); expect(response.status).toBe(200); expect(body).toEqual({ ok: true, redirectTo: "/assets?tab=mine#recent", user: { id: user.id, subject: user.id, username: user.phone, phone: user.phone, displayName: user.displayName, clientId: "platform", organizationId: organization.id, organizationName: organization.name, role: "user", status: "active", authorities: ["ROLE_USER"], scope: [] }, authMode: "user" }); expect(Object.keys(body).sort()).toEqual([...fixture.success.topLevelKeys].sort()); expect(Object.keys(body.user).sort()).toEqual([...fixture.success.publicUserKeys].sort()); for (const key of fixture.success.forbiddenSerializedKeys) { expect(JSON.stringify(body)).not.toContain(`"${key}"`); } const cookieValue = response.cookies.get("zhinian_session")?.value; const session = await parseSessionCookieValue(cookieValue, sessionSecret, 0); expect(session).not.toBeNull(); expect(session?.expiresAt! - session?.issuedAt!).toBe(fixture.localSessionTtlSeconds); expect(session).toMatchObject({ version: 1, authMode: "user", sessionVersion: user.sessionVersion, user: body.user }); const rawSession = unsignedCookiePayload(cookieValue!); expect(rawSession).not.toHaveProperty("accessToken"); expect(rawSession).not.toHaveProperty("tokenType"); }); it("writes the shared Cookie name set and legacy attributes, including stale-chunk clears", async () => { const fixture = await loadFixture(); const cookieFixture = await loadCookieFixture(fixture.cookieFixture); const response = await login({ phone: user.phone, password: "TestPass123" }); const lines = setCookieLines(response); expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length); expect(lines.map((line) => line.slice(0, line.indexOf("=")))).toEqual(cookieFixture.cookie.chunkNames); expect(lines[0]).toContain("HttpOnly"); expect(lines[0]).toContain("Path=/"); expect(lines[0]).toContain("SameSite=lax"); expect(lines[0]).not.toContain("Secure"); expect(lines[0]).toContain("Expires="); for (const line of lines.slice(1)) { expect(line).toContain("Max-Age=0"); expect(line).not.toContain("Expires="); } }); it("freezes 400, 401, 403, 423, 429, and 503 public error semantics", async () => { const { errors, rateLimit } = await loadFixture(); const invalidInput = await login("not-json", "192.0.2.30"); expect({ status: invalidInput.status, body: await invalidInput.json() }).toEqual(errors.invalidInput); const invalidCredentials = await login({ username: " 19900000000 ", password: "wrong" }, "192.0.2.31"); expect({ status: invalidCredentials.status, body: await invalidCredentials.json() }).toEqual(errors.invalidCredentials); await updatePlatformUser(user.id, { status: "disabled" }); const disabled = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.32"); expect({ status: disabled.status, body: await disabled.json() }).toEqual(errors.disabledAccount); await updatePlatformUser(user.id, { status: "active" }); let locked: Response | undefined; for (let attempt = 0; attempt < 5; attempt += 1) { locked = await login({ phone: user.phone, password: "wrong" }, `192.0.2.${40 + attempt}`); } expect({ status: locked?.status, body: await locked?.json() }).toEqual(errors.lockedAccount); for (let attempt = 0; attempt < rateLimit.attemptsPerIp; attempt += 1) { const response = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50"); expect(response.status, `allowed IP attempt ${attempt + 1}`).toBe(401); } const rateLimited = await login({ phone: "19900000000", password: "wrong" }, "192.0.2.50"); expect({ status: rateLimited.status, body: await rateLimited.json() }).toEqual(errors.rateLimited); vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", ""); vi.stubEnv("AUTH_SESSION_SECRET", ""); vi.stubEnv("NEXTAUTH_SECRET", ""); const unconfigured = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.60"); expect({ status: unconfigured.status, body: await unconfigured.json() }).toEqual(errors.unconfigured); expect(rateLimit.windowSeconds).toBe(15 * 60); }); it("rejects a user whose organization is disabled", async () => { const fixture = await loadFixture(); await updatePlatformOrganization(organization.id, { status: "disabled" }); const response = await login({ phone: user.phone, password: "TestPass123" }, "192.0.2.70"); expect({ status: response.status, body: await response.json() }).toEqual(fixture.errors.disabledOrganization); }); });