Files
goclaw/internal/http/packages_updates_test.go
Duy /zuey/andGitHub 425cecb9a3 feat(packages): Phase 2b — apk update flow + pkg-helper v2 protocol (#900) (#7)
* feat(packages): add apk update flow + pkg-helper v2 protocol

- APK update checker/executor via helper IPC (runtime detection, upgrade scan via apk list --upgradable)
- BREAKING: pkg-helper v2 protocol (5 actions: check_apk/check_pip/check_npm/exec_apk/exec_pip, code/data fields, renewable 10min deadline, apkMutex, 1MB scanner)
- Edition gating: SupportsApk + IsAlpineRuntime double-gate (Standard/Full only)
- Backend 3-branch wiring: alpine/apt/yum routes + update_registry, dep_installer helpers
- i18n: 5 apk keys (EN/VI/ZH catalogs)
- Frontend: source pill Alpine badge, APK in updates-list/summary-bar/update-all modal
- E2E tests: apk_e2e build tag covering checker/executor/helper protocol
- Docs: packages-apk.md, security/changelog updates
- Plans + reports under plans/260417-1500-packages-update-phase2b-apk-pkghelper/ + plans/reports/

* docs(packages): journal Phase 2b apk + pkg-helper v2
2026-05-11 15:41:27 +07:00

561 lines
18 KiB
Go

package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ---- test doubles ----
// mockEventPublisher records broadcast calls for assertion.
type mockEventPublisher struct {
mu sync.Mutex
events []bus.Event
}
func (m *mockEventPublisher) Subscribe(_ string, _ bus.EventHandler) {}
func (m *mockEventPublisher) Unsubscribe(_ string) {}
func (m *mockEventPublisher) Broadcast(e bus.Event) {
m.mu.Lock()
defer m.mu.Unlock()
m.events = append(m.events, e)
}
func (m *mockEventPublisher) capturedEvents() []bus.Event {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]bus.Event, len(m.events))
copy(out, m.events)
return out
}
// nopExecutor is a no-op UpdateExecutor that always succeeds.
type nopExecutor struct{ source string }
func (e *nopExecutor) Source() string { return e.source }
func (e *nopExecutor) Update(_ context.Context, name, _ string, _ map[string]any) error {
return nil
}
// partialExecutor fails for the named package, succeeds for all others.
type partialExecutor struct {
source string
failName string
}
func (e *partialExecutor) Source() string { return e.source }
func (e *partialExecutor) Update(_ context.Context, name, _ string, _ map[string]any) error {
if name == e.failName {
return errors.New("injected failure for " + name)
}
return nil
}
// ---- context builders matching existing test patterns ----
// ownerCtx builds a master-scope request context (uuid.Nil = no tenant restriction).
// Each call should pass a unique userID to avoid hitting the package-level rate limiter
// shared across tests (burst=3, rpm=10 on packagesWriteLimiter).
func ownerCtx(base context.Context, userID string) context.Context {
ctx := store.WithUserID(base, userID)
ctx = store.WithTenantID(ctx, uuid.Nil)
ctx = store.WithRole(ctx, store.RoleOwner)
return ctx
}
// tenantAdminCtx builds a non-master tenant-admin context (rejected by requireMasterScope).
func tenantAdminCtx(base context.Context, userID string) context.Context {
tid := uuid.MustParse("aaaabbbb-cccc-dddd-eeee-ffffaaaabbbb")
ctx := store.WithUserID(base, userID)
ctx = store.WithTenantID(ctx, tid)
ctx = store.WithRole(ctx, "admin")
return ctx
}
// ---- registry builder ----
func buildTestRegistry(updates []skills.UpdateInfo) *skills.UpdateRegistry {
cache := &skills.UpdateCache{}
if len(updates) > 0 {
checkedAt := updates[0].CheckedAt
if checkedAt.IsZero() {
checkedAt = time.Now().UTC()
}
cache.ReplaceUpdates(updates, checkedAt)
}
return skills.NewUpdateRegistry(cache, "", time.Hour)
}
// ---- GET /v1/packages/updates ----
func TestHandleListUpdates_EmptyCache(t *testing.T) {
pub := &mockEventPublisher{}
registry := buildTestRegistry(nil)
h := NewPackagesHandler(registry, pub)
req := httptest.NewRequest(http.MethodGet, "/v1/packages/updates", nil)
req = req.WithContext(store.WithRole(store.WithTenantID(store.WithUserID(req.Context(), "u1"), uuid.Nil), "operator"))
w := httptest.NewRecorder()
h.handleListUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, field := range []string{"updates", "stale", "sources", "checkedAt", "ageSeconds", "ttlSeconds"} {
if _, ok := body[field]; !ok {
t.Errorf("response missing field %q", field)
}
}
}
func TestHandleListUpdates_ReturnsUpdates(t *testing.T) {
updates := []skills.UpdateInfo{
{Source: "github", Name: "lazygit", CurrentVersion: "v0.40.0", LatestVersion: "v0.41.0"},
}
registry := buildTestRegistry(updates)
h := NewPackagesHandler(registry, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/packages/updates", nil)
req = req.WithContext(store.WithRole(store.WithTenantID(store.WithUserID(req.Context(), "u1"), uuid.Nil), "operator"))
w := httptest.NewRecorder()
h.handleListUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
var body map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &body)
arr, _ := body["updates"].([]any)
if len(arr) != 1 {
t.Errorf("want 1 update, got %d", len(arr))
}
}
func TestHandleListUpdates_NilRegistry(t *testing.T) {
h := NewPackagesHandler(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/packages/updates", nil)
w := httptest.NewRecorder()
h.handleListUpdates(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d", w.Code)
}
}
// ---- POST /v1/packages/updates/refresh ----
func TestHandleRefreshUpdates_RejectNonMaster(t *testing.T) {
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/refresh", nil)
req = req.WithContext(tenantAdminCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleRefreshUpdates(w, req)
// red-team H5: non-master admin must get 403.
if w.Code != http.StatusForbidden {
t.Fatalf("want 403 for non-master admin, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleRefreshUpdates_MasterPublishesCheckedEvent(t *testing.T) {
// No checkers registered → CheckAll returns empty; still publishes event.
pub := &mockEventPublisher{}
h := NewPackagesHandler(buildTestRegistry(nil), pub)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/refresh", nil)
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleRefreshUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
evts := pub.capturedEvents()
if len(evts) == 0 {
t.Fatal("expected package.update.checked event")
}
if evts[0].Name != eventPackageUpdateChecked {
t.Errorf("event name = %q, want %q", evts[0].Name, eventPackageUpdateChecked)
}
// TenantID must be Nil — only Owner clients receive unscoped events.
if evts[0].TenantID != uuid.Nil {
t.Errorf("event TenantID must be uuid.Nil, got %v", evts[0].TenantID)
}
}
// ---- POST /v1/packages/update ----
func TestHandleUpdatePackage_RejectNonMaster(t *testing.T) {
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/update",
bytes.NewBufferString(`{"package":"github:lazygit"}`))
req = req.WithContext(tenantAdminCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleUpdatePackage(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdatePackage_InvalidBody(t *testing.T) {
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/update",
bytes.NewBufferString(`{invalid`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleUpdatePackage(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400 for invalid JSON, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdatePackage_UnknownPrefix(t *testing.T) {
// Truly unknown prefixes (not github/pip/npm) must return 400.
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/update",
bytes.NewBufferString(`{"package":"garbage:pandas"}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleUpdatePackage(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400 for unknown prefix, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdatePackage_CacheStaleNoVersion(t *testing.T) {
// Empty cache + no toVersion → 409.
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/update",
bytes.NewBufferString(`{"package":"github:lazygit"}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleUpdatePackage(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("want 409 for empty cache+no version, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleUpdatePackage_HappyPath(t *testing.T) {
updates := []skills.UpdateInfo{{
Source: "github", Name: "lazygit",
CurrentVersion: "v0.40.0", LatestVersion: "v0.41.0",
Meta: map[string]any{},
}}
registry := buildTestRegistry(updates)
registry.RegisterExecutor(&nopExecutor{source: "github"})
pub := &mockEventPublisher{}
h := NewPackagesHandler(registry, pub)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/update",
bytes.NewBufferString(`{"package":"github:lazygit","toVersion":"v0.41.0"}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleUpdatePackage(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["ok"] != true {
t.Errorf("want ok=true, got %v", resp["ok"])
}
names := collectEventNames(pub.capturedEvents())
if !sliceContains(names, eventPackageUpdateStarted) {
t.Error("missing package.update.started event")
}
if !sliceContains(names, eventPackageUpdateSucceeded) {
t.Error("missing package.update.succeeded event")
}
}
// ---- POST /v1/packages/updates/apply-all ----
func TestHandleApplyAllUpdates_RejectNonMaster(t *testing.T) {
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/apply-all",
bytes.NewBufferString(`{}`))
req = req.WithContext(tenantAdminCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleApplyAllUpdates(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleApplyAllUpdates_EmptyCacheAlways200(t *testing.T) {
// No cache entries → 200 with non-null empty arrays (red-team M2, M7).
h := NewPackagesHandler(buildTestRegistry(nil), nil)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/apply-all",
bytes.NewBufferString(`{}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleApplyAllUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200 always (red-team M2), got %d: %s", w.Code, w.Body.String())
}
var body map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &body)
// Both arrays must be [] not null (red-team M7 — frontend null-check safety).
succeeded, ok := body["succeeded"].([]any)
if !ok {
t.Error("succeeded must be [] not null")
}
failed, ok := body["failed"].([]any)
if !ok {
t.Error("failed must be [] not null")
}
if len(succeeded)+len(failed) != 0 {
t.Errorf("want 0 items, got succeeded=%d failed=%d", len(succeeded), len(failed))
}
if _, hasDur := body["durationMs"]; !hasDur {
t.Error("response missing durationMs")
}
}
func TestHandleApplyAllUpdates_MixedSuccessFailure(t *testing.T) {
updates := []skills.UpdateInfo{
{Source: "github", Name: "lazygit", CurrentVersion: "v0.40.0", LatestVersion: "v0.41.0", Meta: map[string]any{}},
{Source: "github", Name: "gh", CurrentVersion: "v2.40.0", LatestVersion: "v2.41.0", Meta: map[string]any{}},
}
registry := buildTestRegistry(updates)
// Succeeds for lazygit, fails for gh.
registry.RegisterExecutor(&partialExecutor{source: "github", failName: "gh"})
pub := &mockEventPublisher{}
h := NewPackagesHandler(registry, pub)
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/apply-all",
bytes.NewBufferString(`{"packages":["github:lazygit","github:gh"]}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleApplyAllUpdates(w, req)
// red-team M2: always 200 even with partial failure.
if w.Code != http.StatusOK {
t.Fatalf("want 200 always, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
succeeded, _ := resp["succeeded"].([]any)
failed, _ := resp["failed"].([]any)
if len(succeeded) != 1 {
t.Errorf("want 1 succeeded, got %d", len(succeeded))
}
if len(failed) != 1 {
t.Errorf("want 1 failed, got %d", len(failed))
}
// Verify both started+succeeded and started+failed events were emitted.
names := collectEventNames(pub.capturedEvents())
if !sliceContains(names, eventPackageUpdateSucceeded) {
t.Error("missing package.update.succeeded event")
}
if !sliceContains(names, eventPackageUpdateFailed) {
t.Error("missing package.update.failed event")
}
}
func TestHandleApplyAllUpdates_InvalidSpecInList(t *testing.T) {
// A non-github spec in the list ends up in failed[], others continue.
registry := buildTestRegistry(nil)
registry.RegisterExecutor(&nopExecutor{source: "github"})
h := NewPackagesHandler(registry, nil)
// pip:pandas is invalid for updates; github:lazygit has no cache entry → also failed.
req := httptest.NewRequest(http.MethodPost, "/v1/packages/updates/apply-all",
bytes.NewBufferString(`{"packages":["pip:pandas","github:lazygit"]}`))
req = req.WithContext(ownerCtx(req.Context(), t.Name()))
w := httptest.NewRecorder()
h.handleApplyAllUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
failed, _ := resp["failed"].([]any)
if len(failed) == 0 {
t.Error("expected at least 1 failed entry for invalid/missing spec")
}
}
// ---- resolveUpdateSpec table-driven tests ----
func TestResolveUpdateSpec(t *testing.T) {
cases := []struct {
input string
wantSource string
wantName string
wantOK bool
}{
// pip: valid names
{"pip:requests", "pip", "requests", true},
{"pip:Django", "pip", "Django", true}, // pip allows uppercase
{"pip:my-package", "pip", "my-package", true},
// npm: valid names
{"npm:typescript", "npm", "typescript", true},
{"npm:@angular/core", "npm", "@angular/core", true},
// apk: valid names
{"apk:ripgrep", "apk", "ripgrep", true},
{"apk:node.js", "apk", "node.js", true}, // dot allowed
{"apk:py3-numpy", "apk", "py3-numpy", true}, // hyphen allowed
{"apk:libstdc++", "apk", "libstdc++", true}, // plus allowed
// apk: invalid names
{"apk:", "", "", false}, // empty name
{"apk:BAD;rm -rf /", "", "", false}, // semicolon rejected
{"apk:/etc/passwd", "", "", false}, // slash rejected
{"apk:UPPER", "", "", false}, // uppercase rejected
{"apk:@npm-style", "", "", false}, // at-sign rejected
{"APK:ripgrep", "", "", false}, // case-sensitive prefix
// pip: invalid names — @version suffix must be rejected
{"pip:typescript@latest", "", "", false},
{"pip:bad;name", "", "", false},
{"pip:", "", "", false},
// npm: invalid names
{"npm:typescript@latest", "", "", false},
{"npm:TypeScript", "", "", false}, // npm forbids uppercase
// unknown / malformed prefixes
{"garbage:x", "", "", false},
{"pip", "", "", false}, // no colon
{"", "", "", false},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
src, name, ok := resolveUpdateSpec(tc.input)
if ok != tc.wantOK {
t.Fatalf("resolveUpdateSpec(%q): ok=%v, want %v", tc.input, ok, tc.wantOK)
}
if ok {
if src != tc.wantSource {
t.Errorf("source=%q, want %q", src, tc.wantSource)
}
if name != tc.wantName {
t.Errorf("name=%q, want %q", name, tc.wantName)
}
}
})
}
}
// ---- lockKeyForSource tests ----
func TestLockKeyForSource(t *testing.T) {
cases := []struct {
source string
name string
meta map[string]any
wantKey string
}{
// pip and npm: return name directly (NOT "pip:name" or "npm:name")
{"pip", "requests", nil, "requests"},
{"npm", "@scope/pkg", nil, "@scope/pkg"},
// github: extract repo portion from meta
{"github", "lazygit", map[string]any{"repo": "jesseduffield/lazygit"}, "lazygit"},
{"github", "gh", map[string]any{"repo": "cli/cli"}, "cli"},
// github: fallback to name when meta missing
{"github", "fzf", nil, "fzf"},
// apk: return name directly (same as pip/npm)
{"apk", "ripgrep", nil, "ripgrep"},
{"apk", "ripgrep", map[string]any{"foo": "bar"}, "ripgrep"}, // meta ignored for apk
// unknown source: fallback to name
{"other", "pkg", nil, "pkg"},
}
for _, tc := range cases {
t.Run(tc.source+"/"+tc.name, func(t *testing.T) {
got := lockKeyForSource(tc.source, tc.name, tc.meta)
if got != tc.wantKey {
t.Errorf("lockKeyForSource(%q, %q, meta): got %q, want %q", tc.source, tc.name, got, tc.wantKey)
}
})
}
}
// ---- handleListUpdates availability field ----
func TestHandleListUpdates_IncludesAvailability(t *testing.T) {
registry := buildTestRegistry(nil)
h := NewPackagesHandler(registry, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/packages/updates", nil)
req = req.WithContext(store.WithRole(store.WithTenantID(store.WithUserID(req.Context(), "u1"), uuid.Nil), "operator"))
w := httptest.NewRecorder()
h.handleListUpdates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if _, ok := body["availability"]; !ok {
t.Error("response missing 'availability' field")
}
// availability must be a map (even if empty)
if _, ok := body["availability"].(map[string]any); !ok {
t.Errorf("availability must be map[string]bool, got %T", body["availability"])
}
}
// ---- small utilities ----
func collectEventNames(evts []bus.Event) []string {
out := make([]string, len(evts))
for i, e := range evts {
out[i] = e.Name
}
return out
}
func sliceContains(slice []string, s string) bool {
return slices.Contains(slice, s)
}