refactor: serve static frontend with Go APIs

This commit is contained in:
brother7 committed 2026-08-16 20:47:45 +08:00
1 parent 763d2f0648
commit b14b4fced7
101 files changed
+963 -3928

No files matched your search

@@ -5,9 +5,6 @@ import { isValidPhone, normalizePhone } from "@/lib/server/account-store";
type Fixture = {
version: 1;
phoneCases: Array<{ input: string; normalized: string; valid: boolean }>;
authorizationCases: Array<{ actorRole: string; targetRole: string; allowed: boolean }>;
sessionVersionCases: Array<{ name: string; increment: boolean }>;
archiveTables: string[];
};
const fixtureURL = new URL("../contracts/admin/accounts-organizations-v1.json", import.meta.url);
@@ -21,22 +18,4 @@ describe("administration accounts and organizations v1 contract", () => {
}
});
it("freezes route authorization and mutation policy consumed by Go", async () => {
const fixture = JSON.parse(await readFile(fixtureURL, "utf8")) as Fixture;
const accountsRoute = await readFile(new URL("../app/api/admin/accounts/route.ts", import.meta.url), "utf8");
const passwordRoute = await readFile(new URL("../app/api/admin/accounts/password/route.ts", import.meta.url), "utf8");
const organizationRoute = await readFile(new URL("../app/api/admin/organizations/route.ts", import.meta.url), "utf8");
const store = await readFile(new URL("../lib/server/account-store.ts", import.meta.url), "utf8");
expect(accountsRoute).toContain('target.role !== "user"');
expect(accountsRoute).toContain("actor.organizationId !== target.organizationId");
expect(passwordRoute).toContain('target.role !== "user"');
expect(organizationRoute).toContain("requireSuperAdmin(session.user)");
expect(store).toContain("sessionVersion: nextPassword || patch.role || patch.organizationId !== undefined || patch.status ? current.sessionVersion + 1 : current.sessionVersion");
expect(fixture.authorizationCases.filter((item) => item.allowed)).toHaveLength(2);
expect(fixture.sessionVersionCases.filter((item) => item.increment).map((item) => item.name)).toEqual([
"role mutation", "organization mutation", "status mutation", "password mutation"
]);
for (const table of fixture.archiveTables) expect(store).toContain(`"${table}"`);
});
});
-242
View File
@@ -1,242 +0,0 @@
import { readFile } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequire } from "node:module";
import { getAuthRuntimeConfig } from "@/lib/auth/config";
import type { AuthSession } from "@/lib/auth/session";
const { getOptionalAuthSession } = vi.hoisted(() => ({
getOptionalAuthSession: vi.fn<() => Promise<AuthSession | null>>()
}));
vi.mock("@/lib/server/auth/current-user", () => ({ getOptionalAuthSession }));
import * as currentSessionRoute from "@/app/api/auth/me/route";
type ExpectedConfig = {
required: boolean;
configured: boolean;
sessionSecret: string | null;
};
type CurrentSessionFixture = {
version: 1;
path: string;
methods: {
GET: MethodContract;
HEAD: MethodContract & { executesGet: true };
OPTIONS: MethodContract;
unsupported: MethodContract & { methods: string[] };
};
authConfigurationCases: Array<{
name: string;
environment: Record<string, string>;
expected: ExpectedConfig;
}>;
responses: {
anonymous: CurrentSessionResponse;
authenticatedUser: CurrentSessionResponse;
unboundSuperAdministrator: CurrentSessionResponse;
};
forbiddenSessionKeys: string[];
infrastructureError: {
directGet: "rejects";
transportStatus: number;
mustNotReturnAnonymous: boolean;
};
};
type MethodContract = {
status: number;
body: "json" | "empty";
contentType: string | null;
allow: string | null;
};
type CurrentSessionResponse = {
authenticated: boolean;
authRequired: boolean;
authConfigured: boolean;
authMode: "user" | "admin" | null;
user: Record<string, unknown> | null;
};
const fixtureUrl = new URL("../contracts/auth/current-session-v1.json", import.meta.url);
const require = createRequire(import.meta.url);
const authEnvironmentKeys = [
"NODE_ENV",
"ZHINIAN_AUTH_REQUIRED",
"ZHINIAN_AUTH_DISABLED",
"ZHINIAN_AUTH_SESSION_SECRET",
"AUTH_SESSION_SECRET",
"NEXTAUTH_SECRET"
] as const;
const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
getOptionalAuthSession.mockReset();
restoreAuthEnvironment();
});
async function loadFixture(): Promise<CurrentSessionFixture> {
return JSON.parse(await readFile(fixtureUrl, "utf8")) as CurrentSessionFixture;
}
function restoreAuthEnvironment() {
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);
}
}
function configureAuthEnvironment(environment: Record<string, string>) {
for (const key of authEnvironmentKeys) Reflect.deleteProperty(process.env, key);
for (const [key, value] of Object.entries(environment)) Reflect.set(process.env, key, value);
}
function sessionFor(response: CurrentSessionResponse): AuthSession {
if (!response.user || !response.authMode) throw new Error("authenticated fixture response required");
return {
version: 1,
authMode: response.authMode,
issuedAt: 100,
expiresAt: 200,
sessionVersion: 7,
accessToken: "must-not-leak",
tokenType: "bearer",
user: response.user as AuthSession["user"]
};
}
async function expectJsonResponse(expected: CurrentSessionResponse) {
const fixture = await loadFixture();
const response = await currentSessionRoute.GET();
expect(response.status).toBe(fixture.methods.GET.status);
expect(response.headers.get("content-type")).toBe(fixture.methods.GET.contentType);
expect(response.headers.get("allow")).toBe(fixture.methods.GET.allow);
expect(await response.json()).toEqual(expected);
}
describe("current session HTTP v1 cross-language contract", () => {
it("freezes the path and Next.js automatic method semantics", async () => {
const fixture = await loadFixture();
expect(fixture.version).toBe(1);
expect(fixture.path).toBe("/api/auth/me");
expect(Object.keys(currentSessionRoute).sort()).toEqual(["GET", "runtime"]);
expect(currentSessionRoute.runtime).toBe("nodejs");
expect(fixture.methods).toEqual({
GET: { status: 200, body: "json", contentType: "application/json", allow: null },
HEAD: {
status: 200,
body: "empty",
contentType: "application/json",
allow: null,
executesGet: true
},
OPTIONS: { status: 204, body: "empty", contentType: null, allow: "GET, HEAD, OPTIONS" },
unsupported: {
methods: ["POST", "PUT", "PATCH", "DELETE"],
status: 405,
body: "empty",
contentType: null,
allow: null
}
});
const { autoImplementMethods } = require(
"next/dist/server/route-modules/app-route/helpers/auto-implement-methods.js"
) as {
autoImplementMethods: (handlers: Record<string, unknown>) => Record<string, () => Promise<Response>>;
};
const handlers = autoImplementMethods({ GET: currentSessionRoute.GET });
getOptionalAuthSession.mockResolvedValue(null);
configureAuthEnvironment({ NODE_ENV: "development" });
for (const method of ["HEAD", "OPTIONS", ...fixture.methods.unsupported.methods]) {
const response = await handlers[method]();
const expected = method === "HEAD"
? fixture.methods.HEAD
: method === "OPTIONS"
? fixture.methods.OPTIONS
: fixture.methods.unsupported;
expect(response.status, method).toBe(expected.status);
expect(response.headers.get("content-type"), method).toBe(expected.contentType);
expect(response.headers.get("allow"), method).toBe(expected.allow);
if (method !== "HEAD") expect(await response.text(), method).toBe("");
}
});
it("consumes every auth configuration case through the real runtime resolver", async () => {
const fixture = await loadFixture();
expect(fixture.authConfigurationCases.length).toBeGreaterThanOrEqual(4);
for (const testCase of fixture.authConfigurationCases) {
configureAuthEnvironment(testCase.environment);
const config = getAuthRuntimeConfig();
expect(
{
required: config.required,
configured: config.configured,
sessionSecret: config.sessionSecret ?? null
},
testCase.name
).toEqual(testCase.expected);
}
});
it("returns the exact five-key anonymous response", async () => {
const fixture = await loadFixture();
configureAuthEnvironment({ NODE_ENV: "development" });
getOptionalAuthSession.mockResolvedValue(null);
expect(Object.keys(fixture.responses.anonymous).sort()).toEqual(
["authenticated", "authRequired", "authConfigured", "authMode", "user"].sort()
);
await expectJsonResponse(fixture.responses.anonymous);
});
it.each(["authenticatedUser", "unboundSuperAdministrator"] as const)(
"returns the exact public projection for %s and omits optional fields",
async (caseName) => {
const fixture = await loadFixture();
const expected = fixture.responses[caseName];
configureAuthEnvironment({
NODE_ENV: "production",
ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret"
});
getOptionalAuthSession.mockResolvedValue(sessionFor(expected));
await expectJsonResponse(expected);
const serialized = JSON.stringify(expected);
for (const key of fixture.forbiddenSessionKeys) {
expect(serialized, `${caseName} leaked ${key}`).not.toContain(`"${key}"`);
}
if (caseName === "unboundSuperAdministrator") {
expect(expected.user).not.toHaveProperty("tenantId");
expect(expected.user).not.toHaveProperty("organizationId");
expect(expected.user).not.toHaveProperty("organizationName");
}
}
);
it("propagates infrastructure errors for transport mapping instead of returning anonymous", async () => {
const fixture = await loadFixture();
const failure = new Error("authorization snapshot unavailable");
configureAuthEnvironment({
NODE_ENV: "production",
ZHINIAN_AUTH_SESSION_SECRET: "route-test-secret"
});
getOptionalAuthSession.mockRejectedValue(failure);
expect(fixture.infrastructureError).toEqual({
directGet: "rejects",
transportStatus: 500,
mustNotReturnAnonymous: true
});
await expect(currentSessionRoute.GET()).rejects.toBe(failure);
});
});
-226
View File
@@ -1,226 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SESSION_COOKIE_NAME } from "@/lib/auth/config";
import { chunkCookieValue, chunkedCookieName, createSignedJsonValue, type AuthSession } from "@/lib/auth/session";
const { cookieValues } = vi.hoisted(() => ({
cookieValues: new Map<string, string>(),
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieValues.get(name);
return value === undefined ? undefined : { name, value };
},
})),
}));
import { getShellAuthState } from "@/lib/server/auth/current-user";
const authEnvironmentKeys = [
"NODE_ENV",
"ZHINIAN_AUTH_REQUIRED",
"ZHINIAN_AUTH_SESSION_SECRET",
"ZHINIAN_GO_INTERNAL_BASE_URL",
] as const;
const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]]));
function configureGoBridge() {
Reflect.set(process.env, "NODE_ENV", "production");
Reflect.set(process.env, "ZHINIAN_AUTH_REQUIRED", "true");
Reflect.set(process.env, "ZHINIAN_AUTH_SESSION_SECRET", "bridge-test-secret");
Reflect.set(process.env, "ZHINIAN_GO_INTERNAL_BASE_URL", "http://go-api.internal/");
}
async function seedPlatformSessionCookie() {
const session: AuthSession = {
version: 1,
authMode: "user",
issuedAt: Math.floor(Date.now() / 1000) - 10,
expiresAt: Math.floor(Date.now() / 1000) + 3600,
sessionVersion: 7,
user: {
id: "account-1",
subject: "account-1",
displayName: "旧名称",
clientId: "platform",
organizationId: "org-old",
organizationName: "旧组织",
role: "user",
authorities: ["ROLE_USER"],
scope: [],
},
};
const signed = await createSignedJsonValue(session, "bridge-test-secret");
const chunks = chunkCookieValue(signed, 80);
chunks.forEach((value, index) => cookieValues.set(chunkedCookieName(SESSION_COOKIE_NAME, index), value));
return chunks;
}
afterEach(() => {
vi.unstubAllGlobals();
cookieValues.clear();
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);
}
});
describe("authenticated shell state through the Go identity boundary", () => {
it("refreshes the signed platform session and forwards only its cookie chunks", async () => {
configureGoBridge();
const chunks = await seedPlatformSessionCookie();
cookieValues.set("theme", "dark");
cookieValues.set("analytics_id", "do-not-forward");
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
authenticated: true,
authRequired: true,
authConfigured: true,
authMode: "admin",
user: {
id: "account-1",
subject: "account-1",
username: "13800138001",
phone: "13800138001",
displayName: "刷新名称",
clientId: "platform",
organizationId: "org-1",
organizationName: "刷新组织",
role: "organization_admin",
status: "active",
authorities: ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"],
scope: [],
},
}), { status: 200, headers: { "content-type": "application/json" } }));
vi.stubGlobal("fetch", fetchMock);
await expect(getShellAuthState()).resolves.toEqual({
user: expect.objectContaining({
id: "account-1",
displayName: "刷新名称",
organizationId: "org-1",
role: "organization_admin",
}),
authRequired: true,
authConfigured: true,
isAdmin: true,
isSuperAdmin: false,
});
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith("http://go-api.internal/api/auth/me", {
cache: "no-store",
headers: {
cookie: chunks
.map((value, index) => `${chunkedCookieName(SESSION_COOKIE_NAME, index)}=${value}`)
.join("; "),
},
});
});
it("treats a Go-rejected session as anonymous", async () => {
configureGoBridge();
await seedPlatformSessionCookie();
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
authenticated: false,
authRequired: true,
authConfigured: true,
authMode: null,
user: null,
}), { status: 200, headers: { "content-type": "application/json" } })));
await expect(getShellAuthState()).resolves.toEqual({
user: null,
authRequired: true,
authConfigured: true,
isAdmin: false,
isSuperAdmin: false,
});
});
it.each([
["an upstream error", new Response(null, { status: 503 }), "status 503"],
[
"an invalid authenticated response",
new Response(JSON.stringify({
authenticated: true,
authRequired: true,
authConfigured: true,
authMode: "admin",
user: null,
}), { status: 200, headers: { "content-type": "application/json" } }),
"invalid authenticated response",
],
])("fails closed for %s", async (_caseName, response, expectedMessage) => {
configureGoBridge();
await seedPlatformSessionCookie();
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(response));
await expect(getShellAuthState()).rejects.toThrow(expectedMessage);
});
const validOrganizationAdmin = {
id: "account-1",
subject: "account-1",
username: "13800138001",
phone: "13800138001",
displayName: "刷新名称",
clientId: "platform",
organizationId: "org-1",
organizationName: "刷新组织",
role: "organization_admin",
status: "active",
authorities: ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"],
scope: [],
};
const { role: _role, status: _status, ...userWithoutRoleOrStatus } = validOrganizationAdmin;
const { organizationId: _organizationId, organizationName: _organizationName, ...userWithoutOrganization } =
validOrganizationAdmin;
it.each([
["missing platform role and status", {
authMode: "admin",
authConfigured: true,
user: { ...userWithoutRoleOrStatus, authorities: ["SUPER_ADMIN"] },
}],
["a disabled account", {
authMode: "admin",
authConfigured: true,
user: { ...validOrganizationAdmin, status: "disabled" },
}],
["a mismatched subject", {
authMode: "admin",
authConfigured: true,
user: { ...validOrganizationAdmin, subject: "other-account" },
}],
["a role/authMode mismatch", {
authMode: "user",
authConfigured: true,
user: validOrganizationAdmin,
}],
["an unconfigured authenticated response", {
authMode: "admin",
authConfigured: false,
user: validOrganizationAdmin,
}],
["an organization-bound role without an organization", {
authMode: "admin",
authConfigured: true,
user: userWithoutOrganization,
}],
])("rejects %s", async (_caseName, invalid) => {
configureGoBridge();
await seedPlatformSessionCookie();
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
authenticated: true,
authRequired: true,
authMode: invalid.authMode,
authConfigured: invalid.authConfigured,
user: invalid.user,
}), { status: 200, headers: { "content-type": "application/json" } })));
await expect(getShellAuthState()).rejects.toThrow("invalid authenticated response");
});
});
+2
View File
@@ -25,6 +25,8 @@ describe("AuthLoginPanel", () => {
expect(panelSource).not.toContain('disabled={!configured || submitting || !phone.trim() || !password}');
expect(panelSource).not.toContain("authMode");
expect(panelSource).not.toContain("alternateHref");
expect(panelSource).toContain("window.location.assign(safeBrowserNext(result.redirectTo, next))");
expect(panelSource).not.toContain("window.location.assign(result.redirectTo");
expect(loginPageSource).not.toContain("/auth/admin-login");
expect(adminPageSource).toContain("/auth/login");
});
-84
View File
@@ -1,84 +0,0 @@
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
View File
@@ -1,260 +0,0 @@
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);
});
});
-129
View File
@@ -1,129 +0,0 @@
import { mkdtemp, 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 { parseSessionCookieValue } from "@/lib/auth/session";
import {
createPlatformOrganization,
createPlatformUser,
updatePlatformUser
} from "@/lib/server/account-store";
import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local";
import type { PlatformUserRecord } from "@/lib/types";
import { POST } from "@/app/api/auth/password/route";
const SESSION_SECRET = "test-platform-session-secret-with-enough-entropy";
let runtimeDir = "";
let ordinaryUser: PlatformUserRecord;
describe("platform password auth route", () => {
beforeEach(async () => {
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-auth-"));
vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir);
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", SESSION_SECRET);
vi.stubEnv("ZHINIAN_AUTH_DISABLED", "");
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
resetLocalAuthRateLimitForTests();
const organization = await createPlatformOrganization("测试组织");
ordinaryUser = await createPlatformUser({
phone: "13800138000",
displayName: "测试用户",
password: "TestPass123",
role: "user",
organizationId: organization.id
});
});
afterEach(async () => {
resetLocalAuthRateLimitForTests();
vi.unstubAllEnvs();
await rm(runtimeDir, { force: true, recursive: true });
});
it("logs in every role with the platform phone/password session", async () => {
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
method: "POST",
headers: { "x-forwarded-for": "192.0.2.10" },
body: JSON.stringify({
phone: "138 0013-8000",
password: "TestPass123",
next: "/assets"
})
}));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
redirectTo: "/assets",
authMode: "user",
user: { id: ordinaryUser.id, phone: "13800138000", role: "user", clientId: "platform" }
});
expect(await parseSessionCookieValue(
response.cookies.get("zhinian_session")?.value,
SESSION_SECRET
)).toMatchObject({
authMode: "user",
sessionVersion: 1,
user: { id: ordinaryUser.id, role: "user", clientId: "platform" }
});
});
it("logs in a super administrator through the same endpoint", async () => {
const admin = await createPlatformUser({
phone: "13900139000",
displayName: "平台超级管理员",
password: "AdminPass123",
role: "super_admin"
});
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
method: "POST",
body: JSON.stringify({ phone: admin.phone, password: "AdminPass123", authMode: "admin" })
}));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
authMode: "admin",
user: { id: admin.id, role: "super_admin" }
});
});
it("locks an account after five failed passwords", async () => {
let lastResponse: Response | undefined;
for (let attempt = 1; attempt <= 5; attempt += 1) {
lastResponse = await POST(new Request("http://127.0.0.1/api/auth/password", {
method: "POST",
headers: { "x-forwarded-for": "192.0.2.11" },
body: JSON.stringify({ phone: ordinaryUser.phone, password: "wrong-pass" })
}));
expect(lastResponse.status).toBe(attempt === 5 ? 423 : 401);
}
await expect(lastResponse?.json()).resolves.toMatchObject({ error: "登录失败次数过多,请 15 分钟后再试。" });
const lockedResponse = await POST(new Request("http://127.0.0.1/api/auth/password", {
method: "POST",
body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" })
}));
expect(lockedResponse.status).toBe(423);
});
it("rejects disabled accounts and duplicate phone identities", async () => {
await expect(createPlatformUser({
phone: ordinaryUser.phone,
displayName: "重复账号",
password: "AnotherPass123",
role: "user",
organizationId: ordinaryUser.organizationId
})).rejects.toThrow("该手机号已创建账号。");
await updatePlatformUser(ordinaryUser.id, { status: "disabled" });
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
method: "POST",
body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" })
}));
expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({ error: "账号已停用,请联系管理员。" });
});
});
-74
View File
@@ -1,74 +0,0 @@
import { readFile, readdir } from "node:fs/promises";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
type RouteContract = {
version: 1;
routes: Array<{
method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
path: string;
}>;
};
const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const appRoot = join(repositoryRoot, "app");
const contractPath = join(repositoryRoot, "contracts", "http", "route-surface.v1.json");
const exportedMethod = /^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/gm;
async function findRouteFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return findRouteFiles(path);
return entry.isFile() && entry.name === "route.ts" ? [path] : [];
}),
);
return nested.flat().sort();
}
function routePath(routeFile: string): string {
const routeDirectory = relative(appRoot, dirname(routeFile));
const segments = routeDirectory.split(sep).map((segment) => {
if (segment.startsWith("[...") && segment.endsWith("]")) {
return `{${segment.slice(4, -1)}...}`;
}
if (segment.startsWith("[") && segment.endsWith("]")) {
return `{${segment.slice(1, -1)}}`;
}
return segment;
});
return `/${segments.join("/")}`;
}
async function deriveRouteSurface(routeFiles: string[]): Promise<RouteContract["routes"]> {
const routes = await Promise.all(
routeFiles.map(async (routeFile) => {
const source = await readFile(routeFile, "utf8");
return Array.from(source.matchAll(exportedMethod), (match) => ({
method: match[1] as RouteContract["routes"][number]["method"],
path: routePath(routeFile),
}));
}),
);
return routes.flat().sort((left, right) => {
const leftKey = `${left.path}\u0000${left.method}`;
const rightKey = `${right.path}\u0000${right.method}`;
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
});
}
describe("HTTP route surface contract", () => {
it("exactly records every exported Next route method without loading handlers", async () => {
const routeFiles = await findRouteFiles(appRoot);
const actual = await deriveRouteSurface(routeFiles);
const contract = JSON.parse(await readFile(contractPath, "utf8")) as RouteContract;
expect(routeFiles).toHaveLength(47);
expect(actual).toHaveLength(66);
expect(contract.version).toBe(1);
expect(contract.routes).toEqual(actual);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { access, readFile, readdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { safeBrowserLocationNext, safeBrowserNext } from "@/lib/client/browser-auth";
import nextConfig from "../next.config";
const repositoryRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const appRoot = join(repositoryRoot, "app");
afterEach(() => vi.unstubAllGlobals());
async function findProductionEntryFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return findProductionEntryFiles(path);
return entry.isFile() && /^(?:layout|page|route)\.tsx?$/.test(entry.name) ? [path] : [];
}));
return nested.flat();
}
async function findFilesOrEmpty(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return [];
throw error;
});
const nested = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? findFilesOrEmpty(path) : [path];
}));
return nested.flat();
}
describe("static frontend architecture", () => {
it("exports an image-optimizer-independent static site", () => {
expect(nextConfig.output).toBe("export");
expect(nextConfig.images?.unoptimized).toBe(true);
});
it("has no Next server runtime entry points", async () => {
expect(await findFilesOrEmpty(join(appRoot, "api"))).toEqual([]);
expect(await findFilesOrEmpty(join(appRoot, "uploads"))).toEqual([]);
expect(await findFilesOrEmpty(join(appRoot, "generated-results"))).toEqual([]);
await expect(access(join(repositoryRoot, "middleware.ts"))).rejects.toMatchObject({ code: "ENOENT" });
const entryFiles = await findProductionEntryFiles(appRoot);
expect(entryFiles.filter((path) => path.endsWith("route.ts"))).toEqual([]);
for (const entryFile of entryFiles) {
const source = await readFile(entryFile, "utf8");
expect(source, entryFile).not.toMatch(/(?:from\s+|import\s*\()?["']@\/lib\/server(?:\/|["'])/);
expect(source, entryFile).not.toMatch(/(?:from\s+|import\s*\()?["']next\/(?:headers|server)["']/);
}
});
it("loads current identity from the same-origin Go API", async () => {
const responseBody = {
authenticated: false,
authRequired: true,
authConfigured: true,
authMode: null,
user: null
};
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(responseBody), {
status: 200,
headers: { "content-type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
const { fetchBrowserAuthState } = await import("@/lib/client/browser-auth");
await expect(fetchBrowserAuthState()).resolves.toEqual(responseBody);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith("/api/auth/me", {
method: "GET",
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: undefined
});
});
it("keeps safe same-origin redirects away from auth endpoints", () => {
expect(safeBrowserNext("/billing?tab=wallet#history")).toBe("/billing?tab=wallet#history");
expect(safeBrowserLocationNext({
pathname: "/create",
search: "?mode=video",
hash: "#x"
})).toBe("/create?mode=video#x");
expect(safeBrowserNext("//evil.example/path", "/create?mode=video#x")).toBe("/create?mode=video#x");
for (const unsafe of [
"//evil.example/path",
"/\\evil.example/path",
"/api/auth/logout",
"/auth/login",
"/auth/admin-login"
]) {
expect(safeBrowserNext(unsafe), unsafe).toBe("/create");
}
});
});