Files
nixos-router/pkgs/router-webui/update_test.go
T
nilsandClaude Sonnet 5.5 b9c63273e4 Add signed updates from a bundle or a URL
router-release signs a release as a binary cache; router-update verifies
it against the router's update keys, migrates settings.json, and applies
it with commit-confirm. Includes the web UI page and a VM test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-29 13:34:21 +01:00

247 lines
8.3 KiB
Go

package main
import (
"archive/tar"
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
type entry struct {
name string
body string
typ byte
link string
}
func makeBundle(t *testing.T, entries ...entry) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, e := range entries {
typ := e.typ
if typ == 0 {
typ = tar.TypeReg
}
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.body)), Linkname: e.link}
if typ != tar.TypeReg {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if typ == tar.TypeReg {
tw.Write([]byte(e.body))
}
}
tw.Close()
return buf.Bytes()
}
const hash32 = "00fs4zz9c0jjcvs62wg39b91dydz7zqb"
func goodBundle(t *testing.T) []byte {
return makeBundle(t,
entry{name: "latest", body: `{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":0}`},
entry{name: "cache/", typ: tar.TypeDir},
entry{name: "cache/log/", typ: tar.TypeDir},
entry{name: "cache/nix-cache-info", body: "StoreDir: /nix/store\n"},
entry{name: "cache/" + hash32 + ".narinfo", body: "StorePath: ..."},
entry{name: "cache/nar/" + strings.Repeat("a1", 26) + ".nar.zst", body: "nar"},
)
}
func TestUnbundle(t *testing.T) {
dir := filepath.Join(t.TempDir(), "staged")
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "old"), []byte("x"), 0o600)
if err := unbundle(bytes.NewReader(goodBundle(t)), dir); err != nil {
t.Fatal(err)
}
for _, f := range []string{"latest", "cache/nix-cache-info", "cache/" + hash32 + ".narinfo"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Fatalf("%s: %v", f, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "old")); err == nil {
t.Fatal("the previous staging was kept")
}
}
func TestUnbundleRefusesHostileBundles(t *testing.T) {
good := []entry{
{name: "latest", body: "{}"},
{name: "cache/nix-cache-info", body: "x"},
}
for name, extra := range map[string]entry{
"traversal": {name: "../../etc/passwd", body: "x"},
"absolute": {name: "/etc/passwd", body: "x"},
"nested": {name: "cache/nar/../../../x", body: "x"},
"other file": {name: "cache/evil.sh", body: "x"},
"symlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeSymlink, link: "/etc/passwd"},
"hardlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeLink, link: "/etc/passwd"},
"directory": {name: "etc/", typ: tar.TypeDir},
"duplicate": {name: "latest", body: "{}"},
"huge latest": {name: "latest", body: strings.Repeat("x", maxLatest+1)},
} {
dir := filepath.Join(t.TempDir(), "staged")
entries := append([]entry{}, good...)
if name == "huge latest" {
entries = good[1:] // so the size, not a duplicate, is what is refused
}
err := unbundle(bytes.NewReader(makeBundle(t, append(entries, extra)...)), dir)
if err == nil {
t.Errorf("%s: accepted", name)
}
if _, statErr := os.Stat(dir); statErr == nil {
t.Errorf("%s: left %s behind", name, dir)
}
if _, statErr := os.Stat(dir + ".tmp"); statErr == nil {
t.Errorf("%s: left a temporary directory", name)
}
}
// Incomplete or not a tar at all.
if err := unbundle(bytes.NewReader(makeBundle(t, entry{name: "latest", body: "{}"})), filepath.Join(t.TempDir(), "s")); err == nil {
t.Error("a bundle without a cache was accepted")
}
if err := unbundle(strings.NewReader("this is not a tar file"), filepath.Join(t.TempDir(), "s")); err == nil {
t.Error("garbage was accepted")
}
}
func TestParseUpdateURL(t *testing.T) {
for in, want := range map[string]string{
"https://updates.example.com/router/": "https://updates.example.com/router",
" http://192.168.1.5:8000 ": "http://192.168.1.5:8000",
} {
if got, err := parseUpdateURL(in); err != nil || got != want {
t.Errorf("%q: %q %v", in, got, err)
}
}
for _, in := range []string{"", "ftp://x/y", "file:///etc", "https://user:pw@host/x", "https://host/x?y=1", "updates.example.com", "https://"} {
if _, err := parseUpdateURL(in); err == nil {
t.Errorf("%q accepted", in)
}
}
}
func TestCheckUpdate(t *testing.T) {
a, sys := newTestApp(t)
_ = a
sys.ReleaseFile = filepath.Join(t.TempDir(), "router-release")
os.WriteFile(sys.ReleaseFile, []byte(`{"version":"0.2.0","serial":1000,"schema":0}`), 0o600)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/new/latest":
w.Write([]byte(`{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":1}`))
case "/same/latest":
w.Write([]byte(`{"version":"0.2.0","serial":1000}`))
case "/junk/latest":
w.Write([]byte(`hello`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
if o, err := sys.checkUpdate(srv.URL + "/new"); err != nil || !o.Newer || o.Version != "0.3.0" {
t.Fatalf("new: %+v %v", o, err)
}
if o, err := sys.checkUpdate(srv.URL + "/same"); err != nil || o.Newer {
t.Fatalf("same: %+v %v", o, err)
}
for _, p := range []string{"/junk", "/missing"} {
if _, err := sys.checkUpdate(srv.URL + p); err == nil {
t.Fatalf("%s: no error", p)
}
}
}
func upload(t *testing.T, h http.Handler, token string, cookie *http.Cookie, bundle []byte, force bool) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if force {
mw.WriteField("force", "on")
}
fw, _ := mw.CreateFormFile("bundle", "router-update.bundle")
fw.Write(bundle)
mw.Close()
req := httptest.NewRequest("POST", "/update/upload?csrf="+url.QueryEscape(token), &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestUpdateUploadAndInstall(t *testing.T) {
a, sys := newTestApp(t)
var started []string
startUnit = func(unit string) error { started = append(started, unit); return nil }
orig := startUnit
defer func() { startUnit = orig }()
h := a.routes()
cookie := login(t, h)
token := csrfFrom(t, do(h, "GET", "/update", nil, cookie).Body.String())
if rec := upload(t, h, "wrong", cookie, goodBundle(t), false); rec.Code != http.StatusForbidden {
t.Fatalf("upload with a wrong token: %d", rec.Code)
}
if rec := upload(t, h, token, nil, goodBundle(t), false); rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/login") {
t.Fatalf("upload without a session: %d %s", rec.Code, rec.Header().Get("Location"))
}
if len(started) != 0 {
t.Fatal("the update unit was started by a rejected upload")
}
rec := upload(t, h, token, cookie, makeBundle(t, entry{name: "evil", body: "x"}), false)
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Rejected") || len(started) != 0 {
t.Fatalf("hostile bundle: %q, started %v", loc, started)
}
rec = upload(t, h, token, cookie, goodBundle(t), true)
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Bundle+received") {
t.Fatalf("upload: %d %q", rec.Code, loc)
}
if len(started) != 1 || started[0] != "router-update.service" {
t.Fatalf("started %v", started)
}
var req struct {
Mode string `json:"mode"`
Force bool `json:"force"`
}
data, _ := os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
if err := json.Unmarshal(data, &req); err != nil || req.Mode != "staged" || !req.Force {
t.Fatalf("request: %s", data)
}
if _, err := os.Stat(filepath.Join(sys.updateDir(), "staged", "latest")); err != nil {
t.Fatalf("not staged: %v", err)
}
// Pulling: the saved server, through install.
if rec := do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"ftp://nope"}}, cookie); !strings.Contains(rec.Header().Get("Location"), "Not+saved") {
t.Fatalf("bad url: %q", rec.Header().Get("Location"))
}
do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"http://192.168.10.60:8000/"}}, cookie)
if sys.updateURL() != "http://192.168.10.60:8000" {
t.Fatalf("url: %q", sys.updateURL())
}
do(h, "POST", "/update/install", url.Values{"csrf": {token}}, cookie)
data, _ = os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
if !strings.Contains(string(data), `"mode":"url"`) || !strings.Contains(string(data), "192.168.10.60:8000") {
t.Fatalf("request: %s", data)
}
if page := do(h, "GET", "/update", nil, cookie).Body.String(); !strings.Contains(page, "192.168.10.60:8000") {
t.Fatalf("the page lacks the saved server:\n%s", page)
}
}