feat: add Go password session lifecycle
This commit is contained in:
84
tests/auth-logout-contract.test.ts
Normal file
84
tests/auth-logout-contract.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import * as logoutRoute from "@/app/api/auth/logout/route";
|
||||
|
||||
type LogoutFixture = {
|
||||
version: 1;
|
||||
path: string;
|
||||
methods: string[];
|
||||
status: number;
|
||||
location: string;
|
||||
requiresAuthentication: boolean;
|
||||
cookieFixture: string;
|
||||
duplicateBaseCookieWrite: boolean;
|
||||
};
|
||||
|
||||
type SessionCookieFixture = {
|
||||
cookie: {
|
||||
chunkNames: string[];
|
||||
attributes: { httpOnly: boolean; sameSite: string; path: string };
|
||||
clear: { value: string; maxAgeSeconds: number };
|
||||
};
|
||||
};
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/logout-v1.json", import.meta.url);
|
||||
|
||||
async function loadFixture(): Promise<LogoutFixture> {
|
||||
return JSON.parse(await readFile(fixtureUrl, "utf8")) as LogoutFixture;
|
||||
}
|
||||
|
||||
async function loadCookieFixture(relativePath: string): Promise<SessionCookieFixture> {
|
||||
return JSON.parse(await readFile(new URL(`../contracts/auth/${relativePath}`, import.meta.url), "utf8")) as SessionCookieFixture;
|
||||
}
|
||||
|
||||
function setCookieLines(response: Response): string[] {
|
||||
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
|
||||
return headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("logout HTTP v1 cross-language contract", () => {
|
||||
it("allows anonymous GET and POST and returns the same 307 redirect", async () => {
|
||||
const fixture = await loadFixture();
|
||||
expect({ version: fixture.version, path: fixture.path, methods: fixture.methods }).toEqual({
|
||||
version: 1,
|
||||
path: "/api/auth/logout",
|
||||
methods: ["GET", "POST"]
|
||||
});
|
||||
expect(Object.keys(logoutRoute).sort()).toEqual(["GET", "POST", "runtime"]);
|
||||
expect(fixture.requiresAuthentication).toBe(false);
|
||||
|
||||
for (const method of fixture.methods) {
|
||||
const response = await logoutRoute[method as "GET" | "POST"](
|
||||
new Request("https://app.example.test/api/auth/logout", { method })
|
||||
);
|
||||
expect(response.status, method).toBe(fixture.status);
|
||||
expect(response.headers.get("location"), method).toBe(fixture.location);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears all 20 legacy chunk names and preserves the externally visible duplicate base write", async () => {
|
||||
const fixture = await loadFixture();
|
||||
const cookieFixture = await loadCookieFixture(fixture.cookieFixture);
|
||||
vi.stubEnv("ZHINIAN_AUTH_COOKIE_SECURE", "true");
|
||||
const response = await logoutRoute.POST(new Request("http://127.0.0.1/api/auth/logout", { method: "POST" }));
|
||||
const lines = setCookieLines(response);
|
||||
const names = lines.map((line) => line.slice(0, line.indexOf("=")));
|
||||
|
||||
expect(lines).toHaveLength(cookieFixture.cookie.chunkNames.length + (fixture.duplicateBaseCookieWrite ? 1 : 0));
|
||||
expect(names).toEqual([
|
||||
...cookieFixture.cookie.chunkNames,
|
||||
...(fixture.duplicateBaseCookieWrite ? [cookieFixture.cookie.chunkNames[0]] : [])
|
||||
]);
|
||||
for (const line of lines) {
|
||||
expect(line).toContain("HttpOnly");
|
||||
expect(line).toContain("Path=/");
|
||||
expect(line).toContain("SameSite=lax");
|
||||
expect(line).toContain("Secure");
|
||||
expect(line).toContain(`Max-Age=${cookieFixture.cookie.clear.maxAgeSeconds}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
260
tests/auth-password-login-contract.test.ts
Normal file
260
tests/auth-password-login-contract.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user