feat(coding): add plugin capability policy registry
This commit is contained in:
153
tests/unit/coding-capability-registry.test.ts
Normal file
153
tests/unit/coding-capability-registry.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
|
||||
import {
|
||||
CodingCapabilityRegistryImpl,
|
||||
} from '../../electron/coding-plugins/registry';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
|
||||
const policy: PluginPolicyClientState = {
|
||||
status: 'current',
|
||||
revision: 7,
|
||||
lastVerifiedAt: 1,
|
||||
catalog: {
|
||||
schema_version: 1,
|
||||
catalog_version: 'catalog-1',
|
||||
pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: 'makelore.data-service',
|
||||
supported_contract_versions: [1],
|
||||
status: 'active',
|
||||
capabilities: [
|
||||
{
|
||||
capability_id: 'data-service.control',
|
||||
operations: ['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']
|
||||
.map((operation) => ({
|
||||
operation,
|
||||
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
||||
})),
|
||||
},
|
||||
{
|
||||
capability_id: 'data-service.documents',
|
||||
operations: ['get_document', 'list_documents', 'put_document', 'delete_document']
|
||||
.map((operation) => ({
|
||||
operation,
|
||||
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
||||
})),
|
||||
},
|
||||
{
|
||||
capability_id: 'data-service.preview',
|
||||
operations: ['get', 'list', 'put', 'delete'].map((operation) => ({
|
||||
operation,
|
||||
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
|
||||
})),
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const context = {
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
resourceId: 'resource-a',
|
||||
projectId: 'local-project-a',
|
||||
projectPath: 'C:\\projects\\demo',
|
||||
skillIds: ['data-service'],
|
||||
};
|
||||
|
||||
function adapter(): CodingPluginAdapter {
|
||||
return {
|
||||
pluginId: 'makelore.data-service',
|
||||
async inspect() { return { status: 'ready' }; },
|
||||
async invoke() {
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
payload_schema: 'data-service.v1',
|
||||
data: { id: 'one', data: {}, revision: 1 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registry(overrides: Partial<ConstructorParameters<typeof CodingCapabilityRegistryImpl>[0]> = {}) {
|
||||
return new CodingCapabilityRegistryImpl({
|
||||
policyClient: { getState: () => policy },
|
||||
getEnabledPluginIds: async () => ['makelore.data-service'],
|
||||
adapters: [adapter()],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodingCapabilityRegistry', () => {
|
||||
it('joins package, selection, grant, role, and server policy for parent resources', async () => {
|
||||
const resources = await registry().resolveWorkerResources({
|
||||
projectPath: context.projectPath,
|
||||
assignedSkillIds: context.skillIds,
|
||||
role: 'parent',
|
||||
});
|
||||
expect(resources.catalogRevision).toBe(7);
|
||||
expect(resources.pluginIds).toEqual(['makelore.data-service']);
|
||||
expect(resources.effectiveSkillIds).toEqual(['data-service']);
|
||||
expect(resources.tools.map(({ name }) => name)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('never exposes plugin resources to child workers or before the first catalog', async () => {
|
||||
const child = await registry().resolveWorkerResources({
|
||||
projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'child',
|
||||
});
|
||||
expect(child.pluginIds).toEqual([]);
|
||||
expect(child.tools).toEqual([]);
|
||||
const unavailable = await registry({
|
||||
policyClient: { getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }) },
|
||||
}).resolveWorkerResources({
|
||||
projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'parent',
|
||||
});
|
||||
expect(unavailable.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('revalidates invocation, derives the stable Pi request id, and preserves domain faults', async () => {
|
||||
const result = await registry().invoke({
|
||||
toolName: 'data_service_get_document',
|
||||
context,
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds,
|
||||
value: { collection: 'todos', document_id: 'one' },
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.data-service',
|
||||
capability_id: 'data-service.documents',
|
||||
operation: 'get_document',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
billing: { mode: 'included', status: 'included' },
|
||||
payload_schema: 'data-service.v1',
|
||||
});
|
||||
const invalid = await registry().invoke({
|
||||
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds, value: { collection: 'todos', projectPath: 'forbidden' },
|
||||
});
|
||||
expect(invalid.details).toMatchObject({ success: false, code: 'plugin_input_invalid' });
|
||||
const disabled = await registry({ getEnabledPluginIds: async () => [] }).invoke({
|
||||
toolName: 'data_service_get_document', context, workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds, value: { collection: 'todos', document_id: 'one' },
|
||||
});
|
||||
expect(disabled.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
|
||||
const invalidIdentity = await registry().invoke({
|
||||
toolName: 'data_service_get_document',
|
||||
context: { ...context, runId: '' },
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds,
|
||||
value: { collection: 'todos', document_id: 'one' },
|
||||
});
|
||||
expect(invalidIdentity.details).toMatchObject({
|
||||
success: false, code: 'plugin_input_invalid', request_id: 'invalid-request-id',
|
||||
});
|
||||
expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PI_PRODUCT_FIXTURE_SOURCE,
|
||||
PI_WIRE_TO_PRODUCT_BOUNDARY,
|
||||
} from '../fixtures/coding-conversation-product-fixtures';
|
||||
import { productToolDetails } from '../../shared/coding-conversation-product-tool-protocol';
|
||||
|
||||
function reduceAll(
|
||||
snapshot: ConversationSnapshot,
|
||||
@@ -49,6 +50,62 @@ function envelope(
|
||||
}
|
||||
|
||||
describe('Conversation product contracts', () => {
|
||||
it('accepts bounded capability envelopes for every Data Service operation', () => {
|
||||
const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']);
|
||||
const operations = [
|
||||
'configure', 'inspect', 'list_projects', 'get_document', 'list_documents',
|
||||
'put_document', 'delete_document', 'remove_collection', 'reset', 'remove_project',
|
||||
];
|
||||
for (const operation of operations) {
|
||||
const parsed = productToolDetails({
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.data-service',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: control.has(operation) ? 'data-service.control' : 'data-service.documents',
|
||||
operation,
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: false,
|
||||
status: operation === 'put_document' ? 413 : operation === 'delete_document' ? 409 : 429,
|
||||
code: operation === 'put_document' ? 'document_too_large' : 'rate_limited',
|
||||
error: 'Data Service request was rejected',
|
||||
retryable: true,
|
||||
retry_after_seconds: 30,
|
||||
context: operation === 'put_document'
|
||||
? { resource: 'bytes', actual: 100_000, limit: 98_304 }
|
||||
: operation === 'delete_document'
|
||||
? { current_revision: 3 }
|
||||
: { resource: 'documents' },
|
||||
billing: { mode: 'included', status: 'included' },
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
});
|
||||
expect(parsed).toMatchObject({ schema: 'makelore-capability.v1', operation });
|
||||
}
|
||||
expect(productToolDetails({
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.data-service', plugin_version: '1.0.0',
|
||||
capability_id: 'data-service.documents', operation: 'get_document',
|
||||
request_id: 'pi:run-a:resource-a', success: true, status: 200,
|
||||
code: null, error: null, retryable: false,
|
||||
billing: { mode: 'platform_metered', status: 'refunded', reserved_points: '2.50', actual_points: '1.25' },
|
||||
payload_schema: 'data-service.v1', data: { id: 'one' },
|
||||
})).toMatchObject({ billing: { mode: 'platform_metered', status: 'refunded', actual_points: '1.25' } });
|
||||
expect(productToolDetails({
|
||||
schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0',
|
||||
capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a',
|
||||
success: false, status: 409, code: 'quota_exceeded', error: 'Quota exceeded', retryable: false,
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', data: null,
|
||||
context: { allowed: 1 },
|
||||
})).toBeNull();
|
||||
expect(productToolDetails({
|
||||
schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0',
|
||||
capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a',
|
||||
success: true, status: 200, code: null, error: null, retryable: false,
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
||||
data: { body: 'raw upstream body must be projected' }, extra: 'reject',
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => {
|
||||
const snapshot = createProductSnapshot();
|
||||
expect(isConversationSnapshot(snapshot)).toBe(true);
|
||||
|
||||
98
tests/unit/data-service-plugin-adapter.test.ts
Normal file
98
tests/unit/data-service-plugin-adapter.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DATA_SERVICE_TOOL_DEFINITIONS } from '../../shared/coding-plugins';
|
||||
import type { DataServiceOperations } from '../../electron/services/data-service-client';
|
||||
import {
|
||||
createDataServicePluginAdapter,
|
||||
} from '../../electron/coding-plugins/adapters/data-service';
|
||||
|
||||
const context = {
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
resourceId: 'resource-a',
|
||||
requestId: 'pi:run-a:resource-a',
|
||||
localProjectId: 'local-project-a',
|
||||
projectPath: 'C:\\projects\\demo',
|
||||
durableProjectId: 'durable-project-a',
|
||||
workerRole: 'parent' as const,
|
||||
effectiveSkillIds: ['data-service'],
|
||||
};
|
||||
|
||||
function success<T>(data: T) {
|
||||
return {
|
||||
success: true as const,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false as const,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function operations(): DataServiceOperations {
|
||||
return {
|
||||
configure: vi.fn().mockResolvedValue(success({ configured: true })),
|
||||
inspect: vi.fn().mockResolvedValue(success({ instance_id: 'instance-a' })),
|
||||
listProjects: vi.fn().mockResolvedValue(success({ items: [], total: 0, instance_limit: 20 })),
|
||||
getDocument: vi.fn().mockResolvedValue(success({ id: 'one', data: {}, revision: 1 })),
|
||||
listDocuments: vi.fn().mockResolvedValue(success({ items: [], next_cursor: null, limit: 50 })),
|
||||
putDocument: vi.fn().mockResolvedValue(success({ id: 'one', data: {}, revision: 1 })),
|
||||
deleteDocument: vi.fn().mockResolvedValue(success(null)),
|
||||
removeCollection: vi.fn().mockResolvedValue(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })),
|
||||
reset: vi.fn().mockResolvedValue(success({ instance_id: 'instance-a' })),
|
||||
removeProject: vi.fn().mockResolvedValue(success({ removed: true })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Data Service plugin adapter', () => {
|
||||
it('maps all ten package operations through DataServiceOperations', async () => {
|
||||
const dataService = operations();
|
||||
const adapter = createDataServicePluginAdapter(dataService);
|
||||
const inputs: Record<string, unknown> = {
|
||||
data_service_configure: { collections: ['todos'] },
|
||||
data_service_inspect: {},
|
||||
data_service_list_projects: {},
|
||||
data_service_get_document: { collection: 'todos', document_id: 'one' },
|
||||
data_service_list_documents: { collection: 'todos', limit: 50, cursor: 'cursor-a' },
|
||||
data_service_put_document: { collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1 },
|
||||
data_service_delete_document: { collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true },
|
||||
data_service_remove_collection: { collection: 'todos', confirmed: true },
|
||||
data_service_reset: { confirmed: true },
|
||||
data_service_remove_project: { confirmed: true },
|
||||
};
|
||||
for (const tool of DATA_SERVICE_TOOL_DEFINITIONS) {
|
||||
const result = await adapter.invoke(context, tool, inputs[tool.name]);
|
||||
expect(result).toMatchObject({ success: true, payload_schema: 'data-service.v1' });
|
||||
expect(result).not.toHaveProperty('plugin_id');
|
||||
expect(result).not.toHaveProperty('request_id');
|
||||
}
|
||||
expect(dataService.configure).toHaveBeenCalledWith({ collections: ['todos'] }, context.projectPath);
|
||||
expect(dataService.inspect).toHaveBeenCalledWith(context.projectPath);
|
||||
expect(dataService.listProjects).toHaveBeenCalledWith();
|
||||
expect(dataService.getDocument).toHaveBeenCalledWith({ collection: 'todos', document_id: 'one' }, context.projectPath);
|
||||
expect(dataService.listDocuments).toHaveBeenCalledWith({ collection: 'todos', limit: 50, cursor: 'cursor-a' }, context.projectPath);
|
||||
expect(dataService.putDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', data: { done: false }, if_revision: 1,
|
||||
}, context.projectPath);
|
||||
expect(dataService.deleteDocument).toHaveBeenCalledWith({
|
||||
collection: 'todos', document_id: 'one', if_revision: 1, confirmed: true,
|
||||
}, context.projectPath);
|
||||
});
|
||||
|
||||
it('returns bounded domain context and rejects forbidden or malformed input', async () => {
|
||||
const dataService = operations();
|
||||
const adapter = createDataServicePluginAdapter(dataService);
|
||||
const put = DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === 'data_service_put_document');
|
||||
if (!put) throw new Error('put definition missing');
|
||||
const invalid = await adapter.invoke(context, put, {
|
||||
collection: 'todos', document_id: 'one', data: {}, projectPath: context.projectPath,
|
||||
});
|
||||
expect(invalid).toMatchObject({
|
||||
success: false, status: 422, code: 'plugin_input_invalid', data: null,
|
||||
});
|
||||
const result = await adapter.invoke(context, put, { collection: 'todos', document_id: 'one', data: {} });
|
||||
expect(result).toMatchObject({ success: true, payload_schema: 'data-service.v1' });
|
||||
expect(dataService.putDocument).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../../electron/coding-projects/skill-registry';
|
||||
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
|
||||
import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol';
|
||||
import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry';
|
||||
import type { DataServiceOperations } from '../../electron/services/data-service-client';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
@@ -233,35 +234,38 @@ describe('PI-090 product tools', () => {
|
||||
})).toBeNull();
|
||||
expect(productToolDetails({ schema: 'task-state.v1', tasks: [] })).toBeNull();
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.data-service',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'data-service.control',
|
||||
operation: 'inspect',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
billing: { mode: 'included', status: 'included' },
|
||||
payload_schema: 'data-service.v1',
|
||||
data: { instance_id: 'instance-a' },
|
||||
owner: 'must-be-dropped',
|
||||
})).toEqual({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_inspect',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: { instance_id: 'instance-a' },
|
||||
});
|
||||
expect(productToolDetails({
|
||||
schema: 'data-service.v1',
|
||||
operation: 'data_service_unknown',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
data: null,
|
||||
})).toBeNull();
|
||||
expect(productToolDetails({
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'makelore.data-service',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'data-service.control',
|
||||
operation: 'inspect',
|
||||
request_id: 'pi:run-a:resource-a',
|
||||
success: true,
|
||||
status: 200,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
billing: { mode: 'included', status: 'included' },
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
})).toMatchObject({ schema: 'makelore-capability.v1', operation: 'inspect' });
|
||||
});
|
||||
|
||||
it('stores browser screenshots as attachment ids and never returns base64', async () => {
|
||||
@@ -413,12 +417,42 @@ describe('PI-090 product tools', () => {
|
||||
expect(dataService.reset).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(dataService.removeProject).toHaveBeenCalledWith({ confirmed: true }, root);
|
||||
expect(removed.details).toMatchObject({
|
||||
schema: 'data-service.v1', operation: 'data_service_remove_project',
|
||||
schema: 'makelore-capability.v1', operation: 'remove_project',
|
||||
plugin_id: 'makelore.data-service', request_id: 'pi:run-a:resource-a',
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1',
|
||||
success: true, status: 200, data: { removed: true },
|
||||
});
|
||||
expect(JSON.stringify(removed)).not.toContain(root);
|
||||
});
|
||||
|
||||
it('delegates any non-core product tool to the capability registry', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-generic-plugin-');
|
||||
const invoke = vi.fn().mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'plugin-result' }],
|
||||
details: {
|
||||
schema: 'makelore-capability.v1', plugin_id: 'makelore.example', plugin_version: '1.0.0',
|
||||
capability_id: 'example.capability', operation: 'run', request_id: 'pi:run-a:resource-a',
|
||||
success: true, status: 200, code: null, error: null, retryable: false,
|
||||
billing: { mode: 'included', status: 'included' }, payload_schema: 'example.v1', data: {},
|
||||
},
|
||||
});
|
||||
const registry = { invoke } as unknown as CodingCapabilityRegistry;
|
||||
const tools = new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
capabilityRegistry: registry,
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a', runId: 'run-a', resourceId: 'resource-a',
|
||||
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
await tools.execute('example_tool', context, { value: 1 });
|
||||
expect(invoke).toHaveBeenCalledWith({
|
||||
toolName: 'example_tool', context, workerRole: 'parent', effectiveSkillIds: [], value: { value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects forbidden tool fields and destructive calls without literal confirmation', async () => {
|
||||
const root = await temporaryRoot('makelore-pi-data-input-');
|
||||
const dataService = {
|
||||
@@ -438,15 +472,17 @@ describe('PI-090 product tools', () => {
|
||||
projectId: 'local-project-a', projectPath: root, skillIds: [],
|
||||
};
|
||||
|
||||
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' })).rejects.toThrow(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
await expect(tools.execute('data_service_inspect', context, { owner: 'owner-a' })).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
await expect(tools.execute('data_service_put_document', context, {
|
||||
collection: 'todos', document_id: 'one', data: {}, path: root,
|
||||
})).rejects.toThrow('Data Service tool input is invalid');
|
||||
await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).rejects.toThrow(
|
||||
'Data Service tool input is invalid',
|
||||
);
|
||||
})).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
await expect(tools.execute('data_service_remove_project', context, { confirmed: false })).resolves.toMatchObject({
|
||||
details: { schema: 'makelore-capability.v1', code: 'plugin_input_invalid', status: 422 },
|
||||
});
|
||||
expect(dataService.inspect).not.toHaveBeenCalled();
|
||||
expect(dataService.removeProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
123
tests/unit/plugin-policy-client.test.ts
Normal file
123
tests/unit/plugin-policy-client.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
parsePluginCatalog,
|
||||
PluginPolicyClient,
|
||||
} from '../../electron/services/plugin-policy-client';
|
||||
|
||||
const catalog = {
|
||||
schema_version: 1,
|
||||
catalog_version: 'catalog-1',
|
||||
pricing_version: null,
|
||||
plugins: [{
|
||||
plugin_id: 'makelore.data-service',
|
||||
supported_contract_versions: [1],
|
||||
status: 'active',
|
||||
capabilities: [
|
||||
{
|
||||
capability_id: 'data-service.documents',
|
||||
operations: [
|
||||
{
|
||||
operation: 'get_document',
|
||||
billing: {
|
||||
mode: 'included',
|
||||
entitlement_scope: null,
|
||||
notice: 'Included in development data service',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
describe('PluginPolicyClient', () => {
|
||||
it('parses the exact catalog and rejects unknown or malformed fields', () => {
|
||||
expect(parsePluginCatalog(catalog)).toMatchObject({
|
||||
schema_version: 1,
|
||||
catalog_version: 'catalog-1',
|
||||
plugins: [{ plugin_id: 'makelore.data-service' }],
|
||||
});
|
||||
expect(() => parsePluginCatalog({ ...catalog, extra: true })).toThrow('unexpected fields');
|
||||
expect(() => parsePluginCatalog({
|
||||
...catalog,
|
||||
plugins: [{
|
||||
...catalog.plugins[0],
|
||||
capabilities: [{
|
||||
...catalog.plugins[0].capabilities[0],
|
||||
operations: [{
|
||||
...catalog.plugins[0].capabilities[0].operations[0],
|
||||
billing: { mode: 'included', entitlement_scope: 'wrong', notice: 'nope' },
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('accepts an unavailable platform-metered policy without exposing a price', () => {
|
||||
const parsed = parsePluginCatalog({
|
||||
...catalog,
|
||||
plugins: [{
|
||||
...catalog.plugins[0],
|
||||
capabilities: [{
|
||||
...catalog.plugins[0].capabilities[0],
|
||||
operations: [{
|
||||
...catalog.plugins[0].capabilities[0].operations[0],
|
||||
billing: {
|
||||
mode: 'platform_metered',
|
||||
status: 'billing_unavailable',
|
||||
entitlement_scope: 'data-service.documents',
|
||||
notice: 'Pricing is unavailable',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
expect(parsed.plugins[0]?.capabilities[0]?.operations[0]?.billing).toEqual({
|
||||
mode: 'platform_metered',
|
||||
status: 'billing_unavailable',
|
||||
entitlement_scope: 'data-service.documents',
|
||||
notice: 'Pricing is unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('coalesces concurrent refreshes and marks the verified catalog stale after failure', async () => {
|
||||
let resolveRequest: ((response: Response) => void) | undefined;
|
||||
const fetchImpl = vi.fn(() => new Promise<Response>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}));
|
||||
const client = new PluginPolicyClient({
|
||||
fetchImpl,
|
||||
apiBaseUrl: 'https://works.example',
|
||||
now: () => 123,
|
||||
});
|
||||
const first = client.refresh();
|
||||
const second = client.refresh();
|
||||
expect(first).toBe(second);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
resolveRequest?.(new Response(JSON.stringify(catalog), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
await expect(first).resolves.toMatchObject({ status: 'current', revision: 1 });
|
||||
|
||||
fetchImpl.mockRejectedValueOnce(new Error('offline'));
|
||||
await expect(client.refresh()).resolves.toMatchObject({
|
||||
status: 'stale',
|
||||
revision: 1,
|
||||
errorCode: 'plugin_backend_unavailable',
|
||||
});
|
||||
expect(client.getState().catalog).toEqual(expect.objectContaining({ catalog_version: 'catalog-1' }));
|
||||
});
|
||||
|
||||
it('keeps an unavailable first state when no catalog was verified', async () => {
|
||||
const client = new PluginPolicyClient({ fetchImpl: vi.fn().mockRejectedValue(new Error('offline')) });
|
||||
await expect(client.refresh()).resolves.toMatchObject({
|
||||
status: 'unavailable',
|
||||
catalog: null,
|
||||
revision: 0,
|
||||
errorCode: 'plugin_backend_unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user