Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
TypeScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439import { and, db, eq, inArray, isNull } from "@openstatus/db";import { monitor, monitorRun, monitorTag, monitorTagsToMonitors, notification, notificationsToMonitors, selectWorkspaceSchema, workspace,} from "@openstatus/db/src/schema";import { createWorkspace } from "@openstatus/db/src/test/factories";import { expect } from "@std/expect";import { afterAll, beforeAll, describe, test } from "@std/testing/bdd";
import { clearAuditLog, expectAuditRow, makeApiKeyCtx, makeSystemCtx, makeUserCtx, readAuditLog, withTestTransaction,} from "../../../test/helpers";import type { DrizzleTx, ServiceContext } from "../../context";import { ForbiddenError, LimitExceededError, NotFoundError,} from "../../errors";import { cloneMonitor } from "../clone";import { createMonitor } from "../create";import { deleteMonitor, deleteMonitors } from "../delete";import { getMonitor, listMonitors } from "../list";import { updateMonitorNotifiers, updateMonitorTags } from "../relations";import { triggerMonitorRun } from "../trigger";import { bulkUpdateMonitors, updateMonitorConfig, updateMonitorGeneral,} from "../update";
const TEST_PREFIX = "svc-monitor-test";
let teamCtx: ServiceContext;let freeCtx: ServiceContext;let teamWorkspaceId: number;let freeWorkspaceId: number;let testTagId: number;let testNotificationId: number;
beforeAll(async () => { // Own workspaces per suite โ relying on the shared seeded workspace races // sibling suites that wipe its tags/notifications (e.g. monitor-tag). const team = selectWorkspaceSchema.parse(await createWorkspace()); const free = selectWorkspaceSchema.parse(await createWorkspace()); teamWorkspaceId = team.id; freeWorkspaceId = free.id; teamCtx = makeUserCtx(team, { userId: 1 }); freeCtx = makeUserCtx(free, { userId: 2 });
const tagRow = await db .insert(monitorTag) .values({ workspaceId: team.id, name: `${TEST_PREFIX}-tag`, color: "#000000", }) .returning() .get(); testTagId = tagRow.id;
const notifRow = await db .insert(notification) .values({ workspaceId: team.id, name: `${TEST_PREFIX}-notif`, provider: "email", data: JSON.stringify({ email: "test@example.com" }), }) .returning() .get(); testNotificationId = notifRow.id;});
afterAll(async () => { await db .delete(monitorTag) .where(eq(monitorTag.id, testTagId)) .catch(() => undefined); await db .delete(notification) .where(eq(notification.id, testNotificationId)) .catch(() => undefined); await db .delete(workspace) .where(inArray(workspace.id, [teamWorkspaceId, freeWorkspaceId])) .catch(() => undefined);});
describe("createMonitor", () => { test("http happy path โ stores defaults + emits audit", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-http`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
expect(row.jobType).toBe("http"); expect(row.url).toBe("https://example.com"); expect(row.regions.length).toBeGreaterThan(0);
await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "monitor.create", entityType: "monitor", entityId: row.id, db: tx, }); }); });
test("tcp happy path", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-tcp`, jobType: "tcp", url: "example.com:443", method: "GET", headers: [], assertions: [], active: false, }, }); expect(row.jobType).toBe("tcp"); }); });
test("dns happy path", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-dns`, jobType: "dns", url: "example.com", method: "GET", headers: [], assertions: [ { version: "v1", type: "dnsRecord", key: "A", compare: "eq", target: "1.1.1.1", }, ], active: false, }, }); expect(row.jobType).toBe("dns"); }); });
test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const ctx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( createMonitor({ ctx, input: { name: `${TEST_PREFIX}-read-only`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); });});
describe("deleteMonitor", () => { test("soft-deletes + removes tag / notifier associations", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-delete`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); // Attach relations. await tx .insert(monitorTagsToMonitors) .values({ monitorId: row.id, monitorTagId: testTagId }); await tx.insert(notificationsToMonitors).values({ monitorId: row.id, notificationId: testNotificationId, });
await deleteMonitor({ ctx, input: { id: row.id } });
const after = await tx .select() .from(monitor) .where(eq(monitor.id, row.id)) .get(); expect(after?.deletedAt).toBeInstanceOf(Date); expect(after?.active).toBe(false);
const tagAssoc = await tx .select() .from(monitorTagsToMonitors) .where(eq(monitorTagsToMonitors.monitorId, row.id)) .all(); const notifAssoc = await tx .select() .from(notificationsToMonitors) .where(eq(notificationsToMonitors.monitorId, row.id)) .all(); expect(tagAssoc).toHaveLength(0); expect(notifAssoc).toHaveLength(0); }); });
test("throws NotFoundError for cross-workspace delete", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-cross-ws-delete`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await expect( deleteMonitor({ ctx: { ...freeCtx, db: tx }, input: { id: row.id } }), ).rejects.toBeInstanceOf(NotFoundError); }); });
test("second delete returns NotFoundError (idempotency guard)", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; // Regression for the Cubic P2 fix: `getMonitorInWorkspace` filters // by `isNull(deletedAt)`, so a second `deleteMonitor` on the same // id should throw `NotFoundError` rather than silently re-running // the soft-delete / cascade / audit sequence on a tombstoned row. const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-double-delete`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await deleteMonitor({ ctx, input: { id: row.id } }); await expect( deleteMonitor({ ctx, input: { id: row.id } }), ).rejects.toBeInstanceOf(NotFoundError); }); });});
describe("deleteMonitors (bulk)", () => { test("soft-deletes all ids and strips associations", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const a = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-bulk-a`, jobType: "http", url: "https://a.example.com", method: "GET", headers: [], assertions: [], active: false, }, }); const b = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-bulk-b`, jobType: "http", url: "https://b.example.com", method: "GET", headers: [], assertions: [], active: false, }, });
await deleteMonitors({ ctx, input: { ids: [a.id, b.id] }, });
const rows = await tx .select({ id: monitor.id, deletedAt: monitor.deletedAt }) .from(monitor) .where(inArray(monitor.id, [a.id, b.id])) .all(); expect(rows).toHaveLength(2); for (const r of rows) expect(r.deletedAt).toBeInstanceOf(Date); }); });});
describe("cloneMonitor", () => { test("duplicates the row with a `(Copy)` name", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const source = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-source`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
const clone = await cloneMonitor({ ctx, input: { id: source.id }, });
expect(clone.id).not.toBe(source.id); expect(clone.name).toBe(`${source.name} (Copy)`); expect(clone.url).toBe(source.url); }); });
test("resets status to active even when source is degraded/error", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; // Regression for the Cubic P2 fix: `clone.ts` destructures // `status` out of the source row before spreading, so a clone // always starts in `"active"` state rather than inheriting the // source's stale health. Flipping the source's status via a // direct db update โ `createMonitor` doesn't accept `status` // input, and `"active"` is the insert default. const source = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-clone-status-reset`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await tx .update(monitor) .set({ status: "error" }) .where(eq(monitor.id, source.id));
const clone = await cloneMonitor({ ctx, input: { id: source.id }, });
expect(clone.status).toBe("active"); }); });});
describe("updateMonitorTags / updateMonitorNotifiers", () => { test("replaces the full tag set with deduped ids", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-tags`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
await updateMonitorTags({ ctx, input: { id: row.id, tags: [testTagId, testTagId] }, }); const assoc = await tx .select() .from(monitorTagsToMonitors) .where(eq(monitorTagsToMonitors.monitorId, row.id)) .all(); expect(assoc).toHaveLength(1); }); });
test("throws ForbiddenError when tag is in another workspace", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-tag-forbidden`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await expect( updateMonitorTags({ ctx: { ...freeCtx, db: tx }, input: { id: row.id, tags: [testTagId] }, }), ).rejects.toBeInstanceOf(NotFoundError); // monitor is in another ws }); });
test("replaces the full notifier set", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-notif`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await updateMonitorNotifiers({ ctx, input: { id: row.id, notifiers: [testNotificationId] }, }); const assoc = await tx .select() .from(notificationsToMonitors) .where(eq(notificationsToMonitors.monitorId, row.id)) .all(); expect(assoc).toHaveLength(1); }); });
test("throws ForbiddenError when notifier is in another workspace", async () => { await withTestTransaction(async (tx) => { const ctx = { ...freeCtx, db: tx }; // free ctx owns its own monitor; pass team's notificationId (which is in // team's workspace) into the update under free ctx โ the monitor lookup // would fail first (NotFoundError), so we build an in-free monitor and // try to attach team's notifier. const freeMonitor = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-free-notif`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, }); await expect( updateMonitorNotifiers({ ctx, input: { id: freeMonitor.id, notifiers: [testNotificationId] }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); });});
describe("list / get", () => { test("respects workspace isolation + soft-delete", async () => { await withTestTransaction(async (tx) => { const teamCtxTx = { ...teamCtx, db: tx }; const freeCtxTx = { ...freeCtx, db: tx }; const row = await createMonitor({ ctx: teamCtxTx, input: { name: `${TEST_PREFIX}-list`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, });
await expect( getMonitor({ ctx: freeCtxTx, input: { id: row.id } }), ).rejects.toBeInstanceOf(NotFoundError);
const { items } = await listMonitors({ ctx: freeCtxTx, input: { limit: 100, offset: 0, order: "desc" }, }); expect(items.find((m) => m.id === row.id)).toBeUndefined();
// Soft-delete, then verify it disappears from the team list too. await deleteMonitor({ ctx: teamCtxTx, input: { id: row.id } }); const { items: teamItems } = await listMonitors({ ctx: teamCtxTx, input: { limit: 1000, offset: 0, order: "desc" }, }); expect(teamItems.find((m) => m.id === row.id)).toBeUndefined(); }); });});
// `totalSize` is derived from the page length whenever that is unambiguous,// and only falls back to `count(*)` when it isn't. The derivation must agree// with the count in every case, including past the end of the result set.describe("listMonitors totalSize", () => { /** Seed `n` monitors and return the workspace's true monitor count. */ const seed = async (tx: DrizzleTx, ctx: ServiceContext, n: number) => { for (let i = 0; i < n; i++) { await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-total-${i}`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, }); } const rows = await tx .select({ id: monitor.id }) .from(monitor) .where( and( eq(monitor.workspaceId, ctx.workspace.id), isNull(monitor.deletedAt), ), ) .all(); return rows.length; };
test("a short page derives the total from offset + rows", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const total = await seed(tx, ctx, 3);
const { items, totalSize } = await listMonitors({ ctx, input: { limit: total + 10, offset: 0, order: "desc" }, }); expect(items).toHaveLength(total); expect(totalSize).toBe(total); }); });
test("a full page falls back to count(*)", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const total = await seed(tx, ctx, 3);
const { items, totalSize } = await listMonitors({ ctx, input: { limit: 1, offset: 0, order: "desc" }, }); expect(items).toHaveLength(1); // Derivation alone would say 1; only the count knows the real total. expect(totalSize).toBe(total); }); });
test("a mid-list page reports the full total, not the page end", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const total = await seed(tx, ctx, 3);
const { totalSize } = await listMonitors({ ctx, input: { limit: 2, offset: 1, order: "desc" }, }); expect(totalSize).toBe(total); }); });
test("an offset past the end still reports the true total", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const total = await seed(tx, ctx, 3);
const { items, totalSize } = await listMonitors({ ctx, input: { limit: 10, offset: total + 50, order: "desc" }, }); expect(items).toHaveLength(0); // Regression: deriving `offset + rows.length` here would report the // offset itself as the total. expect(totalSize).toBe(total); }); });
test("an empty result at offset 0 reports zero", async () => { await withTestTransaction(async (tx) => { const ctx = { ...freeCtx, db: tx }; const total = await seed(tx, ctx, 0);
const { items, totalSize } = await listMonitors({ ctx, input: { limit: 100, offset: 0, order: "desc" }, }); expect(items).toHaveLength(total); expect(totalSize).toBe(total); }); });});
describe("updateMonitorGeneral", () => { test("updates name / url / assertions and audits", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-gen`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
const updated = await updateMonitorGeneral({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-gen-renamed`, jobType: "http", url: "https://example.org", method: "POST", headers: [{ key: "X-Test", value: "yes" }], assertions: [], active: true, }, });
expect(updated.name).toBe(`${TEST_PREFIX}-gen-renamed`); expect(updated.url).toBe("https://example.org"); expect(updated.method).toBe("POST"); await expectAuditRow({ workspaceId: teamCtx.workspace.id, action: "monitor.update", entityType: "monitor", entityId: row.id, db: tx, }); }); });
test("persists the gRPC service / TLS mode", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-gen-grpc`, jobType: "grpc", url: "api.example.com:443", method: "GET", headers: [], assertions: [], active: false, }, }); expect(row.grpcService).toBe(null); expect(row.grpcTls).toBe("tls");
const updated = await updateMonitorGeneral({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-gen-grpc`, jobType: "grpc", url: "api.example.com:443", method: "GET", headers: [], assertions: [], active: true, grpcService: "checkout.v1.CheckoutService", grpcTls: "plaintext", }, });
expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); expect(updated.grpcTls).toBe("plaintext"); }); });
test("omitting the gRPC fields leaves them untouched", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-gen-grpc-keep`, jobType: "grpc", url: "api.example.com:443", method: "GET", headers: [], assertions: [], active: false, grpcService: "checkout.v1.CheckoutService", grpcTls: "plaintext", }, });
const updated = await updateMonitorGeneral({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-gen-grpc-keep-2`, jobType: "grpc", url: "api.example.com:443", method: "GET", headers: [], assertions: [], active: true, }, });
expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); expect(updated.grpcTls).toBe("plaintext"); }); });});
describe("bulkUpdateMonitors", () => { test("system actor pauses active monitors and audits", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const a = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-bulk-a`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, }); const b = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-bulk-b`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, });
await bulkUpdateMonitors({ ctx: { ...makeSystemCtx(teamCtx.workspace, { job: "monitor-auto-pause" }), db: tx, }, input: { ids: [a.id, b.id], active: false }, });
const rows = await tx .select() .from(monitor) .where(inArray(monitor.id, [a.id, b.id])) .all(); expect(rows).toHaveLength(2); for (const row of rows) { expect(row.active).toBe(false); }
for (const id of [a.id, b.id]) { const audits = await readAuditLog({ workspaceId: teamCtx.workspace.id, entityType: "monitor", entityId: id, db: tx, }); const hit = audits.find( (row) => row.action === "monitor.update" && row.actorType === "system", ); expect(hit).toBeDefined(); expect(hit?.actorId).toBe("monitor-auto-pause"); } }); });
test("ignores ids outside the workspace", async () => { await withTestTransaction(async (tx) => { const foreign = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-bulk-foreign`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, });
await bulkUpdateMonitors({ ctx: { ...makeSystemCtx(freeCtx.workspace, { job: "monitor-auto-pause" }), db: tx, }, input: { ids: [foreign.id], active: false }, });
const row = await tx .select() .from(monitor) .where(eq(monitor.id, foreign.id)) .get(); expect(row?.active).toBe(true);
const audits = await readAuditLog({ workspaceId: freeCtx.workspace.id, entityType: "monitor", entityId: foreign.id, db: tx, }); expect(audits).toHaveLength(0); }); });
test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const ctx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( bulkUpdateMonitors({ ctx, input: { ids: [1], active: false } }), ).rejects.toBeInstanceOf(ForbiddenError); }); });});
describe("updateMonitorConfig", () => { test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( updateMonitorConfig({ ctx: readOnlyCtx, input: { id: 999_999_999, name: "nope" }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); });
test("patches only supplied fields and leaves the rest intact", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-cfg`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, periodicity: "10m", regions: ["ams"], timeout: 30_000, retry: 2, description: "keep me", }, });
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-cfg-renamed`, retry: 5 }, });
expect(updated.name).toBe(`${TEST_PREFIX}-cfg-renamed`); expect(updated.retry).toBe(5); // Untouched fields survive the patch. expect(updated.url).toBe("https://example.com"); expect(updated.periodicity).toBe("10m"); expect(updated.timeout).toBe(30_000); expect(updated.description).toBe("keep me"); expect(updated.regions).toEqual(["ams"]); }); });
test("emits a single monitor.update audit row per call", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-cfg-audit`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
await updateMonitorConfig({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-cfg-audit-2`, public: true, timeout: 20_000, followRedirects: false, }, });
const rows = await readAuditLog({ workspaceId: teamCtx.workspace.id, entityType: "monitor", entityId: String(row.id), db: tx, }); const updates = rows.filter((r) => r.action === "monitor.update"); expect(updates).toHaveLength(1); }); });
test("throws NotFoundError for cross-workspace id", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-cfg-cross`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
await expect( updateMonitorConfig({ ctx: { ...freeCtx, db: tx }, input: { id: row.id, name: "nope" }, }), ).rejects.toBeInstanceOf(NotFoundError); }); });});
describe("triggerMonitorRun", () => { test("rejects read-only actor before touching the DB", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { ...makeApiKeyCtx(teamCtx.workspace, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, }; await expect( triggerMonitorRun({ ctx: readOnlyCtx, input: { id: 999_999_999 }, }), ).rejects.toBeInstanceOf(ForbiddenError); }); });
test("records a run and returns the monitor with region status", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-trigger`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, regions: ["ams"], }, });
const result = await triggerMonitorRun({ ctx, input: { id: row.id } });
expect(result.monitor.id).toBe(row.id); expect(result.monitor.regions).toEqual(["ams"]); expect(typeof result.runId).toBe("number");
const runs = await tx .select() .from(monitorRun) .where(eq(monitorRun.monitorId, row.id)) .all(); expect(runs).toHaveLength(1); }); });
test("throws NotFoundError for cross-workspace id", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-trigger-cross`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, }, });
await expect( triggerMonitorRun({ ctx: { ...freeCtx, db: tx }, input: { id: row.id }, }), ).rejects.toBeInstanceOf(NotFoundError); }); });});
describe("createMonitor config fields", () => { test("persists every field the public API can set", async () => { await withTestTransaction(async (tx) => { // These columns are only reachable through the API's create path; // if the service silently drops one, the monitor runs with a // default the caller never asked for. const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-full`, jobType: "http", url: "https://example.com", method: "POST", headers: [{ key: "X-Api", value: "1" }], assertions: [], active: true, periodicity: "5m", regions: ["ams", "iad"], description: "full config", public: true, timeout: 12_000, degradedAfter: 3_000, retry: 4, followRedirects: false, otelEndpoint: "https://otel.example.com", otelHeaders: [{ key: "authorization", value: "Bearer x" }], }, });
expect(row.description).toBe("full config"); expect(row.public).toBe(true); expect(row.timeout).toBe(12_000); expect(row.degradedAfter).toBe(3_000); expect(row.retry).toBe(4); expect(row.followRedirects).toBe(false); expect(row.otelEndpoint).toBe("https://otel.example.com"); expect(row.regions).toEqual(["ams", "iad"]); expect(row.periodicity).toBe("5m"); }); });
test("omitted config fields fall through to the column defaults", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-defaults`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, }, });
expect(row.timeout).toBe(45_000); expect(row.retry).toBe(3); expect(row.followRedirects).toBe(true); expect(row.public).toBe(false); expect(row.description).toBe(""); expect(row.degradedAfter).toBe(null); }); });
test("an empty regions array is honoured, not replaced by plan defaults", async () => { await withTestTransaction(async (tx) => { // The API has never auto-assigned regions; `[]` must stay `[]`. const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name: `${TEST_PREFIX}-no-regions`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, regions: [], }, });
expect(row.regions).toEqual([]); }); });});
describe("updateMonitorConfig clearing + replacement", () => { async function seed(tx: DrizzleTx, name: string) { return createMonitor({ ctx: { ...teamCtx, db: tx }, input: { name, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [ { version: "v1", type: "status", compare: "eq", target: 200 }, ], active: false, degradedAfter: 5_000, }, }); }
test("degradedAfter: null clears the column", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-clear-degraded`); expect(row.degradedAfter).toBe(5_000);
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, degradedAfter: null }, }); expect(updated.degradedAfter).toBe(null); }); });
test("degradedAfter omitted leaves the column untouched", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-keep-degraded`);
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-keep-degraded-2` }, }); expect(updated.degradedAfter).toBe(5_000); }); });
// The proto declares 0โ120_000 ms for `timeout`/`degraded_at`; the RPC API // accepted that range before these verbs existed, so they must too. test("accepts the proto's full 0โ120_000 ms timeout range", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-wide-timeout`);
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, timeout: 120_000, degradedAfter: 90_000 }, }); expect(updated.timeout).toBe(120_000); expect(updated.degradedAfter).toBe(90_000);
const created = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-wide-timeout-create`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: false, timeout: 120_000, degradedAfter: 90_000, }, }); expect(created.timeout).toBe(120_000); expect(created.degradedAfter).toBe(90_000); }); });
test("rejects a timeout above the proto's ceiling", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-over-timeout`);
await expect( updateMonitorConfig({ ctx, input: { id: row.id, timeout: 120_001 } }), ).rejects.toThrow(); }); });
test("assertions replace wholesale rather than merging", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-replace-assertions`);
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, assertions: [ { version: "v1", type: "status", compare: "eq", target: 204 }, ], }, });
const stored = JSON.parse(updated.assertions ?? "[]"); expect(stored).toHaveLength(1); expect(stored[0]).toMatchObject({ target: 204 }); }); });
// Matches the pre-service RPC path, where an empty converter result was // `undefined` and Drizzle skipped the column. Omission never clears. test("assertions omitted leaves the stored list untouched", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-keep-assertions`);
const updated = await updateMonitorConfig({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-keep-assertions-2` }, });
const stored = JSON.parse(updated.assertions ?? "[]"); expect(stored).toHaveLength(1); expect(stored[0]).toMatchObject({ target: 200 }); }); });
test("no-op update emits no audit row", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; const row = await seed(tx, `${TEST_PREFIX}-noop`); await clearAuditLog(teamCtx.workspace.id, { db: tx });
await updateMonitorConfig({ ctx, input: { id: row.id, name: `${TEST_PREFIX}-noop` }, });
const rows = await readAuditLog({ workspaceId: teamCtx.workspace.id, entityType: "monitor", entityId: String(row.id), db: tx, }); expect(rows.filter((r) => r.action === "monitor.update")).toHaveLength(0); }); });});
describe("triggerMonitorRun quota", () => { test("throws LimitExceededError once the monthly quota is spent", async () => { await withTestTransaction(async (tx) => { // Quota is the only thing standing between an API key and unbounded // outbound probes, so prove the ceiling actually stops the run. const limit = teamCtx.workspace.limits["synthetic-checks"]; const ctx = { ...teamCtx, db: tx }; const row = await createMonitor({ ctx, input: { name: `${TEST_PREFIX}-quota`, jobType: "http", url: "https://example.com", method: "GET", headers: [], assertions: [], active: true, regions: ["ams"], }, });
for (let i = 0; i < limit; i++) { await tx.insert(monitorRun).values({ monitorId: row.id, workspaceId: teamCtx.workspace.id, runnedAt: new Date(), }); }
await expect( triggerMonitorRun({ ctx, input: { id: row.id } }), ).rejects.toBeInstanceOf(LimitExceededError); }); });});
describe("monitor count quota", () => { const input = (name: string) => ({ name, jobType: "http" as const, url: "https://example.com", method: "GET" as const, headers: [], assertions: [], active: true, });
// Both suite workspaces are team-plan, so pin the cap on the row โ // `assertWithinLimit` re-reads it, and the tx rolls the override back. const capAtOne = async (tx: DrizzleTx) => { await tx .update(workspace) .set({ limits: JSON.stringify({ monitors: 1 }) }) .where(eq(workspace.id, freeWorkspaceId)); return { ...freeCtx, db: tx }; };
test("createMonitor rejects once the plan's monitor cap is spent", async () => { await withTestTransaction(async (tx) => { const ctx = await capAtOne(tx); await createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-1`) });
await expect( createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-2`) }), ).rejects.toBeInstanceOf(LimitExceededError); }); });
test("a soft-deleted monitor frees its slot", async () => { await withTestTransaction(async (tx) => { const ctx = await capAtOne(tx); const first = await createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-reuse-1`), }); await deleteMonitor({ ctx, input: { id: first.id } });
await expect( createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-reuse-2`) }), ).resolves.toBeDefined(); }); });
test("cloneMonitor rejects once the cap is spent", async () => { await withTestTransaction(async (tx) => { const ctx = await capAtOne(tx); const source = await createMonitor({ ctx, input: input(`${TEST_PREFIX}-clone-cap`), });
await expect( cloneMonitor({ ctx, input: { id: source.id } }), ).rejects.toBeInstanceOf(LimitExceededError); }); });});