@@ -2,14 +2,25 @@ package adminapi
22
33import (
44 "bytes"
5+ "context"
6+ "crypto/hmac"
7+ "crypto/sha256"
8+ "encoding/hex"
9+ "encoding/json"
10+ "io"
511 "log/slog"
612 "net/http"
713 "net/http/httptest"
14+ "net/url"
15+ "sort"
16+ "strconv"
817 "strings"
918 "testing"
19+ "time"
1020
1121 "telegram-agent/internal/config"
1222 "telegram-agent/internal/llm"
23+ "telegram-agent/internal/store"
1324)
1425
1526func newTestServer (t * testing.T ) * Server {
@@ -48,6 +59,7 @@ func TestTemplatesParse(t *testing.T) {
4859 viewIndex : data ,
4960 viewRouting : data .Routing , // routing view takes uiRouting directly
5061 viewModelsBrowser : data ,
62+ viewTGAdmin : tgAdminData {FullAdminURL : "https://assistant.example/admin" },
5163 }
5264 for v , d := range cases {
5365 t .Run (v , func (t * testing.T ) {
@@ -62,6 +74,105 @@ func TestTemplatesParse(t *testing.T) {
6274 }
6375}
6476
77+ func signedTGInitData (t * testing.T , token string , userID int64 , authTime time.Time ) string {
78+ t .Helper ()
79+ userJSON , err := json .Marshal (tgAdminUser {ID : userID , FirstName : "Alex" })
80+ if err != nil {
81+ t .Fatal (err )
82+ }
83+ values := url.Values {}
84+ values .Set ("auth_date" , strconv .FormatInt (authTime .Unix (), 10 ))
85+ values .Set ("query_id" , "AAEAAAE" )
86+ values .Set ("user" , string (userJSON ))
87+
88+ keys := make ([]string , 0 , len (values ))
89+ for k := range values {
90+ keys = append (keys , k )
91+ }
92+ sort .Strings (keys )
93+ parts := make ([]string , 0 , len (keys ))
94+ for _ , k := range keys {
95+ parts = append (parts , k + "=" + values .Get (k ))
96+ }
97+
98+ secretMAC := hmac .New (sha256 .New , []byte ("WebAppData" ))
99+ _ , _ = secretMAC .Write ([]byte (token ))
100+ secret := secretMAC .Sum (nil )
101+
102+ hashMAC := hmac .New (sha256 .New , secret )
103+ _ , _ = hashMAC .Write ([]byte (strings .Join (parts , "\n " )))
104+ values .Set ("hash" , hex .EncodeToString (hashMAC .Sum (nil )))
105+ return values .Encode ()
106+ }
107+
108+ func TestValidateTGInitDataAcceptsOwnerSignedData (t * testing.T ) {
109+ s := newTestServer (t )
110+ s .cfgRef .Telegram .BotToken = "123:abc"
111+ now := time .Date (2026 , 6 , 14 , 12 , 0 , 0 , 0 , time .UTC )
112+
113+ user , err := s .validateTGInitData (signedTGInitData (t , "123:abc" , 42 , now ), now )
114+ if err != nil {
115+ t .Fatalf ("validateTGInitData: %v" , err )
116+ }
117+ if user .ID != 42 {
118+ t .Fatalf ("user id = %d, want 42" , user .ID )
119+ }
120+ }
121+
122+ func TestTGAdminAPIRejectsInvalidAuth (t * testing.T ) {
123+ s := newTestServer (t )
124+ s .cfgRef .Telegram .BotToken = "123:abc"
125+ s .cfgRef .Telegram .OwnerChatID = 42
126+
127+ req := httptest .NewRequest (http .MethodGet , "/tg-admin/api/summary" , nil )
128+ req .Header .Set ("X-Telegram-Init-Data" , "auth_date=1&user={}&hash=bad" )
129+ rec := httptest .NewRecorder ()
130+ s .handleTGAdminRouter (rec , req )
131+
132+ if rec .Code != http .StatusUnauthorized {
133+ t .Fatalf ("status = %d, want %d" , rec .Code , http .StatusUnauthorized )
134+ }
135+ }
136+
137+ func TestTGAdminAPIAcceptsOwner (t * testing.T ) {
138+ s := newTestServer (t )
139+ s .cfgRef .Telegram .BotToken = "123:abc"
140+ s .cfgRef .Telegram .OwnerChatID = 42
141+ now := time .Now ()
142+
143+ req := httptest .NewRequest (http .MethodGet , "/tg-admin/api/summary" , nil )
144+ req .Header .Set ("X-Telegram-Init-Data" , signedTGInitData (t , "123:abc" , 42 , now ))
145+ rec := httptest .NewRecorder ()
146+ s .handleTGAdminRouter (rec , req )
147+
148+ if rec .Code != http .StatusOK {
149+ t .Fatalf ("status = %d, want %d, body: %s" , rec .Code , http .StatusOK , rec .Body .String ())
150+ }
151+ var payload tgAdminSummary
152+ if err := json .Unmarshal (rec .Body .Bytes (), & payload ); err != nil {
153+ t .Fatal (err )
154+ }
155+ if payload .Status .Bot != "running" {
156+ t .Fatalf ("bot status = %q" , payload .Status .Bot )
157+ }
158+ }
159+
160+ func TestTGAdminAPIRejectsNonOwner (t * testing.T ) {
161+ s := newTestServer (t )
162+ s .cfgRef .Telegram .BotToken = "123:abc"
163+ s .cfgRef .Telegram .OwnerChatID = 42
164+ now := time .Now ()
165+
166+ req := httptest .NewRequest (http .MethodGet , "/tg-admin/api/summary" , nil )
167+ req .Header .Set ("X-Telegram-Init-Data" , signedTGInitData (t , "123:abc" , 99 , now ))
168+ rec := httptest .NewRecorder ()
169+ s .handleTGAdminRouter (rec , req )
170+
171+ if rec .Code != http .StatusForbidden {
172+ t .Fatalf ("status = %d, want %d" , rec .Code , http .StatusForbidden )
173+ }
174+ }
175+
65176// TestAuthRequired covers the four auth paths + the 401 default.
66177func TestAuthRequired (t * testing.T ) {
67178 s := newTestServer (t )
@@ -213,3 +324,91 @@ func TestHealthz(t *testing.T) {
213324 t .Errorf ("healthz body: %s" , string (body [:n ]))
214325 }
215326}
327+
328+ type fakeChatAgent struct {
329+ history []store.HistoryItem
330+ }
331+
332+ func (f * fakeChatAgent ) Process (context.Context , int64 , llm.Message , func (string )) (string , error ) {
333+ return "" , nil
334+ }
335+
336+ func (f * fakeChatAgent ) ProcessStream (context.Context , int64 , llm.Message , func (string ), func (string )) (string , error ) {
337+ return "" , nil
338+ }
339+
340+ func (f * fakeChatAgent ) GetChatHistory (int64 ) []llm.Message { return nil }
341+
342+ func (f * fakeChatAgent ) GetDisplayHistory (_ int64 , limit , offset int ) []store.HistoryItem {
343+ if offset >= len (f .history ) {
344+ return nil
345+ }
346+ end := offset + limit
347+ if end > len (f .history ) {
348+ end = len (f .history )
349+ }
350+ return f .history [offset :end ]
351+ }
352+
353+ func (f * fakeChatAgent ) ClearChatHistory (int64 ) {}
354+
355+ func (f * fakeChatAgent ) PopLastUserTurn (int64 ) (string , bool ) { return "" , false }
356+
357+ func TestChatInitialAssistantUsesBotClassAndNoRequiredTextarea (t * testing.T ) {
358+ s := newTestServer (t )
359+ s .SetAgent (& fakeChatAgent {history : []store.HistoryItem {{
360+ Role : "assistant" ,
361+ Content : "hello" ,
362+ CreatedAt : time .Date (2026 , 6 , 14 , 12 , 0 , 0 , 0 , time .UTC ),
363+ }}})
364+
365+ req := httptest .NewRequest (http .MethodGet , "/chat" , nil )
366+ rec := httptest .NewRecorder ()
367+ s .handleChat (rec , req )
368+
369+ body := rec .Body .String ()
370+ if ! strings .Contains (body , `chat-msg chat-msg--bot` ) {
371+ t .Fatalf ("assistant bubble should use bot class, body:\n %s" , body )
372+ }
373+ if strings .Contains (body , `chat-msg--assistant` ) {
374+ t .Fatalf ("assistant class leaked into initial chat render:\n %s" , body )
375+ }
376+ if strings .Contains (body , "required></textarea>" ) {
377+ t .Fatal ("chat textarea should not be marked required; image-only submits must be allowed" )
378+ }
379+ }
380+
381+ func TestChatHistoryEscapesLazyLoadedMessages (t * testing.T ) {
382+ s := newTestServer (t )
383+ s .SetAgent (& fakeChatAgent {history : []store.HistoryItem {{
384+ Role : "user" ,
385+ Content : `<img src=x onerror=alert(1)>` ,
386+ ImageURLs : []string {`x" onerror="alert(2)` },
387+ CreatedAt : time .Date (2026 , 6 , 14 , 12 , 0 , 0 , 0 , time .UTC ),
388+ }}})
389+
390+ req := httptest .NewRequest (http .MethodGet , "/chat/history?offset=0" , nil )
391+ rec := httptest .NewRecorder ()
392+ s .handleChatHistory (rec , req )
393+
394+ resp := rec .Result ()
395+ defer resp .Body .Close ()
396+ if resp .StatusCode != http .StatusOK {
397+ t .Fatalf ("status = %d" , resp .StatusCode )
398+ }
399+ data , err := io .ReadAll (resp .Body )
400+ if err != nil {
401+ t .Fatal (err )
402+ }
403+ var payload map [string ]any
404+ if err := json .Unmarshal (data , & payload ); err != nil {
405+ t .Fatal (err )
406+ }
407+ html , _ := payload ["html" ].(string )
408+ if strings .Contains (html , `<img src=x` ) || strings .Contains (html , `src="x" onerror` ) {
409+ t .Fatalf ("lazy history response contains unsafe HTML: %s" , html )
410+ }
411+ if ! strings .Contains (html , `<img src=x onerror=alert(1)>` ) {
412+ t .Fatalf ("lazy history did not preserve escaped user text: %s" , html )
413+ }
414+ }
0 commit comments