import { afterEach, describe, expect, it, vi } from "vitest"; 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", baseUrlConfigured: true, apiKeyConfigured: true, models: ["gpt-4.1"], defaultModel: "gpt-4.1", relayMode: "direct", timeoutMs: 30000, status: "active", redactionPolicy: "default" }; const plugin: GamePluginResponse = { id: "game.example", name: "Example Server", version: "0.1.0", serverType: "example", serverDisplayName: "Example Server", manifestRef: "artifact://manifests/game.example/0.1.0", createFormSchemaRef: "schemas/create-form.schema.json", requiredRunCapabilities: ["process.start", "logs.read"], declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"], permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false }, lifecycleActions: { start: "actions/start.json" }, bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"], pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] }, status: "installed" }; const marketplacePlugin: MarketplacePluginResponse = { id: "game.example", name: "Example Server", description: "Development plugin", version: "0.1.0", serverType: "example", serverDisplayName: "Example Server", supportedOs: ["linux", "darwin"], manifestRef: "artifact://manifests/game.example/0.1.0", createFormSchemaRef: "schemas/create-form.schema.json", capabilities: ["process.install", "process.start", "logs.read"], declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"], permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false }, lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" }, bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"], pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, status: "installed", source: "platform-registry" }; const server: ServerInstanceResponse = { id: "server-1", pluginId: "game.example", pluginVersion: "0.1.0", runEndpointId: "run-local", name: "Example Survival #1", ownerUserId: "user-owner", adminUserIds: ["user-admin-1"], state: "running", configVersion: 1, createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }; const endpoint: RunEndpointResponse = { id: "run-local", displayName: "Local Run", version: "0.1.0", status: "online", capabilities: ["process.install", "process.start", "process.stop"], capacity: { maxJobs: 4, runningJobs: 0, queuedJobs: 1 }, lastHeartbeatAt: "2026-07-03T00:00:00Z" }; const job: JobResponse = { id: "job-1", serverInstanceId: server.id, runEndpointId: endpoint.id, capability: "process.start", 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" }; const artifact = { id: "artifact-1", ownerKind: "job", ownerId: job.id, sizeBytes: 18, checksum: "sha256:artifactchecksum", state: "available", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }; const runtimeActions = { serverInstanceId: server.id, pluginId: plugin.id, runEndpointId: endpoint.id, runStatus: "online", actions: [ { key: "generate-run", label: "Generate run", available: true }, { key: "download-run", label: "Download run", available: true }, { key: "push-run-update", label: "Push run update", available: true }, { key: "dependencies-check", label: "Check dependencies", available: true }, { key: "historical-logs", label: "Historical logs", available: true } ] }; const runtimeDownload: ArtifactDownloadReferenceResponse = { artifactId: "artifact-run-1", ownerKind: "server-instance", ownerId: server.id, filename: "run-linux-amd64", contentType: "application/octet-stream", sizeBytes: 128, checksum: "sha256:runchecksum", state: "available", downloadUrl: "/api/v1/artifacts/artifact-run-1/content", expiresAt: "2026-07-03T00:15:00Z", rangeSupported: true, chunkSizeBytes: 1048576, storageBehavior: "platform-memory-transfer-session" }; const serverFileWorkspace = { serverInstanceId: server.id, pluginId: plugin.id, defaultDirectoryKey: "configs", directories: [{ key: "configs", label: "配置", scope: "config" }], files: [{ key: "config/server.properties", directoryKey: "configs", label: "server.properties", kind: "config", editable: true }], configFields: [], transfer: { channel: "run-file-transfer", uploadChunkSizeBytes: 1048576, downloadChunkSizeBytes: 1048576, maxInlineEditBytes: 65536, maxBrowserUploadBytes: 52428800 }, declaredOnly: true, runtimeWorkspaceScope: "server-runtime" }; describe("PlatformApiClient AI providers", () => { afterEach(() => { setPlatformApiSessionToken(null); setPlatformApiAuthFailureHandler(null); vi.restoreAllMocks(); }); it("calls AI provider management endpoints with named contracts", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/api/v1/ai-providers") && (!init?.method || init.method === "GET")) { return jsonResponse({ items: [provider], count: 1 }); } if (url.endsWith("/api/v1/ai-providers") && init?.method === "POST") { return jsonResponse(provider); } if (url.endsWith("/api/v1/ai-providers/ai.openai") && init?.method === "PUT") { return jsonResponse({ ...provider, name: "OpenAI Relay" }); } if (url.endsWith("/api/v1/ai-providers/ai.openai/status") && init?.method === "POST") { return jsonResponse({ ...provider, status: "disabled" }); } if (url.endsWith("/api/v1/ai-providers/ai.openai/test") && init?.method === "POST") { return jsonResponse({ providerId: provider.id, mode: "metadata", success: true, message: "metadata validation passed" }); } if (url.endsWith("/api/v1/ai-providers/ai.openai/models")) { return jsonResponse({ providerId: provider.id, defaultModel: provider.defaultModel, models: provider.models }); } throw new Error(`unexpected request: ${url}`); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient(); await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 }); const providerRequest = { id: provider.id, name: provider.name, kind: provider.kind, baseUrl: "https://api.openai.com/v1", 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"] }); expect(fetchMock).toHaveBeenCalledTimes(6); }); it("calls console shell resource endpoints with named contracts", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/healthz")) { return jsonResponse({ service: "platform", status: "ok", version: "0.1.0", time: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/game-plugins")) { return jsonResponse({ items: [plugin], count: 1 }); } if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) { return jsonResponse({ items: [server], count: 1 }); } if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "PUT") { expect(JSON.parse(String(init.body))).toEqual({ name: "Example Survival Renamed" }); return jsonResponse({ ...server, name: "Example Survival Renamed" }); } if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") { expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password", force: true, confirmation: "FORCE DELETE" }); return new Response(null, { status: 204 }); } if (url.endsWith("/api/v1/metrics/platform")) { return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/metrics/server-instances")) { return jsonResponse({ items: [ { serverInstanceId: server.id, online: true, playerCount: 5, maxPlayers: 20, tps: 19.8, latencyMs: 42, cpuPercent: 31, memoryPercent: 44, diskPercent: 22, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" } ], count: 1 }); } if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" }); return jsonResponse({ status: "queued", serverInstanceId: server.id, operation: "read", key: "logs/latest.log", job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" } }); } if (url.endsWith("/api/v1/server-instances/server-1/files/workspace") && (!init?.method || init.method === "GET")) { return jsonResponse(serverFileWorkspace); } if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=configs&query=server&recursive=true")) { return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "declared", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], reason: "declared" }); } if (url.endsWith("/api/v1/server-instances/server-1/files/refresh") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" }); return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "pending", entries: [], job: { ...job, id: "job-file-list", capability: "files.list", targetKey: "configs" }, reason: "queued" }); } if (url.endsWith("/api/v1/server-instances/server-1/files/browse") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-browse" }); return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "configs", state: "ready", entries: [{ name: "server.properties", kind: "file", directoryKey: "configs", relativePath: "config/server.properties", logicalKey: "config/server.properties", scope: "config", sizeBytes: 42, editable: true, downloadable: true, remark: "配置文件" }], job: { ...job, id: "job-file-browse", capability: "files.list", targetKey: "configs" }, reason: "目录读取完成。" }); } if (url.endsWith("/api/v1/server-instances/server-1/files/read-snapshot?key=config%2Fserver.properties")) { return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, key: "config/server.properties", state: "ready", content: "server.name=Example\n", version: 3, checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/server-instances/server-1/files/read") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-read" }); return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "read", key: "config/server.properties", job: { ...job, id: "job-server-file-read", capability: "files.read", targetKey: "config/server.properties" } }); } if (url.endsWith("/api/v1/server-instances/server-1/files/write") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" }); return jsonResponse({ status: "queued", serverInstanceId: server.id, pluginId: plugin.id, operation: "write", key: "config/server.properties", job: { ...job, id: "job-server-file-write", capability: "files.write", targetKey: "config/server.properties" } }); } if (url.endsWith("/api/v1/server-instances/server-1/files/upload") && init?.method === "POST") { const body = init.body as FormData; expect(body.get("directoryKey")).toBe("configs"); expect(body.get("relativePath")).toBe(""); expect(body.get("filename")).toBe("server.properties"); expect(body.get("idempotencyKey")).toBe("idem-file-upload"); expect(body.get("file")).toBeInstanceOf(File); return jsonResponse({ status: "queued", serverInstanceId: server.id, directoryKey: "configs", relativePath: "server.properties", artifactId: "artifact-upload-1", inputRef: "artifact://artifact-upload-1", sizeBytes: 20, checksum: "sha256:uploadchecksum", job: { ...job, id: "job-server-file-upload", capability: "files.write", targetKey: "configs/server.properties" } }); } if (url.endsWith("/api/v1/server-instances/server-1/files/download") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ key: "config/server.properties", idempotencyKey: "idem-file-download" }); return jsonResponse({ status: "ready", serverInstanceId: server.id, key: "config/server.properties", filename: "server.properties", contentType: "text/plain; charset=utf-8", content: "server.name=Example\n", checksum: "sha256:filechecksum", sizeBytes: 20, readAt: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/run/endpoints")) { return jsonResponse({ items: [endpoint], count: 1 }); } if (url.endsWith("/api/v1/run/endpoints?status=online")) { return jsonResponse({ items: [endpoint], count: 1 }); } if (url.endsWith("/api/v1/jobs")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/jobs?states=queued%2Crunning%2Cfailed")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/jobs?states=queued%2Crunning%2Cfailed&limit=25")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1&states=queued%2Crunning%2Cfailed")) { return jsonResponse({ items: [job], count: 1 }); } if (url.endsWith("/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=available")) { return jsonResponse({ items: [artifact], count: 1 }); } if (url.endsWith("/api/v1/artifacts/artifact-1/download") && init?.method === "POST") { return jsonResponse({ artifactId: artifact.id, ownerKind: artifact.ownerKind, ownerId: artifact.ownerId, filename: "artifact-1.bin", contentType: "application/octet-stream", sizeBytes: artifact.sizeBytes, checksum: artifact.checksum, state: artifact.state, downloadUrl: "/api/v1/artifacts/artifact-1/content", expiresAt: "2026-07-03T00:15:00Z", rangeSupported: true, chunkSizeBytes: 1048576, storageBehavior: "platform-memory-transfer-session" }); } if (url.endsWith("/api/v1/artifacts/artifact-1/content?offset=0&limit=8")) { return new Response(new TextEncoder().encode("artifact").buffer, { status: 206, headers: { "Content-Type": "application/octet-stream", "Content-Length": "8", "Content-Range": "bytes 0-7/18", "X-Artifact-Id": artifact.id, "X-Artifact-Checksum": artifact.checksum, "X-Artifact-Content-Checksum": "sha256:chunkchecksum", "X-Artifact-Storage": "platform-memory-transfer-session" } }); } if (url.endsWith("/api/v1/artifacts/artifact-1/content")) { return new Response(new TextEncoder().encode("artifact full body").buffer, { status: 200, headers: { "Content-Type": "application/octet-stream", "Content-Length": "18", "X-Artifact-Id": artifact.id, "X-Artifact-Checksum": artifact.checksum, "X-Artifact-Content-Checksum": "sha256:fullchecksum", "X-Artifact-Storage": "platform-memory-transfer-session" } }); } if (url.endsWith("/api/v1/server-instances/workflows/create") && init?.method === "POST") { return jsonResponse({ accepted: true, action: "create", instance: { ...server, state: "installing" }, job: { ...job, capability: "process.install" } }); } if (url.endsWith("/api/v1/server-instances/server-1/start") && init?.method === "POST") { return jsonResponse({ accepted: true, action: "start", instance: server, job }); } 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", summary: "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 }); } if (url.endsWith("/api/v1/server-instances/server-1/administrators") && init?.method === "POST") { return jsonResponse({ ...server, adminUserIds: [...server.adminUserIds, "user-2"] }); } if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") { return jsonResponse({ ...server, adminUserIds: [] }); } 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({ id: "run-dist-1", serverInstanceId: server.id, pluginId: plugin.id, runEndpointId: endpoint.id, targetOs: "linux", targetArch: "amd64", packageFormat: "raw-executable", artifactId: "artifact-run-1", checksum: "sha256:runchecksum", keyGeneration: 1, secretRef: "secret://runtime-keys/server-1/run/current", status: "available", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }); } if (url.endsWith("/api/v1/server-instances/server-1/run/download") && init?.method === "POST") { return jsonResponse(runtimeDownload); } if (url.endsWith("/api/v1/server-instances/server-1/run/key/reset") && init?.method === "POST") { return jsonResponse({ id: "runtime-key-server-1-run-2", serverInstanceId: server.id, componentKind: "run", secretRef: "secret://runtime-keys/server-1/run/current", fingerprint: "abc123def456", generation: 2, status: "active", 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 === "POST") { 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: 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/dependencies/check") && init?.method === "POST") { 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", 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/plugin-bridge/authorize") && init?.method === "POST") { return jsonResponse({ pluginId: plugin.id, routeKey: "logs", action: "logs.query", allowed: true, requiredPermissions: ["server.logs.read"], effectivePermissions: ["server.logs.read"] }); } if (url.endsWith("/api/v1/plugin-bridge/execute") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } }); return jsonResponse({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", status: "ok", result: { entryCount: "0" } }); } if (url.endsWith("/api/v1/ai/invocations") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }); return jsonResponse({ requestId: "ai-1", purpose: "config.suggest", providerId: "ai.openai", model: "gpt-4.1", status: "ok", recommendation: "Review before applying.", configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" }, usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true } }); } throw new Error(`unexpected request: ${url}`); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient(); await expect(client.health()).resolves.toMatchObject({ status: "ok" }); await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 }); await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 }); await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" }); await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined(); await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({ status: "queued", job: { capability: "files.read", targetKey: "logs/latest.log" } }); await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ defaultDirectoryKey: "configs", transfer: { channel: "run-file-transfer" } }); await expect(client.listServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true })).resolves.toMatchObject({ state: "declared", entries: [{ logicalKey: "config/server.properties" }] }); await expect(client.refreshServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-list" })).resolves.toMatchObject({ state: "pending", job: { capability: "files.list" } }); await expect(client.browseServerFiles(server.id, { directoryKey: "configs", query: "server", recursive: true, idempotencyKey: "idem-file-browse" })).resolves.toMatchObject({ state: "ready", entries: [{ logicalKey: "config/server.properties" }] }); await expect(client.getServerFileReadSnapshot(server.id, "config/server.properties")).resolves.toMatchObject({ state: "ready", content: "server.name=Example\n" }); await expect(client.readServerFile(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-read" })).resolves.toMatchObject({ operation: "read", job: { capability: "files.read" } }); await expect(client.writeServerFile(server.id, { key: "config/server.properties", content: "server.name=Example\n", expectedVersion: 3, expectedChecksum: "sha256:filechecksum", idempotencyKey: "idem-file-write" })).resolves.toMatchObject({ operation: "write", job: { capability: "files.write" } }); await expect(client.uploadServerFile(server.id, { directoryKey: "configs", file: new File(["server.name=Example\n"], "server.properties", { type: "text/plain" }), idempotencyKey: "idem-file-upload" })).resolves.toMatchObject({ inputRef: "artifact://artifact-upload-1", job: { capability: "files.write" } }); await expect(client.prepareServerFileDownload(server.id, { key: "config/server.properties", idempotencyKey: "idem-file-download" })).resolves.toMatchObject({ status: "ready", filename: "server.properties", content: "server.name=Example\n" }); await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 }); await expect(client.listRunEndpoints({ status: "online" })).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs()).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(undefined, { states: ["queued", "running", "failed"] })).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(undefined, { states: ["queued", "running", "failed"], limit: 25 })).resolves.toMatchObject({ count: 1 }); await expect(client.listJobs(server.id, { states: ["queued", "running", "failed"] })).resolves.toMatchObject({ count: 1 }); 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.downloadArtifactContent(artifact.id).then((response) => response.arrayBuffer())).resolves.toHaveProperty("byteLength", 18); await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, name: "Server 2", idempotencyKey: "idem-create" })).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: [] }); 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, secretRef: "secret://runtime-keys/server-1/run/current" }); 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: 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.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" }); 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.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(47); }); it("normalizes server file workspace null arrays from older platform responses", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/server-instances/server-1/files/workspace")) { return jsonResponse({ ...serverFileWorkspace, defaultDirectoryKey: "", directories: null, files: null, configFields: null, transfer: { ...serverFileWorkspace.transfer, notes: null } }); } if (url.endsWith("/api/v1/server-instances/server-1/files/list?directoryKey=scum-config")) { return jsonResponse({ serverInstanceId: server.id, pluginId: plugin.id, directoryKey: "scum-config", state: "declared", entries: null }); } throw new Error(`unexpected request: ${url}`); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient(); await expect(client.getServerFileWorkspace(server.id)).resolves.toMatchObject({ directories: [], files: [], configFields: [], transfer: { notes: [] } }); await expect(client.listServerFiles(server.id, { directoryKey: "scum-config" })).resolves.toMatchObject({ entries: [] }); }); it("calls plugin marketplace endpoints with filter and state contracts", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/api/v1/plugin-marketplace/plugins?status=installed&serverType=example&capability=logs.read&keyword=example")) { return jsonResponse({ items: [marketplacePlugin], count: 1 }); } if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example") && (!init?.method || init.method === "GET")) { return jsonResponse(marketplacePlugin); } if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example/state") && init?.method === "POST") { expect(JSON.parse(String(init.body))).toEqual({ action: "disable" }); return jsonResponse({ ...marketplacePlugin, status: "disabled" }); } throw new Error(`unexpected request: ${url}`); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient(); await expect(client.listMarketplacePlugins({ status: "installed", serverType: "example", capability: "logs.read", keyword: "example" })).resolves.toMatchObject({ count: 1 }); await expect(client.getMarketplacePlugin(marketplacePlugin.id)).resolves.toMatchObject({ source: "platform-registry" }); await expect(client.setMarketplacePluginState(marketplacePlugin.id, { action: "disable" })).resolves.toMatchObject({ status: "disabled" }); expect(fetchMock).toHaveBeenCalledTimes(3); }); it("keeps raw key and base URL fields out of provider responses", () => { expect("apiKey" in provider).toBe(false); expect("rawApiKey" in provider).toBe(false); expect(provider.apiKeyConfigured).toBe(true); expect("apiKeyRef" in provider).toBe(false); expect("baseUrl" in provider).toBe(false); expect(provider.baseUrlConfigured).toBe(true); }); it("calls auth endpoints and attaches bearer sessions", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/api/v1/auth/login") && init?.method === "POST") { return jsonResponse({ user: { id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }, sessionId: "session-token", status: "authenticated", message: "登录成功" }); } if (url.endsWith("/api/v1/users/current")) { expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token"); return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }); } if (url.endsWith("/api/v1/auth/logout") && init?.method === "POST") { expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token"); return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${url}`); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient(); const login = await client.login({ account: "operator.local@example.test", password: "operator-local" }); expect(login.sessionId).toBe("session-token"); setPlatformApiSessionToken(login.sessionId ?? null); await expect(client.getCurrentUser()).resolves.toMatchObject({ id: "user-admin" }); await expect(client.logout()).resolves.toBeUndefined(); 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(); }); it("surfaces safe validation details for delete denials", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: "validation_failed", message: "validation failed", details: [ "running or installing server instances require forced-delete confirmation", "config path /Users/operator/private/server.ini is unavailable" ] }), { status: 400, headers: { "Content-Type": "application/json" } }))); const client = new PlatformApiClient("/api/v1", () => "owner-session"); await expect(client.deleteServerInstance("running-server", { password: "secret-password" })).rejects.toMatchObject({ status: 400, code: "validation_failed", message: "运行中或安装中的服务器需要强制删除确认。;config path [host-path] is unavailable" }); }); it("builds encoded server log event stream URLs for the terminal drawer", () => { const client = new PlatformApiClient("/api/v1"); expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events"); expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events"); }); it("scopes explicit terminal history lists to the selected server", async () => { const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify({ items: [], count: 0 }), { status: 200, headers: { "Content-Type": "application/json" } })); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient("/api/v1", () => "terminal-session"); await expect(client.listLogStreams("server/scum 1")).resolves.toEqual({ items: [], count: 0 }); expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/log-streams?serverInstanceId=server%2Fscum%201"); }); it("streams terminal log SSE with bearer authorization and dispatches live events", async () => { const payload = "event: session\ndata: {\"serverInstanceId\":\"server-1\",\"logSessionId\":\"session-2\"}\n\nevent: stream\ndata: {\"id\":\"log-1\",\"serverInstanceId\":\"server-1\"}\n\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"live line\"}}\n\nevent: ready\ndata: {\"serverInstanceId\":\"server-1\"}\n\n"; const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer terminal-session"); expect(new Headers(init?.headers).get("Accept")).toBe("text/event-stream"); return new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(payload)); controller.close(); } }), { status: 200, headers: { "Content-Type": "text/event-stream" } }); }); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient("/api/v1", () => "terminal-session"); const events: string[] = []; const stream = client.openServerLogEvents("server-1"); stream.addEventListener("session", (event) => events.push(`session:${event.data}`)); stream.addEventListener("stream", (event) => events.push(`stream:${event.data}`)); stream.addEventListener("log", (event) => events.push(`log:${event.data}`)); stream.addEventListener("ready", (event) => events.push(`ready:${event.data}`)); await vi.waitFor(() => expect(events).toEqual([ 'session:{"serverInstanceId":"server-1","logSessionId":"session-2"}', 'stream:{"id":"log-1","serverInstanceId":"server-1"}', 'log:{"streamId":"log-1","entry":{"seq":3,"line":"live line"}}', 'ready:{"serverInstanceId":"server-1"}' ])); expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server-1/logs/events"); stream.close(); }); it("reconnects terminal log SSE after the fetch stream ends", async () => { const payloads = [ "id: log-1:3\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"first live line\"}}\n\n", "id: log-1:4\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":4,\"line\":\"second live line\"}}\n\n" ]; const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(payloads[Math.min(fetchMock.mock.calls.length - 1, payloads.length - 1)])); controller.close(); } }), { status: 200, headers: { "Content-Type": "text/event-stream" } })); vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient("/api/v1", () => "terminal-session"); const events: string[] = []; const stream = client.openServerLogEvents("server-1"); stream.addEventListener("log", (event) => events.push(event.data)); await vi.waitFor(() => expect(events).toContain('{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}')); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2), { timeout: 1500 }); expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Last-Event-ID")).toBe("log-1:3"); await vi.waitFor(() => expect(events).toEqual([ '{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}', '{"streamId":"log-1","entry":{"seq":4,"line":"second live line"}}' ]), { timeout: 1500 }); stream.close(); }); it("surfaces password confirmation denials without exposing generic forbidden text", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: "forbidden", message: "password confirmation failed" }), { status: 403, headers: { "Content-Type": "application/json" } }))); const client = new PlatformApiClient("/api/v1", () => "owner-session"); await expect(client.deleteServerInstance("server-1", { password: "wrong-password" })).rejects.toMatchObject({ status: 403, code: "forbidden", message: "当前登录密码不正确,请重新输入。" }); }); it("surfaces allow-listed plugin capability denials", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: "forbidden", message: "plugin does not declare required permission: server.run.distribution" }), { status: 403, headers: { "Content-Type": "application/json" } }))); const client = new PlatformApiClient("/api/v1", () => "admin-session"); await expect(client.generateRunDistribution("server-local-debug", { targetOs: "linux", targetArch: "amd64", idempotencyKey: "test" })).rejects.toMatchObject({ status: 403, code: "forbidden", message: "插件未声明所需权限:server.run.distribution" }); }); }); function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); }