功能修改
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
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,
|
||||
@@ -8,9 +13,11 @@ import {
|
||||
createClientManagerRequest,
|
||||
createBridgeExecutionRequest,
|
||||
createLifecycleDispatchRequest,
|
||||
createProductionPluginLifecycleRequest,
|
||||
createBridgeRequest,
|
||||
createDependencyActionRequest,
|
||||
createLogBackfillRequest,
|
||||
createGameClientBridgeQueueRequest,
|
||||
createRemoteAccessRequest,
|
||||
createRunDistributionRequest,
|
||||
hasPluginPermission,
|
||||
@@ -18,13 +25,145 @@ import {
|
||||
parseClientManagerLifecycleStatus,
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
type RuntimeLogEventDeclaration,
|
||||
type RuntimeClientManagerProfile,
|
||||
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 listProductionGoFiles(directory: string): string[] {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listProductionGoFiles(target);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go") ? [target] : [];
|
||||
});
|
||||
}
|
||||
|
||||
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"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] };
|
||||
manifest.runtimeProfiles = {
|
||||
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }]
|
||||
};
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
if (overviewPage) {
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"];
|
||||
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
|
||||
}
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-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: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-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"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
type MutableLogEventManifest = {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<Record<string, unknown>>;
|
||||
logEvents?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
function validateTemporaryLogEventManifest(mutate?: (manifest: MutableLogEventManifest, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-log-event-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 MutableLogEventManifest;
|
||||
manifest.runtimeProfiles = {
|
||||
logSources: [{ key: "server-events", kind: "file.tail", targetKey: "logs/server", streamKey: "game.server", cursorKind: "fingerprint", retentionDays: 30 }],
|
||||
logEvents: [{ key: "player-login", title: "Player login", sourceKey: "server-events", eventType: "game.login", permission: "server.logs.read", schemaRef: "schemas/log-events/login.event.schema.json", retentionDays: 30, severity: "info" }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ occurredAt: { type: "string", minLength: 1, maxLength: 40 }, playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["occurredAt", "playerId"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-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([]);
|
||||
@@ -34,10 +173,688 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("defines a generated SCUM companion config without inline proof or session material", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { companion?: GameClientBridgeCompanionDeclaration };
|
||||
};
|
||||
const companion = manifest.gameClientBridge.companion;
|
||||
expect(companion).toMatchObject({
|
||||
profileKey: "scum-client-manager",
|
||||
configTemplateKey: "client-config",
|
||||
configFormat: "yaml",
|
||||
platformBaseUrlSource: "run-control",
|
||||
registrationProof: "hmac-sha256",
|
||||
proofMaterialSource: "component-package",
|
||||
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
sessionMode: "component-session",
|
||||
tlsPolicy: "verify-system-roots",
|
||||
heartbeatIntervalSeconds: 30,
|
||||
requestTimeoutSeconds: 15
|
||||
});
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, companion!.configSchemaRef), "utf8"));
|
||||
const example = JSON.parse(fs.readFileSync(path.join(pluginDir, "schemas/companion/config.generated.example.json"), "utf8"));
|
||||
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
|
||||
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
||||
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
||||
});
|
||||
|
||||
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
||||
const policyErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.tlsPolicy = "skip-verification";
|
||||
});
|
||||
expect(policyErrors.some((error) => error.includes("tlsPolicy") || error.includes("secure component registration/session/TLS policy"))).toBe(true);
|
||||
|
||||
const materialErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.proof.sessionToken = "inline-session-material";
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(materialErrors.some((error) => error.includes("inline proof/session material") || error.includes("additional properties"))).toBe(true);
|
||||
|
||||
const environmentErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.proofMaterialEnv = "LD_PRELOAD";
|
||||
});
|
||||
expect(environmentErrors.some((error) => error.includes("proofMaterialEnv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects generated SCUM companion configs with incomplete capabilities or unsafe Platform URLs", () => {
|
||||
const capabilityErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.capabilities = ["component.register", "component.heartbeat", "component.health", "game-client.bridge"];
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(capabilityErrors.some((error) => error.includes("capabilities"))).toBe(true);
|
||||
|
||||
for (const unsafeURL of ["https://user:raw-token@example.test?session=raw-token#fragment", "https://?missing-host"]) {
|
||||
const urlErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.platform.baseUrl = unsafeURL;
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(urlErrors.some((error) => error.includes("platform.baseUrl"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("cross-validates generated SCUM companion config against its declaration and runtime profile", () => {
|
||||
const proofErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.proofMaterialEnv = "OTHER_COMPONENT_PROOF";
|
||||
});
|
||||
expect(proofErrors.some((error) => error.includes("proof.materialEnv") && error.includes("proofMaterialEnv"))).toBe(true);
|
||||
|
||||
const profileErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
const manager = manifest.runtimeProfiles.clientManagers.find((candidate: Record<string, unknown>) => candidate.key === "scum-client-manager");
|
||||
manager.health.requiredCapabilities = manager.health.requiredCapabilities.filter((capability: string) => capability !== "logs.stream");
|
||||
});
|
||||
expect(profileErrors.some((error) => error.includes("capabilities") && error.includes("requiredCapabilities"))).toBe(true);
|
||||
|
||||
const sessionErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.session.mode = "legacy-shared-token";
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(sessionErrors.some((error) => error.includes("session"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe optional fields declared only by the SCUM companion config schema", () => {
|
||||
const schemaErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
schema.properties.hostPath = { type: "string", minLength: 1, maxLength: 200 };
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
|
||||
});
|
||||
expect(schemaErrors.some((error) => error.includes("hostPath") && error.includes("raw host path"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects legacy companion endpoints, insecure TLS flags, and credential keys", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "legacy shared-token endpoint",
|
||||
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
|
||||
schema.description = "legacy /api/v1/scum-clients/commands endpoint";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "insecure TLS flag",
|
||||
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
|
||||
schema.description = "InsecureSkipVerify";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "inline credential key",
|
||||
mutate(schema: Record<string, any>, example: Record<string, any>): void {
|
||||
schema.properties.credential = { type: "string", minLength: 1, maxLength: 200 };
|
||||
example.credential = "legacy-shared-value";
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const errors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as Record<string, any>;
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8")) as Record<string, any>;
|
||||
testCase.mutate(schema, example);
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
|
||||
expect(errors, testCase.name).toContain(
|
||||
"manifest.gameClientBridge.companion.configSchemaRef: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the production SCUM companion free of unsafe legacy transport and updater patterns", () => {
|
||||
const companionDir = path.join(pluginsRoot, "examples/scum-server-plugin/companion");
|
||||
const forbiddenPatterns = [
|
||||
{ name: "legacy shared-token endpoint", pattern: /\/api\/v1\/scum-clients\//i },
|
||||
{ name: "legacy shared credential", pattern: /\b(?:SCUMClientCredential|scum_client_credential)\b/i },
|
||||
{ name: "disabled TLS verification", pattern: /\bInsecureSkipVerify\s*:\s*true\b/ },
|
||||
{ name: "arbitrary process or shell execution", pattern: /(?:\b(?:os\/exec|exec\.Command(?:Context)?|os\.StartProcess|syscall\.Exec)\b|\b(?:bash|zsh|powershell|pwsh|cmd(?:\.exe)?)\s+-[a-z/])/i },
|
||||
{ name: "direct socket transport", pattern: /(?:\bnet\.(?:Dial|DialTimeout)\s*\(|\b(?:tcp|unix|ws):\/\/)/i },
|
||||
{ name: "arbitrary URL self-update or download", pattern: /(?:\b(?:self_?update|update_?url|download_?url|updater)\b|\bhttp\.(?:Get|DefaultClient\.Get)\s*\(|\b(?:curl|wget)\b)/i }
|
||||
];
|
||||
|
||||
const productionFiles = listProductionGoFiles(companionDir);
|
||||
expect(productionFiles.length).toBeGreaterThan(0);
|
||||
for (const file of productionFiles) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const relativeFile = path.relative(companionDir, file);
|
||||
for (const forbidden of forbiddenPatterns) {
|
||||
const match = source.match(forbidden.pattern);
|
||||
expect(match ? `${relativeFile}: ${match[0]}` : null, forbidden.name).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
approvalLevel: string;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string }>;
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
|
||||
logSources?: Array<{ key: string }>;
|
||||
clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
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(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([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("operations");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations")?.commandTypes).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.pages.map((page) => page.key)).toContain("operations");
|
||||
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-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;
|
||||
approvalLevel: string;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const expected = {
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"event.start": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "operator" },
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "platform-admin" }
|
||||
} 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.approvalLevel).toBe(policy.approvalLevel);
|
||||
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 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.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string") {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
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 = ["companion.health", "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 operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
|
||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||
});
|
||||
|
||||
it("declares read-only bounded SCUM database query templates", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
permission: string;
|
||||
engine: string;
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}>;
|
||||
pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const expectedKeys = ["scum.player.by-id", "scum.player.search", "scum.squad.members", "scum.vehicle.owner", "scum.flag.ownership"];
|
||||
const transport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "sqlite-db");
|
||||
|
||||
expect(transport).toMatchObject({ kind: "sqlite", targetKey: "db/sqlite" });
|
||||
expect(transport?.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(new Set(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).size).toBe(manifest.gameClientBridge.queryTemplates.length);
|
||||
for (const template of manifest.gameClientBridge.queryTemplates) {
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.permission).toBe("server.game-client.read");
|
||||
expect(manifest.permissions).toContain(template.permission);
|
||||
expect(template.transportKey).toBe("sqlite-db");
|
||||
expect(template.targetKey).toBe("db/sqlite");
|
||||
expect(template.maxRows).toBeGreaterThanOrEqual(1);
|
||||
expect(template.maxRows).toBeLessThanOrEqual(500);
|
||||
expect(template.timeoutSeconds).toBeGreaterThanOrEqual(1);
|
||||
expect(template.timeoutSeconds).toBeLessThanOrEqual(60);
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/\bselect\b|\binsert\b|\bupdate\b|\bdelete\b|\bpragma\b|dsn|hostpath|socket|password|credential/);
|
||||
|
||||
for (const schemaRef of [template.parameterSchemaRef, template.resultSchemaRef]) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
||||
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.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string") {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bpragma\b|dsn|hostpath|runsocket|password|credential/);
|
||||
visit(schema);
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
|
||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "operations");
|
||||
expect(operationsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(operationsPluginPage?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.remote.access"]));
|
||||
expect(operationsPluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<{ key: string; retentionDays?: number }>;
|
||||
logEvents?: Array<RuntimeLogEventDeclaration>;
|
||||
};
|
||||
};
|
||||
const expectedTypes = ["scum.chat", "scum.login", "scum.logout", "scum.kill", "scum.trade", "scum.mine", "scum.unlock", "scum.admin", "scum.performance"];
|
||||
const logSources = new Map((manifest.runtimeProfiles?.logSources ?? []).map((source) => [source.key, source]));
|
||||
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
|
||||
|
||||
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
|
||||
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
|
||||
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
|
||||
for (const event of logEvents) {
|
||||
const source = logSources.get(event.sourceKey);
|
||||
expect(source).toBeDefined();
|
||||
expect(manifest.permissions).toContain(event.permission);
|
||||
expect(event.retentionDays).toBeGreaterThanOrEqual(1);
|
||||
expect(event.retentionDays).toBeLessThanOrEqual(source?.retentionDays ?? 365);
|
||||
expect(["info", "notice", "warning", "critical"]).toContain(event.severity);
|
||||
expect(event.schemaRef).toMatch(/^schemas\/log-events\/[a-z-]+\.event\.schema\.json$/);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, event.schemaRef), "utf8")) as Record<string, unknown>;
|
||||
expect((schema.properties as Record<string, Record<string, unknown>>).occurredAt).toMatchObject({ type: "string", format: "date-time" });
|
||||
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.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.minimum).toBeDefined();
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/);
|
||||
visit(schema);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsafe semantic log declarations and missing references", () => {
|
||||
const errors = validateTemporaryLogEventManifest((manifest) => {
|
||||
const event = manifest.runtimeProfiles!.logEvents![0];
|
||||
event.eventType = "shell.execute";
|
||||
event.sourceKey = "missing-source";
|
||||
event.permission = "server.game-client.read";
|
||||
event.schemaRef = "schemas/log-events/missing.event.schema.json";
|
||||
event.retentionDays = 366;
|
||||
event.severity = "urgent";
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("eventType") && error.includes("not allowed"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("sourceKey") && error.includes("undeclared log source"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("missing semantic log event schema"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("retentionDays"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("severity"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded semantic log event schemas", () => {
|
||||
const errors = validateTemporaryLogEventManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, details: { type: "string" }, count: { type: "integer" } }, ["hostPath", "details", "count"]));
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("maxLength") && error.includes("bounded event strings"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("bounded event numbers"))).toBe(true);
|
||||
});
|
||||
|
||||
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
runtimeProfiles?: { clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}> };
|
||||
};
|
||||
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
||||
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
|
||||
expect(manager?.build).not.toHaveProperty("workspaceRef");
|
||||
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
||||
expect(manager?.deployment?.arguments).toBeUndefined();
|
||||
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
||||
});
|
||||
|
||||
it("accepts the Minecraft server plugin manifest", () => {
|
||||
expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
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: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-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].approvalLevel = "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"])("rejects unsafe 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") && error.includes("not allowed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects SQL 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") && error.includes("arbitrary SQL content"))).toBe(true);
|
||||
});
|
||||
|
||||
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 missing bridge command approval metadata end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
delete manifest.gameClientBridge.commands[0].approvalLevel;
|
||||
});
|
||||
expect(errors.some((error) => error.includes("approvalLevel") && (error.includes("required") || error.includes("approval metadata")))).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/announcement.schema.json"), "{ invalid", "utf8");
|
||||
});
|
||||
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-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("arbitrary SQL field"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("arbitrary shell"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("unsafe executor capability"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires bounded object schemas for every bridge reference", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.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");
|
||||
|
||||
@@ -79,6 +896,53 @@ describe("plugin manifest validation", () => {
|
||||
});
|
||||
|
||||
describe("plugin SDK", () => {
|
||||
|
||||
it("types generic runtime semantic log event declarations", () => {
|
||||
const declaration: RuntimeLogEventDeclaration = {
|
||||
key: "scum-performance",
|
||||
title: "SCUM server performance",
|
||||
sourceKey: "scum-performance-events",
|
||||
eventType: "scum.performance",
|
||||
permission: "server.logs.read",
|
||||
schemaRef: "schemas/log-events/performance.event.schema.json",
|
||||
retentionDays: 30,
|
||||
severity: "info"
|
||||
};
|
||||
expect(declaration).toMatchObject({ eventType: "scum.performance", permission: "server.logs.read", severity: "info" });
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
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: "scum-client",
|
||||
commandType: "announcement.send",
|
||||
payload: { message: "hello" },
|
||||
idempotencyKey: "announcement-1",
|
||||
expiresAt: "2026-07-20T12:00:00Z"
|
||||
});
|
||||
expect(request.commandType).toBe("announcement.send");
|
||||
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",
|
||||
@@ -321,6 +1185,7 @@ describe("plugin SDK", () => {
|
||||
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"
|
||||
});
|
||||
|
||||
@@ -335,6 +1200,8 @@ describe("plugin SDK", () => {
|
||||
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"
|
||||
}
|
||||
});
|
||||
@@ -370,6 +1237,7 @@ describe("plugin SDK", () => {
|
||||
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", approvalRequired: ["disable", "rollback", "retire"] },
|
||||
runtimeProfiles: {
|
||||
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
|
||||
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
|
||||
@@ -442,4 +1310,17 @@ describe("plugin SDK", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
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", confirmed: "false" } });
|
||||
expect(JSON.stringify(request)).not.toMatch(/apiKey|providerBaseUrl|runSocket|runEndpoint|hostPath|credential/i);
|
||||
expect(() => createProductionPluginLifecycleRequest({ requestId: "plugin-retire-1", context, operation: "retire", idempotencyKey: "plugin-retire-v1" })).toThrow(/confirmation/);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user