package plan import ( "os" "strings" "testing" "time" "github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/state" ) type fake struct { botExists bool rights map[int64][]string usernames map[int64]string } func (f fake) BotExists(string) (bool, error) { return f.botExists, nil } func (f fake) ChannelAdminRights(id int64) ([]string, error) { if r, ok := f.rights[id]; ok { return r, nil } return []string{spec.PostMessages}, nil } func (f fake) ChannelUsername(id int64) (string, error) { return f.usernames[id], nil } func sp() *spec.Presence { return &spec.Presence{ SchemaVersion: "0.1", Campaign: "hall-of-helix", Interface: "i", Bot: spec.Bot{Name: "HelixForge", UsernamePreference: []string{"HelixForgeBot"}}, Channels: map[string]spec.Channel{ spec.Test: {Title: "t", Visibility: spec.Private}, spec.Live: {Title: "p", Visibility: spec.Public, UsernamePreference: []string{"hallofhelix"}}, }, } } func find(p *Plan, kind Kind, target string) *Action { for i := range p.Actions { if p.Actions[i].Kind == kind && p.Actions[i].Target == target { return &p.Actions[i] } } return nil } // First run: everything is created, and the public channel is blocked because // no rendering has been checked yet. func TestFirstRunCreatesAndBlocksPublic(t *testing.T) { p, err := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{}) if err != nil { t.Fatal(err) } if find(p, Attempt, "bot") == nil { t.Error("expected a bot registration attempt") } if find(p, Create, "channel.test") == nil { t.Error("expected the test channel to be created") } if find(p, Defer, "channel.public") == nil { t.Error("public channel must be deferred until the test channel is verified") } // A first run is not an error state. The bot and the test channel must still // be creatable while the public channel waits. if p.Blocked() { t.Errorf("a first run should not block:\n%s", p.Render()) } if p.Empty() { t.Error("a first run has work to do") } } func provisioned(withTestPublication bool) *state.Resolved { rs := &state.Resolved{ Campaign: "hall-of-helix", Bot: state.Bot{Username: "HelixForgeBot", ID: 42}, Channels: map[string]state.Channel{ spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}}, spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}}, }, } if withTestPublication { now := time.Now() c := rs.Channels[spec.Test] c.TestPublicationAt = &now rs.Channels[spec.Test] = c } return rs } // Converged: a second run over an unchanged spec proposes nothing but the // idempotent profile reassertion. func TestConvergedRunIsQuiet(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} p, err := Compute("", sp(), "sha256:x", provisioned(true), f) if err != nil { t.Fatal(err) } if p.Blocked() { t.Fatalf("converged plan should not block:\n%s", p.Render()) } for _, a := range p.Actions { if a.Kind == Create || a.Kind == Attempt { t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target) } } } // Widened rights are a refusal, not a repair. func TestWidenedRightsBlock(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, rights: map[int64][]string{-200: {spec.PostMessages, "can_delete_messages"}}} p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) a := find(p, Block, "channel.public.admin") if a == nil { t.Fatalf("widened rights must block:\n%s", p.Render()) } if !strings.Contains(a.Why, "not allowed to exercise") { t.Errorf("block should explain why: %q", a.Why) } } // A demoted bot blocks too -- losing the right is as much a drift as gaining one. func TestLostRightsBlock(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}, rights: map[int64][]string{-100: {}}} p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "channel.test.admin") == nil { t.Fatalf("a demoted bot must block:\n%s", p.Render()) } } // A username that changed underneath us is not reconciled. func TestUsernameTakeoverBlocks(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "channel.public.username") == nil { t.Fatalf("a changed username must block:\n%s", p.Render()) } } // A missing bot blocks rather than being re-created, which would orphan the // token already in OpenBao. func TestMissingBotBlocks(t *testing.T) { f := fake{botExists: false} p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Block, "bot") == nil { t.Fatalf("an unreachable bot must block:\n%s", p.Render()) } } // Removing a channel from the spec warns; it never deletes. func TestRemovedChannelWarnsNeverDeletes(t *testing.T) { s := sp() delete(s.Channels, spec.Live) f := fake{botExists: true} p, _ := Compute("", s, "sha256:x", provisioned(true), f) a := find(p, Warn, "channel.public") if a == nil { t.Fatalf("expected a warning:\n%s", p.Render()) } for _, act := range p.Actions { if strings.Contains(strings.ToLower(act.Detail), "delete") && act.Kind != Warn { t.Errorf("plan proposed a deletion: %+v", act) } } } // Pointing a state file at a different campaign is a mistake, not a rename. func TestCampaignMismatchBlocks(t *testing.T) { rs := provisioned(true) rs.Campaign = "some-other-campaign" p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true}) if find(p, Block, "campaign") == nil { t.Fatalf("campaign mismatch must block:\n%s", p.Render()) } } func TestRenderShowsRefusalsFirst(t *testing.T) { rs := provisioned(true) rs.Campaign = "some-other-campaign" p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true}) out := p.Render() if !strings.Contains(out, "nothing will be applied") { t.Errorf("a blocked plan should say so:\n%s", out) } if !strings.Contains(out, "BLOCK") { t.Error("expected a BLOCK in the render") } } // A deferral is not a block: the two must not collapse into each other, or a // first run can never apply anything. func TestDeferIsNotBlock(t *testing.T) { p, _ := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{}) if p.Blocked() { t.Fatal("a deferral must not block the plan") } f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}} p2, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if !p2.Blocked() { t.Fatal("real drift must block") } } // Once the test channel is verified, the public channel stops being deferred. func TestVerifiedTestChannelReleasesPublic(t *testing.T) { f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}} p, _ := Compute("", sp(), "sha256:x", provisioned(true), f) if find(p, Defer, "channel.public") != nil { t.Errorf("public should no longer be deferred:\n%s", p.Render()) } } // The plan must not promise an action apply will silently skip. The avatar is // the live case: /setuserpic needs an upload that is not implemented, so the // plan defers it with an explanation instead of claiming a create. func TestAvatarIsDeferredNotPromised(t *testing.T) { s := sp() s.Bot.Avatar = "does-not-exist.png" p, _ := Compute("", s, "sha256:x", &state.Resolved{}, fake{}) if find(p, Create, "bot.avatar") != nil { t.Fatal("plan promised an avatar it could not load") } a := find(p, Defer, "bot.avatar") if a == nil { t.Fatalf("a missing avatar should defer, not block:\n%s", p.Render()) } if !strings.Contains(a.Why, "not there") { t.Errorf("deferral should say the file is missing: %q", a.Why) } if p.Blocked() { t.Error("a missing avatar must not stop everything else applying") } } // A real image is planned as a create, content-addressed. func TestAvatarPlannedFromRealFile(t *testing.T) { const real = "../../../pr-hall-of-helix/presence/telegram.yaml" if _, err := os.Stat(real); err != nil { t.Skip("campaign repo not in this checkout") } s := sp() s.Bot.Avatar = "assets/helix-forge.png" p, _ := Compute(real, s, "sha256:x", &state.Resolved{}, fake{}) if p.Avatar == nil { t.Fatalf("expected a loaded avatar:\n%s", p.Render()) } if find(p, Create, "bot.avatar") == nil { t.Fatalf("expected an avatar create:\n%s", p.Render()) } // Once recorded, the same file must not be re-sent: the digest is what // decides, so an unchanged picture is silence. rs := &state.Resolved{Bot: state.Bot{AvatarDigest: p.Avatar.Digest}} p2, _ := Compute(real, s, "sha256:x", rs, fake{}) if find(p2, Create, "bot.avatar") != nil || find(p2, Update, "bot.avatar") != nil { t.Errorf("an unchanged avatar should propose nothing:\n%s", p2.Render()) } // A different digest means the file was replaced, which is an update. rs.Bot.AvatarDigest = "sha256:something-else" p3, _ := Compute(real, s, "sha256:x", rs, fake{}) if find(p3, Update, "bot.avatar") == nil { t.Errorf("a replaced avatar should update:\n%s", p3.Render()) } }