atlas-iac/services/hermes/router/telegram_media_test.go
2026-08-17 14:39:21 +00:00

439 lines
16 KiB
Go

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)
}
}