1169 lines
64 KiB
TypeScript
1169 lines
64 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
|
|
import {
|
|
bridgeError,
|
|
canRequestBridgeAction,
|
|
createAIInvocationRequest,
|
|
createArtifactOpenRequest,
|
|
createBridgeExecutionRequest,
|
|
createLifecycleDispatchRequest,
|
|
createProductionPluginLifecycleRequest,
|
|
createBridgeRequest,
|
|
createDependencyActionRequest,
|
|
createLogBackfillRequest,
|
|
createGameClientBridgeQueueRequest,
|
|
createRemoteAccessRequest,
|
|
createRunDistributionRequest,
|
|
hasPluginPermission,
|
|
parseArtifactReference,
|
|
parseBridgeExecutionResponse,
|
|
parseAIInvocationResponse,
|
|
type GameClientBridgeQueryTemplateDeclaration,
|
|
type GamePluginManifest,
|
|
type PluginLifecycleActionDeclaration,
|
|
type PluginBridgeContext
|
|
} from "../sdk/index.js";
|
|
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
|
|
|
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
|
|
type MutableBridgeManifest = {
|
|
capabilities: string[];
|
|
permissions: string[];
|
|
remoteAccess?: {
|
|
methods: string[];
|
|
runCapabilities?: string[];
|
|
databaseEngines?: string[];
|
|
};
|
|
runtimeProfiles?: {
|
|
transportProfiles?: Array<Record<string, unknown>>;
|
|
};
|
|
pages?: Array<{ key?: string; permissions?: string[]; bridgeActions?: string[] }>;
|
|
gameClientBridge: {
|
|
commands: Array<Record<string, unknown>>;
|
|
snapshots: Array<Record<string, unknown>>;
|
|
queryTemplates?: Array<Record<string, unknown>>;
|
|
commandRetentionSeconds: number;
|
|
maxCommands: number;
|
|
pages: Array<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
function bridgeObjectSchema(properties: Record<string, unknown>, required: string[] = []): Record<string, unknown> {
|
|
return {
|
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties,
|
|
...(required.length > 0 ? { required } : {})
|
|
};
|
|
}
|
|
|
|
function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unknown): void {
|
|
const target = path.join(fixtureDir, relativePath);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManifest, fixtureDir: string) => void): string[] {
|
|
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-bridge-manifest-"));
|
|
try {
|
|
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
|
const manifestPath = path.join(fixtureDir, "manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
|
|
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.rcon.command"];
|
|
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"];
|
|
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.rcon.command"], databaseEngines: ["sqlite"] };
|
|
manifest.runtimeProfiles = {
|
|
transportProfiles: [
|
|
{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] },
|
|
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.rcon.command"] }
|
|
]
|
|
};
|
|
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
|
if (overviewPage) {
|
|
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access"];
|
|
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
|
|
}
|
|
manifest.gameClientBridge = {
|
|
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
|
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
|
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
|
|
commandRetentionSeconds: 86400,
|
|
maxCommands: 1000,
|
|
pages: [{ pageKey: "overview", commandTypes: ["diagnostic.ping"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }]
|
|
};
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, value: { type: "number" } }, ["playerId", "value"]));
|
|
mutate?.(manifest, fixtureDir);
|
|
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
|
return validateManifestFile(manifestPath);
|
|
} finally {
|
|
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function validateTemporaryScumManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
|
|
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-manifest-"));
|
|
try {
|
|
fs.cpSync(path.join(pluginsRoot, "examples/scum-server-plugin"), fixtureDir, { recursive: true });
|
|
const manifestPath = path.join(fixtureDir, "manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
|
mutate(manifest, fixtureDir);
|
|
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
|
return validateManifestFile(manifestPath);
|
|
} finally {
|
|
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
describe("plugin manifest validation", () => {
|
|
it("accepts the development example manifest", () => {
|
|
expect(validateManifestFile("examples/dev-game-plugin/manifest.json")).toEqual([]);
|
|
});
|
|
|
|
it("accepts the SCUM server plugin manifest", () => {
|
|
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
|
});
|
|
|
|
it("removes raw SQL command surfaces", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
|
gameClientBridge: {
|
|
commands: Array<{ type: string; payloadSchemaRef: string }>;
|
|
queryTemplates?: Array<{ key: string; sqlRef: string }>;
|
|
pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>;
|
|
};
|
|
};
|
|
expect(manifest.gameClientBridge.commands.some((command) => command.type === "diagnostic.ping")).toBe(false);
|
|
expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
|
|
for (const template of manifest.gameClientBridge.queryTemplates ?? []) {
|
|
expect(template.sqlRef).toMatch(/^sql\/scum-db-v57\/[a-z-]+\.sql$/);
|
|
}
|
|
expect(manifest.gameClientBridge.pages.some((page) => (page.queryTemplateKeys ?? []).length > 0)).toBe(false);
|
|
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(false);
|
|
});
|
|
|
|
it("declares bounded SCUM database read templates for platform-dispatched projections", () => {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
|
gameClientBridge: {
|
|
lifecycleProjections?: unknown[];
|
|
queryTemplates?: Array<{
|
|
key: string;
|
|
engine: string;
|
|
transportKey: string;
|
|
targetKey: string;
|
|
pollIntervalSeconds: number;
|
|
maxRows: number;
|
|
timeoutSeconds: number;
|
|
projections?: Array<{ collection: string; rowPath: string; upsertKeys: string[] }>;
|
|
}>;
|
|
};
|
|
};
|
|
const templates = manifest.gameClientBridge.queryTemplates ?? [];
|
|
expect(templates.map((template) => template.key)).toEqual(["scum.database.players", "scum.database.vehicles"]);
|
|
for (const template of templates) {
|
|
expect(template.engine).toBe("sqlite");
|
|
expect(template.transportKey).toBe("scum-database");
|
|
expect(template.targetKey).toBe("scum-database");
|
|
expect(template.pollIntervalSeconds).toBeGreaterThan(0);
|
|
expect(template.pollIntervalSeconds).toBeLessThanOrEqual(300);
|
|
expect(template.maxRows).toBeGreaterThan(0);
|
|
expect(template.maxRows).toBeLessThanOrEqual(500);
|
|
expect(template.timeoutSeconds).toBeGreaterThan(0);
|
|
expect(template.timeoutSeconds).toBeLessThanOrEqual(60);
|
|
expect((template.projections ?? []).length).toBe(1);
|
|
for (const projection of template.projections ?? []) {
|
|
expect(projection.rowPath).toBe("rows");
|
|
expect(projection.upsertKeys.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
expect(templates.find((template) => template.key === "scum.database.players")?.projections?.[0].collection).toBe("scum.users");
|
|
expect(templates.find((template) => template.key === "scum.database.vehicles")?.projections?.[0].collection).toBe("scum.vehicles");
|
|
expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
|
|
});
|
|
|
|
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;
|
|
const assetPaths = manifest.assetFiles.map((file: { path: string }) => file.path);
|
|
const installAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.install), "utf8"));
|
|
const startAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.start), "utf8"));
|
|
const stopAction = JSON.parse(fs.readFileSync(path.join(pluginDir, manifest.actions.stop), "utf8"));
|
|
const installScript = fs.readFileSync(path.join(pluginDir, installAction.executableKey), "utf8");
|
|
const startScript = fs.readFileSync(path.join(pluginDir, startAction.executableKey), "utf8");
|
|
const stopScript = fs.readFileSync(path.join(pluginDir, stopAction.gracefulStop.executableKey), "utf8");
|
|
const rconScript = fs.readFileSync(path.join(pluginDir, "bin/scum-rcon.ps1"), "utf8");
|
|
expect(manifest.runtimeProfiles.serverDeployments).toBeUndefined();
|
|
expect(JSON.stringify(manifest.runtimeProfiles.installPlans ?? [])).not.toContain("steamcmd-app");
|
|
expect(assetPaths).toEqual(expect.arrayContaining(["actions/install.json", "actions/start.json", "actions/stop.json", "bin/scum-install-update.cmd", "bin/scum-start.cmd", "bin/scum-stop.cmd", "bin/scum-rcon.ps1", "assets/map/scum-map-overview.jpg"]));
|
|
expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 validate +quit" } });
|
|
expect(installAction.timeoutMs).toBe(7200000);
|
|
expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", targetExecutableKey: "SCUM/Binaries/Win64/SCUMServer.exe", outputMode: "pipes", environment: { SERVER_LOG_FLAG: "-log -stdout -FullStdOutLogOutput" } });
|
|
expect(installScript).not.toContain("taskkill");
|
|
expect(installScript).toContain("call :stop_running_scum");
|
|
expect(installScript).toContain("scum-stop.cmd");
|
|
expect(installScript).toContain("SCUM server did not shut down gracefully; refusing to update server files.");
|
|
expect(stopAction).toMatchObject({
|
|
mode: "control",
|
|
environment: { SERVER_STOP_SHUTDOWN_COMMAND: "Quit", SERVER_STOP_NOTICE_SECONDS: "15", SERVER_STOP_TIMEOUT_SECONDS: "120" },
|
|
gracefulStop: { executableKey: "bin/scum-stop.cmd", fallback: "terminate", timeoutMs: 300000 }
|
|
});
|
|
expect(stopScript).not.toContain("taskkill");
|
|
expect(stopScript).toContain("Get-CimInstance Win32_Process");
|
|
expect(stopScript).toContain("[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $full");
|
|
expect(stopScript).toContain("scum-rcon.ps1");
|
|
expect(stopScript).toContain("ue4ss\\Mods\\scum_simple_rcon\\config.ini");
|
|
expect(stopScript).toContain('call :send_rcon "Announce %SERVER_STOP_ANNOUNCE%"');
|
|
expect(stopScript).toContain('call :send_rcon "%SERVER_STOP_SHUTDOWN_COMMAND%"');
|
|
expect(stopScript).toContain("exit /b 2");
|
|
expect(rconScript).toContain("Read-RconConfig");
|
|
expect(rconScript).toContain('$config["rcon.password"]');
|
|
expect(rconScript).toContain('$config["rcon.port"]');
|
|
expect(rconScript).toContain("Send-RconPacket");
|
|
expect(rconScript).toContain("Receive-RconPacket");
|
|
expect(stopScript).toContain('set "SCUM_RCON_PORT_ARG=-Port %SERVER_STOP_RCON_PORT%"');
|
|
expect(installScript).toContain("SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\\%");
|
|
expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%");
|
|
expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd");
|
|
expect(installScript).toContain("STEAMCMD_DIR=%SERVER_STEAMCMD_DIR_WINDOWS%");
|
|
expect(installScript).toContain("SERVER_INSTALL_DIR_STEAM=%SERVER_INSTALL_DIR_WINDOWS:\\=/%");
|
|
expect(installScript).toContain("%SERVER_STEAMCMD_INSTALL_DIR_ARG% \"%SERVER_INSTALL_DIR_STEAM%\" %SERVER_STEAMCMD_UPDATE_ARGS%");
|
|
expect(installScript).toContain("SERVER_STEAMCMD_MAX_ATTEMPTS=3");
|
|
expect(installScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%");
|
|
expect(installScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
|
|
expect(installScript).toContain(".scum-exe-path");
|
|
expect(startScript).toContain(".scum-exe-path");
|
|
expect(startScript).toContain("call :install_or_update");
|
|
expect(startScript).toContain("scum-install-update.cmd");
|
|
expect(startScript).toContain("SCUM executable was not found. Running plugin install/update script.");
|
|
expect(startScript).not.toContain("SCUM.log");
|
|
expect(startScript).toContain("%SERVER_INSTALL_DIR_WINDOWS%\\%SERVER_EXECUTABLE_REF:/=\\%");
|
|
expect(startScript).toContain("steamcmd\\steamapps\\common\\SCUM Server");
|
|
expect(startScript).toContain("SCUM_WORKDIR");
|
|
expect(startScript).toContain('if "%SERVER_CREATE_QUERYPORT%"=="" set "SERVER_CREATE_QUERYPORT=27015"');
|
|
expect(startScript).toContain("-port=%SERVER_CREATE_GAMEPORT% -QueryPort=%SERVER_CREATE_QUERYPORT% -MaxPlayers=%SERVER_CREATE_MAXPLAYERS% %SERVER_LOG_FLAG%");
|
|
});
|
|
|
|
it("declares a plugin-owned SCUM build probe that the update button can check", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
|
runtimeProfiles: { dependencyProbes: Array<{ key: string; kind: string; targetKey: string; required?: boolean; steamAppId?: string; platforms?: string[] }> };
|
|
};
|
|
const probe = manifest.runtimeProfiles.dependencyProbes.find((candidate) => candidate.key === "scum-server-build");
|
|
expect(probe).toMatchObject({ kind: "steam.update", targetKey: "steamcmd", steamAppId: "3792580", required: false, platforms: ["windows"] });
|
|
expect(manifest.runtimeProfiles.dependencyProbes.filter((candidate) => candidate.kind === "steam.update")).toEqual([probe]);
|
|
});
|
|
|
|
it("rejects lifecycle executable assets missing from the manifest seed declaration", () => {
|
|
const errors = validateTemporaryScumManifest((manifest) => {
|
|
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd");
|
|
});
|
|
expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true);
|
|
});
|
|
|
|
it("rejects graceful stop scripts missing from the manifest seed declaration", () => {
|
|
const missingAsset = validateTemporaryScumManifest((manifest) => {
|
|
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-stop.cmd");
|
|
});
|
|
expect(missingAsset.some((error) => error.includes("lifecycleAction.stop.gracefulStop.executableKey") && error.includes("manifest.assetFiles"))).toBe(true);
|
|
|
|
const wrongAction = validateTemporaryScumManifest((manifest) => {
|
|
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "actions/install.json");
|
|
});
|
|
expect(wrongAction.some((error) => error.includes("lifecycleAction.install: action file must be declared"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsupported or unsafe inline create-field declarations", () => {
|
|
const malformed = validateTemporaryScumManifest((manifest) => {
|
|
manifest.server.createFields[0].type = "path";
|
|
});
|
|
expect(malformed.some((error) => error.includes("createFields") && error.includes("type"))).toBe(true);
|
|
const unsafe = validateTemporaryScumManifest((manifest) => {
|
|
manifest.server.createFields[0].defaultValue = "/srv/hidden-server";
|
|
});
|
|
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
|
|
});
|
|
|
|
it("declares a read-only SCUM database transport beside the management transports", () => {
|
|
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
remoteAccess?: { databaseEngines?: string[]; runCapabilities?: string[] };
|
|
runtimeProfiles?: {
|
|
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>;
|
|
transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>;
|
|
dataTargets?: Array<{ key?: string; kind?: string }>;
|
|
};
|
|
};
|
|
const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
|
|
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
|
expect(local?.transportKeys).toContain("scum-management");
|
|
expect(local?.transportKeys).not.toContain("scum-database");
|
|
expect(manifest.remoteAccess?.databaseEngines).toEqual(["sqlite"]);
|
|
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
|
expect(manifest.remoteAccess?.runCapabilities).not.toContain("remote.run.db.sqlite.execute");
|
|
expect(manifest.runtimeProfiles?.dataTargets).toEqual([
|
|
expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot" })
|
|
]);
|
|
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }),
|
|
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }),
|
|
expect.objectContaining({ key: "scum-database", kind: "sqlite", targetKey: "scum-database", capabilities: ["remote.run.db.sqlite.query"] })
|
|
]));
|
|
expect(manifest.runtimeProfiles?.transportProfiles?.flatMap((profile) => profile.capabilities ?? [])).not.toContain("remote.run.db.sqlite.execute");
|
|
});
|
|
|
|
it("covers the SCUM 4.1 bridge and lifecycle declarations", () => {
|
|
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
permissions: string[];
|
|
gameClientBridge: {
|
|
commands: Array<{
|
|
type: string;
|
|
permission: string;
|
|
payloadSchemaRef: string;
|
|
resultSchemaRef?: string;
|
|
timeoutSeconds: number;
|
|
maxPayloadBytes: number;
|
|
}>;
|
|
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
|
queryTemplates?: Array<{ key: string; projections?: unknown[] }>;
|
|
lifecycleProjections?: unknown[];
|
|
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
|
|
};
|
|
pages: Array<{ key: string; permissions?: string[] }>;
|
|
fileWorkspace?: {
|
|
defaultDirectoryKey: string;
|
|
directories: Array<{ key: string; label: string; scope: string }>;
|
|
files: Array<{ key: string; directoryKey: string; label: string; kind: string; streamKey?: string; editable?: boolean }>;
|
|
configFields: Array<{ key: string; fileKey: string; configKey: string; label: string }>;
|
|
};
|
|
runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>; logSources?: Array<{ key: string }>; clientManagers?: unknown[] };
|
|
version: string;
|
|
};
|
|
const installAction = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/actions/install.json"), "utf8")) as { environment?: Record<string, string> };
|
|
const serialized = JSON.stringify(manifest).toLowerCase();
|
|
|
|
expect(serialized).not.toContain("local-proof");
|
|
expect(manifest.version).toBe("0.1.26");
|
|
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
|
|
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
|
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
|
"player.lookup",
|
|
"reward.deliver",
|
|
"player.notify",
|
|
"vehicle.spawn",
|
|
"event.start",
|
|
"restart.prepare",
|
|
"maintenance.prepare"
|
|
]));
|
|
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
|
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
|
|
expect(manifest.gameClientBridge.lifecycleProjections).toBeUndefined();
|
|
expect((manifest.gameClientBridge.queryTemplates ?? []).map((template) => template.key)).toEqual(["scum.database.players", "scum.database.vehicles"]);
|
|
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
|
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
|
|
expect(manifest.gameClientBridge.pages.some((page) => page.queryTemplateKeys?.length)).toBe(false);
|
|
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
|
|
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
|
|
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
|
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config");
|
|
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
|
|
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
|
|
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "max-players", "welcome-message", "server-description", "server-playstyle"]));
|
|
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined();
|
|
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"]));
|
|
});
|
|
|
|
it("declares bounded and permissioned SCUM bridge commands", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
|
gameClientBridge: {
|
|
commands: Array<{
|
|
type: string;
|
|
permission: string;
|
|
payloadSchemaRef: string;
|
|
resultSchemaRef?: string;
|
|
timeoutSeconds: number;
|
|
maxPayloadBytes: number;
|
|
}>;
|
|
};
|
|
};
|
|
const expected = {
|
|
"player.lookup": { permission: "server.game-client.read" },
|
|
"reward.deliver": { permission: "server.game-client.command" },
|
|
"player.notify": { permission: "server.game-client.command" },
|
|
"vehicle.spawn": { permission: "server.game-client.command" },
|
|
"event.start": { permission: "server.game-client.command" },
|
|
"restart.prepare": { permission: "server.game-client.maintenance" },
|
|
"maintenance.prepare": { permission: "server.game-client.maintenance" }
|
|
} as const;
|
|
|
|
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected)));
|
|
for (const command of manifest.gameClientBridge.commands) {
|
|
const policy = expected[command.type as keyof typeof expected];
|
|
if (!policy) {
|
|
continue;
|
|
}
|
|
expect(command.permission).toBe(policy.permission);
|
|
expect(command.timeoutSeconds).toBeGreaterThan(0);
|
|
expect(command.timeoutSeconds).toBeLessThanOrEqual(3600);
|
|
expect(command.maxPayloadBytes).toBeGreaterThan(0);
|
|
expect(command.maxPayloadBytes).toBeLessThanOrEqual(65536);
|
|
expect(command.resultSchemaRef).toBeTruthy();
|
|
}
|
|
|
|
const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref)));
|
|
for (const schemaRef of schemaRefs) {
|
|
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
|
const hasPluginInventedCountLimitsRemoved = ["schemas/bridge/reward-deliver.payload.schema.json", "schemas/bridge/event-start.payload.schema.json"].includes(schemaRef);
|
|
const visit = (value: unknown): void => {
|
|
if (Array.isArray(value)) {
|
|
value.forEach(visit);
|
|
return;
|
|
}
|
|
if (typeof value !== "object" || value === null) {
|
|
return;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
|
expect(record.additionalProperties).toBe(false);
|
|
}
|
|
if (record.type === "array") {
|
|
expect(record.items).toBeDefined();
|
|
if (!hasPluginInventedCountLimitsRemoved) expect(record.maxItems).toBeGreaterThan(0);
|
|
}
|
|
if (record.type === "string") {
|
|
expect(record.maxLength).toBeGreaterThan(0);
|
|
}
|
|
if (record.type === "integer" || record.type === "number") {
|
|
if (!hasPluginInventedCountLimitsRemoved) expect(record.maximum).toBeDefined();
|
|
if (typeof record.minimum === "number" && typeof record.maximum === "number") expect(record.minimum).toBeLessThanOrEqual(record.maximum);
|
|
}
|
|
Object.values(record).forEach(visit);
|
|
};
|
|
expect(schema.type).toBe("object");
|
|
expect(schema.additionalProperties).toBe(false);
|
|
visit(schema.properties);
|
|
}
|
|
});
|
|
|
|
it("declares bounded SCUM snapshot schemas for operations projections", () => {
|
|
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
|
gameClientBridge: {
|
|
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
|
pages: Array<{ pageKey: string; snapshotTypes?: string[] }>;
|
|
};
|
|
};
|
|
const expectedTypes = ["online.sessions", "players", "squads", "vehicles", "flags"];
|
|
const snapshotsByType = new Map(manifest.gameClientBridge.snapshots.map((snapshot) => [snapshot.type, snapshot]));
|
|
|
|
expect([...snapshotsByType.keys()]).toEqual(expect.arrayContaining(expectedTypes));
|
|
for (const type of expectedTypes) {
|
|
const snapshot = snapshotsByType.get(type);
|
|
expect(snapshot?.schemaVersion).toBe("1");
|
|
expect(snapshot?.schemaRef).toMatch(/^schemas\/bridge\/[a-z-]+\.snapshot\.schema\.json$/);
|
|
const schema = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin", snapshot!.schemaRef), "utf8")) as {
|
|
type?: string;
|
|
additionalProperties?: boolean;
|
|
properties?: Record<string, { type?: string; maxItems?: number }>;
|
|
};
|
|
expect(schema.type).toBe("object");
|
|
expect(schema.additionalProperties).toBe(false);
|
|
for (const property of Object.values(schema.properties ?? {})) {
|
|
if (property.type === "array") {
|
|
expect(property.maxItems).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
const pageSnapshotTypes = manifest.gameClientBridge.pages.flatMap((page) => page.snapshotTypes ?? []);
|
|
expect(pageSnapshotTypes).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
|
|
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "online.sessions"]));
|
|
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
|
|
});
|
|
|
|
it("keeps SCUM database reads platform-dispatched, read-only, and package-scoped", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
|
capabilities: string[];
|
|
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean };
|
|
gameClientBridge: {
|
|
queryTemplates?: Array<Record<string, unknown>>;
|
|
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
|
|
};
|
|
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
|
runtimeProfiles?: {
|
|
transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }>;
|
|
dataTargets?: Array<{ key: string; kind: string }>;
|
|
};
|
|
assetFiles?: Array<{ path: string }>;
|
|
};
|
|
const templates = manifest.gameClientBridge.queryTemplates ?? [];
|
|
expect(templates.length).toBe(2);
|
|
const assetPaths = (manifest.assetFiles ?? []).map((file) => file.path);
|
|
for (const template of templates) {
|
|
const sqlRef = String(template.sqlRef);
|
|
expect(sqlRef.startsWith("sql/scum-db-v57/")).toBe(true);
|
|
expect(assetPaths).toContain(sqlRef);
|
|
expect(fs.existsSync(path.join(pluginDir, sqlRef))).toBe(true);
|
|
}
|
|
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
|
expect(manifest.capabilities).not.toContain("remote.run.db.sqlite.execute");
|
|
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
|
expect(manifest.remoteAccess?.runCapabilities).not.toContain("remote.run.db.sqlite.execute");
|
|
expect(manifest.remoteAccess?.databaseEngines).toEqual(["sqlite"]);
|
|
expect(manifest.remoteAccess?.rcon).toBe(true);
|
|
expect(manifest.runtimeProfiles?.dataTargets).toEqual([
|
|
expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot" })
|
|
]);
|
|
expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" && profile.capabilities.includes("remote.run.db.sqlite.query"))).toBe(true);
|
|
expect(manifest.gameClientBridge.pages.some((page) => (page.queryTemplateKeys ?? []).length > 0)).toBe(false);
|
|
for (const template of templates) {
|
|
const schemaRefs = [String(template.parameterSchemaRef), String(template.resultSchemaRef)];
|
|
for (const schemaRef of schemaRefs) {
|
|
expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true);
|
|
}
|
|
}
|
|
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
|
|
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
|
|
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
|
|
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
|
|
const workflowsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows");
|
|
expect(playersPage?.queryTemplateKeys).toBeUndefined();
|
|
expect(squadsPage?.queryTemplateKeys).toBeUndefined();
|
|
expect(mapPage?.queryTemplateKeys).toBeUndefined();
|
|
expect(giftsPage?.commandTypes).toEqual(["reward.deliver"]);
|
|
expect(workflowsPage?.commandTypes).toEqual(["event.start"]);
|
|
for (const pageKey of ["players", "squads", "live-map", "gifts", "workflows"]) {
|
|
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
|
|
expect(pluginPage?.permissions).toContain("server.game-client.read");
|
|
expect(pluginPage?.permissions).toContain("server.remote.access");
|
|
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
|
|
}
|
|
for (const pageKey of ["gifts", "workflows"]) {
|
|
expect(manifest.pages.find((page) => page.key === pageKey)?.permissions).toContain("server.game-client.command");
|
|
}
|
|
});
|
|
|
|
it("packages SCUM v57 config and gift metadata without database query assets", () => {
|
|
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
|
gameClientBridge: { dataPacks: Array<{ key: string; databaseUserVersion: number; configMapRefs: string[]; dataRefs?: string[] }> };
|
|
};
|
|
const pack = manifest.gameClientBridge.dataPacks.find((candidate) => candidate.key === "scum-config-v57");
|
|
expect(pack).toMatchObject({ databaseUserVersion: 57 });
|
|
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
|
const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8"));
|
|
const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8"));
|
|
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
|
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
|
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-terrain-4096.webp", width: 4096, height: 4096 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
|
});
|
|
|
|
|
|
it("does not declare a SCUM client-manager profile", () => {
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
|
runtimeProfiles?: { clientManagers?: unknown[] };
|
|
};
|
|
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
|
|
});
|
|
|
|
it("accepts the Minecraft server plugin manifest", () => {
|
|
expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]);
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/minecraft-server-plugin/manifest.json"), "utf8")) as { pages: Array<{ key: string; title: string }>; fileWorkspace?: { files?: Array<{ key: string }> } };
|
|
expect(manifest.pages.map((page) => `${page.key}:${page.title}`)).toEqual(["files:文件管理", "mods-market:mod/插件市场"]);
|
|
expect(manifest.fileWorkspace?.files?.map((file) => file.key)).toEqual(expect.arrayContaining(["server-properties", "ops-json", "whitelist-json", "minecraft-latest-log"]));
|
|
});
|
|
|
|
it("validates typed game-client bridge catalogs", () => {
|
|
const schema = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "manifests/game-plugin.manifest.schema.json"), "utf8"));
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/dev-game-plugin/manifest.json"), "utf8"));
|
|
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
|
manifest.gameClientBridge = {
|
|
commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
|
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
|
commandRetentionSeconds: 86400,
|
|
maxCommands: 1000,
|
|
pages: []
|
|
};
|
|
const validate = new Ajv2020({ allErrors: true }).compile(schema);
|
|
expect(validate(manifest), JSON.stringify(validate.errors)).toBe(true);
|
|
manifest.gameClientBridge.commands[0].unexpectedControlGate = "automatic";
|
|
expect(validate(manifest)).toBe(false);
|
|
});
|
|
|
|
|
|
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
|
|
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
|
});
|
|
|
|
it.each(["sql.execute", "sqlExecute", "database.execute", "database.query"])("rejects arbitrary SQL command type %s independently", (commandType) => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.commands[0].type = commandType;
|
|
});
|
|
expect(errors.some((error) => error.includes("commands[0].type") && error.includes("arbitrary SQL"))).toBe(true);
|
|
});
|
|
|
|
it.each(["shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"])("rejects arbitrary shell command type %s independently", (commandType) => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.commands[0].type = commandType;
|
|
});
|
|
expect(errors.some((error) => error.includes("commands[0].type") && error.includes("arbitrary shell"))).toBe(true);
|
|
});
|
|
|
|
it("rejects raw bridge schema paths end to end", () => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.commands[0].payloadSchemaRef = "/etc/scum-query.json";
|
|
});
|
|
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("raw host paths"))).toBe(true);
|
|
});
|
|
|
|
it("rejects undeclared page command references end to end", () => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.pages[0].commandTypes = ["undeclared.command"];
|
|
});
|
|
expect(errors.some((error) => error.includes("undeclared command undeclared.command"))).toBe(true);
|
|
});
|
|
|
|
it("rejects duplicate and undeclared page query template keys", () => {
|
|
const duplicateErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.queryTemplates?.push({ ...manifest.gameClientBridge.queryTemplates[0] });
|
|
});
|
|
expect(duplicateErrors.some((error) => error.includes("duplicate query template player.by-id"))).toBe(true);
|
|
|
|
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.pages[0].queryTemplateKeys = ["undeclared.query"];
|
|
});
|
|
expect(pageErrors.some((error) => error.includes("undeclared query template undeclared.query"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe or unbounded query template declarations", () => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
Object.assign(manifest.gameClientBridge.queryTemplates?.[0] ?? {}, {
|
|
key: "../raw-query",
|
|
engine: "mysql",
|
|
permission: "server.not-declared",
|
|
parameterSchemaRef: "/etc/query.json",
|
|
maxRows: 501,
|
|
timeoutSeconds: 61
|
|
});
|
|
});
|
|
expect(errors.some((error) => error.includes("queryTemplates/0/key") || error.includes("queryTemplates[0].key"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("engine") && error.includes("sqlite"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("parameterSchemaRef") && error.includes("raw host paths"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("maxRows"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("timeoutSeconds"))).toBe(true);
|
|
});
|
|
|
|
it("requires query templates to match a declared sqlite transport target and capability", () => {
|
|
const targetErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.queryTemplates![0].targetKey = "db/other";
|
|
});
|
|
expect(targetErrors.some((error) => error.includes("targetKey") && error.includes("sqlite transport target"))).toBe(true);
|
|
|
|
const capabilityErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.runtimeProfiles!.transportProfiles![0].capabilities = ["remote.run.files.read"];
|
|
});
|
|
expect(capabilityErrors.some((error) => error.includes("transportKey") && error.includes("remote.run.db.sqlite.query"))).toBe(true);
|
|
});
|
|
|
|
it("requires query template pages to declare template permission and remote access", () => {
|
|
const permissionErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
|
overviewPage!.permissions = overviewPage!.permissions?.filter((permission) => permission !== "server.game-client.read");
|
|
});
|
|
expect(permissionErrors.some((error) => error.includes("page must declare query template permission"))).toBe(true);
|
|
|
|
const actionErrors = validateTemporaryBridgeManifest((manifest) => {
|
|
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
|
overviewPage!.bridgeActions = overviewPage!.bridgeActions?.filter((action) => action !== "remote.access.request");
|
|
});
|
|
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
|
|
});
|
|
|
|
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("keeps opaque query parameter schema field %s", (fieldName) => {
|
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
|
});
|
|
expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef"))).toBe(false);
|
|
});
|
|
|
|
it("keeps opaque SQL-looking text embedded in a query result schema", () => {
|
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ summary: { type: "string", minLength: 1, maxLength: 200, const: "SELECT id FROM players" } }, ["summary"]));
|
|
});
|
|
expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef"))).toBe(false);
|
|
});
|
|
|
|
it("rejects missing bridge schema files end to end", () => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.gameClientBridge.commands[0].resultSchemaRef = "schemas/bridge/missing.schema.json";
|
|
});
|
|
expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("missing bridge schema file"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe executor capabilities end to end", () => {
|
|
const errors = validateTemporaryBridgeManifest((manifest) => {
|
|
manifest.capabilities = [...manifest.capabilities, "shell.exec"];
|
|
});
|
|
expect(errors.some((error) => error.includes("capabilities") && error.includes("allowed values"))).toBe(true);
|
|
});
|
|
|
|
it("rejects invalid bridge schema JSON without throwing", () => {
|
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
|
fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json"), "{ invalid", "utf8");
|
|
});
|
|
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true);
|
|
});
|
|
|
|
it("keeps opaque fields and values in payload, result, and snapshot schemas", () => {
|
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"]));
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"]));
|
|
});
|
|
expect(errors.some((error) => error.includes("payloadSchemaRef") || error.includes("resultSchemaRef") || error.includes("snapshots[0].schemaRef"))).toBe(false);
|
|
});
|
|
|
|
it("requires bounded object schemas for every bridge reference", () => {
|
|
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
|
writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", { type: "object", properties: { message: { type: "string" } } });
|
|
});
|
|
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("additionalProperties to false"))).toBe(true);
|
|
});
|
|
|
|
it("rejects a manifest with an invalid create form schema", () => {
|
|
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
|
|
|
|
expect(errors.some((error) => error.includes("createForm") && error.includes("label"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe direct run and raw AI key requests", () => {
|
|
const errors = validateManifestFile("tests/fixtures/unsafe-manifest.json");
|
|
|
|
expect(errors.some((error) => error.includes("direct run access"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
|
});
|
|
|
|
it("rejects unsafe runtime profile values", () => {
|
|
const errors = validateManifestFile("tests/fixtures/unsafe-runtime-profile-manifest.json");
|
|
|
|
expect(errors.some((error) => error.includes("raw credential or AI/provider key"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("raw host path"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("arbitrary shell"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("not approved for dependency download"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("runtimeProfiles.serverDeployments") && error.includes("no longer supported"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("runtimeProfiles.clientManagers") && error.includes("no longer supported"))).toBe(true);
|
|
});
|
|
|
|
it("validates typed lifecycle declarations and rejects shell/path escapes", () => {
|
|
const declaration: PluginLifecycleActionDeclaration = {
|
|
version: 1,
|
|
action: "start",
|
|
mode: "supervised",
|
|
executableKey: "bin/game-server",
|
|
arguments: ["--foreground"]
|
|
};
|
|
expect(declaration.action).toBe("start");
|
|
const errors = validateLifecycleActionFile("tests/fixtures/unsafe-lifecycle-action.json", "start");
|
|
expect(errors.some((error) => error.includes("pattern") || error.includes("arbitrary shell"))).toBe(true);
|
|
expect(errors.some((error) => error.includes("raw credential"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("plugin SDK", () => {
|
|
|
|
|
|
it("types read-only SQLite query template declarations", () => {
|
|
const declaration: GameClientBridgeQueryTemplateDeclaration = {
|
|
key: "scum.player.by-id",
|
|
title: "Find SCUM player by ID",
|
|
permission: "server.game-client.read",
|
|
engine: "sqlite",
|
|
transportKey: "sqlite-db",
|
|
targetKey: "db/sqlite",
|
|
parameterSchemaRef: "schemas/bridge/queries/player-by-id.parameters.schema.json",
|
|
resultSchemaRef: "schemas/bridge/queries/player-by-id.result.schema.json",
|
|
maxRows: 1,
|
|
timeoutSeconds: 10,
|
|
pollIntervalSeconds: 0
|
|
};
|
|
expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 });
|
|
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
|
});
|
|
|
|
|
|
it("builds safe game-client bridge requests without component transport material", () => {
|
|
const request = createGameClientBridgeQueueRequest({
|
|
profileKey: "example-client",
|
|
commandType: "diagnostic.ping",
|
|
payload: { message: "hello" },
|
|
idempotencyKey: "diagnostic-1",
|
|
expiresAt: "2026-07-20T12:00:00Z"
|
|
});
|
|
expect(request.commandType).toBe("diagnostic.ping");
|
|
expect(request).not.toHaveProperty("sessionToken");
|
|
expect(request).not.toHaveProperty("componentKey");
|
|
expect(request).not.toHaveProperty("runEndpoint");
|
|
expect(request).not.toHaveProperty("hostPath");
|
|
expect(request).not.toHaveProperty("dsn");
|
|
});
|
|
|
|
it("checks declared bridge permissions", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
permissions: ["server.read", "server.logs.read", "server.artifacts.read"]
|
|
};
|
|
|
|
expect(hasPluginPermission(context, "server.logs.read")).toBe(true);
|
|
expect(hasPluginPermission(context, "server.artifacts.read")).toBe(true);
|
|
expect(hasPluginPermission(context, "ai.invoke")).toBe(false);
|
|
});
|
|
|
|
it("builds typed bridge request envelopes without owning transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.read", "server.logs.read"]
|
|
};
|
|
|
|
const request = createBridgeRequest({
|
|
id: "request-1",
|
|
context,
|
|
action: "logs.query",
|
|
payload: { streamKey: "stdout", limit: 100 }
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
id: "request-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
payload: { streamKey: "stdout", limit: 100 }
|
|
});
|
|
});
|
|
|
|
it("checks bridge action permissions and AI purposes locally", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
permissions: ["server.read", "server.logs.read", "ai.invoke"],
|
|
aiPurposes: ["logs.diagnose"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "logs.query")).toBe(true);
|
|
expect(canRequestBridgeAction(context, "files.request")).toBe(false);
|
|
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "logs.diagnose" })).toBe(true);
|
|
expect(canRequestBridgeAction(context, "ai.invoke", { aiPurpose: "config.suggest" })).toBe(false);
|
|
});
|
|
|
|
it("returns safe bridge errors without credential or transport fields", () => {
|
|
const error = bridgeError("missing_permission", "Permission is required", ["server.files.read"]);
|
|
|
|
expect(error).toEqual({
|
|
code: "missing_permission",
|
|
message: "Permission is required",
|
|
details: ["server.files.read"]
|
|
});
|
|
expect(error).not.toHaveProperty("apiKey");
|
|
expect(error).not.toHaveProperty("runSocket");
|
|
expect(error).not.toHaveProperty("hostPath");
|
|
});
|
|
|
|
it("builds and parses execution envelopes without owning transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.read", "server.logs.read"]
|
|
};
|
|
|
|
const request = createBridgeExecutionRequest({
|
|
requestId: "exec-1",
|
|
context,
|
|
action: "logs.query",
|
|
payload: { logStreamId: "log-1", limit: "100" }
|
|
});
|
|
expect(request).toEqual({
|
|
requestId: "exec-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
aiPurpose: undefined,
|
|
payload: { logStreamId: "log-1", limit: "100" }
|
|
});
|
|
expect(request).not.toHaveProperty("fetch");
|
|
expect(request).not.toHaveProperty("authorization");
|
|
|
|
const parsed = parseBridgeExecutionResponse({
|
|
requestId: "exec-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "logs.query",
|
|
status: "ok",
|
|
result: { entryCount: "0" }
|
|
});
|
|
expect(parsed).toMatchObject({ status: "ok", result: { entryCount: "0" } });
|
|
expect(parsed).not.toHaveProperty("apiKey");
|
|
expect(parsed).not.toHaveProperty("runSocket");
|
|
});
|
|
|
|
it("builds mediated AI requests and parses redacted responses", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["ai.invoke"],
|
|
aiPurposes: ["logs.diagnose"]
|
|
};
|
|
|
|
const request = createAIInvocationRequest({
|
|
requestId: "ai-1",
|
|
context,
|
|
purpose: "logs.diagnose",
|
|
prompt: "Summarize warnings",
|
|
contextRefs: { server: "server://server-1" }
|
|
});
|
|
expect(request).toMatchObject({ pluginId: "game.example", purpose: "logs.diagnose", prompt: "Summarize warnings" });
|
|
expect(request).not.toHaveProperty("apiKeyRef");
|
|
expect(request).not.toHaveProperty("providerBaseUrl");
|
|
|
|
const parsed = parseAIInvocationResponse({
|
|
requestId: "ai-1",
|
|
purpose: "logs.diagnose",
|
|
status: "ok",
|
|
recommendation: "Review the warning trend.",
|
|
usage: { model: "mock", mocked: true, inputTokens: 5, outputTokens: 6 }
|
|
});
|
|
expect(parsed).toMatchObject({ status: "ok", usage: { mocked: true } });
|
|
expect(JSON.stringify(parsed)).not.toContain("sk-");
|
|
expect(JSON.stringify(parsed)).not.toContain("apiKeyRef");
|
|
});
|
|
|
|
it("builds artifact open envelopes and parses safe platform references", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.artifacts.read"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "artifacts.open")).toBe(true);
|
|
expect(
|
|
createArtifactOpenRequest({ requestId: "artifact-open-1", context, artifactId: "artifact-1" })
|
|
).toEqual({
|
|
requestId: "artifact-open-1",
|
|
pluginId: "game.example",
|
|
routeKey: "logs",
|
|
serverInstanceId: "server-1",
|
|
action: "artifacts.open",
|
|
aiPurpose: undefined,
|
|
payload: { artifactId: "artifact-1" }
|
|
});
|
|
|
|
const reference = parseArtifactReference({
|
|
artifactId: "artifact-1",
|
|
filename: "artifact-1.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: "64",
|
|
checksum: "sha256:abc",
|
|
downloadUrl: "/api/v1/artifacts/artifact-1/content",
|
|
expiresAt: "2026-07-03T00:15:00Z",
|
|
rangeSupported: "true",
|
|
chunkSizeBytes: "1048576",
|
|
storageBehavior: "platform-memory-transfer-session"
|
|
});
|
|
expect(reference).toMatchObject({ artifactId: "artifact-1", rangeSupported: true });
|
|
|
|
expect(
|
|
parseArtifactReference({
|
|
artifactId: "artifact-1",
|
|
filename: "/Users/operator/password=opaque.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: "64",
|
|
checksum: "sha256:abc",
|
|
downloadUrl: "/api/v1/artifacts/artifact-1/content",
|
|
expiresAt: "2026-07-03T00:15:00Z",
|
|
rangeSupported: "true",
|
|
chunkSizeBytes: "1048576",
|
|
storageBehavior: "plugin-owned opaque storage:// label"
|
|
})
|
|
).toMatchObject({ filename: "/Users/operator/password=opaque.bin", storageBehavior: "plugin-owned opaque storage:// label" });
|
|
|
|
expect(
|
|
parseArtifactReference({
|
|
artifactId: "artifact-1",
|
|
filename: "artifact.bin",
|
|
contentType: "application/octet-stream",
|
|
sizeBytes: "64",
|
|
checksum: "sha256:abc",
|
|
downloadUrl: "storage://bucket/artifact-1",
|
|
expiresAt: "2026-07-03T00:15:00Z",
|
|
rangeSupported: "true",
|
|
chunkSizeBytes: "1048576"
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("builds lifecycle dispatch envelopes without direct run transport", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.example",
|
|
routeKey: "overview",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.lifecycle"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "jobs.dispatch")).toBe(true);
|
|
const request = createLifecycleDispatchRequest({
|
|
requestId: "lifecycle-start-1",
|
|
context,
|
|
action: "start",
|
|
expectedConfigVersion: 2,
|
|
idempotencyKey: "idem-lifecycle-start"
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
requestId: "lifecycle-start-1",
|
|
pluginId: "game.example",
|
|
routeKey: "overview",
|
|
serverInstanceId: "server-1",
|
|
action: "jobs.dispatch",
|
|
aiPurpose: undefined,
|
|
payload: {
|
|
lifecycleAction: "start",
|
|
capability: "process.start",
|
|
expectedConfigVersion: "2",
|
|
idempotencyKey: "idem-lifecycle-start"
|
|
}
|
|
});
|
|
expect(JSON.stringify(request)).not.toContain("http://");
|
|
expect(JSON.stringify(request)).not.toContain("unix://");
|
|
expect(JSON.stringify(request)).not.toContain("/Users/");
|
|
expect(JSON.stringify(request)).not.toContain("Bearer ");
|
|
expect(JSON.stringify(request)).not.toContain("sk-");
|
|
});
|
|
|
|
it("builds remote access request envelopes without direct transport secrets", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.minecraft",
|
|
routeKey: "rcon",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.remote.access"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "remote.access.request")).toBe(true);
|
|
const request = createRemoteAccessRequest({
|
|
requestId: "remote-rcon-1",
|
|
context,
|
|
capability: "remote.run.rcon.command",
|
|
targetKey: "rcon/command",
|
|
inputRef: "input://server-1/rcon/command/1",
|
|
inputs: { playerId: "steam-123", limit: "25" },
|
|
idempotencyKey: "idem-remote-rcon"
|
|
});
|
|
|
|
expect(request).toEqual({
|
|
requestId: "remote-rcon-1",
|
|
pluginId: "game.minecraft",
|
|
routeKey: "rcon",
|
|
serverInstanceId: "server-1",
|
|
action: "remote.access.request",
|
|
aiPurpose: undefined,
|
|
payload: {
|
|
capability: "remote.run.rcon.command",
|
|
targetKey: "rcon/command",
|
|
inputRef: "input://server-1/rcon/command/1",
|
|
"input.playerId": "steam-123",
|
|
"input.limit": "25",
|
|
idempotencyKey: "idem-remote-rcon"
|
|
}
|
|
});
|
|
expect(JSON.stringify(request)).not.toContain("tcp://");
|
|
expect(JSON.stringify(request)).not.toContain("password=");
|
|
expect(JSON.stringify(request)).not.toContain("/Users/");
|
|
});
|
|
|
|
it("types runtime profile declarations without raw credentials", () => {
|
|
const manifest: GamePluginManifest = {
|
|
id: "game.runtime",
|
|
name: "Runtime Fixture",
|
|
version: "0.1.0",
|
|
kind: "game-plugin",
|
|
server: { type: "runtime", displayName: "Runtime Fixture", createFormSchema: "schemas/create-form.schema.json" },
|
|
capabilities: ["process.start", "process.stop", "logs.read"],
|
|
permissions: ["server.read", "server.lifecycle", "server.logs.read"],
|
|
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required" },
|
|
runtimeProfiles: {
|
|
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
|
|
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
|
|
logSources: [{ key: "console", kind: "process.stdout", streamKey: "console", cursorKind: "sequence" }],
|
|
transportProfiles: [{ key: "files", kind: "file", capabilities: ["files.read"] }]
|
|
}
|
|
};
|
|
|
|
expect(manifest.runtimeProfiles?.discovery?.[0].targetKey).toBe("java");
|
|
expect(JSON.stringify(manifest)).not.toContain("password=");
|
|
});
|
|
|
|
it("builds run distribution, dependency, and log backfill envelopes", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.scum",
|
|
routeKey: "remote",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.run.distribution", "server.dependencies.manage", "server.logs.read"]
|
|
};
|
|
|
|
expect(canRequestBridgeAction(context, "run.distribution.request")).toBe(true);
|
|
expect(createRunDistributionRequest({ requestId: "run-gen-1", context, operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" })).toMatchObject({
|
|
action: "run.distribution.request",
|
|
payload: { operation: "generate", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-run" }
|
|
});
|
|
expect(createDependencyActionRequest({ requestId: "dep-1", context, operation: "check", probeKey: "steamcmd", idempotencyKey: "idem-dep" })).toMatchObject({
|
|
action: "dependencies.request",
|
|
payload: { operation: "check", probeKey: "steamcmd" }
|
|
});
|
|
expect(createDependencyActionRequest({ requestId: "dep-2", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}`, idempotencyKey: "idem-dep-install" })).toMatchObject({
|
|
action: "dependencies.request",
|
|
payload: { operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", planDigest: `sha256:${"a".repeat(64)}` }
|
|
});
|
|
expect(() => createDependencyActionRequest({ requestId: "dep-unsafe", context, operation: "install", probeKey: "steamcmd", planKey: "install-steamcmd-linux", idempotencyKey: "idem-dep-unsafe" })).toThrow(/reviewed plan SHA-256 digest/);
|
|
expect(createLogBackfillRequest({ requestId: "logs-1", context, sourceKey: "chat-log", limit: 500, idempotencyKey: "idem-logs" })).toMatchObject({
|
|
action: "logs.backfill.request",
|
|
payload: { sourceKey: "chat-log", limit: "500" }
|
|
});
|
|
});
|
|
|
|
it("builds full plugin lifecycle envelopes through Platform only", () => {
|
|
const context: PluginBridgeContext = {
|
|
pluginId: "game.scum",
|
|
routeKey: "overview",
|
|
serverInstanceId: "server-1",
|
|
permissions: ["server.lifecycle"]
|
|
};
|
|
const request = createProductionPluginLifecycleRequest({ requestId: "plugin-upgrade-1", context, operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" });
|
|
expect(request).toMatchObject({ action: "plugin-lifecycle.request", payload: { operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" } });
|
|
expect(JSON.stringify(request)).not.toMatch(/apiKey|providerBaseUrl|runSocket|runEndpoint|hostPath|credential/i);
|
|
});
|
|
|
|
});
|