//go:build integration package inttest import ( "context" "encoding/json" "net/http" "strings" "testing" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/ads" "scrabble/backend/internal/server" ) // bannerServer assembles a console-capable server with the ads domain wired. func bannerServer(t *testing.T) (*server.Server, *ads.Service) { t.Helper() adsSvc := ads.NewService(ads.NewStore(testDB)) srv := server.New(":0", server.Deps{ Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), Ads: adsSvc, }) return srv, adsSvc } // 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"` } `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, _ := bannerServer(t) accounts := account.NewStore(testDB) id := provisionAccount(t) 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 } // A free account with an empty hint wallet and no role is eligible. p := getBanner() if 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 non-empty hint wallet suppresses it. if _, err := accounts.GrantHints(ctx, id, 5); err != nil { t.Fatalf("grant hints: %v", err) } if p := getBanner(); p.Banner != nil { t.Fatal("a non-empty hint wallet still shows the banner") } }