feat: add Go current-session HTTP adapter
This commit is contained in:
1 parent
c849077591
commit
772795e7eb
10 files changed
+1608
-7
No files matched your search
@@ -0,0 +1,242 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user