@@ -27,19 +27,25 @@ import {
2727} from "./test-support" ;
2828
2929/**
30- * Stands in for `chat_messages`: the append is keyed on (chat_id, message_id) and does
31- * nothing on conflict, so a repeated append is never a second row. Row counts are what
32- * the History panel reads, so the retry tests assert those rather than call counts.
30+ * Stands in for `chats` + `chat_messages`, as `appendOneMessage`'s upsert sees them: the
31+ * insert is scoped to the owning user and — when the caller passes one — the owning
32+ * organization, and keyed on (chat_id, message_id) with nothing done on conflict. So a
33+ * repeat is never a second row and a foreign tenancy is no row at all. Row counts are
34+ * what the History panel reads, which is why these tests assert those, not call counts.
3335 */
34- function transcriptTable ( ) {
36+ function transcriptTable ( owner : { userId : string ; organizationId : string } ) {
3537 const rows : { chatId : string ; messageId : string } [ ] = [ ] ;
3638 const countOf = ( chatId : string , messageId : string ) =>
3739 rows . filter ( ( row ) => row . chatId === chatId && row . messageId === messageId ) . length ;
3840 return {
3941 countOf,
40- insert ( chatId : string , message : UIMessage ) {
41- if ( countOf ( chatId , message . id ) > 0 ) return false ;
42- rows . push ( { chatId, messageId : message . id } ) ;
42+ insert ( args : { chatId : string ; userId : string ; organizationId ?: string ; message : UIMessage } ) {
43+ if ( args . userId !== owner . userId ) return false ;
44+ if ( args . organizationId !== undefined && args . organizationId !== owner . organizationId ) {
45+ return false ;
46+ }
47+ if ( countOf ( args . chatId , args . message . id ) > 0 ) return false ;
48+ rows . push ( { chatId : args . chatId , messageId : args . message . id } ) ;
4349 return true ;
4450 } ,
4551 } ;
@@ -58,12 +64,19 @@ function appendingStore(
5864 await store . appendMessage ( args ) ;
5965 const message = args . message as UIMessage ;
6066 if ( failWhen ( message ) ) throw new Error ( "the append lost the connection" ) ;
61- return table . insert ( args . chatId , message ) ;
67+ return table . insert ( { ... args , message } ) ;
6268 } ,
6369 } ;
6470 return { store : wrapped , calls } ;
6571}
6672
73+ // Every organization a path's appends were scoped to, in order.
74+ function scopedTo ( ...stores : { calls : { appendMessage : unknown [ ] } } [ ] ) {
75+ return stores . flatMap ( ( store ) =>
76+ store . calls . appendMessage . map ( ( call ) => ( call as { organizationId ?: string } ) . organizationId )
77+ ) ;
78+ }
79+
6780describe ( "watch wake narration" , ( ) => {
6881 let harness : MockChatAgentHarness | undefined ;
6982
@@ -427,7 +440,7 @@ describe("watch wake narration", () => {
427440 * its history. Converging on the row is the retry's job.
428441 */
429442 it ( "appends the display copy on a retry that finds the wake already narrated" , async ( ) => {
430- const table = transcriptTable ( ) ;
443+ const table = transcriptTable ( CLIENT_DATA ) ;
431444 const chatId = "chat_wake_retry" ;
432445 const wakeId = "wake:watch:watch_1:fired" ;
433446
@@ -474,6 +487,39 @@ describe("watch wake narration", () => {
474487 // A third delivery repairs nothing, because there is nothing left to repair.
475488 await harness . sendAction ( WAKE ) ;
476489 expect ( table . countOf ( chatId , wakeId ) ) . toBe ( 1 ) ;
490+
491+ // Every write on this path is scoped to the organization the append verifies — the
492+ // repair included, or the repair would be the one write that skips the check.
493+ expect ( scopedTo ( failing , repairing ) ) . toEqual ( [
494+ CLIENT_DATA . organizationId ,
495+ CLIENT_DATA . organizationId ,
496+ CLIENT_DATA . organizationId ,
497+ ] ) ;
498+ } ) ;
499+
500+ /**
501+ * The chat id comes from the watch record and the tenancy from the session's
502+ * `clientData`. If those ever disagree the append has to write nothing, rather than put
503+ * a message in another organization's transcript.
504+ */
505+ it ( "writes nothing when the wake's tenancy doesn't own the chat" , async ( ) => {
506+ const table = transcriptTable ( CLIENT_DATA ) ;
507+ const chatId = "chat_wake_other_org" ;
508+ const { store, calls } = appendingStore ( table , ( ) => false ) ;
509+ harness = mockChatAgent ( dashboardAgent , {
510+ chatId,
511+ clientData : { ...CLIENT_DATA , organizationId : "org_other" } ,
512+ setupLocals : ( { set } ) => {
513+ set ( dashboardAgentStoreKey , store ) ;
514+ set ( dashboardAgentModelKey , mockModel ( [ textStep ( "never asked for" ) ] ) ) ;
515+ } ,
516+ } ) ;
517+
518+ await harness . sendAction ( WAKE ) ;
519+
520+ // Attempted, and refused by the scope the append carries.
521+ expect ( calls . appendMessage ) . toHaveLength ( 1 ) ;
522+ expect ( table . countOf ( chatId , "wake:watch:watch_1:fired" ) ) . toBe ( 0 ) ;
477523 } ) ;
478524} ) ;
479525
@@ -899,7 +945,7 @@ describe("watch investigation", () => {
899945 * History panel lost. The retry finds it already answered and must still land the row.
900946 */
901947 it ( "appends the display copy on a retry that finds the investigation already answered" , async ( ) => {
902- const table = transcriptTable ( ) ;
948+ const table = transcriptTable ( CLIENT_DATA ) ;
903949 const chatId = "chat_investigate_retry" ;
904950 const findingsId = "investigate:watch:watch_1:fired:investigate" ;
905951 const seeded = { id : "inv_seeded" , projectRef : "proj_abc" , environmentRef : "env_abc" } ;
@@ -952,5 +998,42 @@ describe("watch investigation", () => {
952998 // A third delivery repairs nothing, because there is nothing left to repair.
953999 await harness . sendAction ( INVESTIGATE ) ;
9541000 expect ( table . countOf ( chatId , findingsId ) ) . toBe ( 1 ) ;
1001+
1002+ // Every write on this path is scoped to the organization the append verifies — the
1003+ // repair included, or the repair would be the one write that skips the check.
1004+ expect ( scopedTo ( failing , repairing ) ) . toEqual ( [
1005+ CLIENT_DATA . organizationId ,
1006+ CLIENT_DATA . organizationId ,
1007+ CLIENT_DATA . organizationId ,
1008+ ] ) ;
1009+ } ) ;
1010+
1011+ // Same tenancy crossing as the wake's: the kick names the chat, the session names the
1012+ // organization, and a disagreement must not write into another organization's chat.
1013+ it ( "writes nothing when the kick's tenancy doesn't own the chat" , async ( ) => {
1014+ const table = transcriptTable ( CLIENT_DATA ) ;
1015+ const chatId = "chat_investigate_other_org" ;
1016+ const { store, calls } = appendingStore ( table , ( ) => false , {
1017+ openInvestigation : { id : "inv_seeded" , projectRef : "proj_abc" , environmentRef : "env_abc" } ,
1018+ } ) ;
1019+ harness = mockChatAgent ( dashboardAgent , {
1020+ chatId,
1021+ clientData : { ...CLIENT_DATA_WITH_TOKEN , organizationId : "org_other" } ,
1022+ setupLocals : ( { set } ) => {
1023+ set ( dashboardAgentStoreKey , store ) ;
1024+ set (
1025+ dashboardAgentModelKey ,
1026+ recordingModel ( [
1027+ renderStep ( concluded , "inv_seeded" , "tc_verdict" ) ,
1028+ textStep ( "The payload lost order.total." ) ,
1029+ ] ) . model
1030+ ) ;
1031+ } ,
1032+ } ) ;
1033+
1034+ await harness . sendAction ( INVESTIGATE ) ;
1035+
1036+ expect ( calls . appendMessage ) . toHaveLength ( 1 ) ;
1037+ expect ( table . countOf ( chatId , "investigate:watch:watch_1:fired:investigate" ) ) . toBe ( 0 ) ;
9551038 } ) ;
9561039} ) ;
0 commit comments