feat: 完整游戏运维功能
This commit is contained in:
+129
-12
@@ -1,14 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
|
||||
import { PlatformApiClient, PlatformApiError, setPlatformApiAuthFailureHandler, setPlatformApiSessionToken } from "./client";
|
||||
import type { AiProviderResponse, ArtifactDownloadReferenceResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
|
||||
|
||||
const runtimeDigest = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
const provider: AiProviderResponse = {
|
||||
id: "ai.openai",
|
||||
name: "OpenAI",
|
||||
kind: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
apiKeyConfigured: true,
|
||||
models: ["gpt-4.1"],
|
||||
defaultModel: "gpt-4.1",
|
||||
relayMode: "direct",
|
||||
@@ -33,6 +35,7 @@ const plugin: GamePluginResponse = {
|
||||
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
|
||||
tags: ["example"],
|
||||
aiPurposes: ["logs.diagnose"],
|
||||
runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] },
|
||||
status: "installed"
|
||||
};
|
||||
|
||||
@@ -90,6 +93,9 @@ const job: JobResponse = {
|
||||
idempotencyKey: "idem-start",
|
||||
state: "queued",
|
||||
progress: { percent: 0, message: "queued" },
|
||||
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 2, maxBackoffSeconds: 60 },
|
||||
attempt: 0,
|
||||
reconcileCount: 0,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
};
|
||||
@@ -139,6 +145,7 @@ const runtimeDownload: ArtifactDownloadReferenceResponse = {
|
||||
describe("PlatformApiClient AI providers", () => {
|
||||
afterEach(() => {
|
||||
setPlatformApiSessionToken(null);
|
||||
setPlatformApiAuthFailureHandler(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -170,8 +177,21 @@ describe("PlatformApiClient AI providers", () => {
|
||||
const client = new PlatformApiClient();
|
||||
|
||||
await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id });
|
||||
await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
|
||||
const providerRequest = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
kind: provider.kind,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKeyRef: "secret://providers/openai",
|
||||
models: provider.models,
|
||||
defaultModel: provider.defaultModel,
|
||||
relayMode: provider.relayMode,
|
||||
timeoutMs: provider.timeoutMs,
|
||||
redactionPolicy: provider.redactionPolicy
|
||||
};
|
||||
await expect(client.createAiProvider(providerRequest)).resolves.toMatchObject({ id: provider.id });
|
||||
const { id: _id, ...providerUpdate } = providerRequest;
|
||||
await expect(client.updateAiProvider(provider.id, { ...providerUpdate, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
|
||||
await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" });
|
||||
await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" });
|
||||
await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] });
|
||||
@@ -343,6 +363,9 @@ describe("PlatformApiClient AI providers", () => {
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
|
||||
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") {
|
||||
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", auditSummary: "private supervised process identity" } } });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
|
||||
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
|
||||
}
|
||||
@@ -356,6 +379,13 @@ describe("PlatformApiClient AI providers", () => {
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) {
|
||||
return jsonResponse(runtimeActions);
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && (!init?.method || init.method === "GET")) {
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/runtime-binding") && init?.method === "PUT") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "local", bindings: { "server-root": "runtime.server-root" } });
|
||||
return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, profileKey: "local", mode: "local-process", configured: true, keys: [{ key: "server-root", required: true, configured: true, secret: false }], missingKeys: [], status: "complete" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/generate") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" });
|
||||
return jsonResponse({
|
||||
@@ -392,20 +422,49 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" });
|
||||
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" });
|
||||
return jsonResponse({
|
||||
id: "run-update-1",
|
||||
serverInstanceId: server.id,
|
||||
runEndpointId: endpoint.id,
|
||||
artifactId: "artifact-run-1",
|
||||
checksum: "sha256:runchecksum",
|
||||
checksum: runtimeDigest,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
targetRelease: "run-dist-2",
|
||||
previousVersion: "0.1.0",
|
||||
jobId: "job-run-update",
|
||||
idempotencyKey: "idem-run-update",
|
||||
status: "queued",
|
||||
phase: "queued",
|
||||
rollback: false,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "GET") {
|
||||
return jsonResponse({
|
||||
items: [{
|
||||
id: "run-update-1",
|
||||
serverInstanceId: server.id,
|
||||
runEndpointId: endpoint.id,
|
||||
artifactId: "artifact-run-1",
|
||||
checksum: runtimeDigest,
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
targetRelease: "run-dist-2",
|
||||
previousVersion: "0.1.0",
|
||||
jobId: "job-run-update",
|
||||
status: "running",
|
||||
phase: "restart-requested",
|
||||
message: "verified update staged; restart requested",
|
||||
rollback: false,
|
||||
createdAt: "2026-07-03T00:00:00Z",
|
||||
updatedAt: "2026-07-03T00:01:00Z"
|
||||
}],
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
profileKey: "scum-client-manager",
|
||||
@@ -457,8 +516,21 @@ describe("PlatformApiClient AI providers", () => {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
|
||||
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/dependencies") && init?.method === "GET") {
|
||||
return jsonResponse({
|
||||
serverInstanceId: server.id,
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.version,
|
||||
profileKey: "local",
|
||||
targetOs: "linux",
|
||||
targetArch: "amd64",
|
||||
probes: [{ key: "java-21", kind: "java.version", required: true, state: "missing", installPlanKey: "install-java-linux" }],
|
||||
plans: [{ key: "install-java-linux", title: "Install Java", targetOs: "linux", targetArch: "amd64", digest: runtimeDigest, steps: [{ type: "package", targetKey: "java", packageManager: "apt", packageName: "openjdk-21-jre" }] }],
|
||||
updatedAt: "2026-07-03T00:00:00Z"
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/install") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" });
|
||||
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" });
|
||||
return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) {
|
||||
@@ -546,11 +618,12 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
|
||||
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
|
||||
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
|
||||
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
|
||||
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create", profileKey: "local", bindings: {} })).resolves.toMatchObject({
|
||||
action: "create"
|
||||
});
|
||||
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
|
||||
await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" });
|
||||
await expect(client.queryServerProcessStatus(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-status" })).resolves.toMatchObject({ action: "status", job: { executionResult: { processState: "running" } } });
|
||||
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
|
||||
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
|
||||
@@ -558,6 +631,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
const runtime = await client.getServerRuntimeActions(server.id);
|
||||
expect(runtime.runStatus).toBe("online");
|
||||
expect(runtime.actions.some((action) => action.key === "generate-run" && action.available)).toBe(true);
|
||||
await expect(client.getServerRuntimeBinding(server.id)).resolves.toMatchObject({ profileKey: "local", status: "complete", keys: [{ key: "server-root", configured: true }] });
|
||||
await expect(client.updateServerRuntimeBinding(server.id, { profileKey: "local", bindings: { "server-root": "runtime.server-root" } })).resolves.toMatchObject({ status: "complete" });
|
||||
await expect(client.generateRunDistribution(server.id, { targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" })).resolves.toMatchObject({
|
||||
artifactId: "artifact-run-1",
|
||||
keyGeneration: 1,
|
||||
@@ -565,10 +640,11 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
await expect(client.downloadLatestRunDistribution(server.id)).resolves.toMatchObject({ artifactId: "artifact-run-1", rangeSupported: true });
|
||||
await expect(client.resetRunKey(server.id)).resolves.toMatchObject({ componentKind: "run", generation: 2 });
|
||||
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
|
||||
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: runtimeDigest, idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
|
||||
jobId: "job-run-update",
|
||||
status: "queued"
|
||||
});
|
||||
await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] });
|
||||
await expect(
|
||||
client.generateClientManager(server.id, {
|
||||
profileKey: "scum-client-manager",
|
||||
@@ -582,7 +658,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
|
||||
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 });
|
||||
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
|
||||
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||
await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] });
|
||||
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
|
||||
capability: "dependencies.install"
|
||||
});
|
||||
await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
@@ -599,7 +676,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
|
||||
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(38);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(43);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
@@ -651,7 +728,8 @@ describe("PlatformApiClient AI providers", () => {
|
||||
it("keeps raw key fields out of provider responses", () => {
|
||||
expect("apiKey" in provider).toBe(false);
|
||||
expect("rawApiKey" in provider).toBe(false);
|
||||
expect(provider.apiKeyRef).toBe("secret://providers/openai");
|
||||
expect(provider.apiKeyConfigured).toBe(true);
|
||||
expect("apiKeyRef" in provider).toBe(false);
|
||||
});
|
||||
|
||||
it("calls auth endpoints and attaches bearer sessions", async () => {
|
||||
@@ -687,6 +765,45 @@ describe("PlatformApiClient AI providers", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("resets 401 sessions and redacts auth error details", async () => {
|
||||
const onAuthFailure = vi.fn();
|
||||
setPlatformApiSessionToken("expired-session-token");
|
||||
setPlatformApiAuthFailureHandler(onAuthFailure);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||
code: "unauthorized",
|
||||
message: "expired secret://internal/provider raw-token-value /srv/game unix:///tmp/run.sock"
|
||||
}), { status: 401, headers: { "Content-Type": "application/json" } })));
|
||||
|
||||
const client = new PlatformApiClient();
|
||||
const failure = await client.getCurrentUser().catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(PlatformApiError);
|
||||
expect(failure).toMatchObject({ status: 401, code: "unauthorized", message: "会话已失效,请重新登录。" });
|
||||
expect(String(failure)).not.toMatch(/secret:\/\/|raw-token-value|\/srv\/game|unix:\/\//);
|
||||
expect(onAuthFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps 403 as a safe capability denial without clearing the session", async () => {
|
||||
const onAuthFailure = vi.fn();
|
||||
setPlatformApiAuthFailureHandler(onAuthFailure);
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer owner-session");
|
||||
return new Response(JSON.stringify({ code: "forbidden", message: "owner mismatch secret://internal" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PlatformApiClient("/api/v1", () => "owner-session");
|
||||
await expect(client.getServerInstance("other-server")).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: "forbidden",
|
||||
message: "没有权限访问该资源。"
|
||||
});
|
||||
expect(onAuthFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
|
||||
Reference in New Issue
Block a user