diff --git a/apps/workflows/src/checker/private-location.test.ts b/apps/workflows/src/checker/private-location.test.ts index 527cefcbf..8da20e5fb 100644 --- a/apps/workflows/src/checker/private-location.test.ts +++ b/apps/workflows/src/checker/private-location.test.ts @@ -1,4 +1,4 @@ -import { and, db, eq } from "@openstatus/db"; +import { and, db, eq, or } from "@openstatus/db"; import { monitor, notification, @@ -39,6 +39,9 @@ const INACTIVE_MONITOR_ID = 9102; const TEST_NOTIFICATION_ID = 9101; const TEST_LOCATION_ID = 9001; const UNATTACHED_LOCATION_ID = 9002; +const PRIVATE_ONLY_MONITOR_ID = 9103; +const PRIVATE_LOCATION_2_ID = 9003; +const PRIVATE_LOCATION_3_ID = 9004; const cronSecret = env().CRON_SECRET; @@ -105,6 +108,15 @@ describe("updateStatusPrivate", () => { periodicity: "1m", regions: "ams", }, + { + id: PRIVATE_ONLY_MONITOR_ID, + workspaceId, + active: true, + url: "https://private-only.test", + name: "Private-Only Monitor (No Cloud Regions)", + periodicity: "1m", + regions: "", + }, ]) .onConflictDoNothing() .run(); @@ -147,6 +159,61 @@ describe("updateStatusPrivate", () => { }) .onConflictDoNothing() .run(); + + // Additional private locations for multi-location tests + await db + .insert(privateLocation) + .values([ + { + id: PRIVATE_LOCATION_2_ID, + name: "Test Office 2", + token: "test-private-location-token-2", + workspaceId, + createdAt: new Date(), + }, + { + id: PRIVATE_LOCATION_3_ID, + name: "Test Office 3", + token: "test-private-location-token-3", + workspaceId, + createdAt: new Date(), + }, + ]) + .onConflictDoNothing() + .run(); + + // Link all three locations to private-only monitor + await db + .insert(privateLocationToMonitors) + .values([ + { + privateLocationId: TEST_LOCATION_ID, + monitorId: PRIVATE_ONLY_MONITOR_ID, + createdAt: new Date(), + }, + { + privateLocationId: PRIVATE_LOCATION_2_ID, + monitorId: PRIVATE_ONLY_MONITOR_ID, + createdAt: new Date(), + }, + { + privateLocationId: PRIVATE_LOCATION_3_ID, + monitorId: PRIVATE_ONLY_MONITOR_ID, + createdAt: new Date(), + }, + ]) + .onConflictDoNothing() + .run(); + + // Link first location to private-only monitor for notifications + await db + .insert(notificationsToMonitors) + .values({ + monitorId: PRIVATE_ONLY_MONITOR_ID, + notificationId: TEST_NOTIFICATION_ID, + }) + .onConflictDoNothing() + .run(); }); afterAll(async () => { @@ -174,8 +241,52 @@ describe("updateStatusPrivate", () => { .delete(notification) .where(eq(notification.id, TEST_NOTIFICATION_ID)) .run(); + // Cleanup private-only monitor fixtures + await db + .delete(privateLocationMonitorStatus) + .where( + eq(privateLocationMonitorStatus.monitorId, PRIVATE_ONLY_MONITOR_ID), + ) + .run(); + await db + .delete(notificationTrigger) + .where(eq(notificationTrigger.monitorId, PRIVATE_ONLY_MONITOR_ID)) + .run(); + await db + .delete(notificationsToMonitors) + .where(eq(notificationsToMonitors.monitorId, PRIVATE_ONLY_MONITOR_ID)) + .run(); + await db + .delete(privateLocationToMonitors) + .where( + or( + eq(privateLocationToMonitors.privateLocationId, TEST_LOCATION_ID), + eq( + privateLocationToMonitors.privateLocationId, + PRIVATE_LOCATION_2_ID, + ), + eq( + privateLocationToMonitors.privateLocationId, + PRIVATE_LOCATION_3_ID, + ), + ), + ) + .run(); + await db + .delete(privateLocation) + .where( + or( + eq(privateLocation.id, PRIVATE_LOCATION_2_ID), + eq(privateLocation.id, PRIVATE_LOCATION_3_ID), + ), + ) + .run(); await db.delete(monitor).where(eq(monitor.id, TEST_MONITOR_ID)).run(); await db.delete(monitor).where(eq(monitor.id, INACTIVE_MONITOR_ID)).run(); + await db + .delete(monitor) + .where(eq(monitor.id, PRIVATE_ONLY_MONITOR_ID)) + .run(); }); beforeEach(() => { @@ -208,10 +319,20 @@ describe("updateStatusPrivate", () => { .delete(privateLocationMonitorStatus) .where(eq(privateLocationMonitorStatus.monitorId, TEST_MONITOR_ID)) .run(); + await db + .delete(privateLocationMonitorStatus) + .where( + eq(privateLocationMonitorStatus.monitorId, PRIVATE_ONLY_MONITOR_ID), + ) + .run(); await db .delete(notificationTrigger) .where(eq(notificationTrigger.monitorId, TEST_MONITOR_ID)) .run(); + await db + .delete(notificationTrigger) + .where(eq(notificationTrigger.monitorId, PRIVATE_ONLY_MONITOR_ID)) + .run(); }); test("rejects a wrong CRON_SECRET with 401", async () => { @@ -362,4 +483,102 @@ describe("updateStatusPrivate", () => { expect(res.status).toBe(200); assertSpyCalls(mockEmailSendAlert, 0); }); + + test("private-only monitor: updates status when threshold met (2/3 locations agree)", async () => { + // First location reports error → 1/3 < 50% → should NOT update monitor status + const res1 = await post({ + monitorId: String(PRIVATE_ONLY_MONITOR_ID), + privateLocationId: String(TEST_LOCATION_ID), + status: "error", + cronTimestamp: 9310010, + statusCode: 500, + message: "down", + }); + expect(res1.status).toBe(200); + + const monitorAfter1 = await db + .select() + .from(monitor) + .where(eq(monitor.id, PRIVATE_ONLY_MONITOR_ID)) + .get(); + expect(monitorAfter1?.status).toBe("active"); // Threshold not met + + // Second location reports error → 2/3 >= 50% → SHOULD update monitor status + const res2 = await post({ + monitorId: String(PRIVATE_ONLY_MONITOR_ID), + privateLocationId: String(PRIVATE_LOCATION_2_ID), + status: "error", + cronTimestamp: 9310020, + statusCode: 500, + message: "down", + }); + expect(res2.status).toBe(200); + + const monitorAfter2 = await db + .select() + .from(monitor) + .where(eq(monitor.id, PRIVATE_ONLY_MONITOR_ID)) + .get(); + expect(monitorAfter2?.status).toBe("error"); + assertSpyCalls(mockEmailSendAlert, 1); + }); + + test("does not change monitor.status from error when 1/3 locations report degraded (threshold not met)", async () => { + // First seed the monitor to "error" state via 2/3 locations agreeing + await post({ + monitorId: String(PRIVATE_ONLY_MONITOR_ID), + privateLocationId: String(TEST_LOCATION_ID), + status: "error", + cronTimestamp: 9320000, + statusCode: 500, + message: "down", + }); + await post({ + monitorId: String(PRIVATE_ONLY_MONITOR_ID), + privateLocationId: String(PRIVATE_LOCATION_2_ID), + status: "error", + cronTimestamp: 9320005, + statusCode: 500, + message: "down", + }); + + // Now try degraded with only 1/3 → should NOT change + const res = await post({ + monitorId: String(PRIVATE_ONLY_MONITOR_ID), + privateLocationId: String(PRIVATE_LOCATION_3_ID), + status: "degraded", + cronTimestamp: 9320010, + statusCode: 200, + }); + expect(res.status).toBe(200); + + const monitorAfter = await db + .select() + .from(monitor) + .where(eq(monitor.id, PRIVATE_ONLY_MONITOR_ID)) + .get(); + // Monitor should remain "error" because 1/3 < 50% threshold + expect(monitorAfter?.status).toBe("error"); + }); + + test("does not update monitor.status for monitors with cloud regions", async () => { + // TEST_MONITOR_ID has regions: "ams" (cloud region) + const res = await post({ + monitorId: String(TEST_MONITOR_ID), + privateLocationId: String(TEST_LOCATION_ID), + status: "error", + cronTimestamp: 9330010, + statusCode: 500, + message: "down", + }); + expect(res.status).toBe(200); + + const monitorAfter = await db + .select() + .from(monitor) + .where(eq(monitor.id, TEST_MONITOR_ID)) + .get(); + // Monitor has cloud regions, so private status updates should not affect it + expect(monitorAfter?.status).toBe("active"); + }); }); diff --git a/apps/workflows/src/checker/private-location.ts b/apps/workflows/src/checker/private-location.ts index 2907b8d6a..f4653d543 100644 --- a/apps/workflows/src/checker/private-location.ts +++ b/apps/workflows/src/checker/private-location.ts @@ -235,6 +235,22 @@ export async function updateStatusPrivate(c: Context) { switch (status) { case "error": + // Update monitor status for private-only monitors + if ( + !hasCloudRegions && + shouldTriggerIncident && + monitor.status !== "error" + ) { + logger.info("Monitor status changed to error", { + monitor_id: monitor.id, + workspace_id: monitor.workspaceId, + }); + await db + .update(schema.monitor) + .set({ status: "error" }) + .where(eq(schema.monitor.id, monitorIdNumber)); + } + // Create incident only if private-only monitor AND threshold met if (shouldTriggerIncident) { try { @@ -310,18 +326,37 @@ export async function updateStatusPrivate(c: Context) { latency, }, }); - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "alert", - cronTimestamp, - regions, - latency, - incidentId: incident?.id, - }); + // Trigger notifications for cloud monitors always, private-only when threshold met + if (hasCloudRegions || shouldTriggerIncident) { + triggeredNotifications = await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "alert", + cronTimestamp, + regions, + latency, + incidentId: incident?.id, + }); + } break; case "degraded": + // Update monitor status for private-only monitors + if ( + !hasCloudRegions && + shouldTriggerIncident && + monitor.status !== "degraded" + ) { + logger.info("Monitor status changed to degraded", { + monitor_id: monitor.id, + workspace_id: monitor.workspaceId, + }); + await db + .update(schema.monitor) + .set({ status: "degraded" }) + .where(eq(schema.monitor.id, monitorIdNumber)); + } + // Resolve incident if private-only monitor AND threshold met if (shouldTriggerIncident) { try { @@ -354,18 +389,37 @@ export async function updateStatusPrivate(c: Context) { latency, }, }); - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "degraded", - cronTimestamp, - regions, - latency, - incidentId: incident?.id, - }); + // Trigger notifications for cloud monitors always, private-only when threshold met + if (hasCloudRegions || shouldTriggerIncident) { + triggeredNotifications = await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "degraded", + cronTimestamp, + regions, + latency, + incidentId: incident?.id, + }); + } break; case "active": + // Update monitor status for private-only monitors + if ( + !hasCloudRegions && + shouldTriggerIncident && + monitor.status !== "active" + ) { + logger.info("Monitor status changed to active", { + monitor_id: monitor.id, + workspace_id: monitor.workspaceId, + }); + await db + .update(schema.monitor) + .set({ status: "active" }) + .where(eq(schema.monitor.id, monitorIdNumber)); + } + // Resolve incident if private-only monitor AND threshold met if (shouldTriggerIncident) { try { @@ -395,16 +449,19 @@ export async function updateStatusPrivate(c: Context) { latency, }, }); - triggeredNotifications = await triggerNotifications({ - monitorId, - statusCode, - message, - notifType: "recovery", - cronTimestamp, - regions, - latency, - incidentId: incident?.id, - }); + // Trigger notifications for cloud monitors always, private-only when threshold met + if (hasCloudRegions || shouldTriggerIncident) { + triggeredNotifications = await triggerNotifications({ + monitorId, + statusCode, + message, + notifType: "recovery", + cronTimestamp, + regions, + latency, + incidentId: incident?.id, + }); + } break; }