@@ -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 ),
@@ -919,3 +923,99 @@ func TestBoundProjectedListErrorNamesLargestFields(t *testing.T) {
919923 t .Fatalf ("list overflow error = %q, want it to name the largest fields" , err )
920924 }
921925}
926+
927+ // TestBoundProjectedListNeverShortensIdentifierFields pins the identifier
928+ // exemption: keys ending in _id/_key carry values a consumer matches,
929+ // filters, or passes back verbatim (a jq exact-match over --json output, a
930+ // follow-up detail call), so shortening one silently defeats that consumer.
931+ // Even when a page overflows badly enough that the fair cap lands below an
932+ // identifier's own length, identifiers must come back byte-identical and
933+ // only free-text fields shorten; the note must name only the clipped fields.
934+ func TestBoundProjectedListNeverShortensIdentifierFields (t * testing.T ) {
935+ for _ , format := range []string {"json" , "toon" } {
936+ t .Run (format , func (t * testing.T ) {
937+ saveAndResetGlobals (t )
938+ flagOutputFormat = format
939+
940+ rows := make ([]map [string ]any , 10 )
941+ wantIDs := make ([]map [string ]string , len (rows ))
942+ for i := range rows {
943+ eventID := fmt .Sprintf ("%024x" , i )
944+ alertKey := fmt .Sprintf ("%032x" , i )
945+ rows [i ] = map [string ]any {
946+ "event_id" : eventID ,
947+ "alert_key" : alertKey ,
948+ "title" : strings .Repeat ("a" , 500 ),
949+ }
950+ wantIDs [i ] = map [string ]string {"event_id" : eventID , "alert_key" : alertKey }
951+ }
952+
953+ const budget = 1400
954+ note , err := boundProjectedOutput (rows , budget )
955+ if err != nil {
956+ t .Fatalf ("bound projected output: %v" , err )
957+ }
958+
959+ for i , row := range rows {
960+ for _ , field := range []string {"event_id" , "alert_key" } {
961+ if got := row [field ].(string ); got != wantIDs [i ][field ] {
962+ t .Errorf ("row %d %s was shortened: got %q, want byte-identical %q" , i , field , got , wantIDs [i ][field ])
963+ }
964+ }
965+ if title := row ["title" ].(string ); ! strings .HasSuffix (title , "..." ) {
966+ t .Errorf ("row %d title should be shortened with the \" ...\" marker, got %q" , i , title )
967+ }
968+ }
969+
970+ if note == "" {
971+ t .Fatal ("shortened projection returned no note; caller cannot tell values were clipped" )
972+ }
973+ if ! strings .Contains (note , "title" ) {
974+ t .Errorf ("note = %q, want it to name the shortened field (title)" , note )
975+ }
976+ if strings .Contains (note , "event_id" ) || strings .Contains (note , "alert_key" ) {
977+ t .Errorf ("note = %q, want it to name only shortened fields, never exempt identifiers" , note )
978+ }
979+
980+ encoded , err := marshalStructured (rows )
981+ if err != nil {
982+ t .Fatalf ("marshal bounded output: %v" , err )
983+ }
984+ if len (encoded )+ 1 >= budget {
985+ t .Errorf ("bounded %s output is %d bytes, want <%d" , format , len (encoded )+ 1 , budget )
986+ }
987+ })
988+ }
989+ }
990+
991+ // TestBoundProjectedListIdentifierOnlyOverflowErrors pins the other half of
992+ // the identifier exemption: when a page carries nothing shortenable and its
993+ // identifier content alone overflows the budget, the command must fail with
994+ // the narrowing error instead of clipping identifiers to fit — and the rows
995+ // must come back untouched.
996+ func TestBoundProjectedListIdentifierOnlyOverflowErrors (t * testing.T ) {
997+ saveAndResetGlobals (t )
998+ flagOutputFormat = "json"
999+
1000+ // Sized so the full rows overflow 512 bytes while rows with ids clipped
1001+ // to the truncation floor would still fit: the old fair cap "succeeded"
1002+ // by shipping mangled ids, the exemption must instead refuse.
1003+ rows := make ([]map [string ]any , 12 )
1004+ for i := range rows {
1005+ rows [i ] = map [string ]any {"incident_id" : fmt .Sprintf ("%024x" , i )}
1006+ }
1007+ originals := make ([]string , len (rows ))
1008+ for i , row := range rows {
1009+ originals [i ] = row ["incident_id" ].(string )
1010+ }
1011+
1012+ _ , err := boundProjectedOutput (rows , 512 )
1013+ if err == nil || ! strings .Contains (err .Error (), "request fewer rows" ) {
1014+ t .Fatalf ("identifier-only overflow error = %v, want bounded guidance" , err )
1015+ }
1016+ for i , row := range rows {
1017+ if got := row ["incident_id" ].(string ); got != originals [i ] {
1018+ t .Errorf ("row %d incident_id was mutated despite the error: got %q, want %q" , i , got , originals [i ])
1019+ }
1020+ }
1021+ }
0 commit comments