Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions apps/api/src/integration/alarm-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
);
});
39 changes: 37 additions & 2 deletions packages/rpc/src/routers/alarms.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -158,6 +159,26 @@ async function callerCanReadSecrets(
}
}

async function requireAlarmWebsite(
ctx: Context,
organizationId: string,
websiteId: string
): Promise<void> {
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.notFound("Website", websiteId);
}
}

export const alarmsRouter = {
list: protectedProcedure
.route({
Expand Down Expand Up @@ -228,6 +249,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();
Expand Down Expand Up @@ -293,11 +321,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;
Expand Down
Loading