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
134 lines
3.9 KiB
Go
134 lines
3.9 KiB
Go
package avatar
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func png(t *testing.T, w, h uint32, pad int) []byte {
|
|
t.Helper()
|
|
var b bytes.Buffer
|
|
b.WriteString("\x89PNG\r\n\x1a\n")
|
|
b.Write([]byte{0, 0, 0, 13})
|
|
b.WriteString("IHDR")
|
|
binary.Write(&b, binary.BigEndian, w)
|
|
binary.Write(&b, binary.BigEndian, h)
|
|
b.Write(make([]byte, pad))
|
|
return b.Bytes()
|
|
}
|
|
|
|
// newRepo makes a temp directory that is unambiguously a repository root.
|
|
// Without the marker, resolution walks up into whatever repository happens to
|
|
// contain the temp directory -- this machine has a /tmp/.git, which is exactly
|
|
// the ambient surprise these tests must not depend on.
|
|
func newRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
write(t, dir, ".git/HEAD", []byte("ref: refs/heads/main\n"))
|
|
return dir
|
|
}
|
|
|
|
func write(t *testing.T, dir, name string, data []byte) string {
|
|
t.Helper()
|
|
p := filepath.Join(dir, name)
|
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// The schema documents the avatar path as repo-relative. Resolving it against
|
|
// the spec's own directory instead makes a correct spec fail, which is exactly
|
|
// what happened with the real campaign spec.
|
|
func TestPathIsRepoRelativeNotSpecRelative(t *testing.T) {
|
|
repo := newRepo(t)
|
|
write(t, repo, "assets/logo.png", png(t, 600, 600, 64))
|
|
specPath := write(t, repo, "presence/telegram.yaml", []byte("presence: {}\n"))
|
|
|
|
img, err := Load(specPath, "assets/logo.png")
|
|
if err != nil {
|
|
t.Fatalf("repo-relative path did not resolve: %v", err)
|
|
}
|
|
if img.Width != 600 || !img.Square() {
|
|
t.Errorf("got %dx%d", img.Width, img.Height)
|
|
}
|
|
}
|
|
|
|
func TestMissingIsDistinguishable(t *testing.T) {
|
|
repo := newRepo(t)
|
|
spec := write(t, repo, "spec.yaml", []byte("x"))
|
|
_, err := Load(spec, "nope.png")
|
|
if !errors.Is(err, ErrMissing) {
|
|
t.Fatalf("a missing file must be distinguishable from a bad one: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRejectsBadImages(t *testing.T) {
|
|
repo := newRepo(t)
|
|
spec := write(t, repo, "spec.yaml", []byte("x"))
|
|
|
|
write(t, repo, "small.png", png(t, 128, 128, 16))
|
|
if _, err := Load(spec, "small.png"); err == nil {
|
|
t.Error("accepted an image below the minimum dimension")
|
|
} else if errors.Is(err, ErrMissing) {
|
|
t.Error("a too-small image is not a missing one")
|
|
}
|
|
|
|
write(t, repo, "notpng.png", []byte("GIF89a and then some padding bytes here"))
|
|
if _, err := Load(spec, "notpng.png"); err == nil {
|
|
t.Error("accepted a non-PNG")
|
|
}
|
|
|
|
write(t, repo, "zero.png", png(t, 0, 600, 16))
|
|
if _, err := Load(spec, "zero.png"); err == nil {
|
|
t.Error("accepted a zero dimension")
|
|
}
|
|
}
|
|
|
|
// The digest is what decides whether a picture is re-sent, so identical bytes
|
|
// must give an identical digest and different bytes must not.
|
|
func TestDigestIsContentAddressed(t *testing.T) {
|
|
repo := newRepo(t)
|
|
spec := write(t, repo, "spec.yaml", []byte("x"))
|
|
write(t, repo, "a.png", png(t, 600, 600, 32))
|
|
write(t, repo, "b.png", png(t, 600, 600, 32))
|
|
write(t, repo, "c.png", png(t, 600, 600, 33))
|
|
|
|
a, _ := Load(spec, "a.png")
|
|
b, _ := Load(spec, "b.png")
|
|
c, _ := Load(spec, "c.png")
|
|
if a.Digest != b.Digest {
|
|
t.Error("identical content gave different digests")
|
|
}
|
|
if a.Digest == c.Digest {
|
|
t.Error("different content gave the same digest")
|
|
}
|
|
}
|
|
|
|
// A non-square image is accepted but must say what a viewer will lose.
|
|
func TestCropNote(t *testing.T) {
|
|
repo := newRepo(t)
|
|
spec := write(t, repo, "spec.yaml", []byte("x"))
|
|
write(t, repo, "wide.png", png(t, 907, 885, 32))
|
|
img, err := Load(spec, "wide.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if img.Square() {
|
|
t.Fatal("907x885 is not square")
|
|
}
|
|
if note := img.CropNote(); note == "" {
|
|
t.Error("a non-square image should explain the crop")
|
|
}
|
|
|
|
write(t, repo, "sq.png", png(t, 900, 900, 32))
|
|
sq, _ := Load(spec, "sq.png")
|
|
if sq.CropNote() != "" {
|
|
t.Errorf("a square image needs no note, got %q", sq.CropNote())
|
|
}
|
|
}
|