Skip to content

Commit 4ff8859

Browse files
committed
fix(webapp): keep the agent's alert delete to channels the watch type is on
1 parent cbc2edc commit 4ff8859

2 files changed

Lines changed: 250 additions & 1 deletion

File tree

apps/webapp/app/services/dashboardAgentWatchAlerts.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ export async function unsubscribeChannelFromWatchAlerts(
219219
where: scope,
220220
select: { name: true, alertTypes: true },
221221
});
222-
if (!channel) return { ok: false, reason: "not_found" };
222+
// A channel this alert type was never on is out of scope: stripping nothing off it
223+
// would still report success, and an empty list would disable it.
224+
if (!channel || !channel.alertTypes.includes(DASHBOARD_AGENT_WATCH_ALERT_TYPE)) {
225+
return { ok: false, reason: "not_found" };
226+
}
223227

224228
const remaining = channel.alertTypes.filter(
225229
(type) => type !== DASHBOARD_AGENT_WATCH_ALERT_TYPE

apps/webapp/test/dashboardAgentWatches.test.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
8181

8282
const SESSION_SECRET = "test-session-secret-for-watch-tokens";
8383
process.env.SESSION_SECRET = SESSION_SECRET;
84+
// The agent's subscribe endpoint refuses without an email transport configured.
85+
process.env.ALERT_FROM_EMAIL = "alerts@example.com";
86+
process.env.ALERT_EMAIL_TRANSPORT = "smtp";
8487

8588
const {
8689
armDashboardAgentWatchBatch,
@@ -105,6 +108,13 @@ const {
105108
const { runWatchBatchCheck } = await import("~/services/dashboardAgentWatchBatch.server");
106109
const { signDashboardAgentWatchBatchToken, signDashboardAgentWatchToken } =
107110
await import("~/services/dashboardAgentWatchToken.server");
111+
const { loader: alertsLoader, action: alertsAction } =
112+
await import("~/routes/api.v1.dashboard-agent.alerts");
113+
const { action: alertChannelAction } =
114+
await import("~/routes/api.v1.dashboard-agent.alerts.$channelId");
115+
const { findProjectBySlug } = await import("~/models/project.server");
116+
const { DASHBOARD_AGENT_WATCH_ALERT_TYPE } =
117+
await import("~/services/dashboardAgentWatchAlerts.server");
108118

109119
/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */
110120
async function applyAgentSchema(prisma: PrismaClient) {
@@ -1649,6 +1659,241 @@ function storedMessages(seeded: Seeded, chatId: string) {
16491659
}) as Promise<Array<{ id: string; role: string }> | null>;
16501660
}
16511661

1662+
/**
1663+
* The Alerts page authorizes with `findProjectBySlug` alone (see
1664+
* `_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx`):
1665+
* every organization member may list, create and delete a project's alert channels, with
1666+
* no role check. These tests pin that policy and prove the agent's routes never write
1667+
* wider than it.
1668+
*/
1669+
describe("the agent's alert boundary", () => {
1670+
/** A second, plain member of the same organization. */
1671+
async function seedMember(prisma: PrismaClient, seeded: Seeded) {
1672+
const member = await prisma.user.create({
1673+
data: {
1674+
email: `member_${Math.random().toString(36).slice(2, 10)}@example.com`,
1675+
authenticationMethod: "MAGIC_LINK",
1676+
},
1677+
});
1678+
await prisma.orgMember.create({
1679+
data: { organizationId: seeded.organization.id, userId: member.id, role: "MEMBER" },
1680+
});
1681+
return member;
1682+
}
1683+
1684+
async function seedOutsider(prisma: PrismaClient) {
1685+
return prisma.user.create({
1686+
data: {
1687+
email: `outsider_${Math.random().toString(36).slice(2, 10)}@example.com`,
1688+
authenticationMethod: "MAGIC_LINK",
1689+
},
1690+
});
1691+
}
1692+
1693+
async function seedWatchChannel(prisma: PrismaClient, seeded: Seeded, email: string) {
1694+
return prisma.projectAlertChannel.create({
1695+
data: {
1696+
friendlyId: `alert_${Math.random().toString(36).slice(2, 10)}`,
1697+
name: `Watch alerts for ${email}`,
1698+
projectId: seeded.project.id,
1699+
alertTypes: [DASHBOARD_AGENT_WATCH_ALERT_TYPE as never],
1700+
environmentTypes: ["PRODUCTION"],
1701+
type: "EMAIL",
1702+
properties: { email },
1703+
deduplicationKey: `dashboard-agent-watch:${email}`,
1704+
},
1705+
});
1706+
}
1707+
1708+
function listRequest(chatId: string) {
1709+
return {
1710+
request: new Request(
1711+
`https://app.trigger.dev/api/v1/dashboard-agent/alerts?chatId=${chatId}`,
1712+
{ headers: { Authorization: "Bearer tr_uat_test" } }
1713+
),
1714+
params: {},
1715+
context: {} as never,
1716+
} as never;
1717+
}
1718+
1719+
function createRequest(body: Record<string, unknown>) {
1720+
return {
1721+
request: new Request("https://app.trigger.dev/api/v1/dashboard-agent/alerts", {
1722+
method: "POST",
1723+
headers: { Authorization: "Bearer tr_uat_test", "content-type": "application/json" },
1724+
body: JSON.stringify(body),
1725+
}),
1726+
params: {},
1727+
context: {} as never,
1728+
} as never;
1729+
}
1730+
1731+
function deleteRequest(channelId: string, body: Record<string, unknown>) {
1732+
return {
1733+
request: new Request(`https://app.trigger.dev/api/v1/dashboard-agent/alerts/${channelId}`, {
1734+
method: "DELETE",
1735+
headers: { Authorization: "Bearer tr_uat_test", "content-type": "application/json" },
1736+
body: JSON.stringify(body),
1737+
}),
1738+
params: { channelId },
1739+
context: {} as never,
1740+
} as never;
1741+
}
1742+
1743+
postgresTest(
1744+
"the dashboard lets any organization member manage a project's alerts",
1745+
async ({ prisma, postgresContainer }) => {
1746+
await boot(prisma, postgresContainer.getConnectionUri());
1747+
const seeded = await seed(prisma, "alert-policy");
1748+
const member = await seedMember(prisma, seeded);
1749+
const outsider = await seedOutsider(prisma);
1750+
1751+
// The whole of the Alerts page's authorization, for list, create and delete alike.
1752+
expect(
1753+
await findProjectBySlug(seeded.organization.slug, seeded.project.slug, member.id)
1754+
).not.toBeNull();
1755+
expect(
1756+
await findProjectBySlug(seeded.organization.slug, seeded.project.slug, outsider.id)
1757+
).toBeNull();
1758+
}
1759+
);
1760+
1761+
postgresTest(
1762+
"a plain member reads and writes watch alerts through the agent, an outsider reads nothing",
1763+
async ({ prisma, postgresContainer }) => {
1764+
await boot(prisma, postgresContainer.getConnectionUri());
1765+
const seeded = await seed(prisma, "alert-member");
1766+
const member = await seedMember(prisma, seeded);
1767+
await createChat(ctx.agentDb, {
1768+
id: "chat_member",
1769+
organizationId: seeded.organization.id,
1770+
userId: member.id,
1771+
});
1772+
await seedWatchChannel(prisma, seeded, member.email);
1773+
1774+
ctx.actor = {
1775+
userId: member.id,
1776+
client: "dashboard-agent",
1777+
environmentId: seeded.environment.id,
1778+
};
1779+
const listed = (await alertsLoader(listRequest("chat_member"))) as Response;
1780+
expect(listed.status).toBe(200);
1781+
// The same channel the Alerts page would show this member.
1782+
expect((await listed.json()).alerts).toHaveLength(1);
1783+
1784+
// An outsider has no chat here and no membership, so nothing resolves.
1785+
ctx.actor = {
1786+
userId: (await seedOutsider(prisma)).id,
1787+
client: "dashboard-agent",
1788+
environmentId: seeded.environment.id,
1789+
};
1790+
const refused = (await alertsLoader(listRequest("chat_member"))) as Response;
1791+
expect(refused.status).toBe(404);
1792+
}
1793+
);
1794+
1795+
postgresTest(
1796+
"the agent only ever subscribes the caller's own address",
1797+
async ({ prisma, postgresContainer }) => {
1798+
await boot(prisma, postgresContainer.getConnectionUri());
1799+
const seeded = await seed(prisma, "alert-create");
1800+
const member = await seedMember(prisma, seeded);
1801+
await createChat(ctx.agentDb, {
1802+
id: "chat_member",
1803+
organizationId: seeded.organization.id,
1804+
userId: member.id,
1805+
});
1806+
1807+
ctx.actor = {
1808+
userId: member.id,
1809+
client: "dashboard-agent",
1810+
environmentId: seeded.environment.id,
1811+
};
1812+
1813+
const own = (await alertsAction(
1814+
createRequest({ chatId: "chat_member", channel: "email" })
1815+
)) as Response;
1816+
expect(own.status).toBe(200);
1817+
expect((await own.json()).target).toBe(member.email);
1818+
1819+
// The Alerts page would let this member add anyone; the agent may not.
1820+
const other = (await alertsAction(
1821+
createRequest({
1822+
chatId: "chat_member",
1823+
channel: "email",
1824+
email: "someone-else@example.com",
1825+
})
1826+
)) as Response;
1827+
expect(other.status).toBe(400);
1828+
expect(await other.json()).toMatchObject({ code: "email_not_allowed" });
1829+
1830+
expect(
1831+
await prisma.projectAlertChannel.count({ where: { projectId: seeded.project.id } })
1832+
).toBe(1);
1833+
}
1834+
);
1835+
1836+
postgresTest(
1837+
"the agent's delete only takes the watch type off a watch channel",
1838+
async ({ prisma, postgresContainer }) => {
1839+
await boot(prisma, postgresContainer.getConnectionUri());
1840+
const seeded = await seed(prisma, "alert-delete");
1841+
const member = await seedMember(prisma, seeded);
1842+
await createChat(ctx.agentDb, {
1843+
id: "chat_member",
1844+
organizationId: seeded.organization.id,
1845+
userId: member.id,
1846+
});
1847+
const watchChannel = await seedWatchChannel(prisma, seeded, member.email);
1848+
1849+
// A channel the agent never created and has no business touching.
1850+
const runAlerts = await prisma.projectAlertChannel.create({
1851+
data: {
1852+
friendlyId: `alert_${Math.random().toString(36).slice(2, 10)}`,
1853+
name: "Run failures",
1854+
projectId: seeded.project.id,
1855+
alertTypes: ["TASK_RUN"],
1856+
environmentTypes: ["PRODUCTION"],
1857+
type: "EMAIL",
1858+
properties: { email: member.email },
1859+
},
1860+
});
1861+
1862+
ctx.actor = {
1863+
userId: member.id,
1864+
client: "dashboard-agent",
1865+
environmentId: seeded.environment.id,
1866+
};
1867+
1868+
const removed = (await alertChannelAction(
1869+
deleteRequest(watchChannel.id, { chatId: "chat_member" })
1870+
)) as Response;
1871+
expect(removed.status).toBe(200);
1872+
expect(await removed.json()).toMatchObject({ ok: true, disabledChannel: true });
1873+
1874+
// The Alerts page would let a member delete this outright; the agent gets a 404.
1875+
const untouched = (await alertChannelAction(
1876+
deleteRequest(runAlerts.id, { chatId: "chat_member" })
1877+
)) as Response;
1878+
expect(untouched.status).toBe(404);
1879+
expect(
1880+
await prisma.projectAlertChannel.findFirst({ where: { id: runAlerts.id } })
1881+
).toMatchObject({ enabled: true, alertTypes: ["TASK_RUN"] });
1882+
1883+
// An outsider can't reach the channel at all.
1884+
ctx.actor = {
1885+
userId: (await seedOutsider(prisma)).id,
1886+
client: "dashboard-agent",
1887+
environmentId: seeded.environment.id,
1888+
};
1889+
const refused = (await alertChannelAction(
1890+
deleteRequest(watchChannel.id, { chatId: "chat_member" })
1891+
)) as Response;
1892+
expect(refused.status).toBe(404);
1893+
}
1894+
);
1895+
});
1896+
16521897
describe("the watch card submit", () => {
16531898
postgresTest(
16541899
"records what the user confirmed before the watch, and confirms it after",

0 commit comments

Comments
 (0)