@checkstack/healthcheck-backend 1.4.0 → 1.6.0
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/CHANGELOG.md +303 -0
- package/drizzle/0018_abnormal_preak.sql +10 -0
- package/drizzle/meta/0018_snapshot.json +600 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +26 -21
- package/src/ai/assertion-validation.test.ts +117 -0
- package/src/ai/assertion-validation.ts +147 -0
- package/src/ai/healthcheck-capabilities.test.ts +158 -0
- package/src/ai/healthcheck-capabilities.ts +217 -0
- package/src/ai/healthcheck-delete.test.ts +81 -0
- package/src/ai/healthcheck-delete.ts +81 -0
- package/src/ai/healthcheck-projection.test.ts +36 -0
- package/src/ai/healthcheck-propose.test.ts +268 -0
- package/src/ai/healthcheck-propose.ts +290 -0
- package/src/ai/healthcheck-script-tools.test.ts +93 -0
- package/src/ai/healthcheck-script-tools.ts +179 -0
- package/src/ai/healthcheck-update.test.ts +123 -0
- package/src/ai/healthcheck-update.ts +123 -0
- package/src/ai/notify-subscribers.test.ts +109 -0
- package/src/ai/notify-subscribers.ts +176 -0
- package/src/ai/register-ai-tools.test.ts +41 -0
- package/src/ai/register-ai-tools.ts +53 -0
- package/src/ai/shell-env-table.test.ts +47 -0
- package/src/automations.test.ts +2 -1
- package/src/automations.ts +9 -1
- package/src/collector-script-test.test.ts +53 -1
- package/src/collector-script-test.ts +59 -7
- package/src/effective-environments.test.ts +93 -0
- package/src/effective-environments.ts +64 -0
- package/src/health-entity-id.ts +57 -0
- package/src/health-entity.test.ts +405 -31
- package/src/health-entity.ts +99 -43
- package/src/health-state.ts +41 -4
- package/src/healthcheck-gitops-kinds.test.ts +95 -0
- package/src/healthcheck-gitops-kinds.ts +56 -13
- package/src/index.ts +33 -0
- package/src/migration-chain-contract.test.ts +57 -0
- package/src/queue-executor.test.ts +814 -0
- package/src/queue-executor.ts +342 -50
- package/src/realtime-aggregation.test.ts +30 -0
- package/src/realtime-aggregation.ts +16 -0
- package/src/retention-job.ts +167 -93
- package/src/retention-rollup.test.ts +118 -0
- package/src/router.test.ts +120 -1
- package/src/router.ts +20 -0
- package/src/schema.ts +44 -6
- package/src/service.ts +199 -43
- package/src/state-evaluator.test.ts +50 -5
- package/src/state-evaluator.ts +9 -2
- package/src/state-transitions.test.ts +104 -0
- package/src/state-transitions.ts +39 -1
- package/src/validate-configuration.test.ts +205 -0
- package/src/validate-configuration.ts +159 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import { createHealthcheckDeleteTool } from "./healthcheck-delete";
|
|
4
|
+
|
|
5
|
+
const principal: AuthUser = {
|
|
6
|
+
type: "user",
|
|
7
|
+
id: "u1",
|
|
8
|
+
accessRules: ["healthcheck.healthcheck.manage"],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const config = {
|
|
12
|
+
id: "hc1",
|
|
13
|
+
name: "google-com-http",
|
|
14
|
+
strategyId: "healthcheck-http.http",
|
|
15
|
+
config: {},
|
|
16
|
+
intervalSeconds: 60,
|
|
17
|
+
paused: false,
|
|
18
|
+
createdAt: new Date(),
|
|
19
|
+
updatedAt: new Date(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function fakeRpcClient({
|
|
23
|
+
getConfiguration,
|
|
24
|
+
deleteConfiguration,
|
|
25
|
+
}: {
|
|
26
|
+
getConfiguration: ReturnType<typeof mock>;
|
|
27
|
+
deleteConfiguration: ReturnType<typeof mock>;
|
|
28
|
+
}): RpcClient {
|
|
29
|
+
return {
|
|
30
|
+
forPlugin: () => ({ getConfiguration, deleteConfiguration }),
|
|
31
|
+
} as unknown as RpcClient;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("healthcheck.delete tool", () => {
|
|
35
|
+
test("declares destructive effect + the manage rule", () => {
|
|
36
|
+
const tool = createHealthcheckDeleteTool();
|
|
37
|
+
expect(tool.name).toBe("healthcheck.delete");
|
|
38
|
+
expect(tool.effect).toBe("destructive");
|
|
39
|
+
expect(tool.requiredAccessRules).toEqual(["healthcheck.healthcheck.manage"]);
|
|
40
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("dryRun resolves the target and NEVER deletes", async () => {
|
|
44
|
+
const getConfiguration = mock(() => Promise.resolve(config));
|
|
45
|
+
const deleteConfiguration = mock(() => Promise.resolve());
|
|
46
|
+
const rpcClient = fakeRpcClient({ getConfiguration, deleteConfiguration });
|
|
47
|
+
const tool = createHealthcheckDeleteTool();
|
|
48
|
+
const preview = await tool.dryRun!({
|
|
49
|
+
input: { id: "hc1" },
|
|
50
|
+
principal,
|
|
51
|
+
rpcClient,
|
|
52
|
+
});
|
|
53
|
+
expect(deleteConfiguration).not.toHaveBeenCalled();
|
|
54
|
+
expect(preview.summary).toContain("google-com-http");
|
|
55
|
+
expect(preview.summary).toContain("permanent");
|
|
56
|
+
expect(preview.payload).toEqual({ id: "hc1" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("dryRun throws a clear error when the id is unknown", async () => {
|
|
60
|
+
const rpcClient = fakeRpcClient({
|
|
61
|
+
getConfiguration: mock(() => Promise.resolve(undefined)),
|
|
62
|
+
deleteConfiguration: mock(),
|
|
63
|
+
});
|
|
64
|
+
const tool = createHealthcheckDeleteTool();
|
|
65
|
+
await expect(
|
|
66
|
+
tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
|
|
67
|
+
).rejects.toThrow(/No health check found/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("execute (apply) deletes via deleteConfiguration", async () => {
|
|
71
|
+
const deleteConfiguration = mock(() => Promise.resolve());
|
|
72
|
+
const rpcClient = fakeRpcClient({
|
|
73
|
+
getConfiguration: mock(() => Promise.resolve(config)),
|
|
74
|
+
deleteConfiguration,
|
|
75
|
+
});
|
|
76
|
+
const tool = createHealthcheckDeleteTool();
|
|
77
|
+
const result = await tool.execute({ input: { id: "hc1" }, principal, rpcClient });
|
|
78
|
+
expect(deleteConfiguration).toHaveBeenCalledWith("hc1");
|
|
79
|
+
expect(result).toEqual({ id: "hc1", deleted: true });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { qualifyAccessRuleId } from "@checkstack/common";
|
|
3
|
+
import type { RpcClient, AuthUser } from "@checkstack/backend-api";
|
|
4
|
+
import {
|
|
5
|
+
HealthCheckApi,
|
|
6
|
+
healthCheckAccess,
|
|
7
|
+
pluginMetadata as healthcheckPluginMetadata,
|
|
8
|
+
} from "@checkstack/healthcheck-common";
|
|
9
|
+
import type { AiProposalPreview } from "@checkstack/ai-common";
|
|
10
|
+
import type { RegisteredAiTool } from "@checkstack/ai-backend";
|
|
11
|
+
|
|
12
|
+
/** Input for `healthcheck.delete`: the configuration id to remove. */
|
|
13
|
+
export const HealthcheckDeleteInputSchema = z.object({
|
|
14
|
+
id: z.string().min(1),
|
|
15
|
+
});
|
|
16
|
+
export type HealthcheckDeleteInput = z.infer<typeof HealthcheckDeleteInputSchema>;
|
|
17
|
+
|
|
18
|
+
/** Output returned once a human applies the deletion. */
|
|
19
|
+
export interface HealthcheckDeleteApplyResult {
|
|
20
|
+
id: string;
|
|
21
|
+
deleted: true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `healthcheck.delete` - delete a health-check configuration by id.
|
|
26
|
+
*
|
|
27
|
+
* `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes
|
|
28
|
+
* through the propose/apply confirm card in BOTH permission modes (it can never
|
|
29
|
+
* auto-apply). `dryRun` resolves the target config so the confirm card names
|
|
30
|
+
* exactly what will be removed; `execute` (reached only via `apply`) performs
|
|
31
|
+
* the delete. Authorization is the same `configuration.manage` rule the UI
|
|
32
|
+
* delete requires, re-checked at propose AND apply time by the propose/apply
|
|
33
|
+
* service. The underlying RPC calls use the USER-SCOPED client passed at call
|
|
34
|
+
* time, so handler-side authorization (access rules AND per-resource/team
|
|
35
|
+
* scoping) is enforced exactly as a direct UI/RPC call.
|
|
36
|
+
*/
|
|
37
|
+
export function createHealthcheckDeleteTool(): RegisteredAiTool<
|
|
38
|
+
HealthcheckDeleteInput,
|
|
39
|
+
HealthcheckDeleteApplyResult
|
|
40
|
+
> {
|
|
41
|
+
const dryRun = async ({
|
|
42
|
+
input,
|
|
43
|
+
rpcClient,
|
|
44
|
+
}: {
|
|
45
|
+
input: HealthcheckDeleteInput;
|
|
46
|
+
principal: AuthUser;
|
|
47
|
+
rpcClient: RpcClient;
|
|
48
|
+
}): Promise<AiProposalPreview<HealthcheckDeleteInput>> => {
|
|
49
|
+
const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
50
|
+
const config = await healthcheckClient.getConfiguration({ id: input.id });
|
|
51
|
+
if (!config) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`No health check found with id "${input.id}". List health checks first to get a valid id.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
summary: `Delete health check "${config.name}" (strategy ${config.strategyId}). This is permanent and also removes its system assignments.`,
|
|
58
|
+
payload: { id: input.id },
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: "healthcheck.delete",
|
|
64
|
+
description:
|
|
65
|
+
"Delete a health-check configuration by id. DESTRUCTIVE and irreversible - it also removes the check's system assignments. Never deletes directly; a person must approve the confirmation. Find the id with the health-check read tools first.",
|
|
66
|
+
effect: "destructive",
|
|
67
|
+
input: HealthcheckDeleteInputSchema,
|
|
68
|
+
requiredAccessRules: [
|
|
69
|
+
qualifyAccessRuleId(
|
|
70
|
+
healthcheckPluginMetadata,
|
|
71
|
+
healthCheckAccess.configuration.manage,
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
dryRun,
|
|
75
|
+
async execute({ input, rpcClient }) {
|
|
76
|
+
const healthcheckClient = rpcClient.forPlugin(HealthCheckApi);
|
|
77
|
+
await healthcheckClient.deleteConfiguration(input.id);
|
|
78
|
+
return { id: input.id, deleted: true };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildProjectedTool,
|
|
4
|
+
deferredProjectionExecute,
|
|
5
|
+
} from "@checkstack/ai-backend";
|
|
6
|
+
import { healthCheckContract, pluginMetadata } from "@checkstack/healthcheck-common";
|
|
7
|
+
|
|
8
|
+
// Build the projected tool with the SAME inputs the plugin exposes via
|
|
9
|
+
// aiToolProjectionExtensionPoint in `index.ts`, and assert the resulting tool
|
|
10
|
+
// carries the source procedure's contract access rules - NOT the chat
|
|
11
|
+
// transport's `ai.chat.read` gate.
|
|
12
|
+
describe("healthcheck.status projection", () => {
|
|
13
|
+
const tool = buildProjectedTool({
|
|
14
|
+
procedure: healthCheckContract.getConfigurations,
|
|
15
|
+
sourcePluginMetadata: pluginMetadata,
|
|
16
|
+
procedureKey: "getConfigurations",
|
|
17
|
+
name: "healthcheck.status",
|
|
18
|
+
description:
|
|
19
|
+
"List health-check configurations and their current status. Read-only.",
|
|
20
|
+
effect: "read",
|
|
21
|
+
execute: deferredProjectionExecute,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("uses the overridden tool name", () => {
|
|
25
|
+
expect(tool.name).toBe("healthcheck.status");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("is classified as a read-only effect", () => {
|
|
29
|
+
expect(tool.effect).toBe("read");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("inherits the source procedure's access rules, not the chat gate", () => {
|
|
33
|
+
expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
|
|
34
|
+
expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, test, mock } from "bun:test";
|
|
2
|
+
import type { AuthUser, RpcClient } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
createHealthcheckProposeTool,
|
|
5
|
+
extractCollectorScriptSource,
|
|
6
|
+
} from "./healthcheck-propose";
|
|
7
|
+
|
|
8
|
+
const principal: AuthUser = {
|
|
9
|
+
type: "user",
|
|
10
|
+
id: "u1",
|
|
11
|
+
accessRules: ["healthcheck.healthcheck.manage"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const httpStrategy = {
|
|
15
|
+
id: "healthcheck-http.http",
|
|
16
|
+
displayName: "HTTP",
|
|
17
|
+
description: "HTTP probe",
|
|
18
|
+
category: "network",
|
|
19
|
+
configSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: { url: { type: "string" }, method: { type: "string" } },
|
|
22
|
+
required: ["url"],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const scriptCollector = {
|
|
27
|
+
id: "script.inline",
|
|
28
|
+
displayName: "Inline script",
|
|
29
|
+
description: "Inline TS collector",
|
|
30
|
+
configSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: { script: { type: "string" } },
|
|
33
|
+
required: ["script"],
|
|
34
|
+
},
|
|
35
|
+
resultSchema: {},
|
|
36
|
+
allowMultiple: true,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Default fake `validateConfiguration` that reports the draft as valid. */
|
|
40
|
+
const okValidate = () => mock(() => Promise.resolve({ valid: true, errors: [] }));
|
|
41
|
+
|
|
42
|
+
function fakeRpcClient({
|
|
43
|
+
getStrategies,
|
|
44
|
+
getCollectors,
|
|
45
|
+
createConfiguration,
|
|
46
|
+
validateConfiguration,
|
|
47
|
+
}: {
|
|
48
|
+
getStrategies: ReturnType<typeof mock>;
|
|
49
|
+
getCollectors: ReturnType<typeof mock>;
|
|
50
|
+
createConfiguration: ReturnType<typeof mock>;
|
|
51
|
+
validateConfiguration?: ReturnType<typeof mock>;
|
|
52
|
+
}): RpcClient {
|
|
53
|
+
return {
|
|
54
|
+
forPlugin: () => ({
|
|
55
|
+
getStrategies,
|
|
56
|
+
getCollectors,
|
|
57
|
+
createConfiguration,
|
|
58
|
+
validateConfiguration: validateConfiguration ?? okValidate(),
|
|
59
|
+
}),
|
|
60
|
+
} as unknown as RpcClient;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const validInput = {
|
|
64
|
+
name: "Probe foo status",
|
|
65
|
+
strategyId: "healthcheck-http.http",
|
|
66
|
+
config: { url: "https://foo.bar/status" },
|
|
67
|
+
intervalSeconds: 60,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("extractCollectorScriptSource", () => {
|
|
71
|
+
test("reads script or source string", () => {
|
|
72
|
+
expect(extractCollectorScriptSource({ script: "code" })).toBe("code");
|
|
73
|
+
expect(extractCollectorScriptSource({ source: "code2" })).toBe("code2");
|
|
74
|
+
expect(extractCollectorScriptSource({ other: 1 })).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("healthcheck.propose composite tool", () => {
|
|
79
|
+
test("declares mutate effect + a SINGLE healthcheck manage rule", () => {
|
|
80
|
+
const tool = createHealthcheckProposeTool();
|
|
81
|
+
expect(tool.name).toBe("healthcheck.propose");
|
|
82
|
+
expect(tool.effect).toBe("mutate");
|
|
83
|
+
expect(tool.requiredAccessRules).toEqual([
|
|
84
|
+
"healthcheck.healthcheck.manage",
|
|
85
|
+
]);
|
|
86
|
+
// A single rule keeps the framework's AND-gate correct.
|
|
87
|
+
expect(tool.requiredAccessRules).toHaveLength(1);
|
|
88
|
+
expect(typeof tool.dryRun).toBe("function");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("dryRun deep-validates via validateConfiguration and NEVER creates", async () => {
|
|
92
|
+
const validateConfiguration = okValidate();
|
|
93
|
+
const getStrategies = mock(() => Promise.resolve([httpStrategy]));
|
|
94
|
+
const getCollectors = mock(() => Promise.resolve([]));
|
|
95
|
+
const createConfiguration = mock(() => Promise.resolve({}));
|
|
96
|
+
const rpcClient = fakeRpcClient({
|
|
97
|
+
getStrategies,
|
|
98
|
+
getCollectors,
|
|
99
|
+
createConfiguration,
|
|
100
|
+
validateConfiguration,
|
|
101
|
+
});
|
|
102
|
+
const tool = createHealthcheckProposeTool();
|
|
103
|
+
|
|
104
|
+
const preview = await tool.dryRun!({
|
|
105
|
+
input: validInput,
|
|
106
|
+
principal,
|
|
107
|
+
rpcClient,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Deep validation runs against the live registry path, not a hand-rolled
|
|
111
|
+
// presence check.
|
|
112
|
+
expect(validateConfiguration).toHaveBeenCalledTimes(1);
|
|
113
|
+
// The AI never silently creates a health check: create is NOT called at propose.
|
|
114
|
+
expect(createConfiguration).not.toHaveBeenCalled();
|
|
115
|
+
expect(preview.summary).toContain("Probe foo status");
|
|
116
|
+
expect(preview.summary).toContain("HTTP");
|
|
117
|
+
expect(preview.summary).toContain("60s");
|
|
118
|
+
const payload = preview.payload as { name: string; yaml: string };
|
|
119
|
+
expect(payload.name).toBe("Probe foo status");
|
|
120
|
+
expect(payload.yaml).toContain("strategyId:");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("dryRun describes a script collector on the confirm card", async () => {
|
|
124
|
+
const getStrategies = mock(() => Promise.resolve([httpStrategy]));
|
|
125
|
+
const getCollectors = mock(() => Promise.resolve([scriptCollector]));
|
|
126
|
+
const rpcClient = fakeRpcClient({
|
|
127
|
+
getStrategies,
|
|
128
|
+
getCollectors,
|
|
129
|
+
createConfiguration: mock(),
|
|
130
|
+
});
|
|
131
|
+
const tool = createHealthcheckProposeTool();
|
|
132
|
+
|
|
133
|
+
const preview = await tool.dryRun!({
|
|
134
|
+
input: {
|
|
135
|
+
...validInput,
|
|
136
|
+
collectors: [
|
|
137
|
+
{
|
|
138
|
+
id: "c1",
|
|
139
|
+
collectorId: "script.inline",
|
|
140
|
+
config: { script: "export default defineHealthCheck(() => ({}))" },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
principal,
|
|
145
|
+
rpcClient,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(getCollectors).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(preview.summary).toContain("script collector");
|
|
150
|
+
expect(preview.summary).toContain("1 collector");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("dryRun surfaces an unknown-strategy error from validateConfiguration", async () => {
|
|
154
|
+
const validateConfiguration = mock(() =>
|
|
155
|
+
Promise.resolve({
|
|
156
|
+
valid: false,
|
|
157
|
+
errors: [
|
|
158
|
+
{
|
|
159
|
+
path: ["strategyId"],
|
|
160
|
+
message: 'Unknown health-check strategy "nope.nope".',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
const rpcClient = fakeRpcClient({
|
|
166
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
167
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
168
|
+
createConfiguration: mock(),
|
|
169
|
+
validateConfiguration,
|
|
170
|
+
});
|
|
171
|
+
const tool = createHealthcheckProposeTool();
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
tool.dryRun!({
|
|
175
|
+
input: { ...validInput, strategyId: "nope.nope" },
|
|
176
|
+
principal,
|
|
177
|
+
rpcClient,
|
|
178
|
+
}),
|
|
179
|
+
).rejects.toThrow(/invalid/i);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// The deep-vs-lightweight proof at the propose layer: `url` IS present
|
|
183
|
+
// (the old presence check would pass), but it holds the WRONG TYPE. The deep
|
|
184
|
+
// validateConfiguration path rejects it, so dryRun surfaces the error.
|
|
185
|
+
test("dryRun surfaces a deep type error the old presence check would miss", async () => {
|
|
186
|
+
const validateConfiguration = mock(() =>
|
|
187
|
+
Promise.resolve({
|
|
188
|
+
valid: false,
|
|
189
|
+
errors: [{ path: ["config", "url"], message: "Expected string" }],
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
const rpcClient = fakeRpcClient({
|
|
193
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
194
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
195
|
+
createConfiguration: mock(),
|
|
196
|
+
validateConfiguration,
|
|
197
|
+
});
|
|
198
|
+
const tool = createHealthcheckProposeTool();
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
tool.dryRun!({
|
|
202
|
+
// `url` present but a number, not a string.
|
|
203
|
+
input: { ...validInput, config: { url: 12345 } },
|
|
204
|
+
principal,
|
|
205
|
+
rpcClient,
|
|
206
|
+
}),
|
|
207
|
+
).rejects.toThrow(/invalid/i);
|
|
208
|
+
expect(validateConfiguration).toHaveBeenCalledTimes(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("dryRun surfaces an unknown-collector error from validateConfiguration", async () => {
|
|
212
|
+
const validateConfiguration = mock(() =>
|
|
213
|
+
Promise.resolve({
|
|
214
|
+
valid: false,
|
|
215
|
+
errors: [
|
|
216
|
+
{
|
|
217
|
+
path: ["collectors", 0, "collectorId"],
|
|
218
|
+
message: 'Unknown collector "does.not-exist".',
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
const rpcClient = fakeRpcClient({
|
|
224
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
225
|
+
getCollectors: mock(() => Promise.resolve([scriptCollector])),
|
|
226
|
+
createConfiguration: mock(),
|
|
227
|
+
validateConfiguration,
|
|
228
|
+
});
|
|
229
|
+
const tool = createHealthcheckProposeTool();
|
|
230
|
+
|
|
231
|
+
await expect(
|
|
232
|
+
tool.dryRun!({
|
|
233
|
+
input: {
|
|
234
|
+
...validInput,
|
|
235
|
+
collectors: [
|
|
236
|
+
{ id: "c1", collectorId: "does.not-exist", config: {} },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
principal,
|
|
240
|
+
rpcClient,
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow(/invalid/i);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("execute (apply) creates the configuration via createConfiguration", async () => {
|
|
246
|
+
const created = {
|
|
247
|
+
id: "hc1",
|
|
248
|
+
name: "Probe foo status",
|
|
249
|
+
strategyId: "healthcheck-http.http",
|
|
250
|
+
config: { url: "https://foo.bar/status" },
|
|
251
|
+
intervalSeconds: 60,
|
|
252
|
+
paused: false,
|
|
253
|
+
createdAt: new Date(),
|
|
254
|
+
updatedAt: new Date(),
|
|
255
|
+
};
|
|
256
|
+
const createConfiguration = mock(() => Promise.resolve(created));
|
|
257
|
+
const rpcClient = fakeRpcClient({
|
|
258
|
+
getStrategies: mock(() => Promise.resolve([httpStrategy])),
|
|
259
|
+
getCollectors: mock(() => Promise.resolve([])),
|
|
260
|
+
createConfiguration,
|
|
261
|
+
});
|
|
262
|
+
const tool = createHealthcheckProposeTool();
|
|
263
|
+
|
|
264
|
+
const result = await tool.execute({ input: validInput, principal, rpcClient });
|
|
265
|
+
expect(createConfiguration).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(result.configuration.id).toBe("hc1");
|
|
267
|
+
});
|
|
268
|
+
});
|