mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-13 00:22:25 +00:00
816 lines
24 KiB
Go
816 lines
24 KiB
Go
//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
|
|
{"pkg<file", false}, // contains angle bracket
|
|
{"pkg>file", 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)
|
|
}
|
|
}
|
|
}
|