feat: add Go migration compatibility foundation
This commit is contained in:
211
tests/auth-session-contract.test.ts
Normal file
211
tests/auth-session-contract.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config";
|
||||
import {
|
||||
chunkCookieValue,
|
||||
chunkedCookieNames,
|
||||
createSignedJsonValue,
|
||||
parseSessionCookieValue,
|
||||
} from "@/lib/auth/session";
|
||||
import {
|
||||
clearSessionCookieValues,
|
||||
setSessionCookieValue,
|
||||
} from "@/lib/server/auth/session-cookie";
|
||||
|
||||
type CookieContract = {
|
||||
name: string;
|
||||
chunkSize: number;
|
||||
maxChunks: number;
|
||||
maxValueLength: number;
|
||||
chunkNames: string[];
|
||||
writeExample: {
|
||||
valueCharacter: string;
|
||||
valueLength: number;
|
||||
chunkLengths: number[];
|
||||
expiresAtUnix: number;
|
||||
};
|
||||
attributes: {
|
||||
httpOnly: boolean;
|
||||
sameSite: "lax";
|
||||
path: string;
|
||||
productionSecure: boolean;
|
||||
};
|
||||
clear: {
|
||||
value: string;
|
||||
maxAgeSeconds: number;
|
||||
};
|
||||
secureResolutionCases: Array<{
|
||||
name: string;
|
||||
explicit: string;
|
||||
publicBaseUrl: string;
|
||||
requestUrl: string;
|
||||
expected: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type SessionCookieFixture = {
|
||||
version: 1;
|
||||
cookie: CookieContract;
|
||||
secret: string;
|
||||
rawJson: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
cookieValue: string;
|
||||
};
|
||||
|
||||
const fixtureUrl = new URL("../contracts/auth/session-cookie-v1.json", import.meta.url);
|
||||
const cookieEnvironmentKeys = [
|
||||
"ZHINIAN_AUTH_COOKIE_SECURE",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
"ZHINIAN_PUBLIC_BASE_URL",
|
||||
] as const;
|
||||
const originalCookieEnvironment = new Map(
|
||||
cookieEnvironmentKeys.map((key) => [key, process.env[key]])
|
||||
);
|
||||
|
||||
type RecordedCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
options: Record<string, unknown>;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of cookieEnvironmentKeys) {
|
||||
const value = originalCookieEnvironment.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFixture(): Promise<SessionCookieFixture> {
|
||||
return JSON.parse(await readFile(fixtureUrl, "utf8")) as SessionCookieFixture;
|
||||
}
|
||||
|
||||
function cookieRecorder() {
|
||||
const writes: RecordedCookie[] = [];
|
||||
const response = {
|
||||
cookies: {
|
||||
set(name: string, value: string, options: Record<string, unknown>) {
|
||||
writes.push({ name, value, options });
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof setSessionCookieValue>[0];
|
||||
return { response, writes };
|
||||
}
|
||||
|
||||
function configureCookieEnvironment(explicit: string, publicBaseUrl: string) {
|
||||
delete process.env.ZHINIAN_AUTH_COOKIE_SECURE;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
delete process.env.ZHINIAN_PUBLIC_BASE_URL;
|
||||
if (explicit) process.env.ZHINIAN_AUTH_COOKIE_SECURE = explicit;
|
||||
if (publicBaseUrl) process.env.NEXT_PUBLIC_APP_URL = publicBaseUrl;
|
||||
}
|
||||
|
||||
describe("session Cookie v1 cross-language contract", () => {
|
||||
it("matches the language-neutral HMAC and payload golden vector", async () => {
|
||||
const fixture = await loadFixture();
|
||||
const rawSession = JSON.parse(fixture.rawJson) as unknown;
|
||||
|
||||
expect(fixture.version).toBe(1);
|
||||
expect(await createSignedJsonValue(rawSession, fixture.secret)).toBe(fixture.cookieValue);
|
||||
expect(fixture.cookieValue).toBe(`${fixture.payload}.${fixture.signature}`);
|
||||
await expect(parseSessionCookieValue(fixture.cookieValue, fixture.secret, 150)).resolves.toMatchObject({
|
||||
version: 1,
|
||||
authMode: "admin",
|
||||
expiresAt: 200,
|
||||
user: {
|
||||
id: "auth:customPC:1",
|
||||
clientId: "customPC",
|
||||
displayName: "张三",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("freezes cookie names, chunk boundaries, attributes, and stale-chunk clearing", async () => {
|
||||
const { cookie } = await loadFixture();
|
||||
const value = cookie.writeExample.valueCharacter.repeat(cookie.writeExample.valueLength);
|
||||
const expires = new Date(cookie.writeExample.expiresAtUnix * 1000);
|
||||
const { response, writes } = cookieRecorder();
|
||||
configureCookieEnvironment("true", "");
|
||||
|
||||
expect(SESSION_COOKIE_NAME).toBe(cookie.name);
|
||||
expect(chunkedCookieNames(cookie.name, cookie.maxChunks)).toEqual(cookie.chunkNames);
|
||||
expect(chunkCookieValue(value, cookie.chunkSize).map((chunk) => chunk.length)).toEqual(
|
||||
cookie.writeExample.chunkLengths
|
||||
);
|
||||
|
||||
setSessionCookieValue(response, "http://127.0.0.1:3000", value, expires);
|
||||
|
||||
expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames);
|
||||
expect(writes.slice(0, cookie.writeExample.chunkLengths.length).map(({ value: part }) => part.length)).toEqual(
|
||||
cookie.writeExample.chunkLengths
|
||||
);
|
||||
for (const write of writes.slice(0, cookie.writeExample.chunkLengths.length)) {
|
||||
expect(write.options).toMatchObject({
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
sameSite: cookie.attributes.sameSite,
|
||||
secure: cookie.attributes.productionSecure,
|
||||
path: cookie.attributes.path,
|
||||
expires,
|
||||
});
|
||||
expect(write.options).not.toHaveProperty("maxAge");
|
||||
}
|
||||
for (const write of writes.slice(cookie.writeExample.chunkLengths.length)) {
|
||||
expect(write.value).toBe(cookie.clear.value);
|
||||
expect(write.options).toMatchObject({
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
sameSite: cookie.attributes.sameSite,
|
||||
secure: cookie.attributes.productionSecure,
|
||||
path: cookie.attributes.path,
|
||||
maxAge: cookie.clear.maxAgeSeconds,
|
||||
});
|
||||
expect(write.options).not.toHaveProperty("expires");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects values beyond the 20-chunk read ceiling before writing", async () => {
|
||||
const { cookie } = await loadFixture();
|
||||
const { response, writes } = cookieRecorder();
|
||||
|
||||
expect(() =>
|
||||
setSessionCookieValue(
|
||||
response,
|
||||
"https://app.example.test",
|
||||
"x".repeat(cookie.maxValueLength + 1),
|
||||
new Date(cookie.writeExample.expiresAtUnix * 1000)
|
||||
)
|
||||
).toThrow(/maximum supported size/i);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears every possible chunk on logout with the legacy attributes", async () => {
|
||||
const { cookie } = await loadFixture();
|
||||
const { response, writes } = cookieRecorder();
|
||||
configureCookieEnvironment("true", "");
|
||||
|
||||
clearSessionCookieValues(response, "http://127.0.0.1:3000");
|
||||
|
||||
expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames);
|
||||
expect(writes).toHaveLength(cookie.maxChunks);
|
||||
for (const write of writes) {
|
||||
expect(write.value).toBe(cookie.clear.value);
|
||||
expect(write.options).toEqual({
|
||||
httpOnly: cookie.attributes.httpOnly,
|
||||
sameSite: cookie.attributes.sameSite,
|
||||
secure: cookie.attributes.productionSecure,
|
||||
path: cookie.attributes.path,
|
||||
maxAge: cookie.clear.maxAgeSeconds,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared Secure resolution precedence", async () => {
|
||||
const { cookie } = await loadFixture();
|
||||
for (const testCase of cookie.secureResolutionCases) {
|
||||
configureCookieEnvironment(testCase.explicit, testCase.publicBaseUrl);
|
||||
expect(shouldUseSecureAuthCookie(testCase.requestUrl), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
20
tests/go-command-contract.test.ts
Normal file
20
tests/go-command-contract.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Go developer command contract", () => {
|
||||
it("uses one cross-platform runner without changing the caller's global Go environment", async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
expect(packageJson.scripts["go:fmt"]).toBe("node scripts/run-go-command.mjs fmt ./...");
|
||||
expect(packageJson.scripts["go:test"]).toBe("node scripts/run-go-command.mjs test ./...");
|
||||
expect(packageJson.scripts["go:vet"]).toBe("node scripts/run-go-command.mjs vet ./...");
|
||||
expect(packageJson.scripts["go:build"]).toBe("node scripts/run-go-command.mjs build ./cmd/zhinian-api");
|
||||
|
||||
const runner = await readFile(new URL("../scripts/run-go-command.mjs", import.meta.url), "utf8");
|
||||
expect(runner).toContain('CGO_ENABLED: process.env.CGO_ENABLED || "0"');
|
||||
expect(runner).toContain('new URL("../backend/", import.meta.url)');
|
||||
expect(runner).not.toContain("go env -w");
|
||||
});
|
||||
});
|
||||
74
tests/http-route-surface-contract.test.ts
Normal file
74
tests/http-route-surface-contract.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user