fix: route authenticated SSR through Go
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Task: Fix authenticated SSR through Go identity boundary
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260816-fix-authenticated-ssr-6c3f8a21
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260816-fix-authenticated-ssr-6c3f8a21-fix-authenticated-ssr
|
||||
- Worktree: D:\Datas\OthersProjects\NianAIGC-fix-authenticated-ssr-6c3f8a21
|
||||
- Base commit: b26f9679abc343beb64142694add9154a6885b04
|
||||
- Owner: codex
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Fix authenticated Next.js SSR in the split production topology by moving per-request session refresh across the internal Go `/api/auth/me` HTTP boundary.
|
||||
- Add a regression test at the exported `getShellAuthState()` seam and strengthen ACK deployment assertions for the internal Go URL.
|
||||
- Preserve the existing direct-store path when the internal Go URL is absent so local Next.js full-stack development and tests remain supported.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Keep the Next production workload database-free; do not restore `ZHINIAN_DATA_BACKEND`, `DATABASE_URL`, RDS CA, or business credentials to Web.
|
||||
- Preserve signed chunked `zhinian_session` Cookie behavior and per-request account, organization, role, and sessionVersion revalidation in Go.
|
||||
- Forward only the session Cookie chunks to the internal service; never forward unrelated browser Cookies or expose the internal URL to client code.
|
||||
- Fail closed on internal identity transport, status, or response-shape errors; do not silently treat infrastructure failure as anonymous.
|
||||
- Work test-first at the `getShellAuthState()` boundary and keep changes surgical.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `getOptionalAuthSession()` now keeps local full-stack behavior when no internal URL is configured, but in the split production topology it sends only the enumerated `zhinian_session` Cookie chunks to the internal Go `GET /api/auth/me` endpoint.
|
||||
- The Go response is validated before it can rebuild the server-side session. Authenticated responses require a configured platform identity, an active valid role, matching subject and account IDs, role-consistent auth mode, and organization binding for non-super-admin roles; only allowlisted user fields are retained. Anonymous responses return no session, while transport, non-success status, malformed payload, or identity mismatch errors fail closed.
|
||||
- ACK Web runtime configuration now supplies `ZHINIAN_GO_INTERNAL_BASE_URL=http://zhinian-go-api:8080`; the value remains server-only and Web remains database-free.
|
||||
- The manifest checker now pins the internal URL to the Go Service name and port and guards against reintroducing database configuration into Web.
|
||||
- Added a public-seam regression test for `getShellAuthState()` covering refreshed authenticated state, Cookie allowlisting, anonymous rejection, upstream failure, malformed payloads, and six privilege-boundary response variants found during final review.
|
||||
|
||||
## Verification
|
||||
|
||||
- RED: `cmd /c npx.cmd vitest run tests/auth-current-user-go-bridge.test.ts` failed before the implementation because authenticated SSR entered the Next database store and raised `ZHINIAN_DATA_BACKEND must be explicitly set...`.
|
||||
- First review RED: the six strict identity cases resolved instead of rejecting before the response validator was tightened; the missing-role case demonstrated authority fallback granting both admin and super-admin shell state.
|
||||
- GREEN: `cmd /c npx.cmd vitest run tests/auth-current-user-go-bridge.test.ts` — 1 file and 10 tests passed.
|
||||
- `cmd /c npx.cmd tsc --noEmit --incremental false` — passed.
|
||||
- `cmd /c npm.cmd test` — 58 files and 183 tests passed.
|
||||
- `cmd /c npm.cmd run deploy:check` — `ACK manifest assertions passed (9 files)`.
|
||||
- `cmd /c npm.cmd run build` — Next.js 15.5.18 production build passed; `/create` remains dynamically server-rendered.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Build and publish updated Web and deployment artifacts, apply them to the ACK cluster, then smoke-test an authenticated `/create` request. No live deployment was performed by this task.
|
||||
- Run a server-side ACK dry-run when cluster access is available; local verification is limited to repository tests and static manifest assertions.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. This change implements the already accepted Next.js-to-Go SSR identity boundary and does not introduce a new canonical architecture decision.
|
||||
@@ -8,6 +8,7 @@ data:
|
||||
PORT: "3000"
|
||||
ZHINIAN_AUTH_REQUIRED: "1"
|
||||
ZHINIAN_PUBLIC_BASE_URL: https://REPLACE_WITH_PUBLIC_HOST
|
||||
ZHINIAN_GO_INTERNAL_BASE_URL: http://zhinian-go-api:8080
|
||||
---
|
||||
# Go API runtime settings. Provider endpoints/models use code defaults unless
|
||||
# optional provider/OSS credentials are added to the Deployment.
|
||||
|
||||
@@ -24,6 +24,11 @@ RDS/服务商凭据,仅共享会话密钥);Go 工作负载 `zhinian-go-api` 独
|
||||
Node Worker,`worker.yaml` 已弃用保留)。Ingress 按路径分流:页面 → Web,
|
||||
后端路径 → Go,`/api/internal/worker` → 无端点 deny Service。
|
||||
|
||||
Next.js 的生产 SSR 通过 Web runtime ConfigMap 中的
|
||||
`ZHINIAN_GO_INTERNAL_BASE_URL=http://zhinian-go-api:8080` 访问集群内 Go
|
||||
Service。该变量只供 Next 服务端 SSR 使用,不是公开访问地址,不应放入
|
||||
`NEXT_PUBLIC_*` 或暴露给浏览器;公网请求仍通过 Ingress 的公开域名进入。
|
||||
|
||||
Go 镜像构建(四选一,详见 [`backend/README.md`](./backend/README.md)):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config";
|
||||
import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions";
|
||||
import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session";
|
||||
import {
|
||||
chunkedCookieNames,
|
||||
parseSessionCookieValue,
|
||||
readChunkedCookieValue,
|
||||
type AuthSession,
|
||||
type AuthUser
|
||||
} from "@/lib/auth/session";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
import { loadPlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-authorization-store";
|
||||
import { authorizePlatformSession } from "@/lib/server/auth/platform-session";
|
||||
@@ -49,6 +55,127 @@ function localSession(): AuthSession {
|
||||
};
|
||||
}
|
||||
|
||||
type PlatformAuthUser = AuthUser & {
|
||||
role: NonNullable<AuthUser["role"]>;
|
||||
status: "active";
|
||||
};
|
||||
|
||||
type GoCurrentSessionResponse = {
|
||||
authRequired: boolean;
|
||||
authConfigured: boolean;
|
||||
} & (
|
||||
| { authenticated: false; authMode: null; user: null }
|
||||
| { authenticated: true; authMode: AuthSession["authMode"]; user: PlatformAuthUser }
|
||||
);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function parsePlatformAuthUser(value: unknown): PlatformAuthUser | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const role = value.role;
|
||||
if (
|
||||
(role !== "user" && role !== "organization_admin" && role !== "super_admin") ||
|
||||
value.status !== "active" ||
|
||||
typeof value.id !== "string" ||
|
||||
!value.id ||
|
||||
value.subject !== value.id ||
|
||||
typeof value.displayName !== "string" ||
|
||||
typeof value.clientId !== "string" ||
|
||||
!isStringArray(value.authorities) ||
|
||||
!isStringArray(value.scope)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const organizationId = typeof value.organizationId === "string" && value.organizationId.trim()
|
||||
? value.organizationId
|
||||
: undefined;
|
||||
if (role !== "super_admin" && !organizationId) return null;
|
||||
|
||||
const user: PlatformAuthUser = {
|
||||
id: value.id,
|
||||
subject: value.id,
|
||||
displayName: value.displayName,
|
||||
clientId: value.clientId,
|
||||
role,
|
||||
status: "active",
|
||||
authorities: [...value.authorities],
|
||||
scope: [...value.scope]
|
||||
};
|
||||
if (typeof value.username === "string") user.username = value.username;
|
||||
if (typeof value.phone === "string") user.phone = value.phone;
|
||||
if (typeof value.tenantId === "string") user.tenantId = value.tenantId;
|
||||
if (organizationId) user.organizationId = organizationId;
|
||||
if (typeof value.organizationName === "string") user.organizationName = value.organizationName;
|
||||
return user;
|
||||
}
|
||||
|
||||
function parseGoCurrentSessionResponse(value: unknown): GoCurrentSessionResponse {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.authenticated !== "boolean" ||
|
||||
typeof value.authRequired !== "boolean" ||
|
||||
typeof value.authConfigured !== "boolean"
|
||||
) {
|
||||
throw new Error("Go auth/me returned an invalid response.");
|
||||
}
|
||||
if (!value.authenticated) {
|
||||
if (value.authMode !== null || value.user !== null) {
|
||||
throw new Error("Go auth/me returned an invalid anonymous response.");
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
authRequired: value.authRequired,
|
||||
authConfigured: value.authConfigured,
|
||||
authMode: null,
|
||||
user: null
|
||||
};
|
||||
}
|
||||
const user = parsePlatformAuthUser(value.user);
|
||||
if (
|
||||
!value.authConfigured ||
|
||||
(value.authMode !== "user" && value.authMode !== "admin") ||
|
||||
!user ||
|
||||
value.authMode !== (user.role === "user" ? "user" : "admin")
|
||||
) {
|
||||
throw new Error("Go auth/me returned an invalid authenticated response.");
|
||||
}
|
||||
return {
|
||||
authenticated: true,
|
||||
authRequired: value.authRequired,
|
||||
authConfigured: value.authConfigured,
|
||||
authMode: value.authMode,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
async function authorizeSessionThroughGo(session: AuthSession, cookieHeader: string, baseUrl: string) {
|
||||
const endpoint = new URL(`${baseUrl.replace(/\/+$/, "")}/api/auth/me`);
|
||||
if ((endpoint.protocol !== "http:" && endpoint.protocol !== "https:") || endpoint.username || endpoint.password) {
|
||||
throw new AuthConfigurationError("ZHINIAN_GO_INTERNAL_BASE_URL 必须是无凭据的 HTTP(S) 地址。");
|
||||
}
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
cache: "no-store",
|
||||
headers: { cookie: cookieHeader }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Go auth/me request failed with status ${response.status}.`);
|
||||
const currentSession = parseGoCurrentSessionResponse(await response.json());
|
||||
if (!currentSession.authenticated) return null;
|
||||
if (currentSession.user.id !== session.user.id || currentSession.user.clientId !== "platform") {
|
||||
throw new Error("Go auth/me returned a mismatched authenticated user.");
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
authMode: currentSession.authMode,
|
||||
user: currentSession.user
|
||||
} satisfies AuthSession;
|
||||
}
|
||||
|
||||
export async function getOptionalAuthSession(): Promise<AuthSession | null> {
|
||||
const config = getAuthRuntimeConfig();
|
||||
if (!config.sessionSecret) return null;
|
||||
@@ -58,6 +185,18 @@ export async function getOptionalAuthSession(): Promise<AuthSession | null> {
|
||||
config.sessionSecret
|
||||
);
|
||||
if (!session) return null;
|
||||
if (session.user.clientId !== "platform") return null;
|
||||
const internalGoBaseUrl = process.env.ZHINIAN_GO_INTERNAL_BASE_URL?.trim();
|
||||
if (internalGoBaseUrl) {
|
||||
const cookieHeader = chunkedCookieNames(SESSION_COOKIE_NAME)
|
||||
.map((name) => {
|
||||
const value = cookieStore.get(name)?.value;
|
||||
return value === undefined ? null : `${name}=${value}`;
|
||||
})
|
||||
.filter((value): value is string => value !== null)
|
||||
.join("; ");
|
||||
return authorizeSessionThroughGo(session, cookieHeader, internalGoBaseUrl);
|
||||
}
|
||||
const authorization = await authorizePlatformSession(session, loadPlatformAuthorizationSnapshot);
|
||||
return authorization.outcome === "authenticated" ? authorization.session : null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ assert(!migrationJob.includes("DATABASE_CA_CERT_PATH"), "migration Job must keep
|
||||
const web = read("web.yaml");
|
||||
assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica until object storage is shared");
|
||||
assert(web.includes("path: /api/health"), "Web must use process-level readiness (it is database-free in production)");
|
||||
assert(web.includes("name: zhinian-runtime"), "Web must consume the Web runtime ConfigMap");
|
||||
assert(!web.includes("ZHINIAN_DATA_BACKEND"), "Web must not select a database backend in production");
|
||||
assert(!web.includes("DATABASE_URL"), "Web must not receive DATABASE_URL in production");
|
||||
assert(!web.includes("zhinian-web-db"), "Web must not hold RDS credentials in production");
|
||||
assert(!web.includes("rds-ca"), "Web must not mount the RDS CA in production");
|
||||
|
||||
@@ -34,6 +37,14 @@ assert(!goApi.includes("DATABASE_SSL_MODE"), "Go API must keep database TLS insi
|
||||
assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must keep database TLS inside DATABASE_URL");
|
||||
|
||||
const configMap = read("configmap.yaml");
|
||||
const webRuntime = configMap.split("\n---\n", 1)[0];
|
||||
const expectedInternalBaseUrl = "http://zhinian-go-api:8080";
|
||||
const internalBaseUrl = webRuntime.match(/^\s*ZHINIAN_GO_INTERNAL_BASE_URL:\s*(\S+)\s*$/m)?.[1];
|
||||
assert(internalBaseUrl, "Web runtime ConfigMap must define ZHINIAN_GO_INTERNAL_BASE_URL");
|
||||
assert(internalBaseUrl === expectedInternalBaseUrl, "Web runtime ConfigMap must use the cluster-internal Go API URL");
|
||||
assert(!webRuntime.includes("ZHINIAN_DATA_BACKEND"), "Web runtime ConfigMap must not select a database backend");
|
||||
assert(!webRuntime.includes("DATABASE_URL"), "Web runtime ConfigMap must not carry DATABASE_URL");
|
||||
assert(!webRuntime.includes("rds-ca"), "Web runtime ConfigMap must not carry the RDS CA");
|
||||
assert(configMap.includes("ZHINIAN_GO_EMBEDDED_WORKER: \"true\""), "Go runtime ConfigMap must embed the WorkerLoop");
|
||||
assert(configMap.includes("GO_BACKEND_HOST: 0.0.0.0"), "Go runtime ConfigMap must listen on the Pod interface");
|
||||
assert(!configMap.includes("DATABASE_SSL_MODE"), "ConfigMaps must not carry database TLS settings");
|
||||
@@ -50,6 +61,16 @@ assert(ingress.includes("name: zhinian-web"), "Ingress must route pages/static p
|
||||
const service = read("service.yaml");
|
||||
assert(service.includes("name: zhinian-public-deny"), "selectorless deny Service is required");
|
||||
|
||||
const goService = goApi.match(
|
||||
/kind: Service\s+metadata:\s+name:\s+(\S+)[\s\S]*?ports:\s+- name: http\s+port:\s+(\d+)\s+targetPort:\s+(\d+)/,
|
||||
);
|
||||
assert(goService, "Go API Service must declare an HTTP name, port, and targetPort");
|
||||
const [, serviceName, servicePort, targetPort] = goService;
|
||||
const parsedInternalBaseUrl = new URL(internalBaseUrl);
|
||||
assert(parsedInternalBaseUrl.hostname === serviceName, "Web internal Go URL must use the Go API Service name");
|
||||
assert(parsedInternalBaseUrl.port === servicePort, "Web internal Go URL must use the Go API Service port");
|
||||
assert(targetPort === servicePort, "Go API Service targetPort must match its port");
|
||||
|
||||
console.log(`ACK manifest assertions passed (${files.length} files)`);
|
||||
|
||||
function read(file) {
|
||||
|
||||
226
tests/auth-current-user-go-bridge.test.ts
Normal file
226
tests/auth-current-user-go-bridge.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user