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 } func TestDevStarsCheckoutEmitsFormBoundTelegramCredentials(t *testing.T) { h := newTestHandler(t, fakeResolver{}, "https://links.example.test") for _, target := range []string{ "/payments/dev-stars", "/payments/dev-stars?form_id=0", "/payments/dev-stars?form_id=01", "/payments/dev-stars?form_id=not-a-number", } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil)) if rr.Code != http.StatusNotFound { t.Fatalf("GET %s status = %d, want 404", target, rr.Code) } } rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/payments/dev-stars?form_id=-70001", nil)) body := rr.Body.String() if rr.Code != http.StatusOK || rr.Header().Get("Cache-Control") != "no-store" || !strings.Contains(body, "payment_form_submit") || !strings.Contains(body, "type:'telesrv_dev',form_id:'-70001'") || !strings.Contains(body, "No card, Google Play, App Store, or external payment provider will be charged") { t.Fatalf("dev checkout status=%d headers=%v body=%q", rr.Code, rr.Header(), body) } } type fakeGiftWithdrawals struct { value domain.StarGiftWithdrawal found bool completeCalls int } type fakeUniqueGifts struct { bySlug map[string]domain.UniqueStarGift err error calls int } type fakeModerationAppeals struct { link domain.ModerationAppealLink found bool resolveErr error appeal domain.ModerationAppeal submitErr error submitCalls int submitText string } func (f *fakeModerationAppeals) ResolveAppealLink(_ context.Context, _ string, _ time.Time) (domain.ModerationAppealLink, bool, error) { return f.link, f.found, f.resolveErr } func (f *fakeModerationAppeals) Appeal(_ context.Context, appealID int64) (domain.ModerationAppeal, bool, error) { if f.appeal.ID != appealID { return domain.ModerationAppeal{}, false, nil } return f.appeal, true, nil } func (f *fakeModerationAppeals) SubmitAppealLink(_ context.Context, _ string, text string, _ time.Time) (domain.ModerationAppeal, bool, error) { f.submitCalls++ f.submitText = text return f.appeal, true, f.submitErr } func TestHandlerModerationAppealGetAndIdempotentPost(t *testing.T) { now := time.Now().UTC() resolver := &fakeModerationAppeals{ found: true, link: domain.ModerationAppealLink{ ID: 1, CaseID: 2, AppellantUserID: 3, TokenHash: [32]byte{1}, ExpiresAt: now.Add(time.Hour), CreatedAt: now, }, appeal: domain.ModerationAppeal{ ID: 4, CaseID: 2, Status: domain.ModerationAppealPending, }, } handler, err := NewHandler(Config{ StickerSets: fakeResolver{}, ModerationAppeals: resolver, PublicBaseURL: "https://telesrv.example", AppName: "Telesrv", }) if err != nil { t.Fatal(err) } get := httptest.NewRecorder() handler.ServeHTTP(get, httptest.NewRequest( http.MethodGet, "/appeal/valid-token", nil, )) if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), "Case #2") || !strings.Contains(get.Body.String(), "Do not include passwords") { t.Fatalf("GET status=%d body=%s", get.Code, get.Body.String()) } if got := get.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("Cache-Control=%q", got) } if got := get.Header().Get("Referrer-Policy"); got != "no-referrer" { t.Fatalf("Referrer-Policy=%q", got) } post := httptest.NewRecorder() postRequest := httptest.NewRequest( http.MethodPost, "/appeal/valid-token", strings.NewReader("appeal_text=+Please+review+this.+"), ) postRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") handler.ServeHTTP(post, postRequest) if post.Code != http.StatusOK || !strings.Contains(post.Body.String(), "appeal #4") || resolver.submitCalls != 1 || resolver.submitText != "Please review this." { t.Fatalf("POST status=%d calls=%d text=%q body=%s", post.Code, resolver.submitCalls, resolver.submitText, post.Body.String()) } resolver.link.AppealID = 4 retry := httptest.NewRecorder() retryRequest := httptest.NewRequest( http.MethodPost, "/appeal/valid-token", strings.NewReader("appeal_text=must+not+resubmit"), ) retryRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") handler.ServeHTTP(retry, retryRequest) if retry.Code != http.StatusOK || resolver.submitCalls != 1 || !strings.Contains(retry.Body.String(), "appeal #4") { t.Fatalf("retry status=%d calls=%d body=%s", retry.Code, resolver.submitCalls, retry.Body.String()) } resolver.appeal.Status = domain.ModerationAppealGranted granted := httptest.NewRecorder() handler.ServeHTTP(granted, httptest.NewRequest( http.MethodGet, "/appeal/valid-token", nil, )) if granted.Code != http.StatusOK || !strings.Contains(granted.Body.String(), "was granted") { t.Fatalf("granted status=%d body=%s", granted.Code, granted.Body.String()) } } func TestHandlerModerationAppealFailsClosed(t *testing.T) { resolver := &fakeModerationAppeals{} handler, err := NewHandler(Config{ StickerSets: fakeResolver{}, ModerationAppeals: resolver, PublicBaseURL: "https://telesrv.example", }) if err != nil { t.Fatal(err) } missing := httptest.NewRecorder() handler.ServeHTTP(missing, httptest.NewRequest( http.MethodGet, "/appeal/invalid", nil, )) if missing.Code != http.StatusNotFound { t.Fatalf("missing status=%d", missing.Code) } now := time.Now().UTC() resolver.found = true resolver.link = domain.ModerationAppealLink{ ID: 1, CaseID: 2, AppellantUserID: 3, TokenHash: [32]byte{1}, ExpiresAt: now.Add(time.Hour), CreatedAt: now, } resolver.submitErr = domain.ErrModerationCaseConflict conflict := httptest.NewRecorder() conflictRequest := httptest.NewRequest( http.MethodPost, "/appeal/valid", strings.NewReader("appeal_text=still+finalizing"), ) conflictRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") handler.ServeHTTP(conflict, conflictRequest) if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "still being finalized") { t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String()) } } 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://127.0.0.1:2401/nft/" + 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://telesrv.net/addstickers/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://example.test/addemoji/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://127.0.0.1:2401/addlist/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://127.0.0.1:2401/TetrisBot", "Start Bot", "Open telesrv to start a chat with this bot.", `property="og:title" content="Tetris Bot"`, `property="al:android:url" content="telesrv://127.0.0.1:2401/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", AppLinkBase: "owpg://tenant.example.test", 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://links.example.test/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://weba.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://links.example.test/addstickers/stickers_pack"}, {path: "/addemoji/emoji_pack", want: "example-chat://links.example.test/addemoji/emoji_pack"}, {path: "/addlist/shared-folder", want: "example-chat://links.example.test/addlist/shared-folder"}, {path: "/nft/gift-1", want: "example-chat://links.example.test/nft/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: "official app link base", cfg: Config{StickerSets: fakeResolver{}, AppLinkBase: "tg://links.example.test"}}, {name: "app link base path", cfg: Config{StickerSets: fakeResolver{}, AppLinkBase: "owpg://links.example.test/root"}}, {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://telesrv.net/Alice", }, }, { path: "/NewsRoom", wants: []string{ "News Room", "Public channel description", "12 001 subscribers", "View Channel", "https://telesrv.net/NewsRoom", "telesrv://telesrv.net/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(), `