8aa635f0c1
Two fixes that close the loop on dashboard run-now and harden the
agent's restic invocation.
Default paths (interim until P2-01 schedules):
- 0003 migration adds default_paths TEXT NOT NULL DEFAULT '[]'
to hosts and to enrollment_tokens.
- Operator types paths in the Add-host form (textarea, one per
line). They ride on the enrol_token row alongside the
encrypted creds (paths aren't secret — plain JSON column).
- On consume, ConsumeEnrollmentToken still just burns the token;
the new GetEnrollmentTokenAttachments returns both the
re-bindable creds and the path list in one round trip, the
handler transfers them onto the new host row inside CreateHost.
- The dashboard's Run-now and host-detail's "Run backup now"
button now read Host.DefaultPaths and pass them to dispatchJob.
A host with no default paths returns 400 with a friendly
"no paths set" message instead of dispatching a doomed
`restic backup` with no positional args.
- Doc comments explicitly call this out as a Phase 1 interim —
schedules supersede.
Restic env hygiene:
- envSlice() previously omitted HOME / XDG_CACHE_HOME, which
bit the smoke runs whenever the agent was launched outside
systemd (restic refused to start: "neither $XDG_CACHE_HOME
nor $HOME are defined"). Now both are set explicitly: prefer
Env.ExtraEnv overrides, fall back to the agent process's own
HOME, and finally to /var/lib/restic-manager.
- Comment makes the env policy explicit: parent's RESTIC_* /
AWS_* / B2_* env is filtered out by design — control-plane
is the unambiguous source of truth.
JS bug fix in the live log page:
- {{$job.ID | printf "%q"}} produced a literal-quoted JS string,
which then went into the WS URL as ".../jobs/"<ID>"/stream"
→ 404. Switched to '{{$job.ID}}' inside the literal so
html/template's auto-escape does the right thing. Verified
end-to-end: dashboard "Run now" → live progress + log lines
arrive over the WS → succeeded pill renders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.4 KiB
Go
119 lines
3.4 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
stdhttp "net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// newTestServerWithHub mirrors newTestServer but plugs in a real
|
|
// ws.Hub so /ws/agent is available.
|
|
func newTestServerWithHub(t *testing.T) (*Server, string, *store.Store) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
|
|
keyPath := filepath.Join(dir, "secret.key")
|
|
_ = crypto.GenerateKeyFile(keyPath)
|
|
key, _ := crypto.LoadKeyFromFile(keyPath)
|
|
aead, _ := crypto.NewAEAD(key)
|
|
|
|
deps := Deps{
|
|
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
|
|
Store: st,
|
|
AEAD: aead,
|
|
Hub: ws.NewHub(),
|
|
}
|
|
s := New(deps)
|
|
ts := httptest.NewServer(s.srv.Handler)
|
|
t.Cleanup(ts.Close)
|
|
return s, ts.URL, st
|
|
}
|
|
|
|
func TestEnrollmentBadToken(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, _ := newTestServerWithHub(t)
|
|
|
|
body, _ := json.Marshal(enrollRequest{
|
|
Token: "no-such-token", HostName: "host1",
|
|
OS: api.OSLinux, Arch: api.ArchAmd64,
|
|
AgentVersion: "0.1", ResticVersion: "0.17",
|
|
})
|
|
res, err := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusUnauthorized {
|
|
t.Errorf("status: %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentHappyPath(t *testing.T) {
|
|
t.Parallel()
|
|
_, url, st := newTestServerWithHub(t)
|
|
|
|
// Issue a token directly via the store (skipping the operator UI).
|
|
rawToken, _ := auth.NewToken()
|
|
if err := st.CreateEnrollmentToken(context.Background(),
|
|
auth.HashToken(rawToken), 5*time.Minute, "", ""); err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal(enrollRequest{
|
|
Token: rawToken, HostName: "test-host",
|
|
OS: api.OSLinux, Arch: api.ArchAmd64,
|
|
AgentVersion: "0.1", ResticVersion: "0.17",
|
|
})
|
|
res, err := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != stdhttp.StatusCreated {
|
|
buf, _ := io.ReadAll(res.Body)
|
|
t.Fatalf("status %d: %s", res.StatusCode, buf)
|
|
}
|
|
|
|
var er enrollResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&er); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if er.HostID == "" || er.AgentToken == "" {
|
|
t.Errorf("missing fields in response: %+v", er)
|
|
}
|
|
|
|
// Token must not be reusable.
|
|
res2, _ := stdhttp.Post(url+"/api/agents/enroll", "application/json", bytes.NewReader(body))
|
|
defer res2.Body.Close()
|
|
if res2.StatusCode != stdhttp.StatusUnauthorized {
|
|
t.Errorf("re-enrollment with same token should fail, got %d", res2.StatusCode)
|
|
}
|
|
|
|
// Host row exists with matching agent_token_hash.
|
|
got, err := st.LookupHostByAgentToken(context.Background(), auth.HashToken(er.AgentToken))
|
|
if err != nil {
|
|
t.Fatalf("lookup by token: %v", err)
|
|
}
|
|
if got.Name != "test-host" || got.OS != "linux" {
|
|
t.Errorf("host fields: %+v", got)
|
|
}
|
|
}
|