@@ -81,6 +81,9 @@ vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
8181
8282const SESSION_SECRET = "test-session-secret-for-watch-tokens" ;
8383process . 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
8588const {
8689 armDashboardAgentWatchBatch,
@@ -105,6 +108,13 @@ const {
105108const { runWatchBatchCheck } = await import ( "~/services/dashboardAgentWatchBatch.server" ) ;
106109const { 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. */
110120async 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+
16521897describe ( "the watch card submit" , ( ) => {
16531898 postgresTest (
16541899 "records what the user confirmed before the watch, and confirms it after" ,
0 commit comments