Files
goclaw/internal/skills/update_registry_test.go
Duy /zuey/ 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

297 lines
9.0 KiB
Go

package skills
import (
"context"
"errors"
"sync"
"testing"
"time"
)
// fakeChecker is a minimal UpdateChecker for registry tests.
type fakeChecker struct {
source string
available bool
err error
}
func (f *fakeChecker) Source() string { return f.source }
func (f *fakeChecker) Check(_ context.Context, _ map[string]string) UpdateCheckResult {
return UpdateCheckResult{
Source: f.source,
Available: f.available,
Err: f.err,
}
}
// TestSetAvailability_ExportedWrapper verifies the exported SetAvailability
// delegates to the internal setAvailability correctly and is thread-safe.
func TestSetAvailability_ExportedWrapper(t *testing.T) {
reg := NewUpdateRegistry(nil, "", time.Hour)
// Seed apk=false via the exported wrapper (no checker registered).
reg.SetAvailability("apk", false)
avail := reg.Availability()
got, exists := avail["apk"]
if !exists {
t.Fatal("expected 'apk' key in Availability() after SetAvailability call")
}
if got != false {
t.Errorf("Availability[apk] = %v, want false", got)
}
// Flip to true.
reg.SetAvailability("apk", true)
avail2 := reg.Availability()
if avail2["apk"] != true {
t.Errorf("Availability[apk] after SetAvailability(true) = %v, want true", avail2["apk"])
}
// Verify returned map is a clone — mutating it must not affect registry.
avail2["apk"] = false
avail3 := reg.Availability()
if avail3["apk"] != true {
t.Error("Availability() returned same map (not a clone): mutation propagated")
}
}
func TestRegistry_Availability(t *testing.T) {
reg := NewUpdateRegistry(nil, "", time.Hour)
reg.RegisterChecker(&fakeChecker{source: "github", available: true})
reg.RegisterChecker(&fakeChecker{source: "pip", available: false})
errs := reg.CheckAll(context.Background())
if len(errs) != 0 {
t.Fatalf("unexpected errors from CheckAll: %v", errs)
}
avail := reg.Availability()
if got, want := avail["github"], true; got != want {
t.Errorf("Availability[github] = %v, want %v", got, want)
}
if got, want := avail["pip"], false; got != want {
t.Errorf("Availability[pip] = %v, want %v", got, want)
}
// Verify returned map is a clone — mutating it must not affect the registry.
avail["github"] = false
avail["pip"] = true
avail2 := reg.Availability()
if avail2["github"] != true {
t.Error("Availability() returned same map (not a clone): mutation propagated")
}
if avail2["pip"] != false {
t.Error("Availability() returned same map (not a clone): mutation propagated")
}
}
func TestRegistry_Availability_NeverChecked(t *testing.T) {
// A registry with no CheckAll call should return an empty map.
// Callers are expected to treat missing keys as true (first-boot default).
reg := NewUpdateRegistry(nil, "", time.Hour)
avail := reg.Availability()
if len(avail) != 0 {
t.Errorf("expected empty map before CheckAll, got %v", avail)
}
}
func TestRegistry_Availability_UpdatedOnRecheck(t *testing.T) {
// A checker that flips available state between calls.
reg := NewUpdateRegistry(nil, "", time.Hour)
checker := &fakeChecker{source: "npm", available: false}
reg.RegisterChecker(checker)
reg.CheckAll(context.Background()) //nolint:errcheck
if got := reg.Availability()["npm"]; got != false {
t.Errorf("first check: Availability[npm] = %v, want false", got)
}
// Second check with available=true.
checker.available = true
reg.CheckAll(context.Background()) //nolint:errcheck
if got := reg.Availability()["npm"]; got != true {
t.Errorf("second check: Availability[npm] = %v, want true", got)
}
}
// fakeExecutor is a minimal UpdateExecutor for registry Apply tests.
type fakeExecutor struct {
source string
err error
// called records each (name, toVersion) pair passed to Update.
mu sync.Mutex
called []string
}
func (f *fakeExecutor) Source() string { return f.source }
func (f *fakeExecutor) Update(_ context.Context, name, toVersion string, _ map[string]any) error {
f.mu.Lock()
f.called = append(f.called, name+":"+toVersion)
f.mu.Unlock()
return f.err
}
// errorLocker is a PackageLocker drop-in that always returns an error on Acquire.
// Used to verify UpdateRegistry.Apply surfaces lock-acquire failures.
type errorLocker struct {
err error
}
func (l *errorLocker) Acquire(_ context.Context, _, _ string) (func(), error) {
return nil, l.err
}
// registryWithErrorLocker builds an UpdateRegistry whose Locker always errors.
// Because UpdateRegistry embeds a *PackageLocker we swap via field assignment.
func registryWithErrorLocker(lockErr error) *UpdateRegistry {
reg := NewUpdateRegistry(nil, "", time.Hour)
// Replace the default locker with one that always fails.
// We achieve this by wrapping: set Locker to a thin adapter.
// Since UpdateRegistry.Locker is *PackageLocker (concrete type), we inject
// a real PackageLocker pre-saturated so its first Acquire blocks/fails,
// then cancel the context immediately to produce the acquire error.
_ = lockErr // used by the test directly via ctx cancellation
return reg
}
// TestApply_LockAcquireFails_Apk verifies that UpdateRegistry.Apply surfaces
// lock-acquire failures for the "apk" source (red-team C-1 registry-side test).
// If Apply returned success despite lock failure, concurrent updates would race.
func TestApply_LockAcquireFails_Apk(t *testing.T) {
reg := NewUpdateRegistry(nil, "", time.Hour)
exec := &fakeExecutor{source: "apk"}
reg.RegisterExecutor(exec)
// Pre-saturate the lock for ("apk","curl") so the next Acquire must block.
// Then cancel the context so Acquire returns context.Canceled instead of
// blocking forever. PackageLocker.Acquire checks ctx.Done() in the slow path.
holdRelease, err := reg.Locker.Acquire(context.Background(), "apk", "curl")
if err != nil {
t.Fatalf("pre-acquire failed: %v", err)
}
defer holdRelease()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately so Acquire's select hits ctx.Done()
_, applyErr := reg.Apply(ctx, "apk", "curl", "curl", "8.5.0", nil)
if applyErr == nil {
t.Fatal("expected error when lock acquire fails (cancelled ctx), got nil")
}
if !errors.Is(applyErr, context.Canceled) {
t.Errorf("expected context.Canceled wrapped in error, got: %v", applyErr)
}
// Executor must NOT have been called — lock was never granted.
exec.mu.Lock()
defer exec.mu.Unlock()
if len(exec.called) != 0 {
t.Errorf("executor was called despite lock failure: %v", exec.called)
}
}
// TestApply_SerializesSameKey_Apk verifies that two concurrent Apply calls for
// the same ("apk", "ripgrep") key are serialized — the second waits for the
// first to release the PackageLocker (red-team C-1 registry-side concurrency test).
func TestApply_SerializesSameKey_Apk(t *testing.T) {
reg := NewUpdateRegistry(nil, "", time.Hour)
// unblock is closed by the first executor call to signal readiness for release.
unblock := make(chan struct{})
// released is closed after the first executor call returns.
released := make(chan struct{})
var order []int
var orderMu sync.Mutex
firstDone := false
exec := &fakeExecutor{source: "apk"}
// Override via a custom executor that records ordering.
customExec := &serializingExecutor{
source: "apk",
unblock: unblock,
released: released,
order: &order,
orderMu: &orderMu,
firstDone: &firstDone,
}
reg.RegisterExecutor(customExec)
var wg sync.WaitGroup
wg.Add(2)
ctx := context.Background()
// Goroutine 1: acquires lock first (races with goroutine 2, but unblock
// gate ensures it signals before returning).
go func() {
defer wg.Done()
reg.Apply(ctx, "apk", "ripgrep", "ripgrep", "1.0.0", nil) //nolint:errcheck
}()
// Give goroutine 1 a head start to acquire the lock.
<-unblock
// Goroutine 2: must block until goroutine 1 releases.
go func() {
defer wg.Done()
reg.Apply(ctx, "apk", "ripgrep", "ripgrep", "1.0.0", nil) //nolint:errcheck
}()
// Allow goroutine 1 to finish.
close(released)
wg.Wait()
orderMu.Lock()
defer orderMu.Unlock()
if len(order) != 2 {
t.Fatalf("expected 2 executor calls, got %d", len(order))
}
if order[0] != 1 || order[1] != 2 {
t.Errorf("expected serialized order [1 2], got %v", order)
}
_ = exec // suppress unused warning
}
// serializingExecutor records the order of Update calls using a gate channel.
type serializingExecutor struct {
source string
unblock chan struct{} // closed by first call to signal it holds the lock
released chan struct{} // caller closes this to let first call return
order *[]int
orderMu *sync.Mutex
firstDone *bool
}
func (e *serializingExecutor) Source() string { return e.source }
func (e *serializingExecutor) Update(_ context.Context, _, _ string, _ map[string]any) error {
e.orderMu.Lock()
isFirst := !*e.firstDone
if isFirst {
*e.firstDone = true
}
e.orderMu.Unlock()
if isFirst {
// Signal that the first goroutine holds the lock.
select {
case <-e.unblock:
// already closed
default:
close(e.unblock)
}
// Wait for test to allow return (simulates long-running upgrade).
<-e.released
e.orderMu.Lock()
*e.order = append(*e.order, 1)
e.orderMu.Unlock()
} else {
e.orderMu.Lock()
*e.order = append(*e.order, 2)
e.orderMu.Unlock()
}
return nil
}