261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
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<string, unknown>;
|
|
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<PasswordLoginFixture> {
|
|
return JSON.parse(await readFile(fixtureUrl, "utf8")) as PasswordLoginFixture;
|
|
}
|
|
|
|
async function loadCookieFixture(relativePath: string): Promise<SessionCookieFixture> {
|
|
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<string, unknown> {
|
|
const [payload] = cookieValue.split(".");
|
|
return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64url").toString("utf8")) as Record<string, unknown>;
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|