//go:build apk_e2e // +build apk_e2e // Package integration — Phase 2b apk update E2E integration tests. // // Requires: Alpine Linux runtime with /app/pkg-helper running as root. // The test binary MUST be executed as root (or with sufficient privilege to // start pkg-helper) inside an Alpine container with apk on PATH. // // Run: // // go test -tags apk_e2e -v ./tests/integration/... // // NOT run in default CI. Executed on release candidates only (scheduled // Alpine container run). See plans/260417-1500-packages-update-phase2b-apk-pkghelper/ // for the full E2E topology description. // // Pre-conditions (set up once per container): // // apk update // apk add jq # ensure at least one manageable package; downgrade not always possible package integration import ( "context" "errors" "os" "os/exec" "sync" "syscall" "testing" "time" "github.com/nextlevelbuilder/goclaw/internal/skills" ) // skipIfNotAlpine skips the test when /etc/alpine-release is absent. // This prevents accidental execution on Debian/macOS CI runners. func skipIfNotAlpine(t *testing.T) { t.Helper() if _, err := os.Stat("/etc/alpine-release"); err != nil { t.Skip("not an Alpine Linux runtime — skipping apk e2e test") } } // skipIfNotRoot skips the test when the process UID is not 0. // pkg-helper requires root; running without privilege will always fail. func skipIfNotRoot(t *testing.T) { t.Helper() if syscall.Getuid() != 0 { t.Skip("apk e2e tests require root (pkg-helper privilege) — run in privileged container") } } // skipIfApkMissing skips when the apk binary itself is not on PATH. func skipIfApkMissing(t *testing.T) { t.Helper() if _, err := exec.LookPath("apk"); err != nil { t.Skip("apk not on PATH — skipping apk e2e test") } } // apkInstalledVersion returns the currently installed version of a package, // or "" if it is not installed. Uses exec directly rather than going through // pkg-helper so we can inspect system state independently. func apkInstalledVersion(t *testing.T, pkg string) string { t.Helper() out, err := exec.Command("apk", "info", "-e", pkg).CombinedOutput() if err != nil { return "" } _ = out // apk version --quiet returns "-" on stdout. vOut, err := exec.Command("apk", "version", "-q", pkg).Output() if err != nil || len(vOut) == 0 { return "" } // Output is "-\n" — trim and strip name prefix. raw := string(vOut) if len(raw) > 0 && raw[len(raw)-1] == '\n' { raw = raw[:len(raw)-1] } return raw } // ensureApkPackageInstalled installs pkg if not already present. func ensureApkPackageInstalled(t *testing.T, pkg string) { t.Helper() out, err := exec.Command("apk", "add", "--no-progress", "--quiet", pkg).CombinedOutput() if err != nil { t.Fatalf("pre-condition: apk add %q failed: %v\n%s", pkg, err, out) } } // TestApk_UpdatesAvailable_E2E verifies that ApkUpdateChecker detects // at least one outdated package after intentionally not running apk upgrade. // // Strategy: on a freshly launched container from a non-latest tag, there are // typically outdated packages. We run apk update + list-outdated via the checker // and assert the pipeline functions end-to-end. If the container is fully // up-to-date, the test skips rather than fails (not a code bug). func TestApk_UpdatesAvailable_E2E(t *testing.T) { skipIfNotAlpine(t) skipIfApkMissing(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() checker := skills.NewApkUpdateChecker() if checker.Source() != "apk" { t.Fatalf("Source() = %q, want %q", checker.Source(), "apk") } result := checker.Check(ctx, nil) if !result.Available { t.Fatal("ApkUpdateChecker: Available=false on Alpine with apk on PATH — pkg-helper unreachable?") } if result.Err != nil { t.Fatalf("ApkUpdateChecker: unexpected error: %v", result.Err) } t.Logf("apk updates found: %d", len(result.Updates)) for _, u := range result.Updates { t.Logf(" %s: %s → %s", u.Name, u.CurrentVersion, u.LatestVersion) if u.Source != "apk" { t.Errorf("update %q has Source=%q, want 'apk'", u.Name, u.Source) } if u.Name == "" { t.Error("update with empty Name") } if u.CurrentVersion == "" || u.LatestVersion == "" { t.Errorf("update %q has empty version field (current=%q, latest=%q)", u.Name, u.CurrentVersion, u.LatestVersion) } if u.CheckedAt.IsZero() { t.Errorf("update %q has zero CheckedAt", u.Name) } } if len(result.Updates) == 0 { t.Skip("container is fully up-to-date — no updates to assert against; test skipped (not a failure)") } } // TestApk_UpdateSuccess_E2E verifies that ApkUpdateExecutor successfully upgrades // a package that was detected as outdated by the checker. // // Uses the first update from TestApk_UpdatesAvailable_E2E's result set. // Skips if no updates are available. func TestApk_UpdateSuccess_E2E(t *testing.T) { skipIfNotAlpine(t) skipIfApkMissing(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() checker := skills.NewApkUpdateChecker() result := checker.Check(ctx, nil) if !result.Available { t.Fatal("ApkUpdateChecker: Available=false — pkg-helper unreachable?") } if result.Err != nil { t.Fatalf("ApkUpdateChecker: unexpected error: %v", result.Err) } if len(result.Updates) == 0 { t.Skip("no apk updates available — skipping update success test") } // Pick the first update target. Prefer jq/tree/htop (small, isolated). // Avoid musl, busybox, libc (cascade risk documented in P7-R2). safe := []string{"jq", "tree", "htop", "curl", "bash"} var target *skills.UpdateInfo for _, s := range safe { for i := range result.Updates { if result.Updates[i].Name == s { target = &result.Updates[i] break } } if target != nil { break } } if target == nil { // Fall back to first available update if none of the safe list found. target = &result.Updates[0] } t.Logf("upgrading %s: %s → %s", target.Name, target.CurrentVersion, target.LatestVersion) executor := skills.NewApkUpdateExecutor() if err := executor.Update(ctx, target.Name, target.LatestVersion, target.Meta); err != nil { t.Fatalf("ApkUpdateExecutor.Update(%q) failed: %v", target.Name, err) } // Verify: re-run checker; the upgraded package should no longer be outdated. result2 := checker.Check(ctx, nil) for _, u := range result2.Updates { if u.Name == target.Name { t.Errorf("package %q still outdated after upgrade: current=%s latest=%s", target.Name, u.CurrentVersion, u.LatestVersion) } } } // TestApk_UpdateNotFound_E2E verifies that upgrading a non-existent package // returns an error that wraps ErrUpdateApkNotFound. func TestApk_UpdateNotFound_E2E(t *testing.T) { skipIfNotAlpine(t) skipIfApkMissing(t) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() executor := skills.NewApkUpdateExecutor() // "this-does-not-exist-xyz-goclaw-test" is deliberately non-existent. err := executor.Update(ctx, "this-package-does-not-exist-xyz-goclaw", "0.0.0", nil) if err == nil { t.Fatal("expected error for non-existent package, got nil") } // Should be a not_found sentinel (pkg-helper returns code="not_found"). if !errors.Is(err, skills.ErrUpdateApkNotFound) { // Log actual error for diagnosis but don't fail — different apk versions // may use different error messages. The important thing is an error is returned. t.Logf("note: errors.Is(err, ErrUpdateApkNotFound) = false; actual error: %v", err) t.Log("this is acceptable if apk returns a generic error for missing packages") } } // TestApk_ArgInjection_E2E is the security proof test. It verifies that a // package name containing shell metacharacters is rejected at the HTTP/executor // validation layer and that pkg-helper is NEVER invoked. // // This test is critical: it proves that command injection via the package name // field is impossible. The validator must reject before any socket dial. func TestApk_ArgInjection_E2E(t *testing.T) { skipIfNotAlpine(t) // These names contain shell metacharacters or uppercase — all must be rejected. invalidNames := []string{ "curl;rm -rf /", "curl && echo pwned", "curl|cat /etc/passwd", "UPPERCASE", "has space", "-leading-hyphen", "curl@edge", "curl`id`", "curl$(id)", "../../etc/passwd", } executor := skills.NewApkUpdateExecutor() ctx := context.Background() for _, name := range invalidNames { name := name t.Run(name, func(t *testing.T) { err := executor.Update(ctx, name, "", nil) if err == nil { t.Errorf("name=%q: expected validation error, got nil — INJECTION RISK", name) return } // Must be ErrInvalidApkPackageName or wrapping it. if !errors.Is(err, skills.ErrInvalidApkPackageName) { t.Errorf("name=%q: expected ErrInvalidApkPackageName, got: %v", name, err) } t.Logf("name=%q correctly rejected: %v", name, err) }) } } // TestApk_ConcurrentInstallUpgrade_E2E verifies that concurrent apk operations // are serialized: the apkMutex inside pkg-helper ensures only one apk command // runs at a time, preventing database-lock contention. // // We fire N concurrent Update calls for the same package and assert: // - All calls return (no deadlock / timeout). // - No "database locked" errors surface (which would indicate the mutex failed). func TestApk_ConcurrentInstallUpgrade_E2E(t *testing.T) { skipIfNotAlpine(t) skipIfApkMissing(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Ensure jq is installed so concurrent upgrade attempts have a real target. ensureApkPackageInstalled(t, "jq") executor := skills.NewApkUpdateExecutor() const concurrency = 4 errs := make([]error, concurrency) var wg sync.WaitGroup for i := 0; i < concurrency; i++ { i := i wg.Add(1) go func() { defer wg.Done() errs[i] = executor.Update(ctx, "jq", "", nil) }() } wg.Wait() // Count successes and failures. var locked, succeeded int for _, err := range errs { if err == nil { succeeded++ } else if errors.Is(err, skills.ErrUpdateApkLocked) { locked++ t.Errorf("database-locked error: concurrent operations not serialized — apkMutex may be broken") } else { // Other errors (network, etc.) are acceptable in E2E; the important // invariant is no locking errors. t.Logf("concurrent update error (non-lock): %v", err) } } t.Logf("concurrent=%d succeeded=%d locked=%d", concurrency, succeeded, locked) if locked > 0 { t.Fatalf("apkMutex serialization failed: %d database-locked errors observed", locked) } } // TestApk_HelperUnavailable_E2E verifies behavior when pkg-helper socket is // inaccessible. We simulate unavailability by calling with a context that has // already timed out (forces dial failure) and verify the correct sentinel error. // // In a real scenario, this is tested by chmod 000 /tmp/pkg.sock. Since that // requires additional setup and cleanup, we use context cancellation as the // mechanism that causes dial failure in the helper call path. func TestApk_HelperUnavailable_E2E(t *testing.T) { skipIfNotAlpine(t) // Use a pre-cancelled context to force dial failure without mutating the socket. ctx, cancel := context.WithCancel(context.Background()) cancel() // immediately cancelled — all helper calls will fail executor := skills.NewApkUpdateExecutor() err := executor.Update(ctx, "curl", "", nil) if err == nil { // On some systems the cancelled context may still succeed if the call // is fast enough. Log a warning but don't fail. t.Log("note: Update succeeded with cancelled ctx — context propagation is instant here") return } // Error must be non-nil. Acceptable codes: helper_unavailable, helper_error, // or any context-related error. We just verify an error is returned. t.Logf("HelperUnavailable: correctly returned error: %v", err) } // TestApk_Availability_AlpineTrue_E2E verifies the availability map shows // apk=true on Alpine runtime. func TestApk_Availability_AlpineTrue_E2E(t *testing.T) { skipIfNotAlpine(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() cache := &skills.UpdateCache{GitHubETags: make(map[string]string)} registry := skills.NewUpdateRegistry(cache, "", time.Hour) checker := skills.NewApkUpdateChecker() registry.RegisterChecker(checker) errs := registry.CheckAll(ctx) // Errors from check are acceptable (e.g. network failure refreshing index). // What we need is the availability map to show apk=true on Alpine. if len(errs) > 0 { t.Logf("CheckAll returned errors (non-fatal for availability test): %v", errs) } avail := registry.Availability() if !avail["apk"] { t.Errorf("Availability[apk] = false on Alpine runtime, want true") } t.Logf("availability map: %v", avail) }