6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
417 lines
17 KiB
Go
417 lines
17 KiB
Go
//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/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"`
|
|
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, _ := 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")
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|