Pulse/internal/api/config_handlers_sanitize_test.go
rcourtman 5b5386e060 ADA: Add unit tests for sanitizeInstallerURL and sanitizeSetupAuthToken
Add comprehensive test coverage for security-critical URL and token
sanitization functions in config_handlers.go. These functions protect
the setup script endpoint from injection attacks.

TestSanitizeInstallerURL (23 cases): empty/whitespace handling, valid
http/https URLs, fragment stripping, query preservation, control character
rejection, invalid scheme rejection (ftp/file/javascript/data), and
missing host validation.

TestSanitizeSetupAuthToken (19 cases): empty/whitespace handling, valid
hex tokens of various lengths (32-128 chars), mixed case hex, control
character rejection, non-hex character rejection, and length validation.
2025-11-29 18:35:27 +00:00

332 lines
8.2 KiB
Go

package api
import (
"strings"
"testing"
)
func TestSanitizeInstallerURL(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
errMsg string
}{
// Valid cases
{
name: "empty string returns empty",
input: "",
want: "",
wantErr: false,
},
{
name: "whitespace-only returns empty",
input: " \t ",
want: "",
wantErr: false,
},
{
name: "valid http URL",
input: "http://example.com",
want: "http://example.com",
wantErr: false,
},
{
name: "valid https URL",
input: "https://example.com",
want: "https://example.com",
wantErr: false,
},
{
name: "URL with path",
input: "https://example.com/path/to/installer",
want: "https://example.com/path/to/installer",
wantErr: false,
},
{
name: "URL with port",
input: "https://example.com:8080",
want: "https://example.com:8080",
wantErr: false,
},
{
name: "URL with query params preserves them",
input: "https://example.com/installer?version=1.0&arch=amd64",
want: "https://example.com/installer?version=1.0&arch=amd64",
wantErr: false,
},
{
name: "URL with fragment strips fragment",
input: "https://example.com/installer#section",
want: "https://example.com/installer",
wantErr: false,
},
{
name: "URL with fragment and query preserves query, strips fragment",
input: "https://example.com/installer?version=1.0#section",
want: "https://example.com/installer?version=1.0",
wantErr: false,
},
{
name: "URL with leading/trailing whitespace is trimmed",
input: " https://example.com/installer ",
want: "https://example.com/installer",
wantErr: false,
},
{
name: "URL with authentication",
input: "https://user:pass@example.com/installer",
want: "https://user:pass@example.com/installer",
wantErr: false,
},
// Error cases
{
name: "carriage return in URL",
input: "https://example.com\r/installer",
wantErr: true,
errMsg: "control characters",
},
{
name: "newline in URL",
input: "https://example.com\n/installer",
wantErr: true,
errMsg: "control characters",
},
{
name: "both CR and LF in URL",
input: "https://example.com\r\n/installer",
wantErr: true,
errMsg: "control characters",
},
{
name: "ftp scheme rejected",
input: "ftp://example.com/installer",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "file scheme rejected",
input: "file:///etc/passwd",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "javascript scheme rejected",
input: "javascript:alert(1)",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "data scheme rejected",
input: "data:text/plain,hello",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "URL without scheme",
input: "example.com/installer",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "URL without host",
input: "https:///path",
wantErr: true,
errMsg: "host component is required",
},
{
name: "relative URL without host",
input: "/path/to/installer",
wantErr: true,
errMsg: "scheme must be http or https",
},
{
name: "malformed URL",
input: "https://[invalid",
wantErr: true,
errMsg: "failed to parse URL",
},
{
name: "URL with only scheme",
input: "https://",
wantErr: true,
errMsg: "host component is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sanitizeInstallerURL(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("sanitizeInstallerURL() expected error, got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("sanitizeInstallerURL() error = %q, want substring %q", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("sanitizeInstallerURL() unexpected error = %v", err)
return
}
if got != tt.want {
t.Errorf("sanitizeInstallerURL() = %q, want %q", got, tt.want)
}
}
})
}
}
func TestSanitizeSetupAuthToken(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
errMsg string
}{
// Valid cases
{
name: "empty string returns empty",
input: "",
want: "",
wantErr: false,
},
{
name: "whitespace-only returns empty",
input: " \t ",
want: "",
wantErr: false,
},
{
name: "valid 32-char hex token lowercase",
input: "0123456789abcdef0123456789abcdef",
want: "0123456789abcdef0123456789abcdef",
wantErr: false,
},
{
name: "valid 32-char hex token uppercase",
input: "0123456789ABCDEF0123456789ABCDEF",
want: "0123456789ABCDEF0123456789ABCDEF",
wantErr: false,
},
{
name: "valid 32-char hex token mixed case",
input: "0123456789aBcDeF0123456789AbCdEf",
want: "0123456789aBcDeF0123456789AbCdEf",
wantErr: false,
},
{
name: "valid 64-char hex token",
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
wantErr: false,
},
{
name: "valid 128-char hex token (max length)",
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
want: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
wantErr: false,
},
{
name: "valid hex token with leading/trailing whitespace is trimmed",
input: " 0123456789abcdef0123456789abcdef ",
want: "0123456789abcdef0123456789abcdef",
wantErr: false,
},
// Error cases
{
name: "carriage return in token",
input: "0123456789abcdef\r0123456789abcdef",
wantErr: true,
errMsg: "control characters",
},
{
name: "newline in token",
input: "0123456789abcdef\n0123456789abcdef",
wantErr: true,
errMsg: "control characters",
},
{
name: "both CR and LF in token",
input: "0123456789abcdef\r\n123456789abcdef",
wantErr: true,
errMsg: "control characters",
},
{
name: "non-hex character g",
input: "0123456789abcdefg123456789abcdef",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "non-hex character z",
input: "0123456789abcdefz123456789abcdef",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "token with spaces",
input: "0123456789abcdef 0123456789abcdef",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "token with dash",
input: "0123456789abcdef-0123456789abcdef",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "token too short (31 chars)",
input: "0123456789abcdef0123456789abcde",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "token too long (129 chars)",
input: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "special characters",
input: "0123456789abcdef!@#$%^&*()abcdef",
wantErr: true,
errMsg: "hexadecimal",
},
{
name: "empty hex string (just numbers)",
input: "12345678901234567890123456789012",
want: "12345678901234567890123456789012",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := sanitizeSetupAuthToken(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("sanitizeSetupAuthToken() expected error, got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("sanitizeSetupAuthToken() error = %q, want substring %q", err.Error(), tt.errMsg)
}
} else {
if err != nil {
t.Errorf("sanitizeSetupAuthToken() unexpected error = %v", err)
return
}
if got != tt.want {
t.Errorf("sanitizeSetupAuthToken() = %q, want %q", got, tt.want)
}
}
})
}
}