mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old engine stands down after. Deletes the rule/firing tables, routes, UI, and AI tools; keeps notification channels since automations still use them. Also fixes automations' notify step, which still called the deleted repository.
This commit is contained in:
1 parent
e0f14f2ecf
commit
e372b0eda7
101 files changed
+29118
-5539
No files matched your search
@@ -1,471 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { AlertRepository } from "../../../database/repositories/alert-repository.js";
|
||||
import { DataCrypto } from "../../../utils/data-crypto.js";
|
||||
|
||||
describe("AlertRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<AlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'alpha', '127.0.0.1', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
return new AlertRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("manages notification channels", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
enabled: 1,
|
||||
});
|
||||
|
||||
const updated = await repo.updateNotificationChannel(created.id, "user-1", {
|
||||
name: "Ops disabled",
|
||||
enabled: false,
|
||||
});
|
||||
expect(updated).toMatchObject({ name: "Ops disabled", enabled: 0 });
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(1);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-2")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await repo.deleteNotificationChannel(created.id, "user-1")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.listNotificationChannels("user-1")).toHaveLength(0);
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
|
||||
it("manages alert rules and linked channels", async () => {
|
||||
const repo = await createRepository();
|
||||
const ownedChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Owned",
|
||||
type: "ntfy",
|
||||
config: '{"url":"https://ntfy.test","topic":"termix"}',
|
||||
enabled: true,
|
||||
});
|
||||
const foreignChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Foreign",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const created = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [ownedChannel.id, foreignChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
user_id: "user-1",
|
||||
host_id: null,
|
||||
name: "CPU high",
|
||||
trigger_type: "cpu_threshold",
|
||||
threshold_value: 80,
|
||||
channels: [ownedChannel.id],
|
||||
});
|
||||
|
||||
const updated = await repo.updateAlertRule(created.id, "user-1", {
|
||||
name: "CPU very high",
|
||||
hostId: 1,
|
||||
channels: [],
|
||||
now: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
name: "CPU very high",
|
||||
host_id: 1,
|
||||
channels: [],
|
||||
updated_at: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const rules = await repo.listAlertRules("user-1");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].channels).toEqual([]);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-2")).toBe(false);
|
||||
expect(await repo.deleteAlertRule(created.id, "user-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("lists, acknowledges, and prunes firings", async () => {
|
||||
const repo = await createRepository();
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Host offline",
|
||||
enabled: true,
|
||||
triggerType: "host_offline",
|
||||
thresholdValue: null,
|
||||
thresholdDurationSeconds: null,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: rule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: null,
|
||||
message: "down",
|
||||
severity: "critical",
|
||||
});
|
||||
|
||||
const listed = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(listed.total).toBe(1);
|
||||
expect(listed.firings[0]).toMatchObject({
|
||||
rule_id: rule.id,
|
||||
host_name: "alpha",
|
||||
acknowledged: 0,
|
||||
rule_name: "Host offline",
|
||||
});
|
||||
|
||||
await repo.acknowledgeFiring(listed.firings[0].id, "user-1");
|
||||
const unacknowledged = await repo.listAlertFirings({
|
||||
userId: "user-1",
|
||||
acknowledged: false,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(unacknowledged.total).toBe(0);
|
||||
|
||||
await repo.acknowledgeAllFirings("user-1");
|
||||
await repo.pruneFiringsOlderThan("user-1", 0);
|
||||
});
|
||||
|
||||
it("loads enabled rules and notification channels for the alert engine", async () => {
|
||||
const repo = await createRepository();
|
||||
const channel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const disabledChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Disabled",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://disabled.test"}',
|
||||
enabled: false,
|
||||
});
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [channel.id, disabledChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(await repo.listEnabledRulesForHost(1)).toMatchObject([
|
||||
{
|
||||
id: rule.id,
|
||||
userId: "user-1",
|
||||
triggerType: "cpu_threshold",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(await repo.findRuleById(rule.id)).toMatchObject({
|
||||
id: rule.id,
|
||||
cooldownMinutes: 15,
|
||||
});
|
||||
expect(await repo.listEnabledChannelsForRule(rule.id)).toEqual([
|
||||
{
|
||||
id: channel.id,
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps another user's wildcard rules off a host they do not own", async () => {
|
||||
const repo = await createRepository();
|
||||
await adapter!.exec(`
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (2, 'user-2', 'bravo', '127.0.0.2', 22, 'root', 'password');
|
||||
`);
|
||||
|
||||
const ownerRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "Owner wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Other wildcard",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
// Host 1 belongs to user-1, so only user-1's wildcard rule may fire.
|
||||
const forHostOne = await repo.listEnabledRulesForHost(1);
|
||||
expect(forHostOne.map((rule) => rule.id)).toEqual([ownerRule.id]);
|
||||
|
||||
const forHostTwo = await repo.listEnabledRulesForHost(2);
|
||||
expect(forHostTwo.map((rule) => rule.id)).toEqual([otherRule.id]);
|
||||
});
|
||||
|
||||
it("still matches a rule pinned to a specific host", async () => {
|
||||
const repo = await createRepository();
|
||||
const pinned = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "Pinned",
|
||||
enabled: true,
|
||||
triggerType: "disk_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect((await repo.listEnabledRulesForHost(1)).map((r) => r.id)).toEqual([
|
||||
pinned.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("encrypts channel configs at rest and returns them decrypted", async () => {
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
|
||||
try {
|
||||
const repo = await createRepository();
|
||||
const secret = '{"url":"https://ntfy.test","token":"super-secret"}';
|
||||
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ntfy",
|
||||
type: "ntfy",
|
||||
config: secret,
|
||||
enabled: true,
|
||||
});
|
||||
expect(created.config).toBe(secret);
|
||||
|
||||
// The stored bytes must not contain the token in the clear.
|
||||
const stored = await adapter!.query<{ config: string }>(
|
||||
sql`SELECT config FROM notification_channels WHERE id = ${created.id}`,
|
||||
);
|
||||
expect(stored[0].config).not.toContain("super-secret");
|
||||
|
||||
// Every read path hands back plaintext.
|
||||
const listed = await repo.listNotificationChannels("user-1");
|
||||
expect(listed[0].config).toBe(secret);
|
||||
expect(
|
||||
(await repo.findNotificationChannelForUser(created.id, "user-1"))
|
||||
?.config,
|
||||
).toBe(secret);
|
||||
|
||||
const rule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 15,
|
||||
channels: [created.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const engineChannels = await repo.listEnabledChannelsForRule(rule.id);
|
||||
expect(engineChannels[0].config).toBe(secret);
|
||||
|
||||
const rotated = '{"url":"https://ntfy.test","token":"rotated"}';
|
||||
const updated = await repo.updateNotificationChannel(
|
||||
created.id,
|
||||
"user-1",
|
||||
{ config: rotated },
|
||||
);
|
||||
expect(updated?.config).toBe(rotated);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("still reads channel configs written before encryption", async () => {
|
||||
const repo = await createRepository();
|
||||
const plaintext = '{"url":"https://legacy.test"}';
|
||||
const created = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Legacy",
|
||||
type: "webhook",
|
||||
config: plaintext,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const key = crypto.randomBytes(32);
|
||||
const spy = vi
|
||||
.spyOn(DataCrypto, "getUserDataKey")
|
||||
.mockImplementation(() => key);
|
||||
try {
|
||||
const found = await repo.findNotificationChannelForUser(
|
||||
created.id,
|
||||
"user-1",
|
||||
);
|
||||
expect(found?.config).toBe(plaintext);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads host display names for alert payloads", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
expect(await repo.getHostDisplayName(1)).toBe("alpha");
|
||||
expect(await repo.getHostDisplayName(999)).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes all alert data for a user", async () => {
|
||||
let writes = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writes += 1;
|
||||
});
|
||||
|
||||
const userChannel = await repo.createNotificationChannel({
|
||||
userId: "user-1",
|
||||
name: "Ops",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://example.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const otherChannel = await repo.createNotificationChannel({
|
||||
userId: "user-2",
|
||||
name: "Other",
|
||||
type: "webhook",
|
||||
config: '{"url":"https://other.test"}',
|
||||
enabled: true,
|
||||
});
|
||||
const userRule = await repo.createAlertRule({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
name: "CPU high",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 80,
|
||||
thresholdDurationSeconds: 30,
|
||||
cooldownMinutes: 5,
|
||||
channels: [userChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const otherRule = await repo.createAlertRule({
|
||||
userId: "user-2",
|
||||
hostId: null,
|
||||
name: "Memory high",
|
||||
enabled: true,
|
||||
triggerType: "memory_threshold",
|
||||
thresholdValue: 90,
|
||||
thresholdDurationSeconds: 60,
|
||||
cooldownMinutes: 10,
|
||||
channels: [otherChannel.id],
|
||||
now: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-1",
|
||||
ruleId: userRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 95,
|
||||
message: "high",
|
||||
severity: "warning",
|
||||
});
|
||||
await repo.createFiring({
|
||||
userId: "user-2",
|
||||
ruleId: otherRule.id,
|
||||
hostId: 1,
|
||||
hostName: "alpha",
|
||||
value: 91,
|
||||
message: "other",
|
||||
severity: "warning",
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
firingsDeleted: 1,
|
||||
ruleLinksDeleted: 1,
|
||||
rulesDeleted: 1,
|
||||
channelsDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
firingsDeleted: 0,
|
||||
ruleLinksDeleted: 0,
|
||||
rulesDeleted: 0,
|
||||
channelsDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.listNotificationChannels("user-1")).toEqual([]);
|
||||
expect(await repo.listAlertRules("user-1")).toEqual([]);
|
||||
expect(
|
||||
await repo.listAlertFirings({ userId: "user-1", limit: 10, offset: 0 }),
|
||||
).toEqual({ firings: [], total: 0 });
|
||||
expect(await repo.listNotificationChannels("user-2")).toHaveLength(1);
|
||||
expect(await repo.listAlertRules("user-2")).toHaveLength(1);
|
||||
expect(await repo.listEnabledChannelsForRule(otherRule.id)).toHaveLength(1);
|
||||
expect(writes).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,12 @@ const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
describe("buildSystemPrompt", () => {
|
||||
it("tells the assistant to stay inside what was asked", () => {
|
||||
// Without these the assistant answered "what is running on this server"
|
||||
// by also proposing a monitoring script and an alert rule nobody wanted.
|
||||
// by also proposing a monitoring script and an automation nobody wanted.
|
||||
const prompt = buildSystemPrompt(BASE);
|
||||
|
||||
expect(prompt).toContain("nothing beyond it");
|
||||
expect(prompt).toMatch(/Read, then report\. Do not propose anything\./);
|
||||
expect(prompt).toMatch(/no monitoring, no alert rules, no scripts/);
|
||||
expect(prompt).toMatch(/no monitoring automations, no scripts/);
|
||||
expect(prompt).toMatch(/One request means one proposal at most/);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,14 +13,12 @@ const snippetRepository = {
|
||||
deleteSnippet: vi.fn(),
|
||||
};
|
||||
const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const alertRepository = { createAlertRule: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
createCurrentAlertRepository: () => alertRepository,
|
||||
createCurrentAutomationRepository: () => automationRepository,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Once the alert rules have been migrated into automations, both systems hold
|
||||
* a copy of every rule. If the old engine kept evaluating, every alert would
|
||||
* be delivered twice.
|
||||
*/
|
||||
|
||||
const listEnabledRulesForHost = vi.fn();
|
||||
const listEnabledRulesForHostUser = vi.fn();
|
||||
const createFiring = vi.fn();
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentAlertRepository: () => ({
|
||||
listEnabledRulesForHost,
|
||||
listEnabledRulesForHostUser,
|
||||
createFiring,
|
||||
pruneFiringsOlderThan: vi.fn(),
|
||||
listEnabledChannelsForRule: vi.fn(async () => []),
|
||||
findRuleById: vi.fn(async () => null),
|
||||
getHostDisplayName: vi.fn(async () => "host"),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
statsLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/notification-sender.js", () => ({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/discord-sender.js", () => ({
|
||||
sendDiscord: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const alertEngineModule =
|
||||
await import("../../../../../plugins/host-metrics/backend/alert-engine.js");
|
||||
|
||||
const cpuRule = {
|
||||
id: 1,
|
||||
userId: "user-1",
|
||||
hostId: null,
|
||||
name: "CPU",
|
||||
enabled: true,
|
||||
triggerType: "cpu_threshold",
|
||||
thresholdValue: 50,
|
||||
thresholdDurationSeconds: 0,
|
||||
cooldownMinutes: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEnabledRulesForHost.mockResolvedValue([cpuRule]);
|
||||
listEnabledRulesForHostUser.mockResolvedValue([cpuRule]);
|
||||
});
|
||||
|
||||
describe("AlertEngine before migration", () => {
|
||||
it("still evaluates rules", async () => {
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(false);
|
||||
|
||||
await alertEngineModule.AlertEngine.getInstance().evaluateMetrics(7, {
|
||||
cpu: { percent: 90 },
|
||||
});
|
||||
|
||||
expect(listEnabledRulesForHost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertEngine after migration", () => {
|
||||
it("stops evaluating every trigger type", async () => {
|
||||
alertEngineModule.markAlertEngineSuperseded();
|
||||
expect(alertEngineModule.isAlertEngineSuperseded()).toBe(true);
|
||||
|
||||
const engine = alertEngineModule.AlertEngine.getInstance();
|
||||
await engine.evaluateMetrics(7, { cpu: { percent: 99 } });
|
||||
await engine.evaluateStatus(7, false);
|
||||
await engine.evaluateHealthCheck(7, "user-1", "web", false);
|
||||
await engine.evaluateUserLogin(7, "user-1", "root", "10.0.0.1");
|
||||
|
||||
// Nothing is even loaded, so nothing can be delivered a second time.
|
||||
expect(listEnabledRulesForHost).not.toHaveBeenCalled();
|
||||
expect(listEnabledRulesForHostUser).not.toHaveBeenCalled();
|
||||
expect(createFiring).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ const route = ast.statements.find(
|
||||
);
|
||||
if (!route) throw new Error("Login event route is missing");
|
||||
const notify = vi.fn();
|
||||
const legacy = vi.fn().mockResolvedValue(undefined);
|
||||
let handler: (req: unknown, res: unknown) => Promise<void>;
|
||||
runInNewContext(
|
||||
ts.transpileModule(route.getText(ast), {
|
||||
@@ -49,7 +48,6 @@ runInNewContext(
|
||||
},
|
||||
}),
|
||||
notifyAutomationInternalEvent: notify,
|
||||
AlertEngine: { getInstance: () => ({ evaluateUserLogin: legacy }) },
|
||||
},
|
||||
);
|
||||
const body = {
|
||||
@@ -61,7 +59,7 @@ const body = {
|
||||
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("delivers successful SSH login details to automations while retaining the legacy hook", async () => {
|
||||
it("delivers successful SSH login details to automations", async () => {
|
||||
const res = response();
|
||||
await handler(
|
||||
{
|
||||
@@ -75,12 +73,6 @@ it("delivers successful SSH login details to automations while retaining the leg
|
||||
sshUser: "alice",
|
||||
fromIp: "192.0.2.4",
|
||||
});
|
||||
expect(legacy).toHaveBeenCalledExactlyOnceWith(
|
||||
42,
|
||||
"owner",
|
||||
"alice",
|
||||
"192.0.2.4",
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({ ok: true });
|
||||
});
|
||||
|
||||
@@ -101,6 +93,5 @@ it.each([
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(legacy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { triggerLoginAlert } from "../../utils/alert-trigger.js";
|
||||
import { dispatchLoginEvent } from "../../utils/login-event-dispatch.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
describe("triggerLoginAlert", () => {
|
||||
describe("dispatchLoginEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -19,12 +19,12 @@ describe("triggerLoginAlert", () => {
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({
|
||||
operation: "login_alert_trigger_error",
|
||||
operation: "login_event_dispatch_error",
|
||||
hostId: 7,
|
||||
error:
|
||||
'Metrics service returned 401: {"error":"Missing authentication token"}',
|
||||
@@ -41,7 +41,7 @@ describe("triggerLoginAlert", () => {
|
||||
);
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await triggerLoginAlert(7, "user-1", "root", "192.0.2.1");
|
||||
await dispatchLoginEvent(7, "user-1", "root", "192.0.2.1");
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe("triggerLoginAlert", () => {
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
||||
|
||||
await triggerLoginAlert(42, "user-1", "root", "10.0.0.5");
|
||||
await dispatchLoginEvent(42, "user-1", "root", "10.0.0.5");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:30005/internal/login-alert",
|
||||
@@ -83,11 +83,11 @@ describe("triggerLoginAlert", () => {
|
||||
const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {});
|
||||
|
||||
await expect(
|
||||
triggerLoginAlert(1, "user-1", "root", "127.0.0.1"),
|
||||
dispatchLoginEvent(1, "user-1", "root", "127.0.0.1"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"Failed to trigger login alert",
|
||||
"Failed to dispatch login event",
|
||||
expect.objectContaining({ hostId: 1 }),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user