From 229eddd7d71d02293c9a82ef7d44140c138d062f Mon Sep 17 00:00:00 2001 From: ANSHSINGH050404 <114014895+ANSHSINGH050404@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:17:48 +0530 Subject: [PATCH 1/4] fix(rpc): reject alarm websiteIds outside the organization --- packages/rpc/src/routers/alarms.ts | 41 +++- .../routers/alarms.website-ownership.test.ts | 176 ++++++++++++++++++ 2 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 packages/rpc/src/routers/alarms.website-ownership.test.ts diff --git a/packages/rpc/src/routers/alarms.ts b/packages/rpc/src/routers/alarms.ts index e06f6250d..59c23aa2b 100644 --- a/packages/rpc/src/routers/alarms.ts +++ b/packages/rpc/src/routers/alarms.ts @@ -1,8 +1,9 @@ -import { db, eq, withTransaction } from "@databuddy/db"; +import { and, db, eq, isNull, withTransaction } from "@databuddy/db"; import { alarmDestinations, alarms, alarmTriggerTypeValues, + websites, } from "@databuddy/db/schema"; import { MAX_ALARM_DESTINATIONS } from "@databuddy/notifications"; import { ratelimit } from "@databuddy/redis/rate-limit"; @@ -158,6 +159,28 @@ async function callerCanReadSecrets( } } +async function requireAlarmWebsite( + ctx: Context, + organizationId: string, + websiteId: string +): Promise { + const ownedWebsites = await ctx.db + .select({ id: websites.id }) + .from(websites) + .where( + and( + eq(websites.id, websiteId), + eq(websites.organizationId, organizationId), + isNull(websites.deletedAt) + ) + ); + if (ownedWebsites.length === 0) { + throw rpcError.badRequest( + "This alert can only be linked to a website in the selected organization." + ); + } +} + export const alarmsRouter = { list: protectedProcedure .route({ @@ -228,6 +251,13 @@ export const alarmsRouter = { resource: "organization", permissions: ["update"], }); + if (input.websiteId) { + await requireAlarmWebsite( + context, + input.organizationId, + input.websiteId + ); + } const alarmId = randomUUIDv7(); const now = new Date(); @@ -293,11 +323,18 @@ export const alarmsRouter = { ) .output(alarmOutputSchema) .handler(async ({ context, input }) => { - await withResource(context, { + const alarm = await withResource(context, { resource: "alarm", id: input.alarmId, permissions: ["update"], }); + if (input.websiteId) { + await requireAlarmWebsite( + context, + alarm.organizationId, + input.websiteId + ); + } const now = new Date(); const { alarmId, destinations, ...fields } = input; diff --git a/packages/rpc/src/routers/alarms.website-ownership.test.ts b/packages/rpc/src/routers/alarms.website-ownership.test.ts new file mode 100644 index 000000000..7dbd9fc16 --- /dev/null +++ b/packages/rpc/src/routers/alarms.website-ownership.test.ts @@ -0,0 +1,176 @@ +import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import * as drizzleOrm from "drizzle-orm"; +import { createProcedureClient } from "@orpc/server"; +import type { Context } from "../orpc"; + +const ORGANIZATION_A = "org-a"; +const WEBSITE_A = "site-a"; +const WEBSITE_B = "site-b"; + +const mockWithWorkspace = mock(async () => ({ + organizationId: ORGANIZATION_A, + role: "admin", +})); +const mockAppendAuditEvent = mock(async () => undefined); + +function alarmRow() { + const now = new Date("2026-08-21T00:00:00.000Z"); + return { + createdAt: now, + description: null, + destinations: [], + enabled: true, + id: "alarm-a", + name: "Owned alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + updatedAt: now, + websiteId: null, + }; +} + +const txStub = { + delete: () => ({ where: async () => [] }), + insert: () => ({ values: async () => [] }), + update: () => ({ set: () => ({ where: async () => [] }) }), +}; + +let alarmsRouter: typeof import("./alarms").alarmsRouter; + +beforeAll(async () => { + mock.module("@databuddy/auth", () => ({ + auth: { api: { getSession: async () => null } }, + })); + mock.module("@databuddy/db", () => ({ + ...drizzleOrm, + db: { query: { alarms: { findFirst: async () => alarmRow() } } }, + withTransaction: async ( + callback: (tx: typeof txStub) => Promise + ): Promise => callback(txStub), + })); + mock.module("../procedures/with-workspace", () => ({ + withWorkspace: mockWithWorkspace, + })); + const actualServicesAudit = await import("@databuddy/services/audit"); + mock.module("@databuddy/services/audit", () => ({ + ...actualServicesAudit, + appendAuditEvent: mockAppendAuditEvent, + })); + + ({ alarmsRouter } = await import("./alarms")); + + mock.restore(); +}); + +function call(procedure: T, context: Context) { + return createProcedureClient(procedure as never, { context }); +} + +function contextWithMatchedWebsites(matchedWebsiteIds: string[]): Context { + const database = { + select: () => ({ + from: () => ({ + where: async () => matchedWebsiteIds.map((id) => ({ id })), + }), + }), + }; + + return { + auditOrganizationId: undefined, + anonymousId: null, + apiKey: undefined, + db: database, + getBilling: async () => undefined, + headers: new Headers(), + organizationId: ORGANIZATION_A, + session: undefined, + sessionId: null, + user: { + email: "admin@example.com", + id: "user-a", + name: "Admin", + }, + } as Context; +} + +function createInput(websiteId?: string | null) { + return { + description: undefined, + destinations: [ + { config: {}, identifier: "owner@example.com", type: "email" }, + ], + enabled: true, + name: "New alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + ...(websiteId === undefined ? {} : { websiteId }), + }; +} + +describe("alarms website ownership", () => { + beforeEach(() => { + mockWithWorkspace.mockClear(); + mockAppendAuditEvent.mockClear(); + }); + + it("rejects create when the website belongs to another organization", async () => { + await expect( + call( + alarmsRouter.create, + contextWithMatchedWebsites([]) + )(createInput(WEBSITE_B)) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "This alert can only be linked to a website in the selected organization.", + }); + }); + + it("rejects update when the website belongs to another organization", async () => { + await expect( + call( + alarmsRouter.update, + contextWithMatchedWebsites([]) + )({ + alarmId: "alarm-a", + websiteId: WEBSITE_B, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "This alert can only be linked to a website in the selected organization.", + }); + }); + + it("allows create for a website in the selected organization", async () => { + const result = await call( + alarmsRouter.create, + contextWithMatchedWebsites([WEBSITE_A]) + )(createInput(WEBSITE_A)); + + expect(result.id).toBe("alarm-a"); + }); + + it("allows create without a website", async () => { + const result = await call( + alarmsRouter.create, + contextWithMatchedWebsites([]) + )(createInput()); + + expect(result.id).toBe("alarm-a"); + }); + + it("allows update that unlinks the website", async () => { + const result = await call( + alarmsRouter.update, + contextWithMatchedWebsites([]) + )({ + alarmId: "alarm-a", + websiteId: null, + }); + + expect(result.id).toBe("alarm-a"); + }); +}); From 177912f361cbb404ea4aa480a6affeb6bd54efdd Mon Sep 17 00:00:00 2001 From: ANSHSINGH050404 <114014895+ANSHSINGH050404@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:25:54 +0530 Subject: [PATCH 2/4] test(rpc): replace mocked ownership test with isolated-DB integration test --- ...arms.website-ownership.integration.test.ts | 192 ++++++++++++++++++ .../routers/alarms.website-ownership.test.ts | 176 ---------------- 2 files changed, 192 insertions(+), 176 deletions(-) create mode 100644 packages/rpc/src/routers/alarms.website-ownership.integration.test.ts delete mode 100644 packages/rpc/src/routers/alarms.website-ownership.test.ts diff --git a/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts b/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts new file mode 100644 index 000000000..a0087caac --- /dev/null +++ b/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts @@ -0,0 +1,192 @@ +import "@databuddy/db/test-env"; +import { beforeAll, describe, expect, it } from "bun:test"; +import { randomUUIDv7 } from "bun"; +import { sql } from "drizzle-orm"; +import { createProcedureClient } from "@orpc/server"; +import { db } from "@databuddy/db"; +import { + alarms, + member, + organization, + user, + websites, +} from "@databuddy/db/schema"; +import type { Context } from "../orpc"; +import { alarmsRouter } from "./alarms"; + +async function hasDatabase(): Promise { + try { + await db.execute(sql`SELECT 1`); + return true; + } catch { + return false; + } +} + +const databaseAvailable = await hasDatabase(); +if (process.env.CI && !databaseAvailable) { + throw new Error( + "Alarms ownership integration tests need the test database; CI must not skip them." + ); +} +const integration = databaseAvailable ? describe : describe.skip; + +const runId = randomUUIDv7(); +const ORGANIZATION_A = `alarm-test-org-a-${runId}`; +const ORGANIZATION_B = `alarm-test-org-b-${runId}`; +const USER_A = `alarm-test-user-a-${runId}`; +const WEBSITE_A = `alarm-test-site-a-${runId}`; +const WEBSITE_B = `alarm-test-site-b-${runId}`; + +function testContext(): Context { + return { + auditOrganizationId: undefined, + anonymousId: null, + apiKey: undefined, + db, + getBilling: async () => undefined, + headers: new Headers(), + organizationId: ORGANIZATION_A, + session: undefined, + sessionId: null, + user: { + email: `alarm-admin-${runId}@example.com`, + id: USER_A, + name: "Alarm Admin", + }, + } as Context; +} + +function emailDestination() { + return { + config: {}, + identifier: `owner-${runId}@example.com`, + type: "email", + }; +} + +integration("alarms website ownership against isolated PostgreSQL", () => { + let alarmId: string; + + beforeAll(async () => { + const now = new Date(); + await db.insert(organization).values([ + { createdAt: now, id: ORGANIZATION_A, name: "Alarm test org A" }, + { createdAt: now, id: ORGANIZATION_B, name: "Alarm test org B" }, + ]); + await db.insert(user).values({ + createdAt: now, + email: `alarm-admin-${runId}@example.com`, + emailVerified: true, + id: USER_A, + name: "Alarm Admin", + updatedAt: now, + }); + await db.insert(member).values({ + createdAt: now, + id: `alarm-test-member-${runId}`, + organizationId: ORGANIZATION_A, + role: "admin", + userId: USER_A, + }); + await db.insert(websites).values([ + { + domain: `alarm-a-${runId}.example.com`, + id: WEBSITE_A, + organizationId: ORGANIZATION_A, + }, + { + domain: `alarm-b-${runId}.example.com`, + id: WEBSITE_B, + organizationId: ORGANIZATION_B, + }, + ]); + + const created = await createProcedureClient(alarmsRouter.create, { + context: testContext(), + })({ + destinations: [emailDestination()], + name: "Owned alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + websiteId: WEBSITE_A, + }); + alarmId = created.id; + expect(alarms).toBeDefined(); + }); + + it("rejects create when the website belongs to another organization", async () => { + await expect( + createProcedureClient(alarmsRouter.create, { + context: testContext(), + })({ + destinations: [emailDestination()], + name: "Foreign alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + websiteId: WEBSITE_B, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "This alert can only be linked to a website in the selected organization.", + }); + }); + + it("rejects update when the website belongs to another organization", async () => { + await expect( + createProcedureClient(alarmsRouter.update, { + context: testContext(), + })({ + alarmId, + websiteId: WEBSITE_B, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "This alert can only be linked to a website in the selected organization.", + }); + }); + + it("allows create for a website in the selected organization", async () => { + const result = await createProcedureClient(alarmsRouter.create, { + context: testContext(), + })({ + destinations: [emailDestination()], + name: "Another owned alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + websiteId: WEBSITE_A, + }); + + expect(result.id).toBeString(); + }); + + it("allows create without a website", async () => { + const result = await createProcedureClient(alarmsRouter.create, { + context: testContext(), + })({ + destinations: [emailDestination()], + name: "Org-wide alarm", + organizationId: ORGANIZATION_A, + triggerConditions: {}, + triggerType: "traffic_spike", + }); + + expect(result.id).toBeString(); + }); + + it("allows update that unlinks the website", async () => { + const result = await createProcedureClient(alarmsRouter.update, { + context: testContext(), + })({ + alarmId, + websiteId: null, + }); + + expect(result.id).toBe(alarmId); + }); +}); diff --git a/packages/rpc/src/routers/alarms.website-ownership.test.ts b/packages/rpc/src/routers/alarms.website-ownership.test.ts deleted file mode 100644 index 7dbd9fc16..000000000 --- a/packages/rpc/src/routers/alarms.website-ownership.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; -import * as drizzleOrm from "drizzle-orm"; -import { createProcedureClient } from "@orpc/server"; -import type { Context } from "../orpc"; - -const ORGANIZATION_A = "org-a"; -const WEBSITE_A = "site-a"; -const WEBSITE_B = "site-b"; - -const mockWithWorkspace = mock(async () => ({ - organizationId: ORGANIZATION_A, - role: "admin", -})); -const mockAppendAuditEvent = mock(async () => undefined); - -function alarmRow() { - const now = new Date("2026-08-21T00:00:00.000Z"); - return { - createdAt: now, - description: null, - destinations: [], - enabled: true, - id: "alarm-a", - name: "Owned alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - updatedAt: now, - websiteId: null, - }; -} - -const txStub = { - delete: () => ({ where: async () => [] }), - insert: () => ({ values: async () => [] }), - update: () => ({ set: () => ({ where: async () => [] }) }), -}; - -let alarmsRouter: typeof import("./alarms").alarmsRouter; - -beforeAll(async () => { - mock.module("@databuddy/auth", () => ({ - auth: { api: { getSession: async () => null } }, - })); - mock.module("@databuddy/db", () => ({ - ...drizzleOrm, - db: { query: { alarms: { findFirst: async () => alarmRow() } } }, - withTransaction: async ( - callback: (tx: typeof txStub) => Promise - ): Promise => callback(txStub), - })); - mock.module("../procedures/with-workspace", () => ({ - withWorkspace: mockWithWorkspace, - })); - const actualServicesAudit = await import("@databuddy/services/audit"); - mock.module("@databuddy/services/audit", () => ({ - ...actualServicesAudit, - appendAuditEvent: mockAppendAuditEvent, - })); - - ({ alarmsRouter } = await import("./alarms")); - - mock.restore(); -}); - -function call(procedure: T, context: Context) { - return createProcedureClient(procedure as never, { context }); -} - -function contextWithMatchedWebsites(matchedWebsiteIds: string[]): Context { - const database = { - select: () => ({ - from: () => ({ - where: async () => matchedWebsiteIds.map((id) => ({ id })), - }), - }), - }; - - return { - auditOrganizationId: undefined, - anonymousId: null, - apiKey: undefined, - db: database, - getBilling: async () => undefined, - headers: new Headers(), - organizationId: ORGANIZATION_A, - session: undefined, - sessionId: null, - user: { - email: "admin@example.com", - id: "user-a", - name: "Admin", - }, - } as Context; -} - -function createInput(websiteId?: string | null) { - return { - description: undefined, - destinations: [ - { config: {}, identifier: "owner@example.com", type: "email" }, - ], - enabled: true, - name: "New alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - ...(websiteId === undefined ? {} : { websiteId }), - }; -} - -describe("alarms website ownership", () => { - beforeEach(() => { - mockWithWorkspace.mockClear(); - mockAppendAuditEvent.mockClear(); - }); - - it("rejects create when the website belongs to another organization", async () => { - await expect( - call( - alarmsRouter.create, - contextWithMatchedWebsites([]) - )(createInput(WEBSITE_B)) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: - "This alert can only be linked to a website in the selected organization.", - }); - }); - - it("rejects update when the website belongs to another organization", async () => { - await expect( - call( - alarmsRouter.update, - contextWithMatchedWebsites([]) - )({ - alarmId: "alarm-a", - websiteId: WEBSITE_B, - }) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: - "This alert can only be linked to a website in the selected organization.", - }); - }); - - it("allows create for a website in the selected organization", async () => { - const result = await call( - alarmsRouter.create, - contextWithMatchedWebsites([WEBSITE_A]) - )(createInput(WEBSITE_A)); - - expect(result.id).toBe("alarm-a"); - }); - - it("allows create without a website", async () => { - const result = await call( - alarmsRouter.create, - contextWithMatchedWebsites([]) - )(createInput()); - - expect(result.id).toBe("alarm-a"); - }); - - it("allows update that unlinks the website", async () => { - const result = await call( - alarmsRouter.update, - contextWithMatchedWebsites([]) - )({ - alarmId: "alarm-a", - websiteId: null, - }); - - expect(result.id).toBe("alarm-a"); - }); -}); From 6a09e3e9dc223952a705c11bfa1a75db7a0a3f52 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:33 +0300 Subject: [PATCH 3/4] fix(rpc): report alarm websites outside the organization as not found --- packages/rpc/src/routers/alarms.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rpc/src/routers/alarms.ts b/packages/rpc/src/routers/alarms.ts index 59c23aa2b..92939278e 100644 --- a/packages/rpc/src/routers/alarms.ts +++ b/packages/rpc/src/routers/alarms.ts @@ -175,9 +175,7 @@ async function requireAlarmWebsite( ) ); if (ownedWebsites.length === 0) { - throw rpcError.badRequest( - "This alert can only be linked to a website in the selected organization." - ); + throw rpcError.notFound("Website", websiteId); } } From 84d074900551c2861574e434b6192f9af728c86f Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:49 +0300 Subject: [PATCH 4/4] test(api): cover alarm website ownership with the shared integration helpers --- .../src/integration/alarm-handlers.test.ts | 117 +++++++++++ ...arms.website-ownership.integration.test.ts | 192 ------------------ 2 files changed, 117 insertions(+), 192 deletions(-) create mode 100644 apps/api/src/integration/alarm-handlers.test.ts delete mode 100644 packages/rpc/src/routers/alarms.website-ownership.integration.test.ts diff --git a/apps/api/src/integration/alarm-handlers.test.ts b/apps/api/src/integration/alarm-handlers.test.ts new file mode 100644 index 000000000..1c3af46a8 --- /dev/null +++ b/apps/api/src/integration/alarm-handlers.test.ts @@ -0,0 +1,117 @@ +import "@databuddy/test/env"; + +import { appRouter, type Context } from "@databuddy/rpc"; +import { + addToOrganization, + cleanup, + expectCode, + hasTestDb, + insertOrganization, + insertWebsite, + reset, + signUp, + userContext, +} from "@databuddy/test"; +import { createProcedureClient } from "@orpc/server"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +const iit = hasTestDb ? it : it.skip; + +beforeEach(() => reset()); +afterAll(() => cleanup()); + +async function workspace() { + const user = await signUp(); + const [org, foreignOrg] = await Promise.all([ + insertOrganization(), + insertOrganization(), + ]); + if (!(org && foreignOrg)) { + throw new Error("Organization insert returned no row"); + } + await addToOrganization(user.id, org.id, "owner"); + const [website, foreignWebsite] = await Promise.all([ + insertWebsite({ organizationId: org.id }), + insertWebsite({ organizationId: foreignOrg.id }), + ]); + if (!(website && foreignWebsite)) { + throw new Error("Website insert returned no row"); + } + return { context: userContext(user, org.id), org, website, foreignWebsite }; +} + +function createAlarm( + context: Context, + organizationId: string, + websiteId?: string +) { + return createProcedureClient(appRouter.alarms.create, { context })({ + organizationId, + websiteId, + name: "Uptime alert", + triggerType: "uptime", + triggerConditions: {}, + destinations: [ + { type: "email", identifier: "owner@example.com", config: {} }, + ], + }); +} + +describe("alarms website ownership", () => { + iit("rejects a website from another organization on create", async () => { + const { context, org, foreignWebsite } = await workspace(); + + await expectCode( + createAlarm(context, org.id, foreignWebsite.id), + "NOT_FOUND" + ); + }); + + iit("rejects a deleted website on create", async () => { + const { context, org } = await workspace(); + const deleted = await insertWebsite({ + organizationId: org.id, + deletedAt: new Date(), + }); + if (!deleted) { + throw new Error("Website insert returned no row"); + } + + await expectCode(createAlarm(context, org.id, deleted.id), "NOT_FOUND"); + }); + + iit("links a website owned by the organization", async () => { + const { context, org, website } = await workspace(); + + const alarm = await createAlarm(context, org.id, website.id); + + expect(alarm.websiteId).toBe(website.id); + }); + + iit("creates an organization-wide alarm without a website", async () => { + const { context, org } = await workspace(); + + const alarm = await createAlarm(context, org.id); + + expect(alarm.websiteId).toBeNull(); + }); + + iit( + "rejects moving an alarm to a foreign website but allows unlinking", + async () => { + const { context, org, website, foreignWebsite } = await workspace(); + const alarm = await createAlarm(context, org.id, website.id); + const update = createProcedureClient(appRouter.alarms.update, { + context, + }); + + await expectCode( + update({ alarmId: alarm.id, websiteId: foreignWebsite.id }), + "NOT_FOUND" + ); + + const unlinked = await update({ alarmId: alarm.id, websiteId: null }); + expect(unlinked.websiteId).toBeNull(); + } + ); +}); diff --git a/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts b/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts deleted file mode 100644 index a0087caac..000000000 --- a/packages/rpc/src/routers/alarms.website-ownership.integration.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import "@databuddy/db/test-env"; -import { beforeAll, describe, expect, it } from "bun:test"; -import { randomUUIDv7 } from "bun"; -import { sql } from "drizzle-orm"; -import { createProcedureClient } from "@orpc/server"; -import { db } from "@databuddy/db"; -import { - alarms, - member, - organization, - user, - websites, -} from "@databuddy/db/schema"; -import type { Context } from "../orpc"; -import { alarmsRouter } from "./alarms"; - -async function hasDatabase(): Promise { - try { - await db.execute(sql`SELECT 1`); - return true; - } catch { - return false; - } -} - -const databaseAvailable = await hasDatabase(); -if (process.env.CI && !databaseAvailable) { - throw new Error( - "Alarms ownership integration tests need the test database; CI must not skip them." - ); -} -const integration = databaseAvailable ? describe : describe.skip; - -const runId = randomUUIDv7(); -const ORGANIZATION_A = `alarm-test-org-a-${runId}`; -const ORGANIZATION_B = `alarm-test-org-b-${runId}`; -const USER_A = `alarm-test-user-a-${runId}`; -const WEBSITE_A = `alarm-test-site-a-${runId}`; -const WEBSITE_B = `alarm-test-site-b-${runId}`; - -function testContext(): Context { - return { - auditOrganizationId: undefined, - anonymousId: null, - apiKey: undefined, - db, - getBilling: async () => undefined, - headers: new Headers(), - organizationId: ORGANIZATION_A, - session: undefined, - sessionId: null, - user: { - email: `alarm-admin-${runId}@example.com`, - id: USER_A, - name: "Alarm Admin", - }, - } as Context; -} - -function emailDestination() { - return { - config: {}, - identifier: `owner-${runId}@example.com`, - type: "email", - }; -} - -integration("alarms website ownership against isolated PostgreSQL", () => { - let alarmId: string; - - beforeAll(async () => { - const now = new Date(); - await db.insert(organization).values([ - { createdAt: now, id: ORGANIZATION_A, name: "Alarm test org A" }, - { createdAt: now, id: ORGANIZATION_B, name: "Alarm test org B" }, - ]); - await db.insert(user).values({ - createdAt: now, - email: `alarm-admin-${runId}@example.com`, - emailVerified: true, - id: USER_A, - name: "Alarm Admin", - updatedAt: now, - }); - await db.insert(member).values({ - createdAt: now, - id: `alarm-test-member-${runId}`, - organizationId: ORGANIZATION_A, - role: "admin", - userId: USER_A, - }); - await db.insert(websites).values([ - { - domain: `alarm-a-${runId}.example.com`, - id: WEBSITE_A, - organizationId: ORGANIZATION_A, - }, - { - domain: `alarm-b-${runId}.example.com`, - id: WEBSITE_B, - organizationId: ORGANIZATION_B, - }, - ]); - - const created = await createProcedureClient(alarmsRouter.create, { - context: testContext(), - })({ - destinations: [emailDestination()], - name: "Owned alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - websiteId: WEBSITE_A, - }); - alarmId = created.id; - expect(alarms).toBeDefined(); - }); - - it("rejects create when the website belongs to another organization", async () => { - await expect( - createProcedureClient(alarmsRouter.create, { - context: testContext(), - })({ - destinations: [emailDestination()], - name: "Foreign alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - websiteId: WEBSITE_B, - }) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: - "This alert can only be linked to a website in the selected organization.", - }); - }); - - it("rejects update when the website belongs to another organization", async () => { - await expect( - createProcedureClient(alarmsRouter.update, { - context: testContext(), - })({ - alarmId, - websiteId: WEBSITE_B, - }) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: - "This alert can only be linked to a website in the selected organization.", - }); - }); - - it("allows create for a website in the selected organization", async () => { - const result = await createProcedureClient(alarmsRouter.create, { - context: testContext(), - })({ - destinations: [emailDestination()], - name: "Another owned alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - websiteId: WEBSITE_A, - }); - - expect(result.id).toBeString(); - }); - - it("allows create without a website", async () => { - const result = await createProcedureClient(alarmsRouter.create, { - context: testContext(), - })({ - destinations: [emailDestination()], - name: "Org-wide alarm", - organizationId: ORGANIZATION_A, - triggerConditions: {}, - triggerType: "traffic_spike", - }); - - expect(result.id).toBeString(); - }); - - it("allows update that unlinks the website", async () => { - const result = await createProcedureClient(alarmsRouter.update, { - context: testContext(), - })({ - alarmId, - websiteId: null, - }); - - expect(result.id).toBe(alarmId); - }); -});