//go:build !windows package main import ( "encoding/json" "strings" "testing" ) // TestHandleRequest tests the request validation logic. // Note: Command execution tests are not included here since apk is not available // in unit test environments. Integration tests would handle actual execution. func TestHandleRequest(t *testing.T) { // Mock runApkFunc to avoid running actual commands or triggering sudo origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() tests := []struct { name string req request wantValidErr bool errContains string }{ // Validation error cases { name: "missing package", req: request{Action: "install", Package: ""}, wantValidErr: true, errContains: "package required", }, { name: "invalid package name (starts with hyphen)", req: request{Action: "install", Package: "-malicious"}, wantValidErr: true, errContains: "invalid package name", }, { name: "invalid package name (contains semicolon)", req: request{Action: "install", Package: "pkg; rm -rf"}, wantValidErr: true, errContains: "invalid package name", }, { name: "invalid package name (contains space)", req: request{Action: "install", Package: "pkg name"}, wantValidErr: true, errContains: "invalid package name", }, { name: "unknown action", req: request{Action: "unknown", Package: "curl"}, wantValidErr: true, errContains: "unknown action", }, // Validation pass cases (may fail at exec stage, but validation passes) { name: "valid install", req: request{Action: "install", Package: "curl"}, wantValidErr: false, errContains: "", // Validation passed (no validation error) }, { name: "valid uninstall", req: request{Action: "uninstall", Package: "git"}, wantValidErr: false, errContains: "", }, { name: "valid scoped npm package", req: request{Action: "install", Package: "@scope/pkg"}, wantValidErr: false, errContains: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := handleRequest(tt.req) hasValidationErr := contains(resp.Error, "package required") || contains(resp.Error, "invalid package name") || contains(resp.Error, "unknown action") if hasValidationErr != tt.wantValidErr { t.Errorf("validation error = %v, want %v (error: %q)", hasValidationErr, tt.wantValidErr, resp.Error) } if tt.wantValidErr && tt.errContains != "" && !contains(resp.Error, tt.errContains) { t.Errorf("error = %q, want to contain %q", resp.Error, tt.errContains) } }) } } // contains checks if a string contains a substring. func contains(s, substr string) bool { for i := 0; i+len(substr) <= len(s); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // TestValidPkgName tests the package name validation regex. func TestValidPkgName(t *testing.T) { tests := []struct { name string valid bool }{ // Valid package names {"github-cli", true}, {"curl", true}, {"python3", true}, {"my_package", true}, {"package.name", true}, {"c++", true}, {"@scope/pkg", true}, {"pkg123", true}, {"a", true}, {"A", true}, {"0abc", true}, // can start with number {"abc123def", true}, {"pkg-with-hyphens", true}, {"pkg_with_underscores", true}, {"pkg.with.dots", true}, // Invalid package names {"-invalid", false}, // starts with hyphen {"--flag", false}, // starts with hyphen {"pkg name", false}, // contains space {"pkg;cmd", false}, // contains semicolon {"pkg|cmd", false}, // contains pipe {"pkg&cmd", false}, // contains ampersand {"pkg`cmd`", false}, // contains backtick {"pkg$var", false}, // contains dollar sign {"pkgfile", false}, // contains angle bracket {"pkg'quote", false}, // contains quote {"pkg\"quote", false}, // contains quote {"pkg(paren)", false}, // contains parens {"", false}, // empty {" curl", false}, // starts with space {"curl ", false}, // ends with space {"--index-url=evil", false}, // flag pattern {"-u", false}, // short flag } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := validPkgName.MatchString(tt.name) if got != tt.valid { t.Errorf("validPkgName.MatchString(%q) = %v, want %v", tt.name, got, tt.valid) } }) } } // TestHandleRequest_AllActionsValidated tests both install and uninstall actions. func TestHandleRequest_AllActionsValidated(t *testing.T) { // Mock runApkFunc to avoid running actual commands or triggering sudo origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() tests := []struct { action string }{ {"install"}, {"uninstall"}, } for _, tt := range tests { t.Run(tt.action, func(t *testing.T) { resp := handleRequest(request{ Action: tt.action, Package: "valid-package", }) // Validation should pass (no "invalid package" or "package required" error) if contains(resp.Error, "package required") || contains(resp.Error, "invalid package name") { t.Errorf("action %q validation failed: %q", tt.action, resp.Error) } }) } } // TestHandleRequest_EmptyActionFails tests that empty action is rejected. func TestHandleRequest_EmptyActionFails(t *testing.T) { resp := handleRequest(request{ Action: "", Package: "curl", }) if resp.Error == "" { t.Error("empty action should fail validation") } if !contains(resp.Error, "unknown action") { t.Errorf("error = %q, want to contain 'unknown action'", resp.Error) } } // TestHandleRequest_PackageValidationCatchesInjection tests shell injection attempts. func TestHandleRequest_PackageValidationCatchesInjection(t *testing.T) { injections := []string{ "-malicious", "pkg; rm -rf /", "pkg && evil", "pkg || evil", "pkg | evil", "pkg`evil`", "pkg$(evil)", "pkg\nevil", "--allow-untrusted", "--key=value", } for _, inj := range injections { t.Run(inj, func(t *testing.T) { resp := handleRequest(request{ Action: "install", Package: inj, }) if resp.OK || resp.Error == "" { t.Errorf("injection %q should be rejected, got OK=%v err=%q", inj, resp.OK, resp.Error) } }) } } // TestRequest_JSON tests request struct JSON unmarshaling. func TestRequest_JSON(t *testing.T) { tests := []struct { name string json string want request wantErr bool }{ { name: "valid request", json: `{"action":"install","package":"curl"}`, want: request{Action: "install", Package: "curl"}, wantErr: false, }, { name: "empty action", json: `{"action":"","package":"curl"}`, want: request{Action: "", Package: "curl"}, wantErr: false, // JSON parsing succeeds, validation fails later }, { name: "empty package", json: `{"action":"install","package":""}`, want: request{Action: "install", Package: ""}, wantErr: false, // JSON parsing succeeds, validation fails later }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var req request err := unmarshalRequest(tt.json, &req) if (err != nil) != tt.wantErr { t.Errorf("unmarshalRequest() error = %v, wantErr %v", err, tt.wantErr) } if !tt.wantErr && (req.Action != tt.want.Action || req.Package != tt.want.Package) { t.Errorf("unmarshalRequest() = %+v, want %+v", req, tt.want) } }) } } // unmarshalRequest is a helper for testing JSON unmarshaling. func unmarshalRequest(jsonStr string, req *request) error { return json.Unmarshal([]byte(jsonStr), req) } // TestResponse_JSON tests response struct JSON marshaling. func TestResponse_JSON(t *testing.T) { tests := []struct { name string resp response wantOK bool wantErr string omitErr bool }{ { name: "success response", resp: response{OK: true}, wantOK: true, wantErr: "", omitErr: true, }, { name: "error response", resp: response{OK: false, Error: "package not found"}, wantOK: false, wantErr: "package not found", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data, err := marshalResponse(tt.resp) if err != nil { t.Fatalf("marshal failed: %v", err) } var decoded response if err := unmarshalResponse(data, &decoded); err != nil { t.Fatalf("unmarshal failed: %v", err) } if decoded.OK != tt.wantOK { t.Errorf("OK = %v, want %v", decoded.OK, tt.wantOK) } if decoded.Error != tt.wantErr { t.Errorf("Error = %q, want %q", decoded.Error, tt.wantErr) } }) } } // marshalResponse marshals a response (for testing). func marshalResponse(resp response) ([]byte, error) { return json.Marshal(resp) } // unmarshalResponse unmarshals a response (for testing). func unmarshalResponse(data []byte, resp *response) error { return json.Unmarshal(data, resp) } // TestValidPkgNameRegex_Compliance tests compliance with validation rules. func TestValidPkgNameRegex_Compliance(t *testing.T) { // Test that regex enforces security constraints tests := []struct { name string isValid bool riskLevel string }{ // Safe names {"curl", true, ""}, {"github-cli", true, ""}, {"python3", true, ""}, {"@scope/pkg", true, ""}, // Attack patterns that MUST be rejected {"-flag", false, "flag injection"}, {"--option=value", false, "option injection"}, {"pkg; cmd", false, "command injection"}, {"pkg && cmd", false, "command injection"}, {"pkg || cmd", false, "command injection"}, {"pkg | cmd", false, "pipe injection"}, {"pkg`cmd`", false, "command substitution"}, {"pkg$(cmd)", false, "command substitution"}, {"pkg${var}", false, "variable injection"}, {"pkg'x'", false, "quote breaking"}, {"pkg\"x\"", false, "quote breaking"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := validPkgName.MatchString(tt.name) if got != tt.isValid { t.Errorf("validPkgName.MatchString(%q) = %v, want %v (risk: %s)", tt.name, got, tt.isValid, tt.riskLevel) } }) } } // TestHandleRequest_ErrorMessages tests that error messages are clear. func TestHandleRequest_ErrorMessages(t *testing.T) { origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() tests := []struct { name string req request wantErrText string }{ { name: "missing package error text", req: request{Action: "install", Package: ""}, wantErrText: "package required", }, { name: "invalid package error text", req: request{Action: "install", Package: "-bad"}, wantErrText: "invalid package name", }, { name: "unknown action error text", req: request{Action: "rebuild", Package: "curl"}, wantErrText: "unknown action", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := handleRequest(tt.req) if resp.Error == "" { t.Errorf("handleRequest() error = empty, want %q", tt.wantErrText) } if !contains(resp.Error, tt.wantErrText) { t.Errorf("handleRequest() error = %q, want to contain %q", resp.Error, tt.wantErrText) } }) } } // TestHandleRequest_SuccessPath tests that valid requests pass validation. // Note: Actual apk command execution will fail in test environment (no apk available), // but validation should pass. func TestHandleRequest_SuccessPath(t *testing.T) { origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() tests := []struct { action string pkg string }{ {"install", "curl"}, {"uninstall", "git"}, {"install", "github-cli"}, {"uninstall", "openssl"}, } for _, tt := range tests { t.Run(tt.action+"-"+tt.pkg, func(t *testing.T) { resp := handleRequest(request{ Action: tt.action, Package: tt.pkg, }) // Should not fail validation (may fail at exec stage without real apk) // We're testing that validation logic passes, not that apk exists validationErrors := []string{"package required", "invalid package name", "unknown action"} for _, validErr := range validationErrors { if contains(resp.Error, validErr) { t.Errorf("handleRequest(%q, %q) failed validation: %q", tt.action, tt.pkg, resp.Error) } } }) } } // ── v2 tests ───────────────────────────────────────────────────────────────── // TestHandleRequest_UpgradeValidation verifies that the upgrade action uses // the stricter validApkName regex (lowercase only, no @, no /). func TestHandleRequest_UpgradeValidation(t *testing.T) { origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() // Valid names for upgrade (lowercase apk grammar) valid := []string{ "curl", "libstdc++", "gtk+3.0", "ca-certificates", "py3-pip", } for _, pkg := range valid { t.Run("valid/"+pkg, func(t *testing.T) { resp := handleRequest(request{Action: "upgrade", Package: pkg}) // Must pass validation (may fail at apk exec stage — that's OK in unit test) if contains(resp.Error, "package required") || contains(resp.Error, "invalid package name") { t.Errorf("upgrade %q should pass validation, got: %q", pkg, resp.Error) } if resp.Code == "validation" { t.Errorf("upgrade %q got validation code unexpectedly", pkg) } }) } } // TestHandleRequest_UpgradeInjectionPatterns verifies 5 injection patterns are rejected. func TestHandleRequest_UpgradeInjectionPatterns(t *testing.T) { origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() injections := []string{ "-malicious", // leading hyphen "pkg;evil", // semicolon "pkg evil", // space "@edge/curl", // @ prefix (legacy npm compat — rejected by validApkName) "UPPERCASE_PKG", // uppercase rejected by validApkName } for _, pkg := range injections { t.Run(pkg, func(t *testing.T) { resp := handleRequest(request{Action: "upgrade", Package: pkg}) if resp.OK { t.Errorf("upgrade %q should be rejected but got OK=true", pkg) } if resp.Code != "validation" { t.Errorf("upgrade %q: want Code=validation, got %q (error=%q)", pkg, resp.Code, resp.Error) } }) } } // TestHandleRequest_UpgradeRejectsLegacySymbols verifies that pkg@edge (accepted // by legacy validPkgName for install/uninstall) is REJECTED by upgrade action // via the stricter validApkName. func TestHandleRequest_UpgradeRejectsLegacySymbols(t *testing.T) { origRunApkFunc := runApkFunc runApkFunc = func(args ...string) ([]byte, error) { return []byte("mock output"), nil } defer func() { runApkFunc = origRunApkFunc }() legacySymbols := []string{ "pkg@edge", // @ accepted by validPkgName, rejected by validApkName "@scope/pkg", // npm scoped — rejected by validApkName } for _, pkg := range legacySymbols { t.Run(pkg, func(t *testing.T) { // Confirm install/uninstall ACCEPTS it (legacy compat) installResp := handleRequest(request{Action: "install", Package: pkg}) if contains(installResp.Error, "invalid package name") { t.Errorf("install %q should pass validPkgName validation, got %q", pkg, installResp.Error) } // Confirm upgrade REJECTS it (strict apk grammar) upgradeResp := handleRequest(request{Action: "upgrade", Package: pkg}) if upgradeResp.Code != "validation" { t.Errorf("upgrade %q: want Code=validation, got Code=%q error=%q", pkg, upgradeResp.Code, upgradeResp.Error) } }) } } // TestHandleRequest_UpdateIndexRejectsPackage verifies update-index rejects non-empty package. func TestHandleRequest_UpdateIndexRejectsPackage(t *testing.T) { resp := handleRequest(request{Action: "update-index", Package: "curl"}) if resp.OK { t.Error("update-index with package should not return OK=true") } if resp.Code != "validation" { t.Errorf("want Code=validation, got %q", resp.Code) } if !contains(resp.Error, "update-index takes no package") { t.Errorf("error = %q, want to contain 'update-index takes no package'", resp.Error) } } // TestHandleRequest_ListOutdatedRejectsPackage verifies list-outdated rejects non-empty package. func TestHandleRequest_ListOutdatedRejectsPackage(t *testing.T) { resp := handleRequest(request{Action: "list-outdated", Package: "curl"}) if resp.OK { t.Error("list-outdated with package should not return OK=true") } if resp.Code != "validation" { t.Errorf("want Code=validation, got %q", resp.Code) } if !contains(resp.Error, "list-outdated takes no package") { t.Errorf("error = %q, want to contain 'list-outdated takes no package'", resp.Error) } } // TestHandleRequest_UpdateIndexNoPackage verifies update-index passes validation with empty package. func TestHandleRequest_UpdateIndexNoPackage(t *testing.T) { resp := handleRequest(request{Action: "update-index", Package: ""}) // Validation passes — will fail at apk exec in unit test env, but NOT with Code="validation" if resp.Code == "validation" { t.Errorf("update-index with empty package should pass validation, got Code=validation error=%q", resp.Error) } } // TestHandleRequest_ListOutdatedNoPackage verifies list-outdated passes validation with empty package. func TestHandleRequest_ListOutdatedNoPackage(t *testing.T) { resp := handleRequest(request{Action: "list-outdated", Package: ""}) if resp.Code == "validation" { t.Errorf("list-outdated with empty package should pass validation, got Code=validation error=%q", resp.Error) } } // TestHandleRequest_InvalidActionReturnsValidationCode verifies unknown actions // get Code="validation" in the v2 response. func TestHandleRequest_InvalidActionReturnsValidationCode(t *testing.T) { resp := handleRequest(request{Action: "nuke", Package: "curl"}) if resp.Code != "validation" { t.Errorf("unknown action: want Code=validation, got %q", resp.Code) } if !contains(resp.Error, "unknown action") { t.Errorf("error = %q, want to contain 'unknown action'", resp.Error) } } // TestHandleRequest_InvalidJSONCodeValidation verifies malformed JSON sets Code="validation". // We test via handleConn indirectly by confirming the inline code path. func TestHandleRequest_InvalidJsonGetsValidationCode(t *testing.T) { // This tests the inline json error path in handleConn — we verify the // response struct used there has Code="validation". errResp := response{Error: "invalid json", Code: "validation"} if errResp.Code != "validation" { t.Errorf("invalid json response Code = %q, want 'validation'", errResp.Code) } } // TestClassifyApkOutput covers all 7 code branches. func TestClassifyApkOutput(t *testing.T) { fakeErr := &fakeError{"exit status 1"} tests := []struct { name string out string wantCode string }{ { name: "locked database", out: "ERROR: unable to lock database: Permission denied", wantCode: "locked", }, { name: "permission denied (not lock-related)", out: "ERROR: Permission denied while writing", wantCode: "permission", }, { name: "disk full", out: "ERROR: No space left on device", wantCode: "disk_full", }, { name: "not found (unsatisfiable)", out: "ERROR: unsatisfiable constraints: nonexistent-pkg (missing)", wantCode: "not_found", }, { name: "conflict (breaks world)", out: "ERROR: unsatisfiable constraints: foo-1.0 breaks: world[foo=2.0]", wantCode: "conflict", }, { name: "network error", out: "ERROR: unable to fetch https://dl-cdn.alpinelinux.org/: connection refused", wantCode: "network", }, { name: "system error (default)", out: "ERROR: something completely unknown went wrong", wantCode: "system_error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, code := classifyApkOutput(tt.out, fakeErr) if code != tt.wantCode { t.Errorf("classifyApkOutput(%q) code = %q, want %q", tt.out, code, tt.wantCode) } }) } } // fakeError implements the error interface for testing classifyApkOutput. type fakeError struct{ msg string } func (e *fakeError) Error() string { return e.msg } // TestClassifyApkOutput_EmptyOutputFallsBackToErrMsg verifies that when output // is blank, the error message from err.Error() is used. func TestClassifyApkOutput_EmptyOutputFallsBackToErrMsg(t *testing.T) { msg, code := classifyApkOutput("", &fakeError{"apk: something failed"}) if msg != "apk: something failed" { t.Errorf("msg = %q, want 'apk: something failed'", msg) } if code != "system_error" { t.Errorf("code = %q, want 'system_error'", code) } } // TestClassifyApkOutput_TruncatesLongOutput verifies messages >500 chars are truncated. func TestClassifyApkOutput_TruncatesLongOutput(t *testing.T) { longOut := strings.Repeat("x", 600) msg, _ := classifyApkOutput(longOut, &fakeError{"err"}) if len([]rune(msg)) > 502 { // 500 + "…" (multi-byte) t.Errorf("msg length = %d runes, want ≤502", len([]rune(msg))) } if !strings.HasSuffix(msg, "…") { t.Error("truncated msg should end with ellipsis") } } // TestResponseJSONShape verifies Code + Data fields survive marshal/unmarshal // and that omitempty suppresses empty fields. func TestResponseJSONShape(t *testing.T) { t.Run("code and data present", func(t *testing.T) { r := response{OK: false, Error: "x", Code: "conflict", Data: ""} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } s := string(data) if !contains(s, `"code":"conflict"`) { t.Errorf("json %q missing code field", s) } // Data is empty string — omitempty should suppress it. if contains(s, `"data"`) { t.Errorf("json %q should NOT contain data field when empty (omitempty)", s) } }) t.Run("data field present when non-empty", func(t *testing.T) { r := response{OK: true, Data: "curl 7.88\n"} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } s := string(data) if !contains(s, `"data"`) { t.Errorf("json %q missing data field", s) } }) t.Run("omitempty suppresses error and code on OK response", func(t *testing.T) { r := response{OK: true} data, err := json.Marshal(r) if err != nil { t.Fatalf("marshal: %v", err) } s := string(data) if contains(s, `"error"`) { t.Errorf("json %q should NOT contain error field (omitempty)", s) } if contains(s, `"code"`) { t.Errorf("json %q should NOT contain code field (omitempty)", s) } }) } // TestValidApkName tests the strict apk package name validator. func TestValidApkName(t *testing.T) { tests := []struct { name string valid bool }{ // Valid apk names {"curl", true}, {"libstdc++", true}, {"gtk+3.0", true}, {"ca-certificates", true}, {"py3-pip", true}, {"0launch", true}, // starts with digit — valid per apk grammar // Invalid: uppercase {"CURL", false}, {"OpenSSL", false}, // Invalid: @ prefix (npm compat — rejected by validApkName) {"@scope/pkg", false}, // Invalid: slash {"alpine/curl", false}, // Invalid: leading hyphen {"-pkg", false}, // Invalid: spaces/metacharacters {"pkg name", false}, {"pkg;evil", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := validApkName.MatchString(tt.name) if got != tt.valid { t.Errorf("validApkName.MatchString(%q) = %v, want %v", tt.name, got, tt.valid) } }) } } // TestApkMutex_SerializesConcurrentUpgrades verifies that concurrent upgrade // validation calls do not race on the response struct or the mutex itself. // Note: actual apk execution is absent in unit tests; we exercise dispatch only. func TestApkMutex_SerializesConcurrentUpgrades(t *testing.T) { const goroutines = 10 results := make(chan response, goroutines) for i := 0; i < goroutines; i++ { go func() { // All pass validation; execution fails (no apk binary) — that's OK. results <- handleRequest(request{Action: "upgrade", Package: "curl"}) }() } for i := 0; i < goroutines; i++ { resp := <-results // Must NOT be a validation error — the package name is valid. if resp.Code == "validation" { t.Errorf("concurrent upgrade got unexpected validation error: %q", resp.Error) } } }