Files
restic-manager/internal/server/http/agent_assets_test.go
steve fb978ad10c p5-03: docker-only release path (drop goreleaser)
Single public deliverable per tag: a multi-arch server image, with
cross-compiled agent binaries + install scripts + the systemd unit
baked under /opt/restic-manager/dist/. The /agent/binary and
/install/* handlers fall back from <DataDir>/... to that read-only
path so a fresh container Just Works without first-run staging;
operators can still drop a custom build into <DataDir>/ to override
per-host.

Architecture rationale: agent distribution already routes through
the running server, so the release surface mirrors that — there's
no second source of truth to keep in sync.

Workflow .gitea/workflows/release.yml triggers on v*.*.* tag-push
(fan-out :vX.Y.Z / :X.Y / :X, plus :latest once MAJOR>=1) and
workflow_dispatch (snapshot tag only). Pushes to the Gitea
container registry on this instance.

Both binaries grow main.commit + main.date ldflag targets. Makefile
and Dockerfile fill them; release workflow forwards from gitea.sha
plus a UTC timestamp.

Spec : docs/superpowers/specs/2026-05-05-p5-03-docker-only-release.md
Plan : docs/superpowers/plans/2026-05-05-p5-03-docker-only-release.md
2026-05-05 15:18:48 +01:00

168 lines
4.9 KiB
Go

package http
import (
"context"
"io"
stdhttp "net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"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"
)
// newAssetsTestServer is a minimal scaffold for the /agent/binary and
// /install/* handlers. Two roots: one acts as DataDir, the other as
// the image-baked BundledAssetsDir. Either or both may be empty.
func newAssetsTestServer(t *testing.T, populate func(dataDir, bundleDir string)) string {
t.Helper()
root := t.TempDir()
dataDir := filepath.Join(root, "data")
bundleDir := filepath.Join(root, "dist")
for _, d := range []string{
filepath.Join(dataDir, "agent-binaries"),
filepath.Join(dataDir, "install"),
filepath.Join(bundleDir, "agent-binaries"),
filepath.Join(bundleDir, "install"),
} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
if populate != nil {
populate(dataDir, bundleDir)
}
st, err := store.Open(context.Background(), filepath.Join(root, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(root, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
deps := Deps{
Cfg: config.Config{
Listen: ":0",
DataDir: dataDir,
SecretKeyFile: keyPath,
BundledAssetsDir: bundleDir,
},
Store: st,
AEAD: aead,
Hub: ws.NewHub(),
BootstrapToken: "test-token",
}
s := New(deps)
ts := httptest.NewServer(s.srv.Handler)
t.Cleanup(ts.Close)
return ts.URL
}
func writeFile(t *testing.T, path string, body []byte) {
t.Helper()
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func get(t *testing.T, url string) (int, []byte) {
t.Helper()
res, err := stdhttp.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return res.StatusCode, body
}
func TestAgentBinary_DataDirHit(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(dataDir, _ string) {
writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-datadir"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-datadir" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestAgentBinary_BundleFallback(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-bundle"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-bundle" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestAgentBinary_DataDirShadowsBundle(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(dataDir, bundleDir string) {
writeFile(t, filepath.Join(dataDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-datadir"))
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-linux-amd64"),
[]byte("from-bundle"))
})
code, body := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 200 || string(body) != "from-datadir" {
t.Fatalf("operator override should win: got %d %q", code, string(body))
}
}
func TestAgentBinary_BothMiss(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, nil)
code, _ := get(t, url+"/agent/binary?os=linux&arch=amd64")
if code != 404 {
t.Fatalf("expected 404, got %d", code)
}
}
func TestAgentBinary_WindowsNameHasExe(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "agent-binaries", "restic-manager-agent-windows-amd64.exe"),
[]byte("win"))
})
code, body := get(t, url+"/agent/binary?os=windows&arch=amd64")
if code != 200 || string(body) != "win" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestInstallAsset_BundleFallback(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, func(_, bundleDir string) {
writeFile(t, filepath.Join(bundleDir, "install", "install.sh"), []byte("#!/bin/sh\n"))
})
code, body := get(t, url+"/install/install.sh")
if code != 200 || string(body) != "#!/bin/sh\n" {
t.Fatalf("got %d %q", code, string(body))
}
}
func TestInstallAsset_PathTraversalRejected(t *testing.T) {
t.Parallel()
url := newAssetsTestServer(t, nil)
// chi will normalise some traversal attempts, but the handler
// also rejects any rel containing a slash or backslash. The
// path component of the URL after /install/ is the rel.
code, _ := get(t, url+"/install/..%2fpasswd")
if code == 200 {
t.Fatalf("traversal should not return 200")
}
}