Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a conversation in which you send a photo, so the file is uploaded and sent as a message. It is content addressed -- replacing the file is what triggers an update, and the digest is recorded only after BotFather confirms, so a failed upload retries rather than being remembered as done. The image is validated before the conversation starts, because an image rejected halfway leaves the bot registered without a picture. Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials, session presence, salt and the resulting plan, checked in one run that writes nothing and never contacts Telegram. Every failure it reports is one that would otherwise surface after a phone number had been spent. Two bugs it found immediately. The avatar path is documented as repo-relative but resolved against the spec's own directory, so the real campaign spec failed to find its own asset. And the OpenBao error named both variables when only one was missing, sending the reader to check the one already set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa Assistant: claude-code Assistant-Model: opus Assistant-Process: 1361245@bnt-lap001 Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
This commit is contained in:
parent
014ee5c746
commit
7347bd6302
11 changed files with 661 additions and 46 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package plan
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -47,7 +48,7 @@ func find(p *Plan, kind Kind, target string) *Action {
|
|||
// 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{})
|
||||
p, err := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -92,7 +93,7 @@ func provisioned(withTestPublication bool) *state.Resolved {
|
|||
// 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)
|
||||
p, err := Compute("", sp(), "sha256:x", provisioned(true), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -110,7 +111,7 @@ func TestConvergedRunIsQuiet(t *testing.T) {
|
|||
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)
|
||||
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())
|
||||
|
|
@ -124,7 +125,7 @@ func TestWidenedRightsBlock(t *testing.T) {
|
|||
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)
|
||||
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())
|
||||
}
|
||||
|
|
@ -133,7 +134,7 @@ func TestLostRightsBlock(t *testing.T) {
|
|||
// 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)
|
||||
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())
|
||||
}
|
||||
|
|
@ -143,7 +144,7 @@ func TestUsernameTakeoverBlocks(t *testing.T) {
|
|||
// token already in OpenBao.
|
||||
func TestMissingBotBlocks(t *testing.T) {
|
||||
f := fake{botExists: false}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
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())
|
||||
}
|
||||
|
|
@ -154,7 +155,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
|
|||
s := sp()
|
||||
delete(s.Channels, spec.Live)
|
||||
f := fake{botExists: true}
|
||||
p, _ := Compute(s, "sha256:x", provisioned(true), f)
|
||||
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())
|
||||
|
|
@ -170,7 +171,7 @@ func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
|
|||
func TestCampaignMismatchBlocks(t *testing.T) {
|
||||
rs := provisioned(true)
|
||||
rs.Campaign = "some-other-campaign"
|
||||
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
|
||||
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())
|
||||
}
|
||||
|
|
@ -179,7 +180,7 @@ func TestCampaignMismatchBlocks(t *testing.T) {
|
|||
func TestRenderShowsRefusalsFirst(t *testing.T) {
|
||||
rs := provisioned(true)
|
||||
rs.Campaign = "some-other-campaign"
|
||||
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
|
||||
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)
|
||||
|
|
@ -192,12 +193,12 @@ func TestRenderShowsRefusalsFirst(t *testing.T) {
|
|||
// 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{})
|
||||
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)
|
||||
p2, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
||||
if !p2.Blocked() {
|
||||
t.Fatal("real drift must block")
|
||||
}
|
||||
|
|
@ -206,7 +207,7 @@ func TestDeferIsNotBlock(t *testing.T) {
|
|||
// 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)
|
||||
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())
|
||||
}
|
||||
|
|
@ -217,17 +218,52 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
|
|||
// plan defers it with an explanation instead of claiming a create.
|
||||
func TestAvatarIsDeferredNotPromised(t *testing.T) {
|
||||
s := sp()
|
||||
s.Bot.Avatar = "presence/assets/avatar.png"
|
||||
p, _ := Compute(s, "sha256:x", &state.Resolved{}, fake{})
|
||||
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 create that apply does not perform")
|
||||
t.Fatal("plan promised an avatar it could not load")
|
||||
}
|
||||
a := find(p, Defer, "bot.avatar")
|
||||
if a == nil {
|
||||
t.Fatalf("expected the avatar to be deferred:\n%s", p.Render())
|
||||
t.Fatalf("a missing avatar should defer, not block:\n%s", p.Render())
|
||||
}
|
||||
if !strings.Contains(a.Why, "setuserpic") {
|
||||
t.Errorf("deferral should say how to do it by hand: %q", a.Why)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue