//go:build integration package inttest import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/ads" "scrabble/backend/internal/payments" "scrabble/backend/internal/server" ) // bannerServer assembles a console-capable server with the ads domain wired. func bannerServer(t *testing.T) (*server.Server, *ads.Service, *payments.Service) { t.Helper() adsSvc := ads.NewService(ads.NewStore(testDB)) paySvc := newPaymentsService() srv := server.New(":0", server.Deps{ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), Ads: adsSvc, Payments: paySvc, }) return srv, adsSvc, paySvc } // findCampaign returns the id of the campaign with the given name, or fails. func findCampaign(t *testing.T, svc *ads.Service, name string) uuid.UUID { t.Helper() all, err := svc.ListCampaigns(context.Background()) if err != nil { t.Fatalf("list campaigns: %v", err) } for _, c := range all { if c.Name == name { return c.ID } } t.Fatalf("campaign %q not found", name) return uuid.Nil } // defaultCampaign returns the seeded perpetual default campaign. func defaultCampaign(t *testing.T, svc *ads.Service) ads.Campaign { t.Helper() all, err := svc.ListCampaigns(context.Background()) if err != nil { t.Fatalf("list campaigns: %v", err) } for _, c := range all { if c.IsDefault { return c } } t.Fatal("no default campaign seeded") return ads.Campaign{} } // TestBannerConsoleCRUD drives the /_gm/banners console over HTTP against real // stores: pages render, the CSRF guard bites, validation and the default-campaign // protection hold, and a campaign + message round-trips through create/edit/ // reorder/delete. func TestBannerConsoleCRUD(t *testing.T) { ctx := context.Background() srv, adsSvc, _ := bannerServer(t) h := srv.Handler() base := "http://admin.test/_gm" const origin = "http://admin.test" // The list renders and shows the seeded default campaign. if code, body := consoleDo(h, http.MethodGet, base+"/banners", "", ""); code != http.StatusOK || !strings.Contains(body, "Default (house)") { t.Fatalf("banners list = %d, has default=%v", code, strings.Contains(body, "Default (house)")) } name := "Promo-" + uuid.NewString()[:8] // CSRF: a create without a same-origin header is rejected. if code, _ := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", ""); code != http.StatusForbidden { t.Fatalf("create without origin = %d, want 403", code) } // Validation: weight out of range is refused. if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name=Bad&weight=0&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { t.Fatalf("create weight=0 = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) } // A valid create persists. if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=30&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") { t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created")) } id := findCampaign(t, adsSvc, name) // The detail page renders. if code, body := consoleDo(h, http.MethodGet, base+"/banners/"+id.String(), "", ""); code != http.StatusOK || !strings.Contains(body, name) { t.Fatalf("detail = %d, has name=%v", code, strings.Contains(body, name)) } // A message needs both languages. if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { t.Fatalf("add half message = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) } // A bilingual message is added. if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages", "body_en=Hello&body_ru=Привет", origin); code != http.StatusOK || !strings.Contains(body, "Added") { t.Fatalf("add message = %d, has Added=%v", code, strings.Contains(body, "Added")) } cm, err := adsSvc.Campaign(ctx, id) if err != nil || len(cm.Messages) != 1 { t.Fatalf("campaign messages = %d (err %v), want 1", len(cm.Messages), err) } mid := cm.Messages[0].ID // Edit it. if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String(), "body_en=Hi&body_ru=Привет!", origin); code != http.StatusOK { t.Fatalf("edit message = %d", code) } if cm, _ := adsSvc.Campaign(ctx, id); cm.Messages[0].BodyEn != "Hi" { t.Fatalf("edit did not persist: %q", cm.Messages[0].BodyEn) } // Reorder is a no-op for a single message but still succeeds. if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/move", "dir=down", origin); code != http.StatusOK { t.Fatalf("move message = %d", code) } // Delete the message, then the campaign. if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/messages/"+mid.String()+"/delete", "", origin); code != http.StatusOK { t.Fatalf("delete message = %d", code) } if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+id.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") { t.Fatalf("delete campaign = %d, has Deleted=%v", code, strings.Contains(body, "Deleted")) } if _, err := adsSvc.Campaign(ctx, id); err == nil { t.Fatal("campaign still present after delete") } // The default campaign cannot be deleted. def := defaultCampaign(t, adsSvc) if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not saved") { t.Fatalf("delete default = %d, has 'Not saved'=%v", code, strings.Contains(body, "Not saved")) } if _, err := adsSvc.Campaign(ctx, def.ID); err != nil { t.Fatalf("default campaign gone after refused delete: %v", err) } } // TestBannerMessageOwnership guards the campaign/message pairing: a message may // only be edited or deleted through the URL of the campaign it belongs to. This // closes a default-protection bypass — deleting the default's last message via an // unrelated campaign's URL must be refused. func TestBannerMessageOwnership(t *testing.T) { ctx := context.Background() srv, adsSvc, _ := bannerServer(t) h := srv.Handler() base := "http://admin.test/_gm" const origin = "http://admin.test" def := defaultCampaign(t, adsSvc) if len(def.Messages) == 0 { t.Fatal("default campaign has no seeded message") } defMsg := def.Messages[0].ID // A separate timed campaign whose URL we will (mis)use. otherID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{Name: "Own-" + uuid.NewString()[:8], Weight: 10, Enabled: true}) if err != nil { t.Fatalf("create campaign: %v", err) } t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, otherID) }) // Deleting the default's message through the other campaign's URL is refused, // and the default keeps its message (the bypass is closed). if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Not found") { t.Fatalf("cross-campaign delete = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found")) } // Editing it through the wrong campaign is refused too. if code, body := consoleDo(h, http.MethodPost, base+"/banners/"+otherID.String()+"/messages/"+defMsg.String(), "body_en=Hijacked&body_ru=Взлом", origin); code != http.StatusOK || !strings.Contains(body, "Not found") { t.Fatalf("cross-campaign edit = %d, has 'Not found'=%v", code, strings.Contains(body, "Not found")) } // The default's message is intact. again := defaultCampaign(t, adsSvc) if len(again.Messages) != len(def.Messages) { t.Fatalf("default messages = %d, want %d (unchanged)", len(again.Messages), len(def.Messages)) } if again.Messages[0].BodyEn != def.Messages[0].BodyEn { t.Fatalf("default message body changed: %q -> %q", def.Messages[0].BodyEn, again.Messages[0].BodyEn) } } // profileBanner is the banner block of the profile.get JSON response. type profileBanner struct { Banner *struct { Campaigns []struct { Weight int `json:"weight"` Messages []string `json:"messages"` OverrideBg string `json:"override_bg"` } `json:"campaigns"` Timings struct { HoldMs int `json:"hold_ms"` } `json:"timings"` } `json:"banner"` } // TestBannerProfileEligibility checks the profile.get banner block follows // eligibility: a free account sees it, the no_banner role and a non-empty hint // wallet each suppress it. func TestBannerProfileEligibility(t *testing.T) { ctx := context.Background() srv, _, pay := bannerServer(t) accounts := account.NewStore(testDB) id := provisionAccount(t) // getBanner fetches the profile banner with no platform header (an untrusted context). getBanner := func() profileBanner { t.Helper() rec := userGet(t, srv, "/api/v1/user/profile", id) if rec.Code != http.StatusOK { t.Fatalf("profile = %d, want 200", rec.Code) } var p profileBanner if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("decode profile: %v", err) } return p } // getBannerTG fetches the profile banner in a trusted Telegram context — the account's // telegram identity makes telegram the applicable origin for its benefits. getBannerTG := func() profileBanner { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil) req.Header.Set("X-User-ID", id.String()) req.Header.Set("X-Platform", "telegram/android") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("profile = %d, want 200", rec.Code) } var p profileBanner if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("decode profile: %v", err) } return p } // A free account with no role and no benefit is eligible. if p := getBanner(); p.Banner == nil || len(p.Banner.Campaigns) == 0 || p.Banner.Timings.HoldMs <= 0 { t.Fatalf("eligible account: banner=%v", p.Banner) } // The no_banner role suppresses it unconditionally. if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { t.Fatalf("grant role: %v", err) } if p := getBanner(); p.Banner != nil { t.Fatal("no_banner role still shows the banner") } if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { t.Fatalf("revoke role: %v", err) } // A hint wallet no longer suppresses the banner — hints and no-ads are distinct benefits now. if err := pay.Grant(ctx, id, payments.SourceTelegram, 5, 0, false); err != nil { t.Fatalf("grant hints: %v", err) } if p := getBannerTG(); p.Banner == nil { t.Fatal("a hint wallet must NOT suppress the banner") } // An active no-ads benefit applicable in the context suppresses the banner. if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil { t.Fatalf("grant no-ads: %v", err) } if p := getBannerTG(); p.Banner != nil { t.Fatal("an active no-ads benefit must suppress the banner") } // Fail-closed: without a trusted platform the same benefit does not apply, so the banner shows. if p := getBanner(); p.Banner == nil { t.Fatal("an untrusted context must not apply the no-ads benefit (banner should show)") } } // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the // banner block too, so the client's profile keeps the banner instead of losing it until reload. func TestBannerSurvivesProfileUpdate(t *testing.T) { srv, _, _ := bannerServer(t) id := provisionAccount(t) body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + `"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true,` + `"variant_preferences":["erudit_ru"]}` rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body)) req.Header.Set("X-User-ID", id.String()) req.Header.Set("Content-Type", "application/json") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("update profile = %d: %s", rec.Code, rec.Body.String()) } var p profileBanner if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("decode: %v", err) } if p.Banner == nil || len(p.Banner.Campaigns) == 0 { t.Fatalf("profile update dropped the banner block: %v", p.Banner) } } // TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over // HTTP against real Postgres: an incomplete or malformed colour set is refused, a // full two-set override + urgent round-trips through the store (exercising the new // columns and CHECK constraints), clearing the sets removes the override, and the // default campaign stays plain (colours + urgent are ignored for it). func TestBannerConsoleColorsAndUrgent(t *testing.T) { ctx := context.Background() srv, adsSvc, _ := bannerServer(t) h := srv.Handler() base := "http://admin.test/_gm" const origin = "http://admin.test" name := "Brand-" + uuid.NewString()[:8] if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") { t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created")) } id := findCampaign(t, adsSvc, name) t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) }) detail := base + "/banners/" + id.String() // A partial colour set (a missing colour) is refused by validation. partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00" if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") { t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved")) } // A malformed hex is refused. badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00" if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") { t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved")) } // Both colour sets + urgent persist and round-trip through the store. full := "name=" + name + "&weight=20&enabled=on&urgent=on" + "&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" + "&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00" if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") { t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved")) } cm, err := adsSvc.Campaign(ctx, id) if err != nil { t.Fatalf("read campaign: %v", err) } if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" { t.Fatalf("override all = %+v", cm.OverrideAll) } if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" { t.Fatalf("override dark = %+v", cm.OverrideDark) } if !cm.Urgent { t.Fatal("urgent not persisted") } // Clearing the sets (both checkboxes off, urgent off) removes the override. if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK { t.Fatalf("clear = %d", code) } if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent { t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent) } // The default campaign stays plain: submitted colours + urgent are ignored for it. def := defaultCampaign(t, adsSvc) defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK { t.Fatalf("update default = %d", code) } if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent { t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent) } } // TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level: // an urgent campaign preempts the feed (only it shows, with its colours) and reaches // every viewer — including the no_banner role and a non-empty hint wallet that // otherwise suppress the banner. func TestBannerUrgentBypassesEligibility(t *testing.T) { ctx := context.Background() srv, adsSvc, _ := bannerServer(t) accounts := account.NewStore(testDB) id := provisionAccount(t) urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{ Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true, OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}, }) if err != nil { t.Fatalf("create urgent: %v", err) } t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) }) if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil { t.Fatalf("add urgent message: %v", err) } get := func() profileBanner { t.Helper() rec := userGet(t, srv, "/api/v1/user/profile", id) if rec.Code != http.StatusOK { t.Fatalf("profile = %d", rec.Code) } var p profileBanner if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("decode: %v", err) } return p } assertUrgentOnly := func(what string, p profileBanner) { t.Helper() if p.Banner == nil { t.Fatalf("%s: no banner", what) } if len(p.Banner.Campaigns) != 1 { t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns)) } c := p.Banner.Campaigns[0] if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" { t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages) } if c.OverrideBg != "#aa0000" { t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg) } } // A normal viewer sees only the urgent campaign (the default is preempted). assertUrgentOnly("eligible", get()) // The no_banner role no longer suppresses it — urgent reaches everyone. if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { t.Fatalf("grant role: %v", err) } assertUrgentOnly("no_banner", get()) if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { t.Fatalf("revoke role: %v", err) } // A non-empty hint wallet no longer suppresses it either. if _, err := accounts.GrantHints(ctx, id, 5); err != nil { t.Fatalf("grant hints: %v", err) } assertUrgentOnly("hints", get()) }