// announce_test.go — covers POST /api/agents/announce: happy path, // invalid public key, hostname collision flag, rate limit, global // cap (P2-18a). package http import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" stdhttp "net/http" "strings" "testing" "time" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) func newKeypair(t *testing.T) ed25519.PublicKey { t.Helper() pub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519: %v", err) } return pub } func postAnnounce(t *testing.T, url string, req announceRequest) (status int, header stdhttp.Header, body []byte) { t.Helper() b, _ := json.Marshal(req) r, _ := stdhttp.NewRequest("POST", url+"/api/agents/announce", bytes.NewReader(b)) r.Header.Set("Content-Type", "application/json") res, err := stdhttp.DefaultClient.Do(r) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() out := make([]byte, 4096) n, _ := res.Body.Read(out) return res.StatusCode, res.Header, out[:n] } func TestAnnounceHappyPath(t *testing.T) { t.Parallel() _, url, st := newTestServerWithHub(t) pub := newKeypair(t) status, _, body := postAnnounce(t, url, announceRequest{ Hostname: "alice", OS: "linux", Arch: "amd64", AgentVersion: "1.0", ResticVersion: "0.17", PublicKey: base64.StdEncoding.EncodeToString(pub), }) if status != stdhttp.StatusOK { t.Fatalf("status: %d body=%s", status, body) } var ar announceResponse if err := json.Unmarshal(body, &ar); err != nil { t.Fatalf("unmarshal: %v body=%s", err, body) } if ar.PendingID == "" { t.Fatal("missing pending_id") } if !strings.HasPrefix(ar.Fingerprint, "SHA256:") { t.Fatalf("fingerprint shape: %q", ar.Fingerprint) } if ar.HostnameCollision { t.Fatal("first announce shouldn't be a collision") } // Row exists in the store. if _, err := st.GetPendingHost(context.Background(), ar.PendingID); err != nil { t.Fatalf("pending row missing: %v", err) } } func TestAnnounceRejectsBadKey(t *testing.T) { t.Parallel() _, url, _ := newTestServerWithHub(t) status, _, _ := postAnnounce(t, url, announceRequest{ Hostname: "x", OS: "linux", Arch: "amd64", PublicKey: base64.StdEncoding.EncodeToString([]byte("too-short")), }) if status != stdhttp.StatusBadRequest { t.Fatalf("status: got %d, want 400", status) } } func TestAnnounceHostnameCollisionFlag(t *testing.T) { t.Parallel() _, url, _ := newTestServerWithHub(t) pub1 := newKeypair(t) pub2 := newKeypair(t) _, _, _ = postAnnounce(t, url, announceRequest{ Hostname: "dup-host", OS: "linux", Arch: "amd64", PublicKey: base64.StdEncoding.EncodeToString(pub1), }) status, _, body := postAnnounce(t, url, announceRequest{ Hostname: "dup-host", OS: "linux", Arch: "amd64", PublicKey: base64.StdEncoding.EncodeToString(pub2), }) if status != stdhttp.StatusOK { t.Fatalf("status: %d", status) } var ar announceResponse _ = json.Unmarshal(body, &ar) if !ar.HostnameCollision { t.Fatal("expected hostname_collision=true on second announce") } } func TestAnnounceRateLimit(t *testing.T) { // Not t.Parallel — mutates the package-level announceMaxPerMin // var, which would otherwise race other announce tests. _, url, _ := newTestServerWithHub(t) prev := announceMaxPerMin announceMaxPerMin = 2 t.Cleanup(func() { announceMaxPerMin = prev }) pub := newKeypair(t) body := announceRequest{ Hostname: "rl-host", OS: "linux", Arch: "amd64", PublicKey: base64.StdEncoding.EncodeToString(pub), } for i := 0; i < 2; i++ { status, _, _ := postAnnounce(t, url, body) if status != stdhttp.StatusOK { t.Fatalf("call %d: status %d", i, status) } } status, _, _ := postAnnounce(t, url, body) if status != stdhttp.StatusTooManyRequests { t.Fatalf("3rd call: want 429, got %d", status) } } func TestAnnounceGlobalCap(t *testing.T) { // Not t.Parallel — mutates the package-level announceGlobalCap. _, url, st := newTestServerWithHub(t) prev := announceGlobalCap announceGlobalCap = 1 t.Cleanup(func() { announceGlobalCap = prev }) // Pre-seed one row directly via the store so the cap is hit. pub := newKeypair(t) if err := st.CreatePendingHost(context.Background(), &store.PendingHost{ ID: ulid.Make().String(), Hostname: "x", OS: "linux", Arch: "amd64", PublicKey: pub, Fingerprint: store.FingerprintForKey(pub), AnnouncedFromIP: "127.0.0.1", FirstSeenAt: time.Now().UTC(), LastSeenAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour), }); err != nil { t.Fatalf("seed: %v", err) } status, _, _ := postAnnounce(t, url, announceRequest{ Hostname: "next", OS: "linux", Arch: "amd64", PublicKey: base64.StdEncoding.EncodeToString(newKeypair(t)), }) if status != stdhttp.StatusServiceUnavailable { t.Fatalf("want 503 over cap, got %d", status) } }