refactor: serve static frontend with Go APIs
This commit is contained in:
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}"`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "账号已停用,请联系管理员。" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user