Files
mg4/server/releases_test.go
nilsandClaude Opus 5.5 e5549dddb0 App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 18:08:08 +02:00

116 lines
4.2 KiB
Go

package main
import (
"encoding/json"
"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")
}
}