Files
goclaw/internal/skills/apk_update_executor_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

266 lines
8.9 KiB
Go

package skills
import (
"context"
"errors"
"strings"
"testing"
)
// stubApkHelper returns a helper function that always returns the given values.
func stubApkHelper(ok bool, code, data, errMsg string) func(context.Context, string, string) (bool, string, string, string) {
return func(_ context.Context, _, _ string) (bool, string, string, string) {
return ok, code, data, errMsg
}
}
// setApkHelperStub replaces apkHelperCallFunc for the duration of a test and
// restores the original in t.Cleanup.
func setApkHelperStub(t *testing.T, stub func(context.Context, string, string) (bool, string, string, string)) {
t.Helper()
orig := apkHelperCallFunc
apkHelperCallFunc = stub
t.Cleanup(func() { apkHelperCallFunc = orig })
}
func TestApkExecutor_Source(t *testing.T) {
e := NewApkUpdateExecutor()
if got := e.Source(); got != "apk" {
t.Errorf("Source() = %q, want %q", got, "apk")
}
}
func TestApkExecutor_InvalidName(t *testing.T) {
e := NewApkUpdateExecutor()
// helper must NOT be called — validation rejects before dial.
called := false
setApkHelperStub(t, func(_ context.Context, _, _ string) (bool, string, string, string) {
called = true
return true, "", "", ""
})
// Empty name returns a plain error (not wrapped with sentinel); non-empty
// invalid names return ErrInvalidApkPackageName via fmt.Errorf("%w", ...).
emptyErr := e.Update(context.Background(), "", "", nil)
if emptyErr == nil {
t.Error("name=\"\": expected error, got nil")
}
invalidNames := []string{
"UPPERCASE",
"curl;rm",
"curl@edge",
"-leading-hyphen",
"has space",
}
for _, name := range invalidNames {
err := e.Update(context.Background(), name, "", nil)
if err == nil {
t.Errorf("name=%q: expected error, got nil", name)
continue
}
if !errors.Is(err, ErrInvalidApkPackageName) {
t.Errorf("name=%q: errors.Is(err, ErrInvalidApkPackageName) = false; err = %v", name, err)
}
}
if called {
t.Error("helper was called despite invalid name — validation bypass")
}
}
func TestApkExecutor_HelperUnavailable(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "helper_unavailable", "", "pkg-helper unavailable: connection refused"))
err := e.Update(context.Background(), "curl", "8.0.0", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkHelperUnavail) {
t.Errorf("errors.Is(err, ErrUpdateApkHelperUnavail) = false; err = %v", err)
}
}
func TestApkExecutor_ConflictError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "conflict", "", "unsatisfiable constraints"))
err := e.Update(context.Background(), "libssl3", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkConflict) {
t.Errorf("errors.Is(err, ErrUpdateApkConflict) = false; err = %v", err)
}
}
func TestApkExecutor_NotFoundError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "not_found", "", "ERROR: unable to select packages"))
err := e.Update(context.Background(), "nonexistent-pkg", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkNotFound) {
t.Errorf("errors.Is(err, ErrUpdateApkNotFound) = false; err = %v", err)
}
}
func TestApkExecutor_NetworkError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "network", "", "fetch failed: connection timed out"))
err := e.Update(context.Background(), "curl", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkNetwork) {
t.Errorf("errors.Is(err, ErrUpdateApkNetwork) = false; err = %v", err)
}
}
func TestApkExecutor_LockedError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "locked", "", "unable to lock database"))
err := e.Update(context.Background(), "busybox", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkLocked) {
t.Errorf("errors.Is(err, ErrUpdateApkLocked) = false; err = %v", err)
}
}
func TestApkExecutor_PermissionError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "permission", "", "write permission denied"))
err := e.Update(context.Background(), "curl", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkPermission) {
t.Errorf("errors.Is(err, ErrUpdateApkPermission) = false; err = %v", err)
}
}
func TestApkExecutor_DiskFullError(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "disk_full", "", "no space left on device"))
err := e.Update(context.Background(), "musl", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkDiskFull) {
t.Errorf("errors.Is(err, ErrUpdateApkDiskFull) = false; err = %v", err)
}
}
func TestApkExecutor_Success(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(true, "", "", ""))
err := e.Update(context.Background(), "curl", "8.5.0", nil)
if err != nil {
t.Errorf("expected nil error on success, got: %v", err)
}
}
func TestApkExecutor_CtxCancel(t *testing.T) {
e := NewApkUpdateExecutor()
// Stub returns context.Canceled to simulate context cancellation propagated
// from apkHelperCall when the connection deadline fires before response.
setApkHelperStub(t, func(ctx context.Context, _, _ string) (bool, string, string, string) {
// Respect the already-cancelled context.
if err := ctx.Err(); err != nil {
return false, "helper_error", "", err.Error()
}
return false, "helper_error", "", "context canceled"
})
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately before Update is called
err := e.Update(ctx, "curl", "", nil)
if err == nil {
t.Fatal("expected error on cancelled ctx, got nil")
}
// The error wraps a non-sentinel (generic "apk upgrade failed: ...") since
// the stub returns code="helper_error" which maps to nil sentinel, and
// the errMsg "context canceled" doesn't match any ClassifyApkStderr pattern.
// We assert a non-nil error is returned (not a panic or silent success).
if !strings.Contains(err.Error(), "context canceled") {
t.Errorf("expected error mentioning context canceled, got: %v", err)
}
}
// TestApkExecutor_EmptyCode_KnownStderr verifies fallback to ClassifyApkStderr
// when the helper returns an empty code but a recognizable stderr string.
func TestApkExecutor_EmptyCode_KnownStderr(t *testing.T) {
e := NewApkUpdateExecutor()
// Empty code + stderr that ClassifyApkStderr recognizes as ErrUpdateApkLocked.
setApkHelperStub(t, stubApkHelper(false, "", "", "unable to lock database"))
err := e.Update(context.Background(), "curl", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdateApkLocked) {
t.Errorf("fallback classification: errors.Is(err, ErrUpdateApkLocked) = false; err = %v", err)
}
}
// TestApkExecutor_EmptyCode_UnknownStderr verifies that an unrecognized code AND
// unrecognized stderr produce a generic (non-sentinel) error string.
func TestApkExecutor_EmptyCode_UnknownStderr(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(false, "", "", "weird cosmic ray error"))
err := e.Update(context.Background(), "curl", "", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
// Must NOT be any sentinel — it's a generic wrapped error.
sentinels := []error{
ErrUpdateApkConflict, ErrUpdateApkNetwork, ErrUpdateApkLocked,
ErrUpdateApkNotFound, ErrUpdateApkPermission, ErrUpdateApkDiskFull,
ErrUpdateApkHelperUnavail, ErrInvalidApkPackageName,
}
for _, s := range sentinels {
if errors.Is(err, s) {
t.Errorf("unexpected sentinel %v matched for unrecognized stderr", s)
}
}
if !strings.Contains(err.Error(), "apk upgrade failed") {
t.Errorf("expected generic 'apk upgrade failed' message, got: %v", err)
}
}
// TestApkExecutor_NoLockAcquire is a regression test for red-team finding C-1.
// It verifies that ApkUpdateExecutor.Update succeeds even without a pre-acquired
// PackageLocker — proving the executor does NOT attempt a second Acquire that
// would deadlock (PackageLocker is non-reentrant).
//
// If the executor ever adds a sharedPackageLocker().Acquire() call, this test
// will either deadlock (timeout) or return a lock-acquire error, causing failure.
func TestApkExecutor_NoLockAcquire(t *testing.T) {
e := NewApkUpdateExecutor()
setApkHelperStub(t, stubApkHelper(true, "", "", ""))
// Intentionally do NOT set a shared PackageLocker — sharedLocker is nil.
// If the executor calls sharedPackageLocker().Acquire(...), it will panic
// (nil pointer dereference) or block forever, causing a test timeout.
orig := sharedLocker.Load()
sharedLocker.Store(nil)
t.Cleanup(func() { sharedLocker.Store(orig) })
err := e.Update(context.Background(), "curl", "8.5.0", nil)
if err != nil {
t.Errorf("expected nil error (no lock acquire), got: %v", err)
}
}