package web import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "telesrv/internal/domain" ) func newTestHandler(t *testing.T, resolver StickerSetResolver, publicBaseURL string) http.Handler { t.Helper() h, err := NewHandler(Config{StickerSets: resolver, PublicBaseURL: publicBaseURL}) if err != nil { t.Fatalf("NewHandler: %v", err) } return h } func newTestHandlerWithPublicPeers( t *testing.T, resolver StickerSetResolver, users UsernameResolver, channels PublicChannelResolver, privacy AnonymousPrivacyResolver, photos ProfilePhotoResolver, publicBaseURL string, ) http.Handler { t.Helper() h, err := NewHandler(Config{ StickerSets: resolver, Users: users, Channels: channels, Privacy: privacy, Photos: photos, PublicBaseURL: publicBaseURL, }) if err != nil { t.Fatalf("NewHandler: %v", err) } return h } type fakeGiftWithdrawals struct { value domain.StarGiftWithdrawal found bool completeCalls int } type fakeUniqueGifts struct { bySlug map[string]domain.UniqueStarGift err error calls int } func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) { f.calls++ if f.err != nil { return domain.UniqueStarGift{}, false, f.err } value, ok := f.bySlug[strings.ToLower(slug)] return value, ok, nil } func TestHandlerServesUniqueGiftLandingPage(t *testing.T) { const slug = "official-5895603153683874485-7" resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ slug: { ID: 7001, GiftID: 5895603153683874485, Title: "Official Gift", Slug: slug, Num: 7, AvailabilityIssued: 7, AvailabilityTotal: 1000, }, }} handler, err := NewHandler(Config{ StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "http://127.0.0.1:2401", }) if err != nil { t.Fatalf("NewHandler: %v", err) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/"+slug, nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } for _, want := range []string{ "Official Gift", "Collectible #7", "7/1 000 issued", "http://127.0.0.1:2401/nft/" + slug, "telesrv://nft?slug=" + slug, "tg://nft?slug=" + slug, "Open it in the app to view its current details.", } { if !strings.Contains(rr.Body.String(), want) { t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) } } if strings.Contains(rr.Body.String(), `window.location.href = "tg://`) { t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", rr.Body.String()) } if got := rr.Header().Get("Cache-Control"); got != "public, max-age=60, must-revalidate" { t.Fatalf("Cache-Control = %q", got) } } func TestHandlerCanonicalizesUniqueGiftSlug(t *testing.T) { const canonical = "Official-Gift-7" resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ strings.ToLower(canonical): {ID: 7, GiftID: 70, Slug: canonical, Num: 7}, }} handler, err := NewHandler(Config{ StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", }) if err != nil { t.Fatalf("NewHandler: %v", err) } for _, path := range []string{"/nft/official-gift-7", "/nft/" + canonical + "/"} { rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) if rr.Code != http.StatusPermanentRedirect || rr.Header().Get("Location") != "https://telesrv.net/nft/"+canonical { t.Fatalf("%s status=%d location=%q", path, rr.Code, rr.Header().Get("Location")) } } } func TestHandlerRejectsInvalidMissingAndBrokenUniqueGift(t *testing.T) { resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ "broken-1": {ID: 1, GiftID: 2, Slug: "other-1", Num: 1}, }} handler, err := NewHandler(Config{ StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net", }) if err != nil { t.Fatalf("NewHandler: %v", err) } for _, path := range []string{ "/nft/missing-1", "/nft/bad!slug", "/nft/%E4%B8%AD%E6%96%87", "/nft/" + strings.Repeat("x", domain.MaxStarGiftSlugBytes+1), } { rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) if rr.Code != http.StatusNotFound { t.Fatalf("%s status=%d, want 404", path, rr.Code) } } rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/broken-1", nil)) if rr.Code != http.StatusInternalServerError { t.Fatalf("broken aggregate status=%d, want 500", rr.Code) } resolver.err = errors.New("lookup failed") rr = httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/error-1", nil)) if rr.Code != http.StatusInternalServerError { t.Fatalf("lookup error status=%d, want 500", rr.Code) } } func (f *fakeGiftWithdrawals) ResolveWithdrawal(context.Context, string) (domain.StarGiftWithdrawal, bool, error) { return f.value, f.found, nil } func (f *fakeGiftWithdrawals) CompleteWithdrawal(_ context.Context, _ string, _ int) (domain.StarGiftWithdrawal, error) { f.completeCalls++ f.value.Status = "completed" f.value.Gift.OwnerAddress = "telesrv-owner:test" f.value.Gift.GiftAddress = "telesrv-gift:test" return f.value, nil } func TestHandlerCompletesLocalStarGiftWithdrawal(t *testing.T) { resolver := &fakeGiftWithdrawals{found: true, value: domain.StarGiftWithdrawal{ ProviderRequestID: "safe-token", Status: "pending", ExpiresAt: int(time.Now().Add(time.Minute).Unix()), Gift: domain.UniqueStarGift{Title: ``, Slug: "gift-1"}, }} handler, err := NewHandler(Config{StickerSets: fakeResolver{}, GiftWithdrawals: resolver, PublicBaseURL: "https://telesrv.net", AppName: "telesrv"}) if err != nil { t.Fatalf("NewHandler: %v", err) } rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/gift-withdrawal/safe-token", nil)) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Complete local export") || strings.Contains(rr.Body.String(), ``) { t.Fatalf("withdrawal GET status=%d body=%s", rr.Code, rr.Body.String()) } if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self'") { t.Fatalf("withdrawal CSP does not allow its same-origin POST form: %q", csp) } rr = httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/gift-withdrawal/safe-token", strings.NewReader(""))) if rr.Code != http.StatusOK || resolver.completeCalls != 1 || !strings.Contains(rr.Body.String(), "Status: completed") || !strings.Contains(rr.Body.String(), "telesrv-owner:test") || !strings.Contains(rr.Body.String(), "telesrv-gift:test") { t.Fatalf("withdrawal POST calls=%d status=%d body=%s", resolver.completeCalls, rr.Code, rr.Body.String()) } } func TestHandlerServesStickerSetLandingPage(t *testing.T) { resolver := fakeResolver{ "fresh_pack": { ID: 10, ShortName: "fresh_pack", Title: "Fresh Pack", Count: 2, Kind: domain.StickerSetKindStickers, }, } rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/addstickers/fresh_pack", nil) newTestHandler(t, resolver, "https://telesrv.net/").ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "Fresh Pack", "https://telesrv.net/addstickers/fresh_pack", "telesrv://addstickers?set=fresh_pack", "Files are still fetched by the app through MTProto.", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) } } if strings.Contains(body, `window.location.href = "tg://`) { t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body) } if strings.Contains(body, "/upload/getFile") { t.Fatalf("landing page should not expose media download paths:\n%s", body) } } func TestHandlerServesEmojiLandingPage(t *testing.T) { resolver := fakeResolver{ "emoji_pack": { ID: 11, ShortName: "emoji_pack", Title: "Emoji Pack", Count: 1, Kind: domain.StickerSetKindEmoji, Emojis: true, }, } rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/addemoji/emoji_pack", nil) newTestHandler(t, resolver, "https://example.test/base").ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "custom emoji set", "https://example.test/base/addemoji/emoji_pack", "telesrv://addemoji?set=emoji_pack", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) } } } func TestHandlerServesChatlistLandingPage(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/addlist/zNhytIbwRwjaC2GH", nil) newTestHandler(t, fakeResolver{}, "http://127.0.0.1:2401").ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "Shared Folder", "http://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH", "telesrv://addlist?slug=zNhytIbwRwjaC2GH", "preview and add this shared folder", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) } } if strings.Contains(body, `window.location.href = "tg://`) { t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body) } } func TestHandlerServesBotUsernameLandingPage(t *testing.T) { users := fakeUsers{ "tetrisbot": { ID: 1001, Username: "TetrisBot", FirstName: "Tetris Bot", Bot: true, }, } rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/TetrisBot", nil) newTestHandlerWithPublicPeers(t, fakeResolver{}, users, nil, nil, nil, "http://127.0.0.1:2401").ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "Tetris Bot", "bot", "@TetrisBot", "http://127.0.0.1:2401/TetrisBot", "telesrv://resolve?domain=TetrisBot", "Start Bot", "Open telesrv to start a chat with this bot.", `property="og:title" content="Tetris Bot"`, `property="al:android:url" content="telesrv://resolve?domain=TetrisBot"`, } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) } } if strings.Contains(body, `window.location.href = "tg://`) { t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", body) } } func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { h, err := NewHandler(Config{ StickerSets: fakeResolver{ "stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers}, "emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true}, }, Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}}, UniqueGifts: &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{ "gift-1": {ID: 1, GiftID: 10, Slug: "gift-1", Num: 1}, }}, PublicBaseURL: "https://links.example.test", AppScheme: "example-chat", WebBaseURL: "https://web.example.test/client/", AppName: "Example Chat", }) if err != nil { t.Fatalf("NewHandler: %v", err) } rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/Alice?start=hello", nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } body := rr.Body.String() for _, want := range []string{ "example-chat://resolve?domain=Alice&start=hello", "https://web.example.test/client/#?tgaddr=", "Example Chat", "Open Example Chat to send a message to @Alice.", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) } } if strings.Contains(body, "telesrv://") || strings.Contains(body, "https://web.telesrv.net") { t.Fatalf("body contains stale default client link:\n%s", body) } for _, tc := range []struct { path string want string }{ {path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"}, {path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"}, {path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"}, {path: "/nft/gift-1", want: "example-chat://nft?slug=gift-1"}, } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), tc.want) || !strings.Contains(rr.Body.String(), "Example Chat") { t.Fatalf("%s response = %d %q, want configured link %q and brand", tc.path, rr.Code, rr.Body.String(), tc.want) } } } func TestNewHandlerRejectsInvalidClientLinkConfig(t *testing.T) { for _, tc := range []struct { name string cfg Config }{ {name: "missing sticker resolver", cfg: Config{}}, {name: "official scheme", cfg: Config{StickerSets: fakeResolver{}, AppScheme: "tg"}}, {name: "invalid Web base URL", cfg: Config{StickerSets: fakeResolver{}, WebBaseURL: "file:///tmp/web"}}, {name: "invalid app name", cfg: Config{StickerSets: fakeResolver{}, AppName: "bad\nname"}}, } { t.Run(tc.name, func(t *testing.T) { if _, err := NewHandler(tc.cfg); err == nil { t.Fatal("NewHandler succeeded, want error") } }) } } func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { users := fakeUsers{ "alice": { ID: 2001, AccessHash: 987654321, Phone: "+15551234567", Username: "Alice", FirstName: "Alice", LastName: "Example", About: "Public bio", Verified: true, LastSeenAt: 1700000000, }, } channels := fakeChannels{ "newsroom": { ID: 3001, Username: "NewsRoom", Title: "News Room", About: "Public channel description", Broadcast: true, ParticipantsCount: 12001, Verified: true, PhotoID: 301, }, "studygroup": { ID: 3002, Username: "StudyGroup", Title: "Study Group", About: "A public supergroup", Megagroup: true, ParticipantsCount: 1, }, } photos := &fakePhotos{byID: map[int64]domain.Photo{ 301: {ID: 301, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}}, }} handler := newTestHandlerWithPublicPeers(t, fakeResolver{}, users, channels, nil, photos, "https://telesrv.net") for _, tc := range []struct { path string wants []string }{ { path: "/aLiCe/", wants: []string{ "Alice Example", "Public bio", "@Alice", "Send Message", "Verified", "https://telesrv.net/Alice", "telesrv://resolve?domain=Alice", }, }, { path: "/NewsRoom", wants: []string{ "News Room", "Public channel description", "12 001 subscribers", "View Channel", "https://telesrv.net/NewsRoom", "telesrv://resolve?domain=NewsRoom", "/_public/avatar/NewsRoom/301", }, }, { path: "/StudyGroup", wants: []string{ "Study Group", "A public supergroup", "1 member", "View Group", }, }, } { t.Run(tc.path, func(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) } for _, want := range tc.wants { if !strings.Contains(rr.Body.String(), want) { t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) } } if tc.path == "/aLiCe/" && strings.Count(rr.Body.String(), `