comisai 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +1 -4
- package/node_modules/@comis/agent/package.json +1 -1
- package/node_modules/@comis/channels/package.json +1 -1
- package/node_modules/@comis/cli/dist/wizard/steps/06-channels.js +88 -11
- package/node_modules/@comis/cli/dist/wizard/steps/10-write-config.js +4 -0
- package/node_modules/@comis/cli/dist/wizard/types.d.ts +1 -0
- package/node_modules/@comis/cli/package.json +1 -1
- package/node_modules/@comis/core/package.json +1 -1
- package/node_modules/@comis/daemon/package.json +1 -1
- package/node_modules/@comis/gateway/package.json +1 -1
- package/node_modules/@comis/infra/package.json +1 -1
- package/node_modules/@comis/memory/package.json +1 -1
- package/node_modules/@comis/scheduler/package.json +1 -1
- package/node_modules/@comis/shared/package.json +1 -1
- package/node_modules/@comis/skills/package.json +1 -1
- package/package.json +18 -18
- package/node_modules/@comis/core/dist/approval/approval-gate.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/approval/approval-gate.test.js +0 -1003
- package/node_modules/@comis/core/dist/bootstrap.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/bootstrap.test.js +0 -150
- package/node_modules/@comis/core/dist/config/backup.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/backup.test.js +0 -160
- package/node_modules/@comis/core/dist/config/env-substitution.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/env-substitution.test.js +0 -259
- package/node_modules/@comis/core/dist/config/field-metadata.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/field-metadata.test.js +0 -72
- package/node_modules/@comis/core/dist/config/git-manager.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/git-manager.test.js +0 -1042
- package/node_modules/@comis/core/dist/config/immutable-keys.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/immutable-keys.test.js +0 -283
- package/node_modules/@comis/core/dist/config/include-resolver.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/include-resolver.test.js +0 -292
- package/node_modules/@comis/core/dist/config/layered.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/layered.test.js +0 -211
- package/node_modules/@comis/core/dist/config/loader.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/loader.test.js +0 -300
- package/node_modules/@comis/core/dist/config/migrate.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/migrate.test.js +0 -244
- package/node_modules/@comis/core/dist/config/partial-validator.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/partial-validator.test.js +0 -98
- package/node_modules/@comis/core/dist/config/schema-agent-model.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-agent-model.test.js +0 -279
- package/node_modules/@comis/core/dist/config/schema-agent-session.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-agent-session.test.js +0 -242
- package/node_modules/@comis/core/dist/config/schema-agent.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-agent.test.js +0 -645
- package/node_modules/@comis/core/dist/config/schema-channel.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-channel.test.js +0 -341
- package/node_modules/@comis/core/dist/config/schema-coalescer.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-coalescer.test.js +0 -51
- package/node_modules/@comis/core/dist/config/schema-context-engine.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-context-engine.test.js +0 -797
- package/node_modules/@comis/core/dist/config/schema-context-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-context-guard.test.js +0 -111
- package/node_modules/@comis/core/dist/config/schema-daemon.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-daemon.test.js +0 -107
- package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-delivery-timing.test.js +0 -51
- package/node_modules/@comis/core/dist/config/schema-documentation.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-documentation.test.js +0 -63
- package/node_modules/@comis/core/dist/config/schema-gateway.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-gateway.test.js +0 -219
- package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-gemini-cache.test.js +0 -41
- package/node_modules/@comis/core/dist/config/schema-integrations.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-integrations.test.js +0 -92
- package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-lifecycle-reactions.test.js +0 -61
- package/node_modules/@comis/core/dist/config/schema-misc.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-misc.test.js +0 -394
- package/node_modules/@comis/core/dist/config/schema-observability.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-observability.test.js +0 -76
- package/node_modules/@comis/core/dist/config/schema-providers.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-providers.test.js +0 -294
- package/node_modules/@comis/core/dist/config/schema-queue.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-queue.test.js +0 -234
- package/node_modules/@comis/core/dist/config/schema-response-prefix.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-response-prefix.test.js +0 -36
- package/node_modules/@comis/core/dist/config/schema-security.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-security.test.js +0 -54
- package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-sender-trust-display.test.js +0 -60
- package/node_modules/@comis/core/dist/config/schema-serializer.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-serializer.test.js +0 -73
- package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/config/schema-telegram-file-guard.test.js +0 -39
- package/node_modules/@comis/core/dist/context/context.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/context/context.test.js +0 -276
- package/node_modules/@comis/core/dist/domain/agent-response.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/agent-response.test.js +0 -159
- package/node_modules/@comis/core/dist/domain/credential-mapping.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/credential-mapping.test.js +0 -135
- package/node_modules/@comis/core/dist/domain/delivery-origin.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/delivery-origin.test.js +0 -68
- package/node_modules/@comis/core/dist/domain/execution-graph.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/execution-graph.test.js +0 -441
- package/node_modules/@comis/core/dist/domain/memory-entry.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/memory-entry.test.js +0 -193
- package/node_modules/@comis/core/dist/domain/normalized-message.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/normalized-message.test.js +0 -255
- package/node_modules/@comis/core/dist/domain/session-key.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/session-key.test.js +0 -291
- package/node_modules/@comis/core/dist/domain/subagent-context-config.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/subagent-context-config.test.js +0 -131
- package/node_modules/@comis/core/dist/domain/subagent-context-types.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/domain/subagent-context-types.test.js +0 -182
- package/node_modules/@comis/core/dist/event-bus/bus.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/bus.test.js +0 -152
- package/node_modules/@comis/core/dist/event-bus/events-agent.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/events-agent.test.js +0 -409
- package/node_modules/@comis/core/dist/event-bus/events-channel.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/events-channel.test.js +0 -240
- package/node_modules/@comis/core/dist/event-bus/events-infra.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/events-infra.test.js +0 -416
- package/node_modules/@comis/core/dist/event-bus/events-messaging.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/events-messaging.test.js +0 -433
- package/node_modules/@comis/core/dist/event-bus/events.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/event-bus/events.test.js +0 -93
- package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/hooks/hook-runner-global.test.js +0 -29
- package/node_modules/@comis/core/dist/hooks/hook-runner.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/hooks/hook-runner.test.js +0 -490
- package/node_modules/@comis/core/dist/hooks/hook-strategies.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/hooks/hook-strategies.test.js +0 -209
- package/node_modules/@comis/core/dist/hooks/integration.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/hooks/integration.test.js +0 -235
- package/node_modules/@comis/core/dist/hooks/plugin-registry.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/hooks/plugin-registry.test.js +0 -470
- package/node_modules/@comis/core/dist/load-env.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/load-env.test.js +0 -21
- package/node_modules/@comis/core/dist/security/action-classifier.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/action-classifier.test.js +0 -298
- package/node_modules/@comis/core/dist/security/audit-aggregator.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/audit-aggregator.test.js +0 -128
- package/node_modules/@comis/core/dist/security/audit.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/audit.test.js +0 -154
- package/node_modules/@comis/core/dist/security/canary-token.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/canary-token.test.js +0 -45
- package/node_modules/@comis/core/dist/security/config-redaction.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/config-redaction.test.js +0 -107
- package/node_modules/@comis/core/dist/security/external-content.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/external-content.test.js +0 -210
- package/node_modules/@comis/core/dist/security/injection-patterns.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/injection-patterns.test.js +0 -213
- package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/injection-rate-limiter.test.js +0 -194
- package/node_modules/@comis/core/dist/security/input-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/input-guard.test.js +0 -215
- package/node_modules/@comis/core/dist/security/input-security-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/input-security-guard.test.js +0 -215
- package/node_modules/@comis/core/dist/security/input-validator.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/input-validator.test.js +0 -133
- package/node_modules/@comis/core/dist/security/log-sanitizer.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/log-sanitizer.test.js +0 -260
- package/node_modules/@comis/core/dist/security/memory-write-validator.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/memory-write-validator.test.js +0 -62
- package/node_modules/@comis/core/dist/security/output-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/output-guard.test.js +0 -397
- package/node_modules/@comis/core/dist/security/safe-path.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/safe-path.test.js +0 -95
- package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/scoped-secret-manager.test.js +0 -231
- package/node_modules/@comis/core/dist/security/secret-access.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/secret-access.test.js +0 -41
- package/node_modules/@comis/core/dist/security/secret-crypto.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/secret-crypto.test.js +0 -113
- package/node_modules/@comis/core/dist/security/secret-manager.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/secret-manager.test.js +0 -394
- package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/secret-ref-resolver.test.js +0 -268
- package/node_modules/@comis/core/dist/security/secrets-audit.test.d.ts +0 -10
- package/node_modules/@comis/core/dist/security/secrets-audit.test.js +0 -295
- package/node_modules/@comis/core/dist/security/ssrf-guard.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/ssrf-guard.test.js +0 -127
- package/node_modules/@comis/core/dist/security/token-generator.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/security/token-generator.test.js +0 -35
- package/node_modules/@comis/core/dist/tool-metadata.test.d.ts +0 -1
- package/node_modules/@comis/core/dist/tool-metadata.test.js +0 -112
- package/node_modules/@comis/memory/dist/compaction.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/compaction.test.js +0 -298
- package/node_modules/@comis/memory/dist/context-schema.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/context-schema.test.js +0 -246
- package/node_modules/@comis/memory/dist/context-store.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/context-store.test.js +0 -708
- package/node_modules/@comis/memory/dist/credential-mapping-schema.test.d.ts +0 -7
- package/node_modules/@comis/memory/dist/credential-mapping-schema.test.js +0 -73
- package/node_modules/@comis/memory/dist/credential-mapping-store.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/credential-mapping-store.test.js +0 -340
- package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/delivery-mirror-adapter.test.js +0 -165
- package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/delivery-queue-adapter.test.js +0 -263
- package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-batch-indexer.test.js +0 -202
- package/node_modules/@comis/memory/dist/embedding-cache-lru.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-cache-lru.test.js +0 -241
- package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/embedding-cache-sqlite.test.js +0 -678
- package/node_modules/@comis/memory/dist/embedding-cache.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-cache.test.js +0 -213
- package/node_modules/@comis/memory/dist/embedding-fingerprint.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-fingerprint.test.js +0 -87
- package/node_modules/@comis/memory/dist/embedding-hash.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-hash.test.js +0 -31
- package/node_modules/@comis/memory/dist/embedding-integration.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/embedding-integration.test.js +0 -232
- package/node_modules/@comis/memory/dist/embedding-provider-factory.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-provider-factory.test.js +0 -176
- package/node_modules/@comis/memory/dist/embedding-provider-local.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/embedding-provider-local.test.js +0 -76
- package/node_modules/@comis/memory/dist/embedding-provider-openai.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/embedding-provider-openai.test.js +0 -100
- package/node_modules/@comis/memory/dist/embedding-queue.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/embedding-queue.test.js +0 -236
- package/node_modules/@comis/memory/dist/hybrid-search.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/hybrid-search.test.js +0 -476
- package/node_modules/@comis/memory/dist/identity-link-store.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/identity-link-store.test.js +0 -88
- package/node_modules/@comis/memory/dist/memory-api.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/memory-api.test.js +0 -495
- package/node_modules/@comis/memory/dist/memory-concurrency.test.d.ts +0 -20
- package/node_modules/@comis/memory/dist/memory-concurrency.test.js +0 -222
- package/node_modules/@comis/memory/dist/named-graph-store.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/named-graph-store.test.js +0 -227
- package/node_modules/@comis/memory/dist/observability-store.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/observability-store.test.js +0 -704
- package/node_modules/@comis/memory/dist/row-mapper.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/row-mapper.test.js +0 -409
- package/node_modules/@comis/memory/dist/schema.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/schema.test.js +0 -240
- package/node_modules/@comis/memory/dist/secret-store-schema.test.d.ts +0 -7
- package/node_modules/@comis/memory/dist/secret-store-schema.test.js +0 -90
- package/node_modules/@comis/memory/dist/session-store.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/session-store.test.js +0 -262
- package/node_modules/@comis/memory/dist/setup-secrets.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/setup-secrets.test.js +0 -140
- package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.d.ts +0 -1
- package/node_modules/@comis/memory/dist/sqlite-memory-adapter.test.js +0 -888
- package/node_modules/@comis/memory/dist/sqlite-secret-store.test.d.ts +0 -8
- package/node_modules/@comis/memory/dist/sqlite-secret-store.test.js +0 -245
- package/node_modules/@comis/shared/dist/abort.test.d.ts +0 -1
- package/node_modules/@comis/shared/dist/abort.test.js +0 -71
- package/node_modules/@comis/shared/dist/result.test.d.ts +0 -1
- package/node_modules/@comis/shared/dist/result.test.js +0 -178
- package/node_modules/@comis/shared/dist/suppress-error.test.d.ts +0 -1
- package/node_modules/@comis/shared/dist/suppress-error.test.js +0 -50
- package/node_modules/@comis/shared/dist/timeout.test.d.ts +0 -1
- package/node_modules/@comis/shared/dist/timeout.test.js +0 -75
- package/node_modules/@comis/shared/dist/ttl-cache.test.d.ts +0 -1
- package/node_modules/@comis/shared/dist/ttl-cache.test.js +0 -81
|
@@ -1,1003 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { createApprovalGate } from "./approval-gate.js";
|
|
3
|
-
import { TypedEventBus } from "../event-bus/bus.js";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Setup
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
let eventBus;
|
|
11
|
-
let gate;
|
|
12
|
-
const DEFAULT_TIMEOUT_MS = 5000;
|
|
13
|
-
function makeRequest(overrides = {}) {
|
|
14
|
-
return {
|
|
15
|
-
toolName: overrides.toolName ?? "agents.restart",
|
|
16
|
-
action: overrides.action ?? "agents.restart",
|
|
17
|
-
params: overrides.params ?? { agentId: "bot-1" },
|
|
18
|
-
agentId: overrides.agentId ?? "agent-1",
|
|
19
|
-
sessionKey: overrides.sessionKey ?? "default:user1:discord",
|
|
20
|
-
trustLevel: overrides.trustLevel ?? "user",
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
vi.useFakeTimers();
|
|
25
|
-
eventBus = new TypedEventBus();
|
|
26
|
-
gate = createApprovalGate({
|
|
27
|
-
eventBus,
|
|
28
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
gate.dispose();
|
|
33
|
-
vi.useRealTimers();
|
|
34
|
-
});
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
// 1. Request creation
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
describe("request creation", () => {
|
|
39
|
-
it("requestApproval adds request to pending list", () => {
|
|
40
|
-
gate.requestApproval(makeRequest());
|
|
41
|
-
expect(gate.pending()).toHaveLength(1);
|
|
42
|
-
});
|
|
43
|
-
it("requestApproval returns a promise that does not resolve immediately", () => {
|
|
44
|
-
const promise = gate.requestApproval(makeRequest());
|
|
45
|
-
// Promise should be pending (not resolved yet)
|
|
46
|
-
let resolved = false;
|
|
47
|
-
promise.then(() => { resolved = true; });
|
|
48
|
-
// Flush microtask queue
|
|
49
|
-
expect(resolved).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
it("pending() returns request with all expected fields", () => {
|
|
52
|
-
gate.requestApproval(makeRequest({
|
|
53
|
-
toolName: "system.reboot",
|
|
54
|
-
action: "system.reboot",
|
|
55
|
-
params: { target: "host-1" },
|
|
56
|
-
agentId: "agent-2",
|
|
57
|
-
sessionKey: "default:admin:telegram",
|
|
58
|
-
trustLevel: "admin",
|
|
59
|
-
}));
|
|
60
|
-
const [request] = gate.pending();
|
|
61
|
-
expect(request).toBeDefined();
|
|
62
|
-
expect(request.toolName).toBe("system.reboot");
|
|
63
|
-
expect(request.action).toBe("system.reboot");
|
|
64
|
-
expect(request.params).toEqual({ target: "host-1" });
|
|
65
|
-
expect(request.agentId).toBe("agent-2");
|
|
66
|
-
expect(request.sessionKey).toBe("default:admin:telegram");
|
|
67
|
-
expect(request.trustLevel).toBe("admin");
|
|
68
|
-
expect(request.requestId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
69
|
-
expect(typeof request.createdAt).toBe("number");
|
|
70
|
-
expect(request.timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
|
|
71
|
-
});
|
|
72
|
-
it("getRequest(id) returns the correct pending request", () => {
|
|
73
|
-
gate.requestApproval(makeRequest());
|
|
74
|
-
const [pending] = gate.pending();
|
|
75
|
-
const found = gate.getRequest(pending.requestId);
|
|
76
|
-
expect(found).toBe(pending);
|
|
77
|
-
});
|
|
78
|
-
it("getRequest(unknown) returns undefined", () => {
|
|
79
|
-
expect(gate.getRequest("nonexistent-id")).toBeUndefined();
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
// 2. Event emission -- approval:requested
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
describe("approval:requested event", () => {
|
|
86
|
-
it("requestApproval emits approval:requested event on the event bus", () => {
|
|
87
|
-
const handler = vi.fn();
|
|
88
|
-
eventBus.on("approval:requested", handler);
|
|
89
|
-
gate.requestApproval(makeRequest());
|
|
90
|
-
expect(handler).toHaveBeenCalledOnce();
|
|
91
|
-
});
|
|
92
|
-
it("event payload contains all APPR-07 fields", () => {
|
|
93
|
-
const handler = vi.fn();
|
|
94
|
-
eventBus.on("approval:requested", handler);
|
|
95
|
-
gate.requestApproval(makeRequest({
|
|
96
|
-
toolName: "config.write",
|
|
97
|
-
action: "config.write",
|
|
98
|
-
params: { section: "agents" },
|
|
99
|
-
agentId: "agent-3",
|
|
100
|
-
sessionKey: "default:op1:slack",
|
|
101
|
-
trustLevel: "admin",
|
|
102
|
-
}));
|
|
103
|
-
const payload = handler.mock.calls[0][0];
|
|
104
|
-
expect(payload.requestId).toMatch(/^[0-9a-f-]{36}$/);
|
|
105
|
-
expect(payload.toolName).toBe("config.write");
|
|
106
|
-
expect(payload.action).toBe("config.write");
|
|
107
|
-
expect(payload.params).toEqual({ section: "agents" });
|
|
108
|
-
expect(payload.agentId).toBe("agent-3");
|
|
109
|
-
expect(payload.sessionKey).toBe("default:op1:slack");
|
|
110
|
-
expect(payload.trustLevel).toBe("admin");
|
|
111
|
-
expect(typeof payload.createdAt).toBe("number");
|
|
112
|
-
expect(payload.timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// 3. Resolve -- approve
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
describe("resolve -- approve", () => {
|
|
119
|
-
it("resolveApproval with approved=true resolves the promise with approved=true", async () => {
|
|
120
|
-
const promise = gate.requestApproval(makeRequest());
|
|
121
|
-
const [pending] = gate.pending();
|
|
122
|
-
gate.resolveApproval(pending.requestId, true, "operator");
|
|
123
|
-
const result = await promise;
|
|
124
|
-
expect(result.approved).toBe(true);
|
|
125
|
-
expect(result.approvedBy).toBe("operator");
|
|
126
|
-
expect(result.requestId).toBe(pending.requestId);
|
|
127
|
-
expect(typeof result.resolvedAt).toBe("number");
|
|
128
|
-
});
|
|
129
|
-
it("after approval, request is removed from pending list", async () => {
|
|
130
|
-
const promise = gate.requestApproval(makeRequest());
|
|
131
|
-
const [pending] = gate.pending();
|
|
132
|
-
gate.resolveApproval(pending.requestId, true, "admin");
|
|
133
|
-
await promise;
|
|
134
|
-
expect(gate.pending()).toHaveLength(0);
|
|
135
|
-
});
|
|
136
|
-
it("pending() returns empty array after sole request resolved", async () => {
|
|
137
|
-
const promise = gate.requestApproval(makeRequest());
|
|
138
|
-
const [pending] = gate.pending();
|
|
139
|
-
gate.resolveApproval(pending.requestId, true, "admin");
|
|
140
|
-
await promise;
|
|
141
|
-
expect(gate.pending()).toEqual([]);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// 4. Resolve -- deny
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
describe("resolve -- deny", () => {
|
|
148
|
-
it("resolveApproval with approved=false resolves with denial and reason", async () => {
|
|
149
|
-
const promise = gate.requestApproval(makeRequest());
|
|
150
|
-
const [pending] = gate.pending();
|
|
151
|
-
gate.resolveApproval(pending.requestId, false, "operator", "Too risky");
|
|
152
|
-
const result = await promise;
|
|
153
|
-
expect(result.approved).toBe(false);
|
|
154
|
-
expect(result.approvedBy).toBe("operator");
|
|
155
|
-
expect(result.reason).toBe("Too risky");
|
|
156
|
-
expect(result.requestId).toBe(pending.requestId);
|
|
157
|
-
});
|
|
158
|
-
it("after denial, request is removed from pending list", async () => {
|
|
159
|
-
const promise = gate.requestApproval(makeRequest());
|
|
160
|
-
const [pending] = gate.pending();
|
|
161
|
-
gate.resolveApproval(pending.requestId, false, "operator", "Denied");
|
|
162
|
-
await promise;
|
|
163
|
-
expect(gate.pending()).toHaveLength(0);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
// ---------------------------------------------------------------------------
|
|
167
|
-
// 5. Event emission -- approval:resolved
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
describe("approval:resolved event", () => {
|
|
170
|
-
it("resolveApproval emits approval:resolved event", () => {
|
|
171
|
-
const handler = vi.fn();
|
|
172
|
-
eventBus.on("approval:resolved", handler);
|
|
173
|
-
gate.requestApproval(makeRequest());
|
|
174
|
-
const [pending] = gate.pending();
|
|
175
|
-
gate.resolveApproval(pending.requestId, true, "admin");
|
|
176
|
-
expect(handler).toHaveBeenCalledOnce();
|
|
177
|
-
});
|
|
178
|
-
it("event payload includes requestId, approved, approvedBy, reason, resolvedAt", () => {
|
|
179
|
-
const handler = vi.fn();
|
|
180
|
-
eventBus.on("approval:resolved", handler);
|
|
181
|
-
gate.requestApproval(makeRequest());
|
|
182
|
-
const [pending] = gate.pending();
|
|
183
|
-
gate.resolveApproval(pending.requestId, false, "operator", "Suspicious");
|
|
184
|
-
const payload = handler.mock.calls[0][0];
|
|
185
|
-
expect(payload.requestId).toBe(pending.requestId);
|
|
186
|
-
expect(payload.approved).toBe(false);
|
|
187
|
-
expect(payload.approvedBy).toBe("operator");
|
|
188
|
-
expect(payload.reason).toBe("Suspicious");
|
|
189
|
-
expect(typeof payload.resolvedAt).toBe("number");
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// 6. Timeout auto-deny
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
describe("timeout auto-deny", () => {
|
|
196
|
-
it("unanswered request auto-denies after timeout", async () => {
|
|
197
|
-
const promise = gate.requestApproval(makeRequest());
|
|
198
|
-
// Advance time past the timeout
|
|
199
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
200
|
-
const result = await promise;
|
|
201
|
-
expect(result.approved).toBe(false);
|
|
202
|
-
expect(result.approvedBy).toBe("system:timeout");
|
|
203
|
-
expect(result.reason).toBe("Approval request timed out");
|
|
204
|
-
});
|
|
205
|
-
it("approval:resolved event emitted with approvedBy system:timeout", () => {
|
|
206
|
-
const handler = vi.fn();
|
|
207
|
-
eventBus.on("approval:resolved", handler);
|
|
208
|
-
gate.requestApproval(makeRequest());
|
|
209
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
210
|
-
const payload = handler.mock.calls[0][0];
|
|
211
|
-
expect(payload.approved).toBe(false);
|
|
212
|
-
expect(payload.approvedBy).toBe("system:timeout");
|
|
213
|
-
});
|
|
214
|
-
it("request removed from pending list after timeout", () => {
|
|
215
|
-
gate.requestApproval(makeRequest());
|
|
216
|
-
expect(gate.pending()).toHaveLength(1);
|
|
217
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
218
|
-
expect(gate.pending()).toHaveLength(0);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// 7. Idempotent resolve
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
describe("idempotent resolve", () => {
|
|
225
|
-
it("resolving an already-resolved request does not throw", async () => {
|
|
226
|
-
const promise = gate.requestApproval(makeRequest());
|
|
227
|
-
const [pending] = gate.pending();
|
|
228
|
-
const id = pending.requestId;
|
|
229
|
-
gate.resolveApproval(id, true, "admin");
|
|
230
|
-
await promise;
|
|
231
|
-
// Second resolve should be silently ignored
|
|
232
|
-
expect(() => gate.resolveApproval(id, false, "admin2", "Late denial")).not.toThrow();
|
|
233
|
-
});
|
|
234
|
-
it("resolving a timed-out request does not throw", () => {
|
|
235
|
-
gate.requestApproval(makeRequest());
|
|
236
|
-
const [pending] = gate.pending();
|
|
237
|
-
const id = pending.requestId;
|
|
238
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
239
|
-
// Manual resolve after timeout should be silently ignored
|
|
240
|
-
expect(() => gate.resolveApproval(id, true, "admin")).not.toThrow();
|
|
241
|
-
});
|
|
242
|
-
it("resolving an unknown requestId does not throw", () => {
|
|
243
|
-
expect(() => gate.resolveApproval("unknown-id", true, "admin")).not.toThrow();
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
// 8. Multiple concurrent requests
|
|
248
|
-
// ---------------------------------------------------------------------------
|
|
249
|
-
describe("multiple concurrent requests", () => {
|
|
250
|
-
it("two requests can be pending simultaneously", () => {
|
|
251
|
-
gate.requestApproval(makeRequest({ toolName: "tool-a", action: "tool-a.run" }));
|
|
252
|
-
gate.requestApproval(makeRequest({ toolName: "tool-b", action: "tool-b.run" }));
|
|
253
|
-
expect(gate.pending()).toHaveLength(2);
|
|
254
|
-
});
|
|
255
|
-
it("resolving one does not affect the other", async () => {
|
|
256
|
-
const promise1 = gate.requestApproval(makeRequest({ toolName: "tool-a", action: "tool-a.run" }));
|
|
257
|
-
gate.requestApproval(makeRequest({ toolName: "tool-b", action: "tool-b.run" }));
|
|
258
|
-
const pending = gate.pending();
|
|
259
|
-
expect(pending).toHaveLength(2);
|
|
260
|
-
const first = pending[0];
|
|
261
|
-
gate.resolveApproval(first.requestId, true, "admin");
|
|
262
|
-
await promise1;
|
|
263
|
-
// Second request should still be pending
|
|
264
|
-
expect(gate.pending()).toHaveLength(1);
|
|
265
|
-
expect(gate.pending()[0].toolName).toBe("tool-b");
|
|
266
|
-
});
|
|
267
|
-
it("pending() returns correct count throughout lifecycle", async () => {
|
|
268
|
-
expect(gate.pending()).toHaveLength(0);
|
|
269
|
-
const p1 = gate.requestApproval(makeRequest({ toolName: "tool-1", action: "tool-1.run" }));
|
|
270
|
-
expect(gate.pending()).toHaveLength(1);
|
|
271
|
-
const p2 = gate.requestApproval(makeRequest({ toolName: "tool-2", action: "tool-2.run" }));
|
|
272
|
-
expect(gate.pending()).toHaveLength(2);
|
|
273
|
-
const p3 = gate.requestApproval(makeRequest({ toolName: "tool-3", action: "tool-3.run" }));
|
|
274
|
-
expect(gate.pending()).toHaveLength(3);
|
|
275
|
-
// Resolve first
|
|
276
|
-
gate.resolveApproval(gate.pending()[0].requestId, true, "admin");
|
|
277
|
-
await p1;
|
|
278
|
-
expect(gate.pending()).toHaveLength(2);
|
|
279
|
-
// Timeout second
|
|
280
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
281
|
-
await p2;
|
|
282
|
-
await p3;
|
|
283
|
-
expect(gate.pending()).toHaveLength(0);
|
|
284
|
-
});
|
|
285
|
-
});
|
|
286
|
-
// ---------------------------------------------------------------------------
|
|
287
|
-
// 9. dispose()
|
|
288
|
-
// ---------------------------------------------------------------------------
|
|
289
|
-
describe("dispose", () => {
|
|
290
|
-
it("dispose() clears all timers (no lingering timeouts)", () => {
|
|
291
|
-
gate.requestApproval(makeRequest({ toolName: "tool-x" }));
|
|
292
|
-
gate.requestApproval(makeRequest({ toolName: "tool-y" }));
|
|
293
|
-
gate.dispose();
|
|
294
|
-
// Advancing timers should not trigger any timeout auto-deny (timers cleared)
|
|
295
|
-
const resolvedHandler = vi.fn();
|
|
296
|
-
eventBus.on("approval:resolved", resolvedHandler);
|
|
297
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS * 2);
|
|
298
|
-
expect(resolvedHandler).not.toHaveBeenCalled();
|
|
299
|
-
});
|
|
300
|
-
it("pending() returns empty after dispose", () => {
|
|
301
|
-
gate.requestApproval(makeRequest());
|
|
302
|
-
expect(gate.pending()).toHaveLength(1);
|
|
303
|
-
gate.dispose();
|
|
304
|
-
expect(gate.pending()).toHaveLength(0);
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
// ---------------------------------------------------------------------------
|
|
308
|
-
// 10. Denial cache
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
describe("denial cache", () => {
|
|
311
|
-
const DENIAL_CACHE_TTL = 30_000;
|
|
312
|
-
let gateWithTtl;
|
|
313
|
-
beforeEach(() => {
|
|
314
|
-
gateWithTtl = createApprovalGate({
|
|
315
|
-
eventBus,
|
|
316
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
317
|
-
getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
|
|
318
|
-
getBatchApprovalTtlMs: () => 0, // Disable approval cache for denial cache tests
|
|
319
|
-
});
|
|
320
|
-
});
|
|
321
|
-
afterEach(() => {
|
|
322
|
-
gateWithTtl.dispose();
|
|
323
|
-
});
|
|
324
|
-
it("second requestApproval for same sessionKey+action returns cached denial instantly after first denial", async () => {
|
|
325
|
-
// First request: create and deny
|
|
326
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
327
|
-
const [pending1] = gateWithTtl.pending();
|
|
328
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "Not allowed");
|
|
329
|
-
const result1 = await promise1;
|
|
330
|
-
expect(result1.approved).toBe(false);
|
|
331
|
-
// Second request: same sessionKey + action should resolve instantly from cache
|
|
332
|
-
const promise2 = gateWithTtl.requestApproval(makeRequest());
|
|
333
|
-
const result2 = await promise2;
|
|
334
|
-
expect(result2.approved).toBe(false);
|
|
335
|
-
// Should NOT appear in pending list (resolved instantly from cache)
|
|
336
|
-
expect(gateWithTtl.pending()).toHaveLength(0);
|
|
337
|
-
});
|
|
338
|
-
it("cached denial uses approvedBy 'system:cached-denial'", async () => {
|
|
339
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
340
|
-
const [pending1] = gateWithTtl.pending();
|
|
341
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
342
|
-
await promise1;
|
|
343
|
-
const promise2 = gateWithTtl.requestApproval(makeRequest());
|
|
344
|
-
const result2 = await promise2;
|
|
345
|
-
expect(result2.approvedBy).toBe("system:cached-denial");
|
|
346
|
-
expect(result2.reason).toContain("Auto-denied");
|
|
347
|
-
expect(result2.reason).toContain("agents.restart");
|
|
348
|
-
});
|
|
349
|
-
it("cached denial expires after TTL and next request creates a real pending entry", async () => {
|
|
350
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
351
|
-
const [pending1] = gateWithTtl.pending();
|
|
352
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
353
|
-
await promise1;
|
|
354
|
-
// Advance past denial cache TTL
|
|
355
|
-
vi.advanceTimersByTime(DENIAL_CACHE_TTL + 1);
|
|
356
|
-
// Next request should create a real pending entry (cache expired)
|
|
357
|
-
gateWithTtl.requestApproval(makeRequest());
|
|
358
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
359
|
-
});
|
|
360
|
-
it("approval (not denial) does NOT populate the denial cache", async () => {
|
|
361
|
-
// Approve the first request
|
|
362
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
363
|
-
const [pending1] = gateWithTtl.pending();
|
|
364
|
-
gateWithTtl.resolveApproval(pending1.requestId, true, "operator");
|
|
365
|
-
await promise1;
|
|
366
|
-
// Second request should create a real pending entry (no cache)
|
|
367
|
-
gateWithTtl.requestApproval(makeRequest());
|
|
368
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
369
|
-
});
|
|
370
|
-
it("different action on same sessionKey is NOT cached", async () => {
|
|
371
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest({ action: "agents.create" }));
|
|
372
|
-
const [pending1] = gateWithTtl.pending();
|
|
373
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
374
|
-
await promise1;
|
|
375
|
-
// Same sessionKey but different action — should create a real pending entry
|
|
376
|
-
gateWithTtl.requestApproval(makeRequest({ action: "agents.delete" }));
|
|
377
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
378
|
-
});
|
|
379
|
-
it("clearDenialCache(sessionKey) removes entries for that session", async () => {
|
|
380
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
381
|
-
const [pending1] = gateWithTtl.pending();
|
|
382
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
383
|
-
await promise1;
|
|
384
|
-
// Clear cache for this session
|
|
385
|
-
gateWithTtl.clearDenialCache("default:user1:discord");
|
|
386
|
-
// Next request should create a real pending entry (cache cleared)
|
|
387
|
-
gateWithTtl.requestApproval(makeRequest());
|
|
388
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
389
|
-
});
|
|
390
|
-
it("clearDenialCache() with no args clears all entries", async () => {
|
|
391
|
-
// Deny for two different sessions
|
|
392
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
|
|
393
|
-
const [p1] = gateWithTtl.pending();
|
|
394
|
-
gateWithTtl.resolveApproval(p1.requestId, false, "op", "No");
|
|
395
|
-
await promise1;
|
|
396
|
-
const promise2 = gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
|
|
397
|
-
const [p2] = gateWithTtl.pending();
|
|
398
|
-
gateWithTtl.resolveApproval(p2.requestId, false, "op", "No");
|
|
399
|
-
await promise2;
|
|
400
|
-
// Clear all denial cache entries
|
|
401
|
-
gateWithTtl.clearDenialCache();
|
|
402
|
-
// Both sessions should create real pending entries
|
|
403
|
-
gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
|
|
404
|
-
gateWithTtl.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
|
|
405
|
-
expect(gateWithTtl.pending()).toHaveLength(2);
|
|
406
|
-
});
|
|
407
|
-
it("dispose() clears denial cache", async () => {
|
|
408
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
409
|
-
const [pending1] = gateWithTtl.pending();
|
|
410
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
411
|
-
await promise1;
|
|
412
|
-
gateWithTtl.dispose();
|
|
413
|
-
// After dispose, create a fresh gate with same eventBus to test that
|
|
414
|
-
// the old gate's denial cache was cleared. However, since denial cache
|
|
415
|
-
// is internal to the gate instance, we test by creating a new request
|
|
416
|
-
// on a NEW gate (the old gate's cache is irrelevant after dispose).
|
|
417
|
-
// Instead, test that the old gate does not return cached denials after
|
|
418
|
-
// dispose by checking that a new request on a fresh gate works normally.
|
|
419
|
-
const freshGate = createApprovalGate({
|
|
420
|
-
eventBus,
|
|
421
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
422
|
-
getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
|
|
423
|
-
});
|
|
424
|
-
freshGate.requestApproval(makeRequest());
|
|
425
|
-
expect(freshGate.pending()).toHaveLength(1);
|
|
426
|
-
freshGate.dispose();
|
|
427
|
-
});
|
|
428
|
-
it("cached denial emits approval:resolved event", async () => {
|
|
429
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
430
|
-
const [pending1] = gateWithTtl.pending();
|
|
431
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "No");
|
|
432
|
-
await promise1;
|
|
433
|
-
const resolvedHandler = vi.fn();
|
|
434
|
-
eventBus.on("approval:resolved", resolvedHandler);
|
|
435
|
-
const promise2 = gateWithTtl.requestApproval(makeRequest());
|
|
436
|
-
await promise2;
|
|
437
|
-
expect(resolvedHandler).toHaveBeenCalledOnce();
|
|
438
|
-
const payload = resolvedHandler.mock.calls[0][0];
|
|
439
|
-
expect(payload.approved).toBe(false);
|
|
440
|
-
expect(payload.approvedBy).toBe("system:cached-denial");
|
|
441
|
-
});
|
|
442
|
-
it("timeout denial does NOT populate the denial cache", async () => {
|
|
443
|
-
// First request: let it time out
|
|
444
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
445
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
446
|
-
const result1 = await promise1;
|
|
447
|
-
expect(result1.approved).toBe(false);
|
|
448
|
-
expect(result1.approvedBy).toBe("system:timeout");
|
|
449
|
-
// Second identical request: should create a real pending entry (no cached denial)
|
|
450
|
-
gateWithTtl.requestApproval(makeRequest());
|
|
451
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
452
|
-
});
|
|
453
|
-
it("explicit denial still populates cache even after timeout fix", async () => {
|
|
454
|
-
// First request: deny explicitly
|
|
455
|
-
const promise1 = gateWithTtl.requestApproval(makeRequest());
|
|
456
|
-
const [pending1] = gateWithTtl.pending();
|
|
457
|
-
gateWithTtl.resolveApproval(pending1.requestId, false, "operator", "Nope");
|
|
458
|
-
await promise1;
|
|
459
|
-
// Second identical request: should return from cache
|
|
460
|
-
const promise2 = gateWithTtl.requestApproval(makeRequest());
|
|
461
|
-
const result2 = await promise2;
|
|
462
|
-
expect(result2.approved).toBe(false);
|
|
463
|
-
expect(result2.approvedBy).toBe("system:cached-denial");
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
// ---------------------------------------------------------------------------
|
|
467
|
-
// 11. Batch parallel requests
|
|
468
|
-
// ---------------------------------------------------------------------------
|
|
469
|
-
describe("batch parallel requests", () => {
|
|
470
|
-
it("second request for same sessionKey::action joins existing pending instead of creating new entry", async () => {
|
|
471
|
-
const promise1 = gate.requestApproval(makeRequest());
|
|
472
|
-
const promise2 = gate.requestApproval(makeRequest());
|
|
473
|
-
// Only one pending entry (second batched onto first)
|
|
474
|
-
expect(gate.pending()).toHaveLength(1);
|
|
475
|
-
// Resolve the single pending entry
|
|
476
|
-
const [pending] = gate.pending();
|
|
477
|
-
gate.resolveApproval(pending.requestId, true, "admin");
|
|
478
|
-
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
479
|
-
expect(result1.approved).toBe(true);
|
|
480
|
-
expect(result1.approvedBy).toBe("admin");
|
|
481
|
-
expect(result2.approved).toBe(true);
|
|
482
|
-
expect(result2.approvedBy).toBe("admin");
|
|
483
|
-
});
|
|
484
|
-
it("batched follower resolves when primary is approved", async () => {
|
|
485
|
-
const promise1 = gate.requestApproval(makeRequest());
|
|
486
|
-
const promise2 = gate.requestApproval(makeRequest());
|
|
487
|
-
const [pending] = gate.pending();
|
|
488
|
-
gate.resolveApproval(pending.requestId, true, "operator");
|
|
489
|
-
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
490
|
-
expect(result1.approved).toBe(true);
|
|
491
|
-
expect(result2.approved).toBe(true);
|
|
492
|
-
});
|
|
493
|
-
it("batched follower resolves when primary times out", async () => {
|
|
494
|
-
const promise1 = gate.requestApproval(makeRequest());
|
|
495
|
-
const promise2 = gate.requestApproval(makeRequest());
|
|
496
|
-
vi.advanceTimersByTime(DEFAULT_TIMEOUT_MS + 1);
|
|
497
|
-
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
498
|
-
expect(result1.approved).toBe(false);
|
|
499
|
-
expect(result1.approvedBy).toBe("system:timeout");
|
|
500
|
-
expect(result2.approved).toBe(false);
|
|
501
|
-
expect(result2.approvedBy).toBe("system:timeout");
|
|
502
|
-
});
|
|
503
|
-
it("different action on same sessionKey creates separate pending entries (no batching)", () => {
|
|
504
|
-
gate.requestApproval(makeRequest({ action: "agents.create" }));
|
|
505
|
-
gate.requestApproval(makeRequest({ action: "agents.delete" }));
|
|
506
|
-
expect(gate.pending()).toHaveLength(2);
|
|
507
|
-
});
|
|
508
|
-
it("approval:requested event emitted only once for batched requests", () => {
|
|
509
|
-
const handler = vi.fn();
|
|
510
|
-
eventBus.on("approval:requested", handler);
|
|
511
|
-
gate.requestApproval(makeRequest());
|
|
512
|
-
gate.requestApproval(makeRequest());
|
|
513
|
-
// Only the primary request emits the event; the follower joins silently
|
|
514
|
-
expect(handler).toHaveBeenCalledOnce();
|
|
515
|
-
});
|
|
516
|
-
});
|
|
517
|
-
// ---------------------------------------------------------------------------
|
|
518
|
-
// 12. Serialization and restore
|
|
519
|
-
// ---------------------------------------------------------------------------
|
|
520
|
-
describe("serialization and restore", () => {
|
|
521
|
-
it("serializePending() returns correct data for pending requests", () => {
|
|
522
|
-
gate.requestApproval(makeRequest({ action: "agents.create", toolName: "agents_manage" }));
|
|
523
|
-
gate.requestApproval(makeRequest({ action: "agents.delete", toolName: "agents_manage" }));
|
|
524
|
-
const serialized = gate.serializePending();
|
|
525
|
-
expect(serialized).toHaveLength(2);
|
|
526
|
-
expect(serialized[0].action).toBe("agents.create");
|
|
527
|
-
expect(serialized[0].toolName).toBe("agents_manage");
|
|
528
|
-
expect(serialized[0].sessionKey).toBe("default:user1:discord");
|
|
529
|
-
expect(serialized[0].trustLevel).toBe("user");
|
|
530
|
-
expect(typeof serialized[0].requestId).toBe("string");
|
|
531
|
-
expect(typeof serialized[0].createdAt).toBe("number");
|
|
532
|
-
expect(serialized[0].timeoutMs).toBe(DEFAULT_TIMEOUT_MS);
|
|
533
|
-
expect(serialized[1].action).toBe("agents.delete");
|
|
534
|
-
});
|
|
535
|
-
it("restorePending() restores with correct remaining timeout", async () => {
|
|
536
|
-
const now = Date.now();
|
|
537
|
-
const record = {
|
|
538
|
-
requestId: "00000000-0000-0000-0000-000000000001",
|
|
539
|
-
toolName: "agents_manage",
|
|
540
|
-
action: "agents.create",
|
|
541
|
-
params: { agent_id: "bot-1" },
|
|
542
|
-
agentId: "agent-1",
|
|
543
|
-
sessionKey: "default:user1:discord",
|
|
544
|
-
trustLevel: "user",
|
|
545
|
-
createdAt: now - 1000, // 1 second ago
|
|
546
|
-
timeoutMs: 5000, // 5 second timeout -> 4 seconds remaining
|
|
547
|
-
};
|
|
548
|
-
const restored = gate.restorePending([record]);
|
|
549
|
-
expect(restored).toBe(1);
|
|
550
|
-
expect(gate.pending()).toHaveLength(1);
|
|
551
|
-
expect(gate.pending()[0].requestId).toBe("00000000-0000-0000-0000-000000000001");
|
|
552
|
-
// Advance 3999ms -- should still be pending (4000ms remaining)
|
|
553
|
-
vi.advanceTimersByTime(3999);
|
|
554
|
-
expect(gate.pending()).toHaveLength(1);
|
|
555
|
-
// Advance 2 more ms -- should have timed out
|
|
556
|
-
vi.advanceTimersByTime(2);
|
|
557
|
-
expect(gate.pending()).toHaveLength(0);
|
|
558
|
-
});
|
|
559
|
-
it("restorePending() skips expired records", () => {
|
|
560
|
-
const now = Date.now();
|
|
561
|
-
const record = {
|
|
562
|
-
requestId: "00000000-0000-0000-0000-000000000002",
|
|
563
|
-
toolName: "agents_manage",
|
|
564
|
-
action: "agents.create",
|
|
565
|
-
params: { agent_id: "bot-2" },
|
|
566
|
-
agentId: "agent-1",
|
|
567
|
-
sessionKey: "default:user1:discord",
|
|
568
|
-
trustLevel: "user",
|
|
569
|
-
createdAt: now - 10000, // 10 seconds ago
|
|
570
|
-
timeoutMs: 5000, // 5 second timeout -- already expired
|
|
571
|
-
};
|
|
572
|
-
const restored = gate.restorePending([record]);
|
|
573
|
-
expect(restored).toBe(0);
|
|
574
|
-
expect(gate.pending()).toHaveLength(0);
|
|
575
|
-
});
|
|
576
|
-
it("restorePending() emits approval:requested events for restored entries", () => {
|
|
577
|
-
const handler = vi.fn();
|
|
578
|
-
eventBus.on("approval:requested", handler);
|
|
579
|
-
const now = Date.now();
|
|
580
|
-
const record = {
|
|
581
|
-
requestId: "00000000-0000-0000-0000-000000000003",
|
|
582
|
-
toolName: "agents_manage",
|
|
583
|
-
action: "agents.delete",
|
|
584
|
-
params: { agent_id: "bot-3" },
|
|
585
|
-
agentId: "agent-1",
|
|
586
|
-
sessionKey: "default:user1:discord",
|
|
587
|
-
trustLevel: "admin",
|
|
588
|
-
createdAt: now - 500,
|
|
589
|
-
timeoutMs: 10000,
|
|
590
|
-
};
|
|
591
|
-
gate.restorePending([record]);
|
|
592
|
-
expect(handler).toHaveBeenCalledOnce();
|
|
593
|
-
const payload = handler.mock.calls[0][0];
|
|
594
|
-
expect(payload.requestId).toBe("00000000-0000-0000-0000-000000000003");
|
|
595
|
-
expect(payload.action).toBe("agents.delete");
|
|
596
|
-
expect(payload.toolName).toBe("agents_manage");
|
|
597
|
-
});
|
|
598
|
-
});
|
|
599
|
-
// ---------------------------------------------------------------------------
|
|
600
|
-
// 13. Dispose with system:shutdown
|
|
601
|
-
// ---------------------------------------------------------------------------
|
|
602
|
-
describe("dispose with system:shutdown", () => {
|
|
603
|
-
it("dispose() resolves pending promises with system:shutdown denial", async () => {
|
|
604
|
-
const promise = gate.requestApproval(makeRequest());
|
|
605
|
-
gate.dispose();
|
|
606
|
-
const result = await promise;
|
|
607
|
-
expect(result.approved).toBe(false);
|
|
608
|
-
expect(result.approvedBy).toBe("system:shutdown");
|
|
609
|
-
expect(result.reason).toBe("Daemon shutting down");
|
|
610
|
-
});
|
|
611
|
-
it("system:shutdown denial does NOT populate denial cache", async () => {
|
|
612
|
-
const DENIAL_CACHE_TTL = 30_000;
|
|
613
|
-
const gateWithTtl = createApprovalGate({
|
|
614
|
-
eventBus,
|
|
615
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
616
|
-
getDenialCacheTtlMs: () => DENIAL_CACHE_TTL,
|
|
617
|
-
getBatchApprovalTtlMs: () => 0, // Disable approval cache for this test
|
|
618
|
-
});
|
|
619
|
-
// Create a request and resolve it with system:shutdown directly
|
|
620
|
-
const promise = gateWithTtl.requestApproval(makeRequest());
|
|
621
|
-
const [pending] = gateWithTtl.pending();
|
|
622
|
-
gateWithTtl.resolveApproval(pending.requestId, false, "system:shutdown", "Shutting down");
|
|
623
|
-
await promise;
|
|
624
|
-
// Next identical request should create a real pending entry (not cached)
|
|
625
|
-
gateWithTtl.requestApproval(makeRequest());
|
|
626
|
-
expect(gateWithTtl.pending()).toHaveLength(1);
|
|
627
|
-
gateWithTtl.dispose();
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
// ---------------------------------------------------------------------------
|
|
631
|
-
// 14. Approval cache
|
|
632
|
-
// ---------------------------------------------------------------------------
|
|
633
|
-
describe("approval cache", () => {
|
|
634
|
-
const APPROVAL_CACHE_TTL = 15_000;
|
|
635
|
-
let gateWithApprovalCache;
|
|
636
|
-
beforeEach(() => {
|
|
637
|
-
gateWithApprovalCache = createApprovalGate({
|
|
638
|
-
eventBus,
|
|
639
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
640
|
-
getDenialCacheTtlMs: () => 30_000,
|
|
641
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
642
|
-
});
|
|
643
|
-
});
|
|
644
|
-
afterEach(() => {
|
|
645
|
-
gateWithApprovalCache.dispose();
|
|
646
|
-
});
|
|
647
|
-
// -- Cache hit tests --
|
|
648
|
-
it("second requestApproval for same sessionKey+action returns cached approval instantly after first approval", async () => {
|
|
649
|
-
// First request: create and approve
|
|
650
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
651
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
652
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
653
|
-
const result1 = await promise1;
|
|
654
|
-
expect(result1.approved).toBe(true);
|
|
655
|
-
// Second request: same sessionKey + action should resolve instantly from cache
|
|
656
|
-
const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
657
|
-
const result2 = await promise2;
|
|
658
|
-
expect(result2.approved).toBe(true);
|
|
659
|
-
// Should NOT appear in pending list (resolved instantly from cache)
|
|
660
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(0);
|
|
661
|
-
});
|
|
662
|
-
it("cached approval uses approvedBy 'system:cached-approval'", async () => {
|
|
663
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
664
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
665
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
666
|
-
await promise1;
|
|
667
|
-
const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
668
|
-
const result2 = await promise2;
|
|
669
|
-
expect(result2.approvedBy).toBe("system:cached-approval");
|
|
670
|
-
expect(result2.reason).toContain("Auto-approved");
|
|
671
|
-
expect(result2.reason).toContain("agents.restart");
|
|
672
|
-
});
|
|
673
|
-
it("cached approval emits approval:resolved event", async () => {
|
|
674
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
675
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
676
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
677
|
-
await promise1;
|
|
678
|
-
const resolvedHandler = vi.fn();
|
|
679
|
-
eventBus.on("approval:resolved", resolvedHandler);
|
|
680
|
-
const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
681
|
-
await promise2;
|
|
682
|
-
expect(resolvedHandler).toHaveBeenCalledOnce();
|
|
683
|
-
const payload = resolvedHandler.mock.calls[0][0];
|
|
684
|
-
expect(payload.approved).toBe(true);
|
|
685
|
-
expect(payload.approvedBy).toBe("system:cached-approval");
|
|
686
|
-
});
|
|
687
|
-
// -- Cache miss tests --
|
|
688
|
-
it("cached approval expires after TTL and next request creates a real pending entry", async () => {
|
|
689
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
690
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
691
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
692
|
-
await promise1;
|
|
693
|
-
// Advance past approval cache TTL
|
|
694
|
-
vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
|
|
695
|
-
// Next request should create a real pending entry (cache expired)
|
|
696
|
-
gateWithApprovalCache.requestApproval(makeRequest());
|
|
697
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(1);
|
|
698
|
-
});
|
|
699
|
-
it("different action on same sessionKey is NOT cached", async () => {
|
|
700
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest({ action: "agents.create" }));
|
|
701
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
702
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
703
|
-
await promise1;
|
|
704
|
-
// Same sessionKey but different action -- should create a real pending entry
|
|
705
|
-
gateWithApprovalCache.requestApproval(makeRequest({ action: "agents.delete" }));
|
|
706
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(1);
|
|
707
|
-
});
|
|
708
|
-
it("denial does NOT populate the approval cache", async () => {
|
|
709
|
-
// Deny the first request
|
|
710
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
711
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
712
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, false, "operator", "Not allowed");
|
|
713
|
-
await promise1;
|
|
714
|
-
// Clear denial cache so we can test approval cache in isolation
|
|
715
|
-
gateWithApprovalCache.clearDenialCache();
|
|
716
|
-
// Second request should create a real pending entry (no cached approval)
|
|
717
|
-
gateWithApprovalCache.requestApproval(makeRequest());
|
|
718
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(1);
|
|
719
|
-
});
|
|
720
|
-
// -- Disable test --
|
|
721
|
-
it("batchApprovalTtlMs=0 disables approval cache entirely", async () => {
|
|
722
|
-
const disabledGate = createApprovalGate({
|
|
723
|
-
eventBus,
|
|
724
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
725
|
-
getBatchApprovalTtlMs: () => 0,
|
|
726
|
-
});
|
|
727
|
-
// Approve first request
|
|
728
|
-
const promise1 = disabledGate.requestApproval(makeRequest());
|
|
729
|
-
const [pending1] = disabledGate.pending();
|
|
730
|
-
disabledGate.resolveApproval(pending1.requestId, true, "operator");
|
|
731
|
-
await promise1;
|
|
732
|
-
// Second request should create a real pending entry (cache disabled)
|
|
733
|
-
disabledGate.requestApproval(makeRequest());
|
|
734
|
-
expect(disabledGate.pending()).toHaveLength(1);
|
|
735
|
-
disabledGate.dispose();
|
|
736
|
-
});
|
|
737
|
-
// -- Clear tests --
|
|
738
|
-
it("clearApprovalCache(sessionKey) removes entries for that session", async () => {
|
|
739
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
740
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
741
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
742
|
-
await promise1;
|
|
743
|
-
// Clear cache for this session
|
|
744
|
-
gateWithApprovalCache.clearApprovalCache("default:user1:discord");
|
|
745
|
-
// Next request should create a real pending entry (cache cleared)
|
|
746
|
-
gateWithApprovalCache.requestApproval(makeRequest());
|
|
747
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(1);
|
|
748
|
-
});
|
|
749
|
-
it("clearApprovalCache() with no args clears all entries", async () => {
|
|
750
|
-
// Approve for two different sessions
|
|
751
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
|
|
752
|
-
const [p1] = gateWithApprovalCache.pending();
|
|
753
|
-
gateWithApprovalCache.resolveApproval(p1.requestId, true, "op");
|
|
754
|
-
await promise1;
|
|
755
|
-
const promise2 = gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
|
|
756
|
-
const [p2] = gateWithApprovalCache.pending();
|
|
757
|
-
gateWithApprovalCache.resolveApproval(p2.requestId, true, "op");
|
|
758
|
-
await promise2;
|
|
759
|
-
// Clear all approval cache entries
|
|
760
|
-
gateWithApprovalCache.clearApprovalCache();
|
|
761
|
-
// Both sessions should create real pending entries
|
|
762
|
-
gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user1:discord" }));
|
|
763
|
-
gateWithApprovalCache.requestApproval(makeRequest({ sessionKey: "default:user2:telegram" }));
|
|
764
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(2);
|
|
765
|
-
});
|
|
766
|
-
// -- Dispose test --
|
|
767
|
-
it("dispose() clears approval cache", async () => {
|
|
768
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
769
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
770
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
771
|
-
await promise1;
|
|
772
|
-
gateWithApprovalCache.dispose();
|
|
773
|
-
// After dispose, create a fresh gate to verify old cache is gone
|
|
774
|
-
const freshGate = createApprovalGate({
|
|
775
|
-
eventBus,
|
|
776
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
777
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
778
|
-
});
|
|
779
|
-
freshGate.requestApproval(makeRequest());
|
|
780
|
-
expect(freshGate.pending()).toHaveLength(1);
|
|
781
|
-
freshGate.dispose();
|
|
782
|
-
});
|
|
783
|
-
// -- Source-level mutual invalidation tests --
|
|
784
|
-
// These tests read the source .ts file directly to verify structural correctness
|
|
785
|
-
// of mutual invalidation branches that are hard to test purely behaviorally.
|
|
786
|
-
const sourceFilePath = join(dirname(fileURLToPath(import.meta.url)), "approval-gate.ts");
|
|
787
|
-
it("resolveApproval approved path calls denialCache.delete (source-level)", () => {
|
|
788
|
-
const source = readFileSync(sourceFilePath, "utf-8");
|
|
789
|
-
// In the approved branch, denialCache.delete should be called for mutual invalidation
|
|
790
|
-
expect(source).toContain("denialCache.delete(cacheKey)");
|
|
791
|
-
});
|
|
792
|
-
it("resolveApproval explicit deny path calls approvalCache.delete (source-level)", () => {
|
|
793
|
-
const source = readFileSync(sourceFilePath, "utf-8");
|
|
794
|
-
// In the explicit deny branch, approvalCache.delete should be called for mutual invalidation
|
|
795
|
-
expect(source).toContain("approvalCache.delete(cacheKey)");
|
|
796
|
-
});
|
|
797
|
-
it("resolveApproval timeout path calls approvalCache.delete (source-level)", () => {
|
|
798
|
-
const source = readFileSync(sourceFilePath, "utf-8");
|
|
799
|
-
// The timeout branch should also delete from approvalCache
|
|
800
|
-
// Verify by checking that system:timeout branch contains approvalCache.delete
|
|
801
|
-
const timeoutSection = source.split('"system:timeout"')[1];
|
|
802
|
-
expect(timeoutSection).toBeDefined();
|
|
803
|
-
expect(timeoutSection.substring(0, 200)).toContain("approvalCache.delete");
|
|
804
|
-
});
|
|
805
|
-
it("resolveApproval shutdown path does NOT call approvalCache.delete (source-level)", () => {
|
|
806
|
-
const source = readFileSync(sourceFilePath, "utf-8");
|
|
807
|
-
// The shutdown branch should NOT touch approvalCache
|
|
808
|
-
// Extract the shutdown block between system:shutdown and system:timeout in resolveApproval
|
|
809
|
-
const shutdownStart = source.indexOf('"system:shutdown"');
|
|
810
|
-
const timeoutStart = source.indexOf('"system:timeout"');
|
|
811
|
-
expect(shutdownStart).toBeGreaterThan(-1);
|
|
812
|
-
expect(timeoutStart).toBeGreaterThan(-1);
|
|
813
|
-
const shutdownBlock = source.substring(shutdownStart, timeoutStart);
|
|
814
|
-
expect(shutdownBlock).not.toContain("approvalCache.delete");
|
|
815
|
-
});
|
|
816
|
-
it("cached approval does NOT re-populate cache (no infinite TTL extension) (source-level)", () => {
|
|
817
|
-
const source = readFileSync(sourceFilePath, "utf-8");
|
|
818
|
-
// Verify the guard: approvedBy !== "system:cached-approval" before populating approval cache
|
|
819
|
-
expect(source).toContain('!== "system:cached-approval"');
|
|
820
|
-
});
|
|
821
|
-
});
|
|
822
|
-
// ---------------------------------------------------------------------------
|
|
823
|
-
// 15. Approval cache serialization and logging (Phase 437)
|
|
824
|
-
// ---------------------------------------------------------------------------
|
|
825
|
-
describe("approval cache serialization and logging", () => {
|
|
826
|
-
const APPROVAL_CACHE_TTL = 15_000;
|
|
827
|
-
let gateWithApprovalCache;
|
|
828
|
-
beforeEach(() => {
|
|
829
|
-
gateWithApprovalCache = createApprovalGate({
|
|
830
|
-
eventBus,
|
|
831
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
832
|
-
getDenialCacheTtlMs: () => 30_000,
|
|
833
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
834
|
-
});
|
|
835
|
-
});
|
|
836
|
-
afterEach(() => {
|
|
837
|
-
gateWithApprovalCache.dispose();
|
|
838
|
-
});
|
|
839
|
-
// -- Serialization tests (SERIAL-01, TEST-02) --
|
|
840
|
-
it("serializeApprovalCache returns empty array when no cached entries", () => {
|
|
841
|
-
const entries = gateWithApprovalCache.serializeApprovalCache();
|
|
842
|
-
expect(entries).toEqual([]);
|
|
843
|
-
});
|
|
844
|
-
it("serializeApprovalCache returns populated entries after approval", async () => {
|
|
845
|
-
const promise = gateWithApprovalCache.requestApproval(makeRequest());
|
|
846
|
-
const [pending] = gateWithApprovalCache.pending();
|
|
847
|
-
gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
|
|
848
|
-
await promise;
|
|
849
|
-
const entries = gateWithApprovalCache.serializeApprovalCache();
|
|
850
|
-
expect(entries).toHaveLength(1);
|
|
851
|
-
expect(entries[0].cacheKey).toBe("default:user1:discord::agents.restart");
|
|
852
|
-
expect(entries[0].resolution.approved).toBe(true);
|
|
853
|
-
expect(entries[0].resolution.approvedBy).toBe("operator");
|
|
854
|
-
expect(entries[0].expiresAt).toBeGreaterThan(Date.now());
|
|
855
|
-
});
|
|
856
|
-
it("serializeApprovalCache skips expired entries", async () => {
|
|
857
|
-
const promise = gateWithApprovalCache.requestApproval(makeRequest());
|
|
858
|
-
const [pending] = gateWithApprovalCache.pending();
|
|
859
|
-
gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
|
|
860
|
-
await promise;
|
|
861
|
-
// Advance time past TTL
|
|
862
|
-
vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
|
|
863
|
-
const entries = gateWithApprovalCache.serializeApprovalCache();
|
|
864
|
-
expect(entries).toEqual([]);
|
|
865
|
-
});
|
|
866
|
-
it("restoreApprovalCache restores valid entries and auto-approves subsequent requests", async () => {
|
|
867
|
-
const entry = {
|
|
868
|
-
cacheKey: "default:user1:discord::agents.restart",
|
|
869
|
-
resolution: {
|
|
870
|
-
requestId: "00000000-0000-0000-0000-000000000099",
|
|
871
|
-
approved: true,
|
|
872
|
-
approvedBy: "operator",
|
|
873
|
-
resolvedAt: Date.now(),
|
|
874
|
-
},
|
|
875
|
-
expiresAt: Date.now() + 15_000,
|
|
876
|
-
};
|
|
877
|
-
const restored = gateWithApprovalCache.restoreApprovalCache([entry]);
|
|
878
|
-
expect(restored).toBe(1);
|
|
879
|
-
// Submit same request -- should resolve from cache
|
|
880
|
-
const promise = gateWithApprovalCache.requestApproval(makeRequest());
|
|
881
|
-
const result = await promise;
|
|
882
|
-
expect(result.approved).toBe(true);
|
|
883
|
-
expect(result.approvedBy).toBe("system:cached-approval");
|
|
884
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(0);
|
|
885
|
-
});
|
|
886
|
-
it("restoreApprovalCache skips entries with expired expiresAt", () => {
|
|
887
|
-
const entry = {
|
|
888
|
-
cacheKey: "default:user1:discord::agents.restart",
|
|
889
|
-
resolution: {
|
|
890
|
-
requestId: "00000000-0000-0000-0000-000000000098",
|
|
891
|
-
approved: true,
|
|
892
|
-
approvedBy: "operator",
|
|
893
|
-
resolvedAt: Date.now() - 20_000,
|
|
894
|
-
},
|
|
895
|
-
expiresAt: Date.now() - 1000,
|
|
896
|
-
};
|
|
897
|
-
const restored = gateWithApprovalCache.restoreApprovalCache([entry]);
|
|
898
|
-
expect(restored).toBe(0);
|
|
899
|
-
// Submit request -- should go to pending (not cached)
|
|
900
|
-
gateWithApprovalCache.requestApproval(makeRequest());
|
|
901
|
-
expect(gateWithApprovalCache.pending()).toHaveLength(1);
|
|
902
|
-
});
|
|
903
|
-
it("serialize then restore round-trip preserves cache behavior", async () => {
|
|
904
|
-
// Create and approve on first gate
|
|
905
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
906
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
907
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
908
|
-
await promise1;
|
|
909
|
-
// Serialize from first gate
|
|
910
|
-
const entries = gateWithApprovalCache.serializeApprovalCache();
|
|
911
|
-
expect(entries).toHaveLength(1);
|
|
912
|
-
// Create a NEW gate and restore
|
|
913
|
-
const newGate = createApprovalGate({
|
|
914
|
-
eventBus,
|
|
915
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
916
|
-
getDenialCacheTtlMs: () => 30_000,
|
|
917
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
918
|
-
});
|
|
919
|
-
const restored = newGate.restoreApprovalCache(entries);
|
|
920
|
-
expect(restored).toBe(1);
|
|
921
|
-
// Submit same request on new gate -- should auto-approve from restored cache
|
|
922
|
-
const promise2 = newGate.requestApproval(makeRequest());
|
|
923
|
-
const result2 = await promise2;
|
|
924
|
-
expect(result2.approved).toBe(true);
|
|
925
|
-
expect(result2.approvedBy).toBe("system:cached-approval");
|
|
926
|
-
newGate.dispose();
|
|
927
|
-
});
|
|
928
|
-
// -- Shutdown ordering test (SERIAL-02) --
|
|
929
|
-
it("dispose clears approval cache -- serialization must happen before dispose", async () => {
|
|
930
|
-
const promise = gateWithApprovalCache.requestApproval(makeRequest());
|
|
931
|
-
const [pending] = gateWithApprovalCache.pending();
|
|
932
|
-
gateWithApprovalCache.resolveApproval(pending.requestId, true, "operator");
|
|
933
|
-
await promise;
|
|
934
|
-
// Before dispose: serialization returns entries
|
|
935
|
-
const before = gateWithApprovalCache.serializeApprovalCache();
|
|
936
|
-
expect(before).toHaveLength(1);
|
|
937
|
-
// After dispose: serialization returns empty
|
|
938
|
-
gateWithApprovalCache.dispose();
|
|
939
|
-
const after = gateWithApprovalCache.serializeApprovalCache();
|
|
940
|
-
expect(after).toEqual([]);
|
|
941
|
-
});
|
|
942
|
-
// -- Cache logging tests (OBS-03) --
|
|
943
|
-
it("logger.debug called on approval cache hit with cacheKey and ttlRemainingMs", async () => {
|
|
944
|
-
const debugFn = vi.fn();
|
|
945
|
-
const gateWithLogger = createApprovalGate({
|
|
946
|
-
eventBus,
|
|
947
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
948
|
-
getDenialCacheTtlMs: () => 30_000,
|
|
949
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
950
|
-
logger: { debug: debugFn },
|
|
951
|
-
});
|
|
952
|
-
// First: approve
|
|
953
|
-
const promise1 = gateWithLogger.requestApproval(makeRequest());
|
|
954
|
-
const [pending1] = gateWithLogger.pending();
|
|
955
|
-
gateWithLogger.resolveApproval(pending1.requestId, true, "operator");
|
|
956
|
-
await promise1;
|
|
957
|
-
// Second: cache hit
|
|
958
|
-
const promise2 = gateWithLogger.requestApproval(makeRequest());
|
|
959
|
-
await promise2;
|
|
960
|
-
expect(debugFn).toHaveBeenCalledWith(expect.objectContaining({
|
|
961
|
-
cacheKey: "default:user1:discord::agents.restart",
|
|
962
|
-
action: "agents.restart",
|
|
963
|
-
}), "Approval cache hit");
|
|
964
|
-
gateWithLogger.dispose();
|
|
965
|
-
});
|
|
966
|
-
it("expired approval cache entry does not produce cache hit", async () => {
|
|
967
|
-
const debugFn = vi.fn();
|
|
968
|
-
const gateWithLogger = createApprovalGate({
|
|
969
|
-
eventBus,
|
|
970
|
-
getTimeoutMs: () => DEFAULT_TIMEOUT_MS,
|
|
971
|
-
getDenialCacheTtlMs: () => 30_000,
|
|
972
|
-
getBatchApprovalTtlMs: () => APPROVAL_CACHE_TTL,
|
|
973
|
-
logger: { debug: debugFn },
|
|
974
|
-
});
|
|
975
|
-
// First: approve
|
|
976
|
-
const promise1 = gateWithLogger.requestApproval(makeRequest());
|
|
977
|
-
const [pending1] = gateWithLogger.pending();
|
|
978
|
-
gateWithLogger.resolveApproval(pending1.requestId, true, "operator");
|
|
979
|
-
await promise1;
|
|
980
|
-
// Advance past TTL
|
|
981
|
-
vi.advanceTimersByTime(APPROVAL_CACHE_TTL + 1);
|
|
982
|
-
// Submit same request -- TTLCache auto-evicts expired entry, so no cache hit
|
|
983
|
-
gateWithLogger.requestApproval(makeRequest());
|
|
984
|
-
// No "Approval cache hit" debug log should be emitted for the second request
|
|
985
|
-
const cacheHitCalls = debugFn.mock.calls.filter((call) => call[1] === "Approval cache hit");
|
|
986
|
-
expect(cacheHitCalls).toHaveLength(0);
|
|
987
|
-
// The request should be pending (not auto-resolved from cache)
|
|
988
|
-
expect(gateWithLogger.pending()).toHaveLength(1);
|
|
989
|
-
gateWithLogger.dispose();
|
|
990
|
-
});
|
|
991
|
-
it("no logger crash when logger dep is omitted", async () => {
|
|
992
|
-
// This gate uses the beforeEach gate which has NO logger dep
|
|
993
|
-
const promise1 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
994
|
-
const [pending1] = gateWithApprovalCache.pending();
|
|
995
|
-
gateWithApprovalCache.resolveApproval(pending1.requestId, true, "operator");
|
|
996
|
-
await promise1;
|
|
997
|
-
// Second request: cache hit -- should not throw even without logger
|
|
998
|
-
const promise2 = gateWithApprovalCache.requestApproval(makeRequest());
|
|
999
|
-
const result2 = await promise2;
|
|
1000
|
-
expect(result2.approved).toBe(true);
|
|
1001
|
-
expect(result2.approvedBy).toBe("system:cached-approval");
|
|
1002
|
-
});
|
|
1003
|
-
});
|