@@ -781,12 +781,16 @@ func TestAlertEventListFieldsProjectionUnchanged(t *testing.T) {
781781// dropped to 3 bytes or below, truncateUTF8Bytes's no-room-for-a-marker
782782// fallback returned raw, unmarked bytes indistinguishable from a genuinely
783783// short value. Even under extreme row/field pressure that forces every
784- // string field to shrink, every shortened value must carry the "..." marker.
784+ // shortenable string field to shrink, every shortened value must carry the
785+ // "..." marker. (Row count is sized so the exempt _id fields and the JSON
786+ // envelope still fit at the truncation floor — more rows would tip the page
787+ // into the identifier-overflow error pinned by
788+ // TestBoundProjectedListIdentifierOnlyOverflowErrors.)
785789func TestBoundProjectedListNeverEmitsUnmarkedTruncation (t * testing.T ) {
786790 saveAndResetGlobals (t )
787791 flagOutputFormat = "json"
788792
789- rows := make ([]map [string ]any , 100 )
793+ rows := make ([]map [string ]any , 90 )
790794 for i := range rows {
791795 rows [i ] = map [string ]any {
792796 "event_id" : fmt .Sprintf ("%024x" , i ),
@@ -1098,3 +1102,99 @@ func TestChannelEscalateRuleListStructuredProjection(t *testing.T) {
10981102 }
10991103 })
11001104}
1105+
1106+ // TestBoundProjectedListNeverShortensIdentifierFields pins the identifier
1107+ // exemption: keys ending in _id/_key carry values a consumer matches,
1108+ // filters, or passes back verbatim (a jq exact-match over --json output, a
1109+ // follow-up detail call), so shortening one silently defeats that consumer.
1110+ // Even when a page overflows badly enough that the fair cap lands below an
1111+ // identifier's own length, identifiers must come back byte-identical and
1112+ // only free-text fields shorten; the note must name only the clipped fields.
1113+ func TestBoundProjectedListNeverShortensIdentifierFields (t * testing.T ) {
1114+ for _ , format := range []string {"json" , "toon" } {
1115+ t .Run (format , func (t * testing.T ) {
1116+ saveAndResetGlobals (t )
1117+ flagOutputFormat = format
1118+
1119+ rows := make ([]map [string ]any , 10 )
1120+ wantIDs := make ([]map [string ]string , len (rows ))
1121+ for i := range rows {
1122+ eventID := fmt .Sprintf ("%024x" , i )
1123+ alertKey := fmt .Sprintf ("%032x" , i )
1124+ rows [i ] = map [string ]any {
1125+ "event_id" : eventID ,
1126+ "alert_key" : alertKey ,
1127+ "title" : strings .Repeat ("a" , 500 ),
1128+ }
1129+ wantIDs [i ] = map [string ]string {"event_id" : eventID , "alert_key" : alertKey }
1130+ }
1131+
1132+ const budget = 1400
1133+ note , err := boundProjectedOutput (rows , budget )
1134+ if err != nil {
1135+ t .Fatalf ("bound projected output: %v" , err )
1136+ }
1137+
1138+ for i , row := range rows {
1139+ for _ , field := range []string {"event_id" , "alert_key" } {
1140+ if got := row [field ].(string ); got != wantIDs [i ][field ] {
1141+ t .Errorf ("row %d %s was shortened: got %q, want byte-identical %q" , i , field , got , wantIDs [i ][field ])
1142+ }
1143+ }
1144+ if title := row ["title" ].(string ); ! strings .HasSuffix (title , "..." ) {
1145+ t .Errorf ("row %d title should be shortened with the \" ...\" marker, got %q" , i , title )
1146+ }
1147+ }
1148+
1149+ if note == "" {
1150+ t .Fatal ("shortened projection returned no note; caller cannot tell values were clipped" )
1151+ }
1152+ if ! strings .Contains (note , "title" ) {
1153+ t .Errorf ("note = %q, want it to name the shortened field (title)" , note )
1154+ }
1155+ if strings .Contains (note , "event_id" ) || strings .Contains (note , "alert_key" ) {
1156+ t .Errorf ("note = %q, want it to name only shortened fields, never exempt identifiers" , note )
1157+ }
1158+
1159+ encoded , err := marshalStructured (rows )
1160+ if err != nil {
1161+ t .Fatalf ("marshal bounded output: %v" , err )
1162+ }
1163+ if len (encoded )+ 1 >= budget {
1164+ t .Errorf ("bounded %s output is %d bytes, want <%d" , format , len (encoded )+ 1 , budget )
1165+ }
1166+ })
1167+ }
1168+ }
1169+
1170+ // TestBoundProjectedListIdentifierOnlyOverflowErrors pins the other half of
1171+ // the identifier exemption: when a page carries nothing shortenable and its
1172+ // identifier content alone overflows the budget, the command must fail with
1173+ // the narrowing error instead of clipping identifiers to fit — and the rows
1174+ // must come back untouched.
1175+ func TestBoundProjectedListIdentifierOnlyOverflowErrors (t * testing.T ) {
1176+ saveAndResetGlobals (t )
1177+ flagOutputFormat = "json"
1178+
1179+ // Sized so the full rows overflow 512 bytes while rows with ids clipped
1180+ // to the truncation floor would still fit: the old fair cap "succeeded"
1181+ // by shipping mangled ids, the exemption must instead refuse.
1182+ rows := make ([]map [string ]any , 12 )
1183+ for i := range rows {
1184+ rows [i ] = map [string ]any {"incident_id" : fmt .Sprintf ("%024x" , i )}
1185+ }
1186+ originals := make ([]string , len (rows ))
1187+ for i , row := range rows {
1188+ originals [i ] = row ["incident_id" ].(string )
1189+ }
1190+
1191+ _ , err := boundProjectedOutput (rows , 512 )
1192+ if err == nil || ! strings .Contains (err .Error (), "request fewer rows" ) {
1193+ t .Fatalf ("identifier-only overflow error = %v, want bounded guidance" , err )
1194+ }
1195+ for i , row := range rows {
1196+ if got := row ["incident_id" ].(string ); got != originals [i ] {
1197+ t .Errorf ("row %d incident_id was mutated despite the error: got %q, want %q" , i , got , originals [i ])
1198+ }
1199+ }
1200+ }
0 commit comments