store+server: P2-18a announce-and-approve schema + endpoint
migration 0011 adds pending_hosts table (id, hostname, public_key,
fingerprint, expiry). store/pending_hosts.go covers full CRUD plus
hostname-collision count + expired-row sweeper.
POST /api/agents/announce takes {hostname, os, arch, agent_version,
restic_version, public_key (base64)}, returns {pending_id,
fingerprint, hostname_collision}. Per-source-IP token-bucket
rate limit (10/min) + global cap of 100 in-flight rows. Public
key must be exactly 32 bytes (Ed25519).
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
_, url, _ := newTestServerWithHub(t)
|
||||
// Lower the limit for the duration of this test (the limiter is
|
||||
// per-server-instance so we don't disturb parallel tests).
|
||||
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) {
|
||||
t.Parallel()
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user