836 lines
42 KiB
TypeScript
836 lines
42 KiB
TypeScript
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", approvalRequired: ["disable", "rollback", "retire"] },
|
|
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", approvalRequired: ["disable", "rollback", "retire"] },
|
|
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: "generate-client-manager", label: "Generate client manager", 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"
|
|
};
|
|
|
|
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/run/endpoints")) {
|
|
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/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/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/client-managers/generate") && init?.method === "POST") {
|
|
expect(JSON.parse(String(init.body))).toEqual({
|
|
profileKey: "scum-client-manager",
|
|
targetOs: "windows",
|
|
targetArch: "amd64",
|
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
|
sourceRevision: "main",
|
|
idempotencyKey: "idem-client-generate"
|
|
});
|
|
return jsonResponse({
|
|
id: "client-dist-1",
|
|
serverInstanceId: server.id,
|
|
pluginId: plugin.id,
|
|
profileKey: "scum-client-manager",
|
|
targetOs: "windows",
|
|
targetArch: "amd64",
|
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
|
sourceRevision: "main",
|
|
buildJobId: "client-build-1",
|
|
artifactId: "artifact-client-1",
|
|
checksum: "sha256:clientchecksum",
|
|
keyGeneration: 1,
|
|
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
|
status: "available",
|
|
createdAt: "2026-07-03T00:00:00Z",
|
|
updatedAt: "2026-07-03T00:00:00Z"
|
|
});
|
|
}
|
|
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") {
|
|
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "scum-client-manager" });
|
|
return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "scum-client-manager.exe" });
|
|
}
|
|
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") {
|
|
expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "scum-client-manager" });
|
|
return jsonResponse({
|
|
id: "runtime-key-server-1-client-2",
|
|
serverInstanceId: server.id,
|
|
componentKind: "client-manager",
|
|
componentKey: "scum-client-manager",
|
|
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
|
|
fingerprint: "def456abc123",
|
|
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/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.listRunEndpoints()).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.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, 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.generateClientManager(server.id, {
|
|
profileKey: "scum-client-manager",
|
|
targetOs: "windows",
|
|
targetArch: "amd64",
|
|
repositoryUrl: "https://github.com/F88888/scum_client.git",
|
|
sourceRevision: "main",
|
|
idempotencyKey: "idem-client-generate"
|
|
})
|
|
).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "scum-client-manager" });
|
|
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.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(36);
|
|
});
|
|
|
|
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", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500");
|
|
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<Uint8Array>({ 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", { historyLimit: 2 });
|
|
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?historyLimit=2");
|
|
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<Uint8Array>({ 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" }
|
|
});
|
|
}
|