release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367). - Home shows "Version X of the app is on your server." / "…is downloaded and ready." with Update / Install; the button opens a bottom sheet holding the same About card Settings shows, so there is one install path. Each build is told at most once a day, kept on disk. - The shell-wide update banner and its in-memory dismissals are gone. - AppUpdates (singleton) holds the offer and the install, so Home and Settings show the same state. Checks run when either screen opens, on every return to the front, every 5 minutes while shown, and on Home's pull to refresh. - UpdateFetchWorker downloads the newer APK ahead on an unmetered network (battery and storage not low, three tries); the download writes a .part file and keeps it under the build's name only once its size and sha256 match, so Install is usually one tap. - Back from allowing installs, the update the listener asked for carries on (kept in SavedStateHandle) instead of asking them to tap again. - An offer needs a higher code AND a different name: the same app built again at a later minute is not offered. - Server: /api/client/version reports the APK's sha256, worked out from the served file and cached by size and mtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
10 KiB
Go
295 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// authedRequest builds a request with a fake user in context, mirroring
|
|
// what the auth.RequireUser middleware injects in production.
|
|
// userIDByte fills every byte of the user's UUID, so two requests with
|
|
// different userIDByte values get distinct rate-limit slots.
|
|
func authedRequest(method, path string, userIDByte byte) *http.Request {
|
|
user := dbq.User{
|
|
ID: pgtype.UUID{
|
|
Bytes: [16]byte{userIDByte, userIDByte, userIDByte, userIDByte,
|
|
userIDByte, userIDByte, userIDByte, userIDByte,
|
|
userIDByte, userIDByte, userIDByte, userIDByte,
|
|
userIDByte, userIDByte, userIDByte, userIDByte},
|
|
Valid: true,
|
|
},
|
|
Username: "tester",
|
|
}
|
|
r := httptest.NewRequest(method, path, nil)
|
|
ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), user)
|
|
return r.WithContext(ctx)
|
|
}
|
|
|
|
// withClientAPKDir points the handlers at a fresh temp dir for each
|
|
// test and restores the env var on cleanup. Returns the dir.
|
|
func withClientAPKDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
prev, hadPrev := os.LookupEnv("MINSTREL_CLIENT_APK_DIR")
|
|
t.Setenv("MINSTREL_CLIENT_APK_DIR", dir)
|
|
t.Cleanup(func() {
|
|
if hadPrev {
|
|
t.Setenv("MINSTREL_CLIENT_APK_DIR", prev)
|
|
}
|
|
// t.Setenv auto-restores the prior empty/unset state at end of
|
|
// test, so the !hadPrev branch needs no explicit Unsetenv.
|
|
})
|
|
return dir
|
|
}
|
|
|
|
func TestClientVersion_404WhenNoAPK(t *testing.T) {
|
|
withClientAPKDir(t)
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientVersion_404WhenAPKButNoVersion(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), []byte("fake apk"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// writeClientAssets stages an APK plus a raw sidecar body, and returns the
|
|
// APK's size so callers can assert size_bytes without recomputing it.
|
|
func writeClientAssets(t *testing.T, sidecar string) int64 {
|
|
t.Helper()
|
|
dir := withClientAPKDir(t)
|
|
body := []byte("fake apk content")
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, clientVersionFile), []byte(sidecar), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return int64(len(body))
|
|
}
|
|
|
|
func getClientVersion(t *testing.T) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientVersion(rr, httptest.NewRequest(http.MethodGet, "/api/client/version", nil))
|
|
return rr
|
|
}
|
|
|
|
func TestClientVersion_200WithBothFiles(t *testing.T) {
|
|
size := writeClientAssets(t, `{"name":"2026.09.10.1432","code":3523847,"channel":"stable"}`+"\n")
|
|
rr := getClientVersion(t)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp clientVersionResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Version != "2026.09.10.1432" {
|
|
t.Errorf("version: want 2026.09.10.1432, got %q", resp.Version)
|
|
}
|
|
if resp.Code == nil {
|
|
t.Fatal("code: want 3523847, got absent — the client decides on this, so absent means it silently falls back to name comparison")
|
|
}
|
|
if *resp.Code != 3523847 {
|
|
t.Errorf("code: want 3523847, got %d", *resp.Code)
|
|
}
|
|
if resp.Channel != "stable" {
|
|
t.Errorf("channel: want stable, got %q", resp.Channel)
|
|
}
|
|
if resp.APKURL != "/api/client/apk" {
|
|
t.Errorf("apk_url: want /api/client/apk, got %q", resp.APKURL)
|
|
}
|
|
if resp.SizeBytes != size {
|
|
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
|
}
|
|
want := sha256.Sum256([]byte("fake apk content"))
|
|
if resp.SHA256 != hex.EncodeToString(want[:]) {
|
|
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
|
|
}
|
|
}
|
|
|
|
// The digest is cached by size and modification time; a new APK dropped in
|
|
// place must be answered with its own digest, not the old one's.
|
|
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
|
|
writeClientAssets(t, `{"name":"a","code":1}`)
|
|
first := decodeClientVersion(t, getClientVersion(t))
|
|
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
|
|
apk := filepath.Join(dir, clientAPKFilename)
|
|
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := decodeClientVersion(t, getClientVersion(t))
|
|
want := sha256.Sum256([]byte("another, longer build"))
|
|
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
|
|
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
|
|
}
|
|
}
|
|
|
|
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
|
|
t.Helper()
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp clientVersionResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// A release published before ordering keys were recorded has a name and
|
|
// genuinely no code. That must arrive as ABSENT, not as 0 — zero would claim
|
|
// the build is infinitely old and offer an update to everyone forever.
|
|
func TestClientVersion_CodeAbsentIsOmittedNotZero(t *testing.T) {
|
|
writeClientAssets(t, `{"name":"2026.09.09","code":null,"channel":"stable"}`)
|
|
rr := getClientVersion(t)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp clientVersionResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Code != nil {
|
|
t.Errorf("code: want absent, got %d", *resp.Code)
|
|
}
|
|
// The wire must omit the key entirely, so a client can distinguish
|
|
// "this server reports no code" from "this build's code is 0".
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, present := raw["code"]; present {
|
|
t.Errorf("code key should be omitted entirely, body was %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The failure this guards is the one nobody reports: if an unreadable sidecar
|
|
// produced an empty version, every client would compare against nothing,
|
|
// decide it was current, and go quiet. "I cannot read this" and "there is
|
|
// nothing newer" must not be the same answer.
|
|
func TestClientVersion_MalformedSidecarErrorsRatherThanReportingNothing(t *testing.T) {
|
|
for _, sidecar := range []string{
|
|
"2026.09.10.1432", // the OLD plain-text format
|
|
`{"name":"x",`, // truncated JSON
|
|
`{"code":123,"channel":"dev"}`, // valid JSON, no name
|
|
"",
|
|
} {
|
|
writeClientAssets(t, sidecar)
|
|
rr := getClientVersion(t)
|
|
if rr.Code == http.StatusOK {
|
|
t.Errorf("sidecar %q: want an error status, got 200 with body %s", sidecar, rr.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_401WhenUnauthenticated(t *testing.T) {
|
|
withClientAPKDir(t)
|
|
testResetClientAPKRateLimit()
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
// No user in context — middleware would block in prod; handler also
|
|
// rejects defensively in case the route ever lands outside the
|
|
// authed group by accident.
|
|
h.handleClientAPK(rr, httptest.NewRequest(http.MethodGet, "/api/client/apk", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("want 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_404WhenMissing(t *testing.T) {
|
|
withClientAPKDir(t)
|
|
testResetClientAPKRateLimit()
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientAPK(rr, authedRequest(http.MethodGet, "/api/client/apk", 0x01))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("want 404, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_StreamsWithCorrectContentType(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
testResetClientAPKRateLimit()
|
|
body := []byte("PK\x03\x04 fake apk bytes")
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
rr := httptest.NewRecorder()
|
|
h.handleClientAPK(rr, authedRequest(http.MethodGet, "/api/client/apk", 0x02))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d", rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Content-Type"); got != "application/vnd.android.package-archive" {
|
|
t.Errorf("Content-Type: want application/vnd.android.package-archive, got %q", got)
|
|
}
|
|
if rr.Body.Len() != len(body) {
|
|
t.Errorf("body length: want %d, got %d", len(body), rr.Body.Len())
|
|
}
|
|
if got := rr.Body.Bytes(); string(got) != string(body) {
|
|
t.Errorf("body bytes mismatch")
|
|
}
|
|
}
|
|
|
|
func TestClientAPK_RateLimit_429OnRapidSecondCall(t *testing.T) {
|
|
dir := withClientAPKDir(t)
|
|
testResetClientAPKRateLimit()
|
|
if err := os.WriteFile(filepath.Join(dir, clientAPKFilename), []byte("apk"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
|
|
|
// First call from user 0x03 — succeeds.
|
|
rr1 := httptest.NewRecorder()
|
|
h.handleClientAPK(rr1, authedRequest(http.MethodGet, "/api/client/apk", 0x03))
|
|
if rr1.Code != http.StatusOK {
|
|
t.Fatalf("first call: want 200, got %d", rr1.Code)
|
|
}
|
|
|
|
// Immediate second call from same user — rate-limited.
|
|
rr2 := httptest.NewRecorder()
|
|
h.handleClientAPK(rr2, authedRequest(http.MethodGet, "/api/client/apk", 0x03))
|
|
if rr2.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("second call: want 429, got %d", rr2.Code)
|
|
}
|
|
if got := rr2.Header().Get("Retry-After"); got == "" {
|
|
t.Errorf("expected Retry-After header on 429 response")
|
|
}
|
|
|
|
// Different user — not rate-limited.
|
|
rr3 := httptest.NewRecorder()
|
|
h.handleClientAPK(rr3, authedRequest(http.MethodGet, "/api/client/apk", 0x04))
|
|
if rr3.Code != http.StatusOK {
|
|
t.Errorf("different user: want 200, got %d", rr3.Code)
|
|
}
|
|
}
|