The update relay logs every check (car, number of releases, newest pre-release) and every download (car, file, size, then bytes and time, or that it was cut short); never the forge's address or token, tested. Go 1.27.3 for the build image and toolchain: govulncheck had found GO-2026-6809 in crypto/x509 under 1.26.9; source and binary now clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
151 lines
5.5 KiB
Go
151 lines
5.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const testForgeToken = "forge-read-token-0123456789"
|
|
|
|
// fakeForge answers like Gitea for one pre-release with an APK and its sidecar, and only to
|
|
// the right token.
|
|
func fakeForge(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
auth := func(h http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Authorization") != "token "+testForgeToken {
|
|
http.NotFound(w, r) // a private repository looks absent without a login
|
|
return
|
|
}
|
|
h(w, r)
|
|
}
|
|
}
|
|
mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases", auth(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`[{"id":7,"tag_name":"car-2.16.0","prerelease":true,"draft":false,"assets":[
|
|
{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","size":4},
|
|
{"id":12,"name":"NilsMG4-unstable-2.16.0.apk.sha256","size":64}]}]`))
|
|
}))
|
|
mux.HandleFunc("GET /api/v1/repos/nils/mg4/releases/7/assets/11", auth(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"id":11,"name":"NilsMG4-unstable-2.16.0.apk","uuid":"abc-uuid"}`))
|
|
}))
|
|
mux.HandleFunc("GET /attachments/abc-uuid", auth(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte("APK!"))
|
|
}))
|
|
return httptest.NewTLSServer(mux)
|
|
}
|
|
|
|
func releasesAPI(t *testing.T, forge *httptest.Server) http.Handler {
|
|
t.Helper()
|
|
api, _ := newTestAPI()
|
|
api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: testForgeToken, Client: forge.Client()}
|
|
return api.Routes()
|
|
}
|
|
|
|
func TestReleasesAreRelayedWithDownloadsThroughTheServer(t *testing.T) {
|
|
forge := fakeForge(t)
|
|
defer forge.Close()
|
|
h := releasesAPI(t, forge)
|
|
if rec := do(t, h, "GET", "/v1/app/releases", "", ""); rec.Code != 401 {
|
|
t.Fatalf("without the car's token: %d", rec.Code)
|
|
}
|
|
rec := do(t, h, "GET", "/v1/app/releases", testToken, "")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("%d %s", rec.Code, rec.Body)
|
|
}
|
|
var got []carRelease
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 1 || !got[0].Prerelease || len(got[0].Assets) != 2 ||
|
|
got[0].Assets[0].BrowserDownloadURL != "https://car.example.com/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk" {
|
|
t.Fatalf("releases %+v", got)
|
|
}
|
|
if strings.Contains(rec.Body.String(), testForgeToken) || strings.Contains(rec.Body.String(), forge.URL) {
|
|
t.Fatal("the forge or its token leaked to the car")
|
|
}
|
|
file := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, "")
|
|
if file.Code != 200 || file.Body.String() != "APK!" {
|
|
t.Fatalf("download %d %q", file.Code, file.Body)
|
|
}
|
|
// The name must be the asset's own: no asking for one file under another's name.
|
|
if rec := do(t, h, "GET", "/v1/app/assets/7/11/other.apk", testToken, ""); rec.Code != 404 {
|
|
t.Fatalf("wrong name: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAWrongForgeTokenIsABadGatewayNotALeak(t *testing.T) {
|
|
forge := fakeForge(t)
|
|
defer forge.Close()
|
|
api, _ := newTestAPI()
|
|
api.Releases = &Releases{ForgeURL: forge.URL, Repo: "nils/mg4", Token: "wrong", Client: forge.Client()}
|
|
if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 502 {
|
|
t.Fatalf("%d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAppUpdatesAreOffWithoutAForge(t *testing.T) {
|
|
api, _ := newTestAPI()
|
|
if rec := do(t, api.Routes(), "GET", "/v1/app/releases", testToken, ""); rec.Code != 404 {
|
|
t.Fatalf("%d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestRedirectsAwayFromTheForgeAreRefused(t *testing.T) {
|
|
rel := &Releases{ForgeURL: "https://git.example.com"}
|
|
away, _ := http.NewRequest("GET", "https://elsewhere.example.com/x", nil)
|
|
if rel.sameHostOnly(away, nil) == nil {
|
|
t.Fatal("redirect to another host followed")
|
|
}
|
|
same, _ := http.NewRequest("GET", "https://git.example.com/attachments/x", nil)
|
|
if err := rel.sameHostOnly(same, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestForgeURLMustBeHTTPS(t *testing.T) {
|
|
env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
|
|
"INGEST_TOKEN": "0123456789abcdef01234567", "FORGE_URL": "http://git.example.com"}
|
|
if _, err := loadConfig(func(k string) string { return env[k] }); err == nil {
|
|
t.Fatal("plain http forge accepted")
|
|
}
|
|
}
|
|
|
|
// The server's log says who checked for and fetched an update, and how it went — the car's
|
|
// installation (here the shared token), the newest pre-release, the file and the bytes — and
|
|
// never the forge's token or address.
|
|
func TestUpdatesAreLogged(t *testing.T) {
|
|
var logged bytes.Buffer
|
|
previous := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
|
|
defer slog.SetDefault(previous)
|
|
|
|
forge := fakeForge(t)
|
|
defer forge.Close()
|
|
h := releasesAPI(t, forge)
|
|
if rec := do(t, h, "GET", "/v1/app/releases", testToken, ""); rec.Code != 200 {
|
|
t.Fatalf("%d", rec.Code)
|
|
}
|
|
if rec := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, ""); rec.Code != 200 {
|
|
t.Fatalf("%d", rec.Code)
|
|
}
|
|
out := logged.String()
|
|
for _, want := range []string{
|
|
`msg="update check" installation="shared token" releases=1 newest=car-2.16.0`,
|
|
`msg="update download started" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk`,
|
|
`msg="update handed out" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk sent=4`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("log lacks %q:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, testForgeToken) || strings.Contains(out, forge.URL) {
|
|
t.Fatal("the forge or its token leaked into the log")
|
|
}
|
|
}
|