fluid-telegram/internal/plan/plan_test.go
tegwick 1e9481e108 Finish the provisioner's remaining plane: honest check, adapter config, IEI-2
plan --check treated BotFather profile reassertion as drift, so a scheduled
check could never go quiet. Reassertion is now its own kind; a changed spec
digest is the drift signal. apply reasserts the profile, updates channel copy
when the spec changes, and is tested against a fake Telegram surface.

The adapter reads its chat id from resolved state and its token from OpenBao,
never the operator session. IEI-2 records that an approved provisioning plan
is not autonomous Daimon action.

Assistant: grok
Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
2026-09-14 02:55:58 +02:00

300 lines
9.9 KiB
Go

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, which is not drift.
func TestConvergedRunIsQuiet(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
rs := provisioned(true)
rs.SpecDigest = "sha256:x"
p, err := Compute("", sp(), "sha256:x", rs, f)
if err != nil {
t.Fatal(err)
}
if p.Blocked() {
t.Fatalf("converged plan should not block:\n%s", p.Render())
}
if p.Drifted() {
t.Fatalf("a converged presence must not count as drift:\n%s", p.Render())
}
if !p.Empty() {
t.Fatalf("plan --check must stay quiet on a converged presence:\n%s", p.Render())
}
if !p.NeedsApply() {
t.Fatal("apply still reasserts the profile even when the check is quiet")
}
if find(p, Reassert, "bot.profile") == nil {
t.Fatalf("expected a profile reassertion:\n%s", p.Render())
}
for _, a := range p.Actions {
if a.Kind == Create || a.Kind == Attempt || a.Kind == Update {
t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target)
}
}
}
// A changed spec is drift even when live observation still matches the old state.
func TestSpecDigestChangeIsDrift(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
rs := provisioned(true)
rs.SpecDigest = "sha256:old"
p, err := Compute("", sp(), "sha256:new", rs, f)
if err != nil {
t.Fatal(err)
}
if find(p, Update, "spec") == nil {
t.Fatalf("a changed spec must be an update:\n%s", p.Render())
}
if !p.Drifted() {
t.Fatal("plan --check must fail after the spec changes")
}
}
// 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. A declared
// avatar whose file is missing is deferred, not created: everything else can
// still apply, and the picture is set on a later run once the file is there.
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())
}
}