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
at incident-api
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598import { eq } from "@openstatus/db";import { invitation, monitor, notification, page, selectWorkspaceSchema, user, usersToWorkspaces, workspace,} from "@openstatus/db/src/schema";import { getLimits } from "@openstatus/db/src/schema/plan/utils";import { expect } from "@std/expect";import { describe, test } from "@std/testing/bdd";
import { expectAuditRow, makeApiKeyCtx, readAuditLog, withTestTransaction,} from "../../../test/helpers";import type { DrizzleTx, ServiceContext } from "../../context";import { ForbiddenError } from "../../errors";import { downgradeWorkspaceToFree, previewWorkspaceDowngrade,} from "../index.ts";
const OLDEST = new Date("2020-01-01T00:00:00Z");const NEWER = new Date("2021-01-01T00:00:00Z");
/** * Insert a self-contained `team` workspace with everything the free tier * can't hold: two active monitors, two pages (the survivor carrying paid * access features), two notifications, an owner + two members, and a * pending + accepted invitation. Everything is scoped to the returned * workspace so assertions don't collide with seed data. Rolled back by the * enclosing `withTestTransaction`. */async function seedTeamWorkspace(tx: DrizzleTx) { const wsRow = await tx .insert(workspace) .values({ slug: "svc-downgrade-test", name: "Downgrade Test", plan: "team", stripeId: "cus_svc_downgrade_test", subscriptionId: "sub_svc_downgrade_test", limits: JSON.stringify(getLimits("team")), }) .returning() .get(); const ws = selectWorkspaceSchema.parse(wsRow);
const ownerUserId = 900_001; const memberAId = 900_002; const memberBId = 900_003; await tx.insert(user).values([ { id: ownerUserId, tenantId: "svc-downgrade-owner" }, { id: memberAId, tenantId: "svc-downgrade-member-a", email: "svc-downgrade-member-a@example.test", }, { id: memberBId, tenantId: "svc-downgrade-member-b" }, ]); await tx.insert(usersToWorkspaces).values([ { workspaceId: ws.id, userId: ownerUserId, role: "owner" }, { workspaceId: ws.id, userId: memberAId, role: "member" }, { workspaceId: ws.id, userId: memberBId, role: "admin" }, ]);
const oldestMonitor = await tx .insert(monitor) .values({ workspaceId: ws.id, url: "https://oldest.example.com", active: true, createdAt: OLDEST, }) .returning() .get(); const newerMonitor = await tx .insert(monitor) .values({ workspaceId: ws.id, url: "https://newer.example.com", active: true, createdAt: NEWER, }) .returning() .get();
const keptPage = await tx .insert(page) .values({ workspaceId: ws.id, title: "Kept Page", description: "", slug: "svc-downgrade-kept", customDomain: "status.acme.test", password: "hunter2", accessType: "password", allowIndex: false, createdAt: OLDEST, }) .returning() .get(); const deletedPage = await tx .insert(page) .values({ workspaceId: ws.id, title: "Deleted Page", description: "", slug: "svc-downgrade-deleted", customDomain: "", createdAt: NEWER, }) .returning() .get();
const emailNotification = await tx .insert(notification) .values({ workspaceId: ws.id, name: "keep-email", provider: "email", data: "{}", createdAt: NEWER, }) .returning() .get(); const discordNotification = await tx .insert(notification) .values({ workspaceId: ws.id, name: "drop-discord", provider: "discord", data: "{}", createdAt: OLDEST, }) .returning() .get();
const pendingInvitation = await tx .insert(invitation) .values({ workspaceId: ws.id, email: "pending@example.test", token: "svc-downgrade-pending", expiresAt: NEWER, }) .returning() .get(); const acceptedInvitation = await tx .insert(invitation) .values({ workspaceId: ws.id, email: "accepted@example.test", token: "svc-downgrade-accepted", expiresAt: NEWER, acceptedAt: OLDEST, }) .returning() .get();
return { ws, ownerUserId, memberAId, memberBId, oldestMonitor, newerMonitor, keptPage, deletedPage, emailNotification, discordNotification, pendingInvitation, acceptedInvitation, };}
describe("downgradeWorkspaceToFree", () => { test("flips the plan to free and resets billing columns", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, }; await tx .update(workspace) .set({ trialEndsAt: new Date("2027-01-01T00:00:00Z") }) .where(eq(workspace.id, s.ws.id));
await downgradeWorkspaceToFree({ ctx });
const after = await tx .select() .from(workspace) .where(eq(workspace.id, s.ws.id)) .get(); expect(after?.plan).toBe("free"); expect(after?.subscriptionId).toBeNull(); expect(after?.paidUntil).toBeNull(); expect(after?.endsAt).toBeNull(); expect(after?.trialEndsAt).toBeNull(); // Compare parsed content, not the raw string โ the verb persists // `limitsSchema`-canonicalised JSON (key order differs from the // config object returned by `getLimits`). expect(JSON.parse(after?.limits ?? "{}")).toEqual(getLimits("free"));
await expectAuditRow({ workspaceId: s.ws.id, action: "workspace.update", entityType: "workspace", entityId: s.ws.id, actorType: "system", db: tx, });
const [wsAudit] = await readAuditLog({ workspaceId: s.ws.id, entityType: "workspace", entityId: s.ws.id, db: tx, }); expect(wsAudit?.metadata).toMatchObject({ reason: "subscription_deleted", from: "team", to: "free", }); }); });
test("stamps the given reason instead of the default", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const withSso = await tx .update(workspace) .set({ workosOrganizationId: "org_downgrade_reason", ssoEnabled: true }) .where(eq(workspace.id, s.ws.id)) .returning() .get(); const ctx: ServiceContext = { workspace: selectWorkspaceSchema.parse(withSso), actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
await downgradeWorkspaceToFree({ ctx, input: { reason: "trial_ended" } });
const [wsAudit] = await readAuditLog({ workspaceId: s.ws.id, entityType: "workspace", entityId: s.ws.id, db: tx, }); expect(wsAudit?.metadata).toMatchObject({ reason: "trial_ended" }); const [ssoAudit] = await readAuditLog({ workspaceId: s.ws.id, entityType: "workspace_sso", entityId: s.ws.id, db: tx, }); expect(ssoAudit?.metadata).toMatchObject({ reason: "trial_ended" }); }); });
test("deactivates all but the oldest active monitor", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
await downgradeWorkspaceToFree({ ctx });
const oldest = await tx .select() .from(monitor) .where(eq(monitor.id, s.oldestMonitor.id)) .get(); const newer = await tx .select() .from(monitor) .where(eq(monitor.id, s.newerMonitor.id)) .get(); expect(oldest?.active).toBe(true); expect(newer?.active).toBe(false);
await expectAuditRow({ workspaceId: s.ws.id, action: "monitor.update", entityType: "monitor", entityId: s.newerMonitor.id, db: tx, }); }); });
test("deletes all but the oldest page and strips the survivor's paid features", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
const { customDomains } = await downgradeWorkspaceToFree({ ctx }); expect(customDomains).toContain("status.acme.test");
const deleted = await tx .select() .from(page) .where(eq(page.id, s.deletedPage.id)) .get(); expect(deleted).toBeUndefined();
const kept = await tx .select() .from(page) .where(eq(page.id, s.keptPage.id)) .get(); expect(kept?.customDomain).toBe(""); expect(kept?.password).toBeNull(); expect(kept?.accessType).toBe("public"); // no-index is paid-only โ the survivor must become indexable again. expect(kept?.allowIndex).toBe(true);
await expectAuditRow({ workspaceId: s.ws.id, action: "page.delete", entityType: "page", entityId: s.deletedPage.id, db: tx, }); await expectAuditRow({ workspaceId: s.ws.id, action: "page.update", entityType: "page", entityId: s.keptPage.id, db: tx, }); }); });
test("keeps the email notification, deletes the rest", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
await downgradeWorkspaceToFree({ ctx });
const kept = await tx .select() .from(notification) .where(eq(notification.id, s.emailNotification.id)) .get(); const dropped = await tx .select() .from(notification) .where(eq(notification.id, s.discordNotification.id)) .get(); expect(kept).toBeDefined(); expect(dropped).toBeUndefined();
await expectAuditRow({ workspaceId: s.ws.id, action: "notification.delete", entityType: "notification", entityId: s.discordNotification.id, db: tx, }); }); });
test("removes every non-owner member, one audit row each", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
await downgradeWorkspaceToFree({ ctx });
const remaining = await tx .select() .from(usersToWorkspaces) .where(eq(usersToWorkspaces.workspaceId, s.ws.id)) .all(); expect(remaining.map((r) => r.userId).sort()).toEqual([s.ownerUserId]);
for (const userId of [s.memberAId, s.memberBId]) { await expectAuditRow({ workspaceId: s.ws.id, action: "member.delete", entityType: "member", entityId: userId, actorType: "system", db: tx, }); } }); });
test("deletes pending invitations but keeps accepted ones", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
await downgradeWorkspaceToFree({ ctx });
const pending = await tx .select() .from(invitation) .where(eq(invitation.id, s.pendingInvitation.id)) .get(); const accepted = await tx .select() .from(invitation) .where(eq(invitation.id, s.acceptedInvitation.id)) .get(); expect(pending).toBeUndefined(); expect(accepted).toBeDefined();
await expectAuditRow({ workspaceId: s.ws.id, action: "invitation.delete", entityType: "invitation", entityId: s.pendingInvitation.id, db: tx, });
const acceptedAudit = await readAuditLog({ workspaceId: s.ws.id, entityType: "invitation", entityId: s.acceptedInvitation.id, db: tx, }); expect(acceptedAudit).toHaveLength(0); }); });
test("returns what the cascade actually trimmed", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
const { trimmed } = await downgradeWorkspaceToFree({ ctx });
expect(trimmed).toEqual({ monitorsDeactivated: 1, pagesDeleted: ["Deleted Page"], keptPageTitle: "Kept Page", notificationsDeleted: 1, invitationsDeleted: 1, membersRemovedCount: 2, // member B has no email on file and cannot be notified membersRemoved: ["svc-downgrade-member-a@example.test"], }); }); });
test("preview matches what the downgrade then trims, and changes nothing", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { workspace: s.ws, actor: { type: "system", job: "stripe-subscription-updated" }, db: tx, };
const { customDomains, ssoEnabled, ...preview } = await previewWorkspaceDowngrade({ ctx });
expect(customDomains).toEqual(["status.acme.test"]); expect(ssoEnabled).toBe(false); const pages = await tx .select() .from(page) .where(eq(page.workspaceId, s.ws.id)) .all(); expect(pages.length).toBe(2);
const { trimmed } = await downgradeWorkspaceToFree({ ctx }); expect(preview).toEqual(trimmed); }); });
test("returns an empty trim for a workspace with nothing to remove", async () => { await withTestTransaction(async (tx) => { const wsRow = await tx .insert(workspace) .values({ slug: "svc-downgrade-empty", name: "Empty", plan: "team", limits: JSON.stringify(getLimits("team")), }) .returning() .get(); const ctx: ServiceContext = { workspace: selectWorkspaceSchema.parse(wsRow), actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
const { trimmed } = await downgradeWorkspaceToFree({ ctx });
expect(trimmed).toEqual({ monitorsDeactivated: 0, pagesDeleted: [], keptPageTitle: null, notificationsDeleted: 0, invitationsDeleted: 0, membersRemovedCount: 0, membersRemoved: [], }); }); });
test("rejects a read-only api key actor", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx); const ctx: ServiceContext = { ...makeApiKeyCtx(s.ws, { keyId: "k-read", userId: 1, scopes: ["read"], }), db: tx, };
await expect(downgradeWorkspaceToFree({ ctx })).rejects.toBeInstanceOf( ForbiddenError, ); }); });
test("disables SSO so JIT cannot re-add the trimmed members", async () => { await withTestTransaction(async (tx) => { const s = await seedTeamWorkspace(tx);
const withSso = await tx .update(workspace) .set({ workosOrganizationId: "org_downgrade_test", ssoEnabled: true }) .where(eq(workspace.id, s.ws.id)) .returning() .get();
const ctx: ServiceContext = { workspace: selectWorkspaceSchema.parse(withSso), actor: { type: "system", job: "stripe-subscription-deleted" }, db: tx, };
const result = await downgradeWorkspaceToFree({ ctx }); expect(result.ssoDisabled).toBe(true);
const after = await tx .select() .from(workspace) .where(eq(workspace.id, s.ws.id)) .get(); expect(after?.ssoEnabled).toBe(false); // Organization link is kept so a re-upgrade doesn't require reconnecting. expect(after?.workosOrganizationId).toBe("org_downgrade_test");
await expectAuditRow({ workspaceId: s.ws.id, action: "workspace_sso.update", entityType: "workspace_sso", entityId: s.ws.id, db: tx, }); }); });});