mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 22:14:37 +00:00
* 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
266 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|