package main import ( "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "sync" "testing" ) type recordedTelegramRequest struct { Method string Fields map[string]string FileField string FileName string FileData []byte } type telegramAPIRecorder struct { mu sync.Mutex requests []recordedTelegramRequest failures map[string]int } func (recorder *telegramAPIRecorder) snapshot() []recordedTelegramRequest { recorder.mu.Lock() defer recorder.mu.Unlock() return append([]recordedTelegramRequest(nil), recorder.requests...) } func newFakeTelegramAPI(t *testing.T, failures map[string]int) (*httptest.Server, *telegramAPIRecorder) { t.Helper() recorder := &telegramAPIRecorder{failures: failures} server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { method := strings.TrimPrefix(request.URL.Path, "/") recorded := recordedTelegramRequest{Method: method, Fields: map[string]string{}} if strings.HasPrefix(request.Header.Get("Content-Type"), "multipart/form-data") { if err := request.ParseMultipartForm(64 << 20); err != nil { t.Fatalf("parse Telegram multipart request: %v", err) } for key, values := range request.MultipartForm.Value { if len(values) > 0 { recorded.Fields[key] = values[0] } } for _, field := range []string{"photo", "document"} { files := request.MultipartForm.File[field] if len(files) == 0 { continue } file, err := files[0].Open() if err != nil { t.Fatal(err) } recorded.FileData, err = io.ReadAll(file) file.Close() if err != nil { t.Fatal(err) } recorded.FileField = field recorded.FileName = files[0].Filename } } else { if err := request.ParseForm(); err != nil { t.Fatal(err) } for key, values := range request.Form { if len(values) > 0 { recorded.Fields[key] = values[0] } } } recorder.mu.Lock() recorder.requests = append(recorder.requests, recorded) fail := recorder.failures[method] > 0 if fail { recorder.failures[method]-- } recorder.mu.Unlock() writer.Header().Set("Content-Type", "application/json") if fail { writer.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(writer).Encode(map[string]any{ "ok": false, "error_code": 400, "description": "test upload rejection", }) return } _ = json.NewEncoder(writer).Encode(map[string]any{"ok": true, "result": map[string]any{}}) })) return server, recorder } func newTelegramMediaTestBot(t *testing.T, telegramURL string, backendURL func(int) string) *telegramBot { t.Helper() router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 2, backendURL) if err != nil { t.Fatal(err) } router.backendMediaURL = backendURL bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router) bot.apiBase = telegramURL return bot } func pngFixture() []byte { return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("telegram-test-image")...) } func TestParseTelegramReplyRemovesOneOrMoreInternalMarkers(t *testing.T) { reply := parseTelegramReply("Created these.\n\nMEDIA:/opt/data/cache/images/one.png\nMEDIA:/opt/data/workspace/two.png") if reply.Text != "Created these." { t.Fatalf("unexpected caption %q", reply.Text) } if len(reply.MediaPaths) != 2 || reply.MediaPaths[0] != "/opt/data/cache/images/one.png" || reply.MediaPaths[1] != "/opt/data/workspace/two.png" { t.Fatalf("unexpected media paths: %#v", reply.MediaPaths) } if strings.Contains(reply.Text, "MEDIA:") || strings.Contains(reply.Text, "/opt/data") { t.Fatalf("internal marker leaked into caption %q", reply.Text) } } func TestParseTelegramReplyScrubsMalformedMarkersAndBarePrivatePaths(t *testing.T) { reply := parseTelegramReply( "Delivered. MEDIA: /opt/data/cache/images/one.png and /workspace/private/two.png; MEDIA:", ) if len(reply.MediaPaths) != 1 || reply.MediaPaths[0] != "/opt/data/cache/images/one.png" { t.Fatalf("valid spaced marker was not retained for delivery: %#v", reply) } for _, unsafe := range []string{"MEDIA:", "/opt/data/", "/workspace/"} { if strings.Contains(reply.Text, unsafe) { t.Fatalf("unsafe transport detail %q leaked in %q", unsafe, reply.Text) } } } func TestNormalizeTenantMediaPath(t *testing.T) { allowed := map[string]string{ "/workspace/./renders//portrait.png": "/opt/data/workspace/renders/portrait.png", "/opt/data/workspace/./reports/result.pdf": "/opt/data/workspace/reports/result.pdf", "/opt/data/cache/images//generated/image.png": "/opt/data/cache/images/generated/image.png", } for input, expected := range allowed { actual, err := normalizeTenantMediaPath(input) if err != nil || actual != expected { t.Errorf("normalize %q: got %q, %v; want %q", input, actual, err, expected) } } for _, input := range []string{ "relative.png", "/opt/data/cache/images/../secrets/token.png", "/opt/data/workspace/../../other-tenant/image.png", "/opt/data/cache/images-foreign/image.png", "/opt/data/tenants/1/image.png", `/opt/data/cache/images\..\secret.png`, } { if normalized, err := normalizeTenantMediaPath(input); err == nil { t.Errorf("unsafe path %q normalized to %q", input, normalized) } } } func TestSendReplyTextOnlyUsesSendMessage(t *testing.T) { telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { t.Fatal("text-only reply attempted a tenant media request") return "" }) if err := bot.sendReply(42, 0, "Hello from Hermes"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 1 || requests[0].Method != "sendMessage" || requests[0].Fields["text"] != "Hello from Hermes" { t.Fatalf("unexpected Telegram requests: %#v", requests) } } func TestSendReplyUploadsPhotoWithCaptionThroughTenantAuth(t *testing.T) { var mediaRequests int tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { mediaRequests++ if request.URL.Path != "/media" || request.URL.Query().Get("path") != "/opt/data/workspace/renders/portrait.png" { t.Fatalf("unexpected tenant media URL %s", request.URL.String()) } if request.Header.Get("Authorization") != "Bearer relay-secret" { t.Fatal("tenant media relay authentication was not set") } if request.Header.Get("X-Hermes-Tenant-Slot") != "1" { t.Fatalf("unexpected tenant slot %q", request.Header.Get("X-Hermes-Tenant-Slot")) } writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(slot int) string { if slot != 1 { t.Fatalf("requested media from slot %d", slot) } return tenant.URL }) if err := bot.sendReply(42, 1, "Created it.\nMEDIA:/workspace/./renders/portrait.png"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 1 || requests[0].Method != "sendPhoto" || requests[0].FileField != "photo" { t.Fatalf("unexpected Telegram requests: %#v", requests) } if requests[0].Fields["caption"] != "Created it." || requests[0].Fields["chat_id"] != "42" { t.Fatalf("unexpected photo fields: %#v", requests[0].Fields) } if requests[0].FileName != "portrait.png" || string(requests[0].FileData) != string(pngFixture()) { t.Fatalf("unexpected uploaded photo: %#v", requests[0]) } } func TestSendReplyUploadsMultipleMarkersAndCaptionsOnlyFirst(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { path := request.URL.Query().Get("path") switch filepath.Ext(path) { case ".png": writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) case ".pdf": writer.Header().Set("Content-Type", "application/pdf") _, _ = writer.Write([]byte("%PDF-1.4 test")) default: writer.WriteHeader(http.StatusNotFound) } })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) err := bot.sendReply(7, 0, "Two files.\nMEDIA:/opt/data/cache/images/one.png\nMEDIA:/opt/data/workspace/two.pdf") if err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 2 || requests[0].Method != "sendPhoto" || requests[1].Method != "sendDocument" { t.Fatalf("unexpected Telegram methods: %#v", requests) } if requests[0].Fields["caption"] != "Two files." || requests[1].Fields["caption"] != "" { t.Fatalf("caption was not scoped to first upload: %#v", requests) } } func TestSendReplySendsLongCaptionSeparately(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) caption := strings.Repeat("detail ", 180) if err := bot.sendReply(7, 0, caption+"\nMEDIA:/opt/data/cache/images/one.png"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 2 || requests[0].Method != "sendMessage" || requests[1].Method != "sendPhoto" { t.Fatalf("long caption was not sent separately: %#v", requests) } if requests[0].Fields["text"] != strings.TrimSpace(caption) || requests[1].Fields["caption"] != "" { t.Fatalf("long caption leaked into photo upload: %#v", requests) } } func TestSendReplyRejectsTraversalWithoutFetchingTenant(t *testing.T) { telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { t.Fatal("traversal path reached the tenant backend") return "" }) if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/../auth.json"); err == nil { t.Fatal("expected traversal rejection") } assertOnlySafeFallback(t, recorder.snapshot()) } func TestSendReplyRejectsTenantSymlinkEscape(t *testing.T) { root := filepath.Join(t.TempDir(), "images") outside := filepath.Join(t.TempDir(), "outside.png") if err := os.MkdirAll(root, 0700); err != nil { t.Fatal(err) } if err := os.WriteFile(outside, pngFixture(), 0600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(root, "escape.png")); err != nil { t.Fatal(err) } tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { logical := request.URL.Query().Get("path") relative := strings.TrimPrefix(logical, "/opt/data/cache/images/") target, err := filepath.EvalSymlinks(filepath.Join(root, relative)) if err != nil || !pathWithin(target, root) { writer.WriteHeader(http.StatusForbidden) return } _, _ = os.Open(target) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/escape.png"); err == nil { t.Fatal("expected tenant symlink rejection") } assertOnlySafeFallback(t, recorder.snapshot()) } func TestSendReplyHandlesMissingTenantFile(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusNotFound) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) if err := bot.sendReply(9, 0, "MEDIA:/opt/data/cache/images/missing.png"); err == nil { t.Fatal("expected missing-file error") } assertOnlySafeFallback(t, recorder.snapshot()) } func TestSendPhotoFailureFallsBackToDocument(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, map[string]int{"sendPhoto": 1}) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) if err := bot.sendReply(11, 0, "Portrait.\nMEDIA:/opt/data/cache/images/portrait.png"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 2 || requests[0].Method != "sendPhoto" || requests[1].Method != "sendDocument" { t.Fatalf("photo did not fall back to document: %#v", requests) } if requests[1].Fields["caption"] != "Portrait." || requests[1].FileField != "document" { t.Fatalf("document fallback lost content: %#v", requests[1]) } } func TestMislabeledImageUsesSniffedDocumentType(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write([]byte("%PDF-1.4 mislabeled")) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) if err := bot.sendReply(11, 0, "MEDIA:/opt/data/cache/images/mislabeled.png"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 1 || requests[0].Method != "sendDocument" { t.Fatalf("mislabeled media was not routed as a document: %#v", requests) } } func TestUploadFailureSendsSafeTextWithoutInternalPath(t *testing.T) { tenant := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) })) defer tenant.Close() telegram, recorder := newFakeTelegramAPI(t, map[string]int{"sendPhoto": 1, "sendDocument": 1}) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(int) string { return tenant.URL }) if err := bot.sendReply(11, 0, "Created it.\nMEDIA:/opt/data/cache/images/private.png"); err == nil { t.Fatal("expected upload failure") } requests := recorder.snapshot() if len(requests) != 4 || requests[2].Method != "sendMessage" || requests[3].Method != "sendMessage" { t.Fatalf("unexpected failure requests: %#v", requests) } for _, request := range requests { for _, value := range request.Fields { if strings.Contains(value, "MEDIA:") || strings.Contains(value, "/opt/data/") { t.Fatalf("internal path leaked to Telegram: %#v", requests) } } } if requests[2].Fields["text"] == "" || requests[3].Fields["text"] != "Created it." { t.Fatalf("safe fallback or response text missing: %#v", requests) } } func TestSendReplyUsesOnlyLinkedTenantSlot(t *testing.T) { tenantZero := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("cross-tenant media request reached slot zero") })) defer tenantZero.Close() tenantOne := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Header.Get("X-Hermes-Tenant-Slot") != "1" { t.Fatalf("unexpected tenant header %q", request.Header.Get("X-Hermes-Tenant-Slot")) } writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write(pngFixture()) })) defer tenantOne.Close() telegram, recorder := newFakeTelegramAPI(t, nil) defer telegram.Close() bot := newTelegramMediaTestBot(t, telegram.URL, func(slot int) string { return []string{tenantZero.URL, tenantOne.URL}[slot] }) if err := bot.sendReply(99, 1, "MEDIA:/opt/data/cache/images/tenant-one.png"); err != nil { t.Fatal(err) } requests := recorder.snapshot() if len(requests) != 1 || requests[0].Fields["chat_id"] != strconv.FormatInt(99, 10) { t.Fatalf("unexpected Telegram delivery: %#v", requests) } } func assertOnlySafeFallback(t *testing.T, requests []recordedTelegramRequest) { t.Helper() if len(requests) != 1 || requests[0].Method != "sendMessage" { t.Fatalf("expected only a safe fallback message, got %#v", requests) } text := requests[0].Fields["text"] if text == "" || strings.Contains(text, "MEDIA:") || strings.Contains(text, "/opt/data/") { t.Fatalf("unsafe fallback %q", text) } }