Files
tiennm99bot/internal/modules/util/sticker_download_test.go
T
tiennm99 2503353e68 feat(util): replace per-user sticker packs with one shared, self-creating pack
/addsticker becomes a single stateless command in util, writing to one
env-configured set (STICKER_PACK_NAME, default miti99_by_miti99bot).
AddStickerToSet takes the set owner's user ID rather than the caller's, so
nothing is per-user any more: the sticker module's pack records, slug
reservations, pending deletes, per-user locks and its eight other commands are
removed with it.

The pack creates itself on first use. A positive STICKERSET_INVALID from the
add triggers createNewStickerSet owned by OWNER_ID, seeded with the sticker
that triggered it and titled with the slug half of the name; a name that is
occupied but unwritable is reported instead of taken over. The mandatory
"_by_<bot_username>" suffix is Telegram's own proof of authorship, so a
misconfigured pack name is refused offline before any API call. StickerSet
exposes no owner ID, so ownership is only provable when Telegram refuses.

Video, GIF, animation and video-note sources are transcoded to WEBM/VP9 with
ffmpeg: long edge scaled to exactly 512 in either direction, cut to 3s, capped
at 30fps, audio dropped, retried down a CRF ladder until under 256KB. Animated
and video stickers are copied by file_id with no conversion. Sticker format is
per-sticker since Bot API 7.2, so one pack holds all three.

ffmpeg cannot ship in distroless/static and Go has no VP9 encoder, so the
runtime base becomes alpine with apk add ffmpeg. The image grows from roughly
20MB to 213MB, and the transcode holds the single dispatcher worker — bounded
at 20s per encode and a 45s handler deadline for moving sources, against 10s
for stills.
2026-09-04 10:34:29 +07:00

158 lines
5.3 KiB
Go

package util
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot"
)
// The download URL is "https://api.telegram.org/file/bot<TOKEN>/<path>", and
// every transport failure from http.Client.Do is a *url.Error whose Error()
// embeds it in full. The dispatcher logs a handler's returned error verbatim,
// so an error that carried the URL would print the bot token to stdout and
// every log shipper downstream.
//
// This asserts the property directly rather than trusting the discipline: a
// forced transport failure must produce an error that contains neither the
// token, nor "bot", nor any part of the URL.
func TestDownloadFile_ErrorNeverLeaksTokenOrURL(t *testing.T) {
const token = "123456:SUPER-SECRET-BOT-TOKEN"
// A server that accepts getFile, then hangs up mid-download.
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/file_1.jpg"}}`))
return
}
// The file fetch: close the connection without a response.
hj, ok := w.(http.Hijacker)
if !ok {
srv.CloseClientConnections()
return
}
conn, _, err := hj.Hijack()
if err == nil {
_ = conn.Close()
}
}))
defer srv.Close()
b, err := bot.New(token, bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
_, err = downloadFile(context.Background(), b, "f1", maxSourceBytes)
if err == nil {
t.Fatal("downloadFile succeeded against a hung-up server; want an error")
}
if !errors.Is(err, errDownloadFailed) {
t.Errorf("err = %v, want it to be errDownloadFailed", err)
}
text := err.Error()
for _, forbidden := range []string{token, "SUPER-SECRET", "bot", "http", srv.URL} {
if strings.Contains(text, forbidden) {
t.Errorf("error text %q contains %q — the token or URL can reach the logs", text, forbidden)
}
}
// Unwrapping must not reach the original either: %v on a wrapped *url.Error
// would put the URL back.
if inner := errors.Unwrap(err); inner != nil && strings.Contains(inner.Error(), token) {
t.Errorf("unwrapped error %q still carries the token", inner)
}
}
// classify must reduce an error to a fixed label. It inspects only the error's
// type, never its text, so there is no path by which a URL can ride along.
func TestClassify_ReturnsFixedLabels(t *testing.T) {
allowed := map[string]bool{"none": true, "timeout": true, "cancelled": true, "transport": true, "unknown": true}
cases := []error{
nil,
context.DeadlineExceeded,
context.Canceled,
errors.New("https://api.telegram.org/file/bot123:SECRET/x.jpg refused"),
}
for _, err := range cases {
got := classify(err)
if !allowed[got] {
t.Errorf("classify(%v) = %q, which is not one of the fixed labels", err, got)
}
if strings.Contains(got, "SECRET") || strings.Contains(got, "http") {
t.Errorf("classify leaked error text: %q", got)
}
}
}
// The size guard runs on the metadata getFile returns, so an oversized file
// costs zero bytes of transfer.
func TestDownloadFile_RejectsOversizedBeforeFetching(t *testing.T) {
var fetches int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":99999999,"file_path":"photos/huge.jpg"}}`))
return
}
fetches++
_, _ = w.Write([]byte("should never be fetched"))
}))
defer srv.Close()
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
if _, err := downloadFile(context.Background(), b, "f1", maxSourceBytes); err == nil {
t.Fatal("downloadFile accepted an oversized file")
}
if fetches != 0 {
t.Errorf("made %d HTTP fetches for an oversized file, want 0", fetches)
}
}
// Content-Length is attacker-controlled; the reader itself is what bounds the
// transfer.
func TestDownloadFile_BoundsBodyRegardlessOfContentLength(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/getFile") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"f1","file_unique_id":"u1","file_size":10,"file_path":"photos/lie.jpg"}}`))
return
}
// Claims to be tiny, sends far more.
w.Header().Set("Content-Length", "10")
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
chunk := make([]byte, 64<<10)
for written := 0; written < maxSourceBytes+(128<<10); written += len(chunk) {
if _, err := w.Write(chunk); err != nil {
return
}
if flusher != nil {
flusher.Flush()
}
}
}))
defer srv.Close()
b, err := bot.New("t:t", bot.WithSkipGetMe(), bot.WithServerURL(srv.URL))
if err != nil {
t.Fatalf("bot.New: %v", err)
}
data, err := downloadFile(context.Background(), b, "f1", maxSourceBytes)
if err == nil {
t.Fatalf("downloadFile accepted %d bytes despite the %d-byte cap", len(data), maxSourceBytes)
}
}