phase 1 foundations: api types, store, crypto, auth
Lands the bottom three layers of Phase 1: P1-08 internal/api: protocol_version + envelope + every WS message shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc). Wire-format tests pin the JSON shape so a rename here breaks tests instead of silently breaking the agent. P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite, embed.FS + a tiny version table for hand-rolled migrations. 0001_initial.sql covers every table from spec.md §5 plus enrollment_tokens and host_schedule_version. Typed accessors for users / sessions / enrollment / audit. WAL + foreign_keys + busy_timeout on by default. P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with per-message random nonce. Key file lifecycle (generate + refuse-to-overwrite, load with size validation). Optional additionalData binds ciphertext to the row that owns it. P1-04 internal/auth (partial — passwords + tokens; sessions middleware lands with the HTTP handlers): argon2id following RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify. HashToken stores SHA-256 of session/agent/enrollment tokens so a stolen DB doesn't hand over credentials. Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires it); CI + Dockerfile + README updated. Markdown lint diagnostics on tasks.md cleared. All packages tested. ~70 new tests pass in <1s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnvelopeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hello := HelloPayload{
|
||||
ProtocolVersion: CurrentProtocolVersion,
|
||||
AgentVersion: "0.1.0",
|
||||
ResticVersion: "0.17.1",
|
||||
Hostname: "test-host",
|
||||
OS: OSLinux,
|
||||
Arch: ArchAmd64,
|
||||
}
|
||||
|
||||
env, err := Marshal(MsgHello, "", hello)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
wire, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal envelope: %v", err)
|
||||
}
|
||||
|
||||
var decoded Envelope
|
||||
if err := json.Unmarshal(wire, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
if decoded.Type != MsgHello {
|
||||
t.Errorf("type: got %q want %q", decoded.Type, MsgHello)
|
||||
}
|
||||
|
||||
var got HelloPayload
|
||||
if err := decoded.UnmarshalPayload(&got); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if got != hello {
|
||||
t.Errorf("round-trip mismatch: %+v != %+v", got, hello)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeNilPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env, err := Marshal(MsgHeartbeat, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if len(env.Payload) != 0 {
|
||||
t.Errorf("nil payload should encode as empty, got %q", env.Payload)
|
||||
}
|
||||
// Unmarshalling nothing into anything must not error.
|
||||
var hb HeartbeatPayload
|
||||
if err := env.UnmarshalPayload(&hb); err != nil {
|
||||
t.Errorf("unmarshal empty payload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvelopeRPCCorrelation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := CommandRunPayload{JobID: "01HJ8K7", Kind: JobBackup}
|
||||
env, err := Marshal(MsgCommandRun, "req-1", cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if env.ID != "req-1" {
|
||||
t.Errorf("id not preserved: %q", env.ID)
|
||||
}
|
||||
|
||||
res := CommandResultPayload{JobID: "01HJ8K7", Accepted: true}
|
||||
resEnv, err := Marshal(MsgCommandResult, env.ID, res)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
if resEnv.ID != env.ID {
|
||||
t.Errorf("rpc id mismatch: req=%q res=%q", env.ID, resEnv.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ep := ErrorPayload{
|
||||
Code: ErrProtocolTooOld,
|
||||
Message: "agent protocol_version 0 below minimum 1",
|
||||
HelpURL: "https://example.com/upgrade",
|
||||
}
|
||||
env, err := Marshal(MsgError, "", ep)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
wire, _ := json.Marshal(env)
|
||||
|
||||
var decoded Envelope
|
||||
if err := json.Unmarshal(wire, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
var got ErrorPayload
|
||||
if err := decoded.UnmarshalPayload(&got); err != nil {
|
||||
t.Fatalf("unmarshal payload: %v", err)
|
||||
}
|
||||
if got.Code != ErrProtocolTooOld {
|
||||
t.Errorf("code: got %q want %q", got.Code, ErrProtocolTooOld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolVersionConstants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if CurrentProtocolVersion < 1 {
|
||||
t.Errorf("CurrentProtocolVersion must be >= 1, got %d", CurrentProtocolVersion)
|
||||
}
|
||||
if MinAgentProtocolVersion > CurrentProtocolVersion {
|
||||
t.Errorf("min %d > current %d — server would refuse all agents",
|
||||
MinAgentProtocolVersion, CurrentProtocolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobProgressShapeStable(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Locks the JSON field names from spec.md §6.2 so a rename here
|
||||
// breaks tests instead of silently breaking the agent.
|
||||
p := JobProgressPayload{
|
||||
JobID: "j", PercentDone: 0.5, FilesDone: 1, TotalFiles: 2,
|
||||
BytesDone: 100, TotalBytes: 200, ETASeconds: 30, ThroughputBps: 1000,
|
||||
}
|
||||
raw, _ := json.Marshal(p)
|
||||
want := `{"job_id":"j","percent_done":0.5,"files_done":1,"total_files":2,"bytes_done":100,"total_bytes":200,"eta_seconds":30,"throughput_bps":1000}`
|
||||
if string(raw) != want {
|
||||
t.Errorf("wire shape drifted:\n got %s\n want %s", raw, want)
|
||||
}
|
||||
}
|
||||
|
||||
// touch time so the import is used by other tests in this file when
|
||||
// they grow over time.
|
||||
var _ = time.Now
|
||||
Reference in New Issue
Block a user