mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add Go branch-coverage tests for pure config and helper packages
Adds table-driven branch-coverage unit tests for previously untested pure helper functions across internal/alerts/config, internal/config, internal/ai/safety, internal/ai/modelresolution, internal/operationreceipt, internal/models, internal/securityutil and pkg/securityutil. New test files only, with no source changes. Covers alert-config normalization and validation, sensitive-path and redaction classifiers, URL normalizers, provider model resolution, operation-receipt decoding, credential masking, and account-to-org role mapping. 57 TestBranchCov functions in 12 files, all vet and gofmt clean.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
package modelresolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
// TestBranchCovSelectRecommendedProviderModel exercises every branch of
|
||||
// SelectRecommendedProviderModel: the empty/unusable-catalog returns, the
|
||||
// "first usable becomes best" seeding, and each tie-break arm inside
|
||||
// recommendedModelBetter (blessed -> suitability rank -> Notable -> CreatedAt
|
||||
// presence -> CreatedAt value -> lexical sort key on Name -> sort key on ID ->
|
||||
// stable index tie-break).
|
||||
func TestBranchCovSelectRecommendedProviderModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
models []providers.ModelInfo
|
||||
wantID string
|
||||
wantOK bool
|
||||
// wantExtra lets a case assert a non-compared field on the winner (used
|
||||
// to prove the stable index tie-break returns the earlier entry).
|
||||
wantExtra func(*testing.T, providers.ModelInfo)
|
||||
}{
|
||||
{
|
||||
name: "nil slice returns zero value and false",
|
||||
models: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "empty slice returns zero value and false",
|
||||
models: []providers.ModelInfo{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "only blank IDs return false",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: ""},
|
||||
{ID: " "},
|
||||
{ID: "\t"},
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "only specialized entries return false",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "text-embedding-3"},
|
||||
{ID: "tts-1"},
|
||||
{ID: "omni-moderation"},
|
||||
{Name: "whisper transcription"},
|
||||
},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "specialized marker in name filters entry out",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "acme-1", Name: "Acme Embedding v2"},
|
||||
{ID: "gpt-4o"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "blank and specialized entries skipped, later valid wins",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: " "},
|
||||
{ID: ""},
|
||||
{ID: "text-embedding-3"},
|
||||
{ID: "gpt-4o"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "single usable chat model wins",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "blessed model beats non-blessed regardless of order",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o"},
|
||||
{ID: "qwen3:8b"},
|
||||
},
|
||||
wantID: "qwen3:8b",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "blessed model beats non-blessed when blessed is first",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "qwen3:8b"},
|
||||
{ID: "gpt-4o"},
|
||||
},
|
||||
wantID: "qwen3:8b",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "blessed equivalent id qwen3:latest also wins",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o"},
|
||||
{ID: "qwen3:latest"},
|
||||
},
|
||||
wantID: "qwen3:latest",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "blessed id matched case-insensitively and returned verbatim",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o"},
|
||||
{ID: " QWEN3:8B "},
|
||||
},
|
||||
wantID: " QWEN3:8B ",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "chat rank beats unknown rank",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o"},
|
||||
{ID: "research-only-alpha"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "chat rank beats unknown rank when unknown is first",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "research-only-alpha"},
|
||||
{ID: "gpt-4o"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "notable flag wins among equal rank",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o", Notable: false},
|
||||
{ID: "gpt-4.1", Notable: true},
|
||||
},
|
||||
wantID: "gpt-4.1",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "notable flag wins when notable is first",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4.1", Notable: true},
|
||||
{ID: "gpt-4o", Notable: false},
|
||||
},
|
||||
wantID: "gpt-4.1",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "presence of created timestamp beats absence",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o", Notable: true, CreatedAt: 0},
|
||||
{ID: "gpt-4.1", Notable: true, CreatedAt: 1700000000},
|
||||
},
|
||||
wantID: "gpt-4.1",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "newer created timestamp wins",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o", Notable: true, CreatedAt: 1700000000},
|
||||
{ID: "gpt-4.1", Notable: true, CreatedAt: 1800000000},
|
||||
},
|
||||
wantID: "gpt-4.1",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "newer created timestamp wins when newer is first",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4.1", Notable: true, CreatedAt: 1800000000},
|
||||
{ID: "gpt-4o", Notable: true, CreatedAt: 1700000000},
|
||||
},
|
||||
wantID: "gpt-4.1",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "lexical sort key by name breaks tie",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-z", Notable: true, Name: "Zeta"},
|
||||
{ID: "gpt-a", Notable: true, Name: "Alpha"},
|
||||
},
|
||||
wantID: "gpt-a",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "sort key falls back to id when name is empty",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "zzz-unknown"},
|
||||
{ID: "aaa-unknown"},
|
||||
},
|
||||
wantID: "aaa-unknown",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "stable index tie-break returns earlier entry",
|
||||
models: []providers.ModelInfo{
|
||||
{ID: "gpt-4o", Name: "Same", Description: "first"},
|
||||
{ID: "gpt-4o", Name: "Same", Description: "second"},
|
||||
},
|
||||
wantID: "gpt-4o",
|
||||
wantOK: true,
|
||||
wantExtra: func(t *testing.T, got providers.ModelInfo) {
|
||||
t.Helper()
|
||||
if got.Description != "first" {
|
||||
t.Fatalf("index tie-break returned Description %q, want %q (earlier index)", got.Description, "first")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, ok := SelectRecommendedProviderModel(tt.models)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("SelectRecommendedProviderModel ok = %v, want %v (got=%#v)", ok, tt.wantOK, got)
|
||||
}
|
||||
if !tt.wantOK {
|
||||
return
|
||||
}
|
||||
if got.ID != tt.wantID {
|
||||
t.Fatalf("SelectRecommendedProviderModel ID = %q, want %q", got.ID, tt.wantID)
|
||||
}
|
||||
if tt.wantExtra != nil {
|
||||
tt.wantExtra(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovIsModelUsableWithConfig exercises every branch of
|
||||
// IsModelUsableWithConfig (chatOnly=false): empty/whitespace model, retired
|
||||
// quickstart provider, nil config, configured-but-unconfigured provider, and
|
||||
// the configured-provider true path. It also pins the notable behaviour that
|
||||
// the non-chat variant does NOT perform chat-suitability filtering, so a
|
||||
// specialized model on a configured provider still reports usable.
|
||||
func TestBranchCovIsModelUsableWithConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.AIConfig
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil config returns false", cfg: nil, model: "openai:gpt-4o", want: false},
|
||||
{name: "empty model returns false", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "", want: false},
|
||||
{name: "whitespace-only model returns false", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: " ", want: false},
|
||||
{name: "quickstart provider returns false", cfg: &config.AIConfig{}, model: "quickstart:foo", want: false},
|
||||
{name: "quickstart provider returns false even with credentials present", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "quickstart:gpt-4o", want: false},
|
||||
{name: "explicit configured openai returns true", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "openai:gpt-4o", want: true},
|
||||
{name: "explicit configured anthropic returns true", cfg: &config.AIConfig{AnthropicAPIKey: "ak"}, model: "anthropic:claude-sonnet-4", want: true},
|
||||
{name: "explicit configured deepseek returns true", cfg: &config.AIConfig{DeepSeekAPIKey: "ds"}, model: "deepseek:deepseek-v4", want: true},
|
||||
{name: "explicit configured gemini returns true", cfg: &config.AIConfig{GeminiAPIKey: "gm"}, model: "gemini:gemini-3-flash", want: true},
|
||||
{name: "explicit configured openrouter returns true", cfg: &config.AIConfig{OpenRouterAPIKey: "or"}, model: "openrouter:openai/gpt-4o", want: true},
|
||||
{name: "ollama configured via base url returns true", cfg: &config.AIConfig{OllamaBaseURL: "http://localhost:11434"}, model: "ollama:llama3.2", want: true},
|
||||
{name: "whitespace-padded model is trimmed then accepted", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: " openai:gpt-4o ", want: true},
|
||||
{name: "heuristic gpt prefix resolves to openai configured", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "gpt-4o", want: true},
|
||||
{name: "heuristic claude prefix resolves to anthropic configured", cfg: &config.AIConfig{AnthropicAPIKey: "ak"}, model: "claude-sonnet-4", want: true},
|
||||
{name: "unconfigured anthropic returns false", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "anthropic:claude-sonnet-4", want: false},
|
||||
{name: "unrecognized local name defaults to ollama unconfigured", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "totally-unknown-local-model", want: false},
|
||||
{name: "specialized model still usable on configured provider (chatOnly=false skips suitability)", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "openai:text-embedding-3", want: true},
|
||||
{name: "specialized realtime model still usable on configured provider", cfg: &config.AIConfig{OpenAIAPIKey: "sk"}, model: "openai:gpt-4o-realtime", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := IsModelUsableWithConfig(tt.cfg, tt.model); got != tt.want {
|
||||
t.Fatalf("IsModelUsableWithConfig(cfg, %q) = %v, want %v", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovIsModelUsableWithConfig_NonNilZeroConfig pins the nil-vs-zero
|
||||
// distinction: a non-nil config with no credentials set must report false
|
||||
// (driven by HasProvider, not by the nil guard), distinguishing the
|
||||
// cfg==nil short-circuit from the unconfigured-provider branch.
|
||||
func TestBranchCovIsModelUsableWithConfig_NonNilZeroConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.AIConfig{}
|
||||
if IsModelUsableWithConfig(cfg, "openai:gpt-4o") {
|
||||
t.Fatalf("IsModelUsableWithConfig on zero-value non-nil config = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package safety
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This file adds branch-coverage tests for the path- and redaction-related
|
||||
// helpers in package safety. It targets branches that the existing test files
|
||||
// leave uncovered: empty/zero inputs, every switch arm (including defaults),
|
||||
// error/short-circuit returns, normalization edge cases, and the multi-arm
|
||||
// PEM/kv redaction state machine in RedactSensitiveText / RedactSensitiveValue.
|
||||
|
||||
func TestBranchCovIsSensitivePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantSense bool
|
||||
wantReasons []string // any of these substrings is acceptable
|
||||
}{
|
||||
// Empty / zero input -> early return.
|
||||
{"empty path", "", false, nil},
|
||||
|
||||
// System credential databases: switch-case arms.
|
||||
{"etc shadow", "/etc/shadow", true, []string{"system credential file"}},
|
||||
{"etc gshadow", "/etc/gshadow", true, []string{"system credential file"}},
|
||||
{"etc sudoers", "/etc/sudoers", true, []string{"system credential file"}},
|
||||
|
||||
// filepath.Clean + ToLower normalization paths.
|
||||
{"cleaned dotdot shadow", "/etc/../etc/shadow", true, []string{"system credential file"}},
|
||||
{"trailing slash shadow", "/etc/shadow/", true, []string{"system credential file"}},
|
||||
{"uppercase shadow lowercased", "/ETC/SHADOW", true, []string{"system credential file"}},
|
||||
{"mixed-case Sudoers", "/EtC/SuDoErS", true, []string{"system credential file"}},
|
||||
|
||||
// SSH directory contains-arm.
|
||||
{"ssh dir config", "/home/u/.ssh/config", true, []string{"ssh key/config directory"}},
|
||||
{"ssh dir anywhere", "/root/.ssh/random", true, []string{"ssh key/config directory"}},
|
||||
|
||||
// SSH key suffix loop, every name. NOTE: these paths deliberately do NOT
|
||||
// contain "/.ssh/" so they fall through the contains-arm and reach the
|
||||
// HasSuffix("/<name>") arm (otherwise the contains-arm wins and returns
|
||||
// "ssh key/config directory" first).
|
||||
{"ssh id_rsa suffix", "/tmp/id_rsa", true, []string{"ssh key material"}},
|
||||
{"ssh id_ed25519 suffix", "/tmp/id_ed25519", true, []string{"ssh key material"}},
|
||||
{"ssh authorized_keys suffix", "/root/authorized_keys", true, []string{"ssh key material"}},
|
||||
{"ssh known_hosts suffix", "/root/known_hosts", true, []string{"ssh key material"}},
|
||||
|
||||
// Bare relative ssh filename (no slash) does NOT match the suffix arm.
|
||||
{"bare id_rsa no slash", "id_rsa", false, nil},
|
||||
|
||||
// Secrets directory prefix loop, every prefix.
|
||||
{"run secrets prefix", "/run/secrets/db", true, []string{"secrets directory"}},
|
||||
{"var run secrets prefix", "/var/run/secrets/db", true, []string{"secrets directory"}},
|
||||
{"etc secrets prefix", "/etc/secrets/db", true, []string{"secrets directory"}},
|
||||
{"secrets root prefix", "/secrets/db", true, []string{"secrets directory"}},
|
||||
|
||||
// /proc/<pid>/environ combined predicate.
|
||||
{"proc environ", "/proc/1/environ", true, []string{"process environment file"}},
|
||||
// Only one half of the /proc/ + /environ predicate -> not sensitive.
|
||||
{"proc without environ", "/proc/1/status", false, nil},
|
||||
{"environ without proc", "/tmp/environ", false, nil},
|
||||
|
||||
// Private key / cert extension loop, every ext.
|
||||
{"pem ext", "/srv/tls/server.pem", true, []string{"private key or certificate file"}},
|
||||
{"key ext", "/srv/tls/server.key", true, []string{"private key or certificate file"}},
|
||||
{"p12 ext", "/srv/cert.p12", true, []string{"private key or certificate file"}},
|
||||
{"pfx ext", "/srv/cert.pfx", true, []string{"private key or certificate file"}},
|
||||
|
||||
// ai.enc store: HasSuffix OR Contains arms.
|
||||
{"ai enc suffix", "/var/lib/pulse/ai.enc", true, []string{"pulse encrypted AI provider config store"}},
|
||||
{"ai enc contains embedded", "/tmp/ai.enc.bak", true, []string{"pulse encrypted AI provider config store"}},
|
||||
|
||||
// Credentials dotfile suffix loop, every base name.
|
||||
{"env dotfile", "/app/.env", true, []string{"credentials dotfile"}},
|
||||
{"npmrc dotfile", "/home/u/.npmrc", true, []string{"credentials dotfile"}},
|
||||
{"pypirc dotfile", "/home/u/.pypirc", true, []string{"credentials dotfile"}},
|
||||
{"netrc dotfile", "/home/u/.netrc", true, []string{"credentials dotfile"}},
|
||||
{"aws credentials", "/home/u/.aws/credentials", true, []string{"credentials dotfile"}},
|
||||
// Bare relative ".env" (no slash) -> suffix arm does NOT match.
|
||||
{"bare env no slash", ".env", false, nil},
|
||||
|
||||
// Wholly benign path -> final return (false, "").
|
||||
{"benign readme", "/srv/app/README.md", false, nil},
|
||||
{"benign source", "/srv/app/main.go", false, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, reason := IsSensitivePath(tt.path)
|
||||
if got != tt.wantSense {
|
||||
t.Fatalf("IsSensitivePath(%q) sense = %v, want %v (reason=%q)", tt.path, got, tt.wantSense, reason)
|
||||
}
|
||||
if !tt.wantSense {
|
||||
if reason != "" {
|
||||
t.Errorf("IsSensitivePath(%q) benign returned reason %q, want empty", tt.path, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
// For sensitive hits, reason must be non-empty and contain an expected substring.
|
||||
if reason == "" {
|
||||
t.Fatalf("IsSensitivePath(%q) returned true with empty reason", tt.path)
|
||||
}
|
||||
matched := false
|
||||
for _, want := range tt.wantReasons {
|
||||
if strings.Contains(reason, want) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("IsSensitivePath(%q) reason = %q, want one of %v", tt.path, reason, tt.wantReasons)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovCommandTouchesSensitivePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd string
|
||||
wantSense bool
|
||||
wantReasons []string
|
||||
}{
|
||||
// Empty command -> early return. (Lowercasing "" still yields "".)
|
||||
{"empty command", "", false, nil},
|
||||
|
||||
// Each high-confidence substring arm of the loop.
|
||||
{"etc shadow substring", "cat /etc/shadow", true, []string{"references sensitive path"}},
|
||||
{"etc gshadow substring", "cat /etc/gshadow", true, []string{"references sensitive path"}},
|
||||
{"etc sudoers substring", "grep root /etc/sudoers", true, []string{"references sensitive path"}},
|
||||
{"run secrets substring", "ls /run/secrets/db", true, []string{"references sensitive path"}},
|
||||
{"var run secrets substring", "ls /var/run/secrets/db", true, []string{"references sensitive path"}},
|
||||
{"ssh substring", "cat /home/u/.ssh/id_rsa", true, []string{"references sensitive path"}},
|
||||
{"ai enc substring", "cp /var/lib/pulse/ai.enc /tmp", true, []string{"references sensitive path"}},
|
||||
|
||||
// Case-insensitivity: input is lowercased before matching.
|
||||
{"uppercase etc shadow", "CAT /ETC/SHADOW", true, []string{"references sensitive path"}},
|
||||
|
||||
// Combined /proc/ + environ predicate: both substrings required.
|
||||
{"proc environ combined", "cat /proc/42/environ", true, []string{"references process environment file"}},
|
||||
{"proc only not environ", "cat /proc/42/status", false, nil},
|
||||
{"environ only not proc", "cat /tmp/environ", false, nil},
|
||||
|
||||
// Benign command -> final return (false, "").
|
||||
{"benign ls", "ls -la /tmp", false, nil},
|
||||
{"benign ps", "ps aux", false, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, reason := CommandTouchesSensitivePath(tt.cmd)
|
||||
if got != tt.wantSense {
|
||||
t.Fatalf("CommandTouchesSensitivePath(%q) sense = %v, want %v (reason=%q)", tt.cmd, got, tt.wantSense, reason)
|
||||
}
|
||||
if !tt.wantSense {
|
||||
if reason != "" {
|
||||
t.Errorf("CommandTouchesSensitivePath(%q) benign returned reason %q, want empty", tt.cmd, reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatalf("CommandTouchesSensitivePath(%q) returned true with empty reason", tt.cmd)
|
||||
}
|
||||
matched := false
|
||||
for _, want := range tt.wantReasons {
|
||||
if strings.Contains(reason, want) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("CommandTouchesSensitivePath(%q) reason = %q, want one of %v", tt.cmd, reason, tt.wantReasons)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovIsSensitiveValueCarrierFieldName(t *testing.T) {
|
||||
positive := []string{
|
||||
"value", "values", "default", "defaults", "example", "examples", "enum", "const",
|
||||
}
|
||||
negative := []string{
|
||||
// Empty / whitespace-only -> normalized "" -> switch default.
|
||||
"",
|
||||
" ",
|
||||
"\t\n",
|
||||
// Unrelated names -> default arm.
|
||||
"description",
|
||||
"username",
|
||||
"type",
|
||||
// Looks related but normalizes to something not in the switch.
|
||||
"defaultvalue", // default + value concatenated
|
||||
"exampleurl",
|
||||
}
|
||||
|
||||
for _, n := range positive {
|
||||
if !IsSensitiveValueCarrierFieldName(n) {
|
||||
t.Errorf("IsSensitiveValueCarrierFieldName(%q) = false, want true", n)
|
||||
}
|
||||
}
|
||||
for _, n := range negative {
|
||||
if IsSensitiveValueCarrierFieldName(n) {
|
||||
t.Errorf("IsSensitiveValueCarrierFieldName(%q) = true, want false", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalization: case-insensitive and punctuation stripping.
|
||||
normalizations := map[string]string{
|
||||
"VALUE": "value",
|
||||
"Defaults": "defaults",
|
||||
"Example": "example",
|
||||
" enum ": "enum",
|
||||
"ex-ample": "example", // hyphen stripped -> "example" matches
|
||||
}
|
||||
for raw, want := range normalizations {
|
||||
// Both the raw and the equivalent canonical form must classify identically.
|
||||
if IsSensitiveValueCarrierFieldName(raw) != IsSensitiveValueCarrierFieldName(want) {
|
||||
t.Errorf("normalization mismatch: raw=%q canonical=%q classify differently", raw, want)
|
||||
}
|
||||
if !IsSensitiveValueCarrierFieldName(raw) {
|
||||
t.Errorf("IsSensitiveValueCarrierFieldName(%q) = false, want true (canonical %q)", raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovIsSensitiveFieldName(t *testing.T) {
|
||||
positive := []string{
|
||||
"password", "passwd", "passphrase", "secret", "token", "apikey",
|
||||
"clientsecret", "privatekey", "accesstoken", "refreshtoken",
|
||||
"authorization", "xapikey", "credential", "credentials",
|
||||
}
|
||||
negative := []string{
|
||||
// Empty -> early return (normalized == "").
|
||||
"",
|
||||
" ",
|
||||
// Unrelated names -> switch default.
|
||||
"username",
|
||||
"displayname",
|
||||
"email",
|
||||
// Sensitive-looking but normalizes away from the list.
|
||||
"secretpassword", // concatenated, not in list
|
||||
}
|
||||
|
||||
for _, n := range positive {
|
||||
if !IsSensitiveFieldName(n) {
|
||||
t.Errorf("IsSensitiveFieldName(%q) = false, want true", n)
|
||||
}
|
||||
}
|
||||
for _, n := range negative {
|
||||
if IsSensitiveFieldName(n) {
|
||||
t.Errorf("IsSensitiveFieldName(%q) = true, want false", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalization: punctuation/case-insensitivity maps to canonical arms.
|
||||
cases := map[string]string{
|
||||
"API-Key": "apikey",
|
||||
"api_key": "apikey",
|
||||
"ClientSecret": "clientsecret",
|
||||
"client-secret": "clientsecret",
|
||||
"X-API-Key": "xapikey",
|
||||
"Password": "password",
|
||||
"password!!!": "password", // non-alnum stripped
|
||||
" token ": "token",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if IsSensitiveFieldName(raw) != IsSensitiveFieldName(want) {
|
||||
t.Errorf("normalization mismatch: raw=%q canonical=%q classify differently", raw, want)
|
||||
}
|
||||
if !IsSensitiveFieldName(raw) {
|
||||
t.Errorf("IsSensitiveFieldName(%q) = false, want true (canonical %q)", raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovRedactSensitiveText(t *testing.T) {
|
||||
// Empty input -> early return ("", 0).
|
||||
t.Run("empty input returns empty", func(t *testing.T) {
|
||||
out, n := RedactSensitiveText("")
|
||||
if out != "" || n != 0 {
|
||||
t.Fatalf("RedactSensitiveText(\"\") = (%q, %d), want (\"\", 0)", out, n)
|
||||
}
|
||||
})
|
||||
|
||||
// Newline/whitespace-only input: no redactions. The "drop empty lines" loop
|
||||
// only drops *truly-empty* lines; whitespace-only lines (e.g. " ") survive.
|
||||
// For input "\n\n \n" the surviving line is " ", so output is " " (count 0).
|
||||
t.Run("whitespace lines are not secret and pass through", func(t *testing.T) {
|
||||
out, n := RedactSensitiveText("\n\n \n")
|
||||
if n != 0 {
|
||||
t.Fatalf("RedactSensitiveText(%q) count = %d, want 0", "\n\n \n", n)
|
||||
}
|
||||
if out != " " {
|
||||
t.Errorf("RedactSensitiveText(%q) = %q, want %q (only truly-empty lines are dropped)", "\n\n \n", out, " ")
|
||||
}
|
||||
})
|
||||
|
||||
// kvSecretRE arm: only the value portion is replaced, key context preserved.
|
||||
t.Run("kv secret value redacted key preserved", func(t *testing.T) {
|
||||
out, n := RedactSensitiveText("password: hunter2")
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 redaction, got %d (%q)", n, out)
|
||||
}
|
||||
if strings.Contains(out, "hunter2") {
|
||||
t.Errorf("value leaked: %q", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, "password:") || !strings.Contains(out, "[REDACTED]") {
|
||||
t.Errorf("key context lost: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
// PEM state machine: begin marker -> "[REDACTED PEM BLOCK]", body dropped,
|
||||
// end marker reached -> inPEM=false and the marker line is also dropped.
|
||||
t.Run("pem block begin body and end", func(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"prelude",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"ZmFrZS1iYXNlNjQtcGVtLmJvZHk=",
|
||||
"-----END PRIVATE KEY-----",
|
||||
"epilogue",
|
||||
}, "\n")
|
||||
out, n := RedactSensitiveText(input)
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly 1 redaction (begin marker), got %d (%q)", n, out)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED PEM BLOCK]") {
|
||||
t.Errorf("missing PEM block marker: %q", out)
|
||||
}
|
||||
// Both base64 body and END marker must be stripped.
|
||||
if strings.Contains(out, "ZmFrZS1iYXNlNjQtcGVtLmJvZHk=") {
|
||||
t.Errorf("PEM body leaked: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "END PRIVATE KEY") {
|
||||
t.Errorf("PEM END marker should be dropped, present in %q", out)
|
||||
}
|
||||
// Lines outside the block are retained.
|
||||
if !strings.Contains(out, "prelude") || !strings.Contains(out, "epilogue") {
|
||||
t.Errorf("non-PEM lines dropped: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
// PEM state machine: unterminated block -> inPEM stays true to EOF,
|
||||
// every subsequent line (including a would-be secret kv line) is blanked.
|
||||
t.Run("unterminated pem swallows rest", func(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"-----BEGIN CERTIFICATE-----",
|
||||
"line1",
|
||||
"password: should-not-be-processed-by-kv-path",
|
||||
}, "\n")
|
||||
out, n := RedactSensitiveText(input)
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly 1 redaction, got %d (%q)", n, out)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED PEM BLOCK]") {
|
||||
t.Errorf("missing PEM block marker: %q", out)
|
||||
}
|
||||
// The kv line inside the unterminated PEM is blanked, NOT kv-redacted.
|
||||
if strings.Contains(out, "should-not-be-processed-by-kv-path") {
|
||||
t.Errorf("PEM body line leaked: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "[REDACTED]") {
|
||||
t.Errorf("kv redactor should not run inside PEM block: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
// redactLineSecretPatterns: each provider-token regex produces its own marker.
|
||||
// Covers AWS, JWT, OpenAI-style, Google API key, GitHub token arms.
|
||||
t.Run("provider token patterns", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
}{
|
||||
{"aws access key", "id=AKIAIOSFODNN7EXAMPLE"},
|
||||
{"jwt", "tok=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"},
|
||||
{"openai key", "k=sk-abcdef123456"},
|
||||
{"google api key", "k=AIzaSyABCdefghIJKlmnoPQRstuVWxyz1234567"},
|
||||
{"github token", "t=ghp_abcdefghijklmnopqrstuvwxyz"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
out, n := RedactSensitiveText(c.line)
|
||||
if n == 0 {
|
||||
t.Fatalf("expected a redaction, got 0 for %q", c.line)
|
||||
}
|
||||
if strings.Contains(out, "AKIAIOSFODNN7EXAMPLE") && c.name == "aws access key" {
|
||||
t.Errorf("aws key leaked: %q", out)
|
||||
}
|
||||
// All markers are documented as bracketed; ensure no raw secret remains
|
||||
// and that the result contains a "[REDACTED" token.
|
||||
if !strings.Contains(out, "[REDACTED") {
|
||||
t.Errorf("missing redaction marker in %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Header / URL / quoted-JSON arms (distinct from kvSecretRE).
|
||||
t.Run("header and url and json forms", func(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`Authorization: Bearer abc123def456`,
|
||||
`x-api-key: somekeyvalue`,
|
||||
`https://user:pass@example.test/`,
|
||||
`{"secret":"shh"}`,
|
||||
`GET https://example.test/v1?key=AIzaSyAbcDefGhiJklMnoPqrStuVwxYz1234567`,
|
||||
}, "\n")
|
||||
out, n := RedactSensitiveText(input)
|
||||
if n == 0 {
|
||||
t.Fatalf("expected redactions, got 0")
|
||||
}
|
||||
for _, leak := range []string{"abc123def456", "somekeyvalue", "user:pass@", "shh", "AIzaSyAbcDefGhiJklMnoPqrStuVwxYz1234567"} {
|
||||
if strings.Contains(out, leak) {
|
||||
t.Errorf("leaked %q in %q", leak, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// No-match text passes through unchanged with count 0.
|
||||
t.Run("no secrets returns unchanged", func(t *testing.T) {
|
||||
in := "just a regular log line with no secrets"
|
||||
out, n := RedactSensitiveText(in)
|
||||
if out != in || n != 0 {
|
||||
t.Fatalf("RedactSensitiveText(%q) = (%q, %d), want unchanged/0", in, out, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovRedactSensitiveValue(t *testing.T) {
|
||||
// Branch: input "" -> RedactSensitiveText returns ("", 0); TrimSpace == "" -> return as-is.
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
out, n := RedactSensitiveValue("")
|
||||
if out != "" || n != 0 {
|
||||
t.Fatalf("RedactSensitiveValue(\"\") = (%q, %d), want (\"\", 0)", out, n)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: redacted text is whitespace-only (non-empty but trims to "").
|
||||
// RedactSensitiveText leaves the surviving whitespace line intact (" "),
|
||||
// and the TrimSpace(redacted)=="" arm returns that untrimmed value with the
|
||||
// inner count (0) WITHOUT forcing the [REDACTED] marker.
|
||||
t.Run("whitespace only trims empty short circuits", func(t *testing.T) {
|
||||
out, n := RedactSensitiveValue("\n\n \n")
|
||||
if n != 0 {
|
||||
t.Fatalf("RedactSensitiveValue(%q) count = %d, want 0", "\n\n \n", n)
|
||||
}
|
||||
if out != " " {
|
||||
t.Errorf("RedactSensitiveValue(%q) = %q, want %q (TrimSpace arm preserves untrimmed redacted text)", "\n\n \n", out, " ")
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: redacted already equals the canonical marker -> returned unchanged,
|
||||
// count NOT incremented. Achievable by feeding the literal marker string.
|
||||
t.Run("already redacted marker", func(t *testing.T) {
|
||||
out, n := RedactSensitiveValue("[REDACTED]")
|
||||
if out != "[REDACTED]" {
|
||||
t.Fatalf("RedactSensitiveValue(\"[REDACTED]\") = %q, want \"[REDACTED]\"", out)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected count 0 for already-redacted marker, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
// Branch: non-empty, non-marker plain value -> final arm forces the marker
|
||||
// and increments count by 1 (key-context collapses any non-secret value).
|
||||
t.Run("plain value collapsed to marker", func(t *testing.T) {
|
||||
out, n := RedactSensitiveValue("just a plain string, nothing secret-shaped")
|
||||
if out != "[REDACTED]" {
|
||||
t.Fatalf("RedactSensitiveValue(plain) = %q, want \"[REDACTED]\"", out)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected count 1 for forced collapse, got %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
// A value that itself contains a redactable token: count reflects the inner
|
||||
// redaction (>=1) PLUS the final collapse increment.
|
||||
t.Run("value with embedded token then collapsed", func(t *testing.T) {
|
||||
out, n := RedactSensitiveValue("token=ghp_abcdefghijklmnopqrstuvwxyz")
|
||||
if out != "[REDACTED]" {
|
||||
t.Fatalf("RedactSensitiveValue(embedded) = %q, want \"[REDACTED]\"", out)
|
||||
}
|
||||
if n < 2 {
|
||||
t.Errorf("expected count >= 2 (inner redaction + collapse), got %d", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config"
|
||||
)
|
||||
|
||||
// This file adds branch-coverage tests for the identity-normalization helpers
|
||||
// in internal/alerts/config/identity.go:
|
||||
// - CanonicalAlertResourceType (previously only exercised indirectly via the
|
||||
// alerts facade wrapper; its switch arms and trim/lowercase normalization
|
||||
// are covered directly here).
|
||||
// - CanonicalResourceTypeKeys (legacy-alias rejection paths returning a true
|
||||
// nil, case-insensitivity, and equivalence between display aliases and
|
||||
// their canonical hyphenated forms).
|
||||
|
||||
// TestBranchCovCanonicalAlertResourceType exercises every switch arm of
|
||||
// CanonicalAlertResourceType directly, including the whitespace trimming and
|
||||
// case-folding performed before the switch, each multi-token display alias,
|
||||
// every vsphere/virtual-machine variant, and the default passthrough.
|
||||
func TestBranchCovCanonicalAlertResourceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// Normalization: leading/trailing whitespace is trimmed and the value
|
||||
// is lower-cased before the switch is consulted.
|
||||
{name: "trim and lowercase", in: " Kubernetes Pod ", want: "pod"},
|
||||
{name: "all uppercased input", in: "VMWARE VM", want: "vmware-vm"},
|
||||
{name: "mixed case input", in: "TruENas SYStem", want: "truenas-system"},
|
||||
|
||||
// Multi-token display aliases -> hyphenated canonical forms.
|
||||
{name: "kubernetes cluster", in: "kubernetes cluster", want: "k8s-cluster"},
|
||||
{name: "kubernetes node", in: "kubernetes node", want: "k8s-node"},
|
||||
{name: "kubernetes deployment", in: "kubernetes deployment", want: "k8s-deployment"},
|
||||
{name: "kubernetes namespace", in: "kubernetes namespace", want: "k8s-namespace"},
|
||||
{name: "kubernetes pod", in: "kubernetes pod", want: "pod"},
|
||||
{name: "truenas system", in: "truenas system", want: "truenas-system"},
|
||||
{name: "truenas pool", in: "truenas pool", want: "truenas-pool"},
|
||||
{name: "truenas dataset", in: "truenas dataset", want: "truenas-dataset"},
|
||||
{name: "truenas disk", in: "truenas disk", want: "truenas-disk"},
|
||||
|
||||
// vmware/vsphere host variants collapse to vmware-host.
|
||||
{name: "vmware host", in: "vmware host", want: "vmware-host"},
|
||||
{name: "vsphere host", in: "vsphere host", want: "vmware-host"},
|
||||
|
||||
// vmware/vsphere vm + "virtual machine" variants collapse to vmware-vm.
|
||||
{name: "vmware vm", in: "vmware vm", want: "vmware-vm"},
|
||||
{name: "vsphere vm", in: "vsphere vm", want: "vmware-vm"},
|
||||
{name: "vmware virtual machine", in: "vmware virtual machine", want: "vmware-vm"},
|
||||
{name: "vsphere virtual machine", in: "vsphere virtual machine", want: "vmware-vm"},
|
||||
|
||||
// datastore / network variants.
|
||||
{name: "vmware datastore", in: "vmware datastore", want: "vmware-datastore"},
|
||||
{name: "vsphere datastore", in: "vsphere datastore", want: "vmware-datastore"},
|
||||
{name: "vmware network", in: "vmware network", want: "vmware-network"},
|
||||
{name: "vsphere network", in: "vsphere network", want: "vmware-network"},
|
||||
|
||||
// Default arm: unknown tokens and already-canonical tokens pass through
|
||||
// unchanged (after lowercasing/trimming).
|
||||
{name: "unknown type passthrough", in: "custom-thing", want: "custom-thing"},
|
||||
{name: "already canonical k8s-cluster", in: "k8s-cluster", want: "k8s-cluster"},
|
||||
{name: "already canonical vmware-vm", in: "vmware-vm", want: "vmware-vm"},
|
||||
{name: "single token node", in: "node", want: "node"},
|
||||
|
||||
// Empty / whitespace-only input: TrimSpace yields "" which hits default.
|
||||
{name: "empty string", in: "", want: ""},
|
||||
{name: "whitespace only", in: " ", want: ""},
|
||||
{name: "tabs only", in: "\t\t", want: ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := alertconfig.CanonicalAlertResourceType(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("CanonicalAlertResourceType(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCanonicalResourceTypeKeysLegacyNil verifies that inputs which
|
||||
// are rejected as unsupported legacy aliases (or empty) return a true nil
|
||||
// slice rather than an empty non-nil slice. This exercises both the
|
||||
// `typeKey == ""` branch and the `isUnsupportedLegacyAlertResourceType` branch
|
||||
// of the early return, covering legacy tokens sourced from the
|
||||
// unifiedresources alias map as well as the local switch in
|
||||
// isUnsupportedLegacyAlertResourceType that are not asserted by the existing
|
||||
// alerts-package test.
|
||||
func TestBranchCovCanonicalResourceTypeKeysLegacyNil(t *testing.T) {
|
||||
// Each of these must yield a nil slice (the `return nil` path).
|
||||
legacyCases := []string{
|
||||
// Empty / blank -> typeKey == "" branch.
|
||||
"",
|
||||
" ",
|
||||
// Local switch arms in isUnsupportedLegacyAlertResourceType not
|
||||
// previously asserted.
|
||||
"qemu", "lxc", "docker container", "dockercontainer", "docker service",
|
||||
"dockerservice", "k8s pod", "kubernetes", "kubernetes-cluster",
|
||||
"agent disk", "agentdisk", "pbs server", "pbsserver", "pmg server",
|
||||
"proxmox mail gateway",
|
||||
// unifiedresources-backed aliases.
|
||||
"system_container", "docker_container", "app_container",
|
||||
"docker_host", "kubernetes_cluster", "k8s_cluster",
|
||||
// Sanity: a couple already covered by the alerts-package test still
|
||||
// return nil here when invoked through the config package directly.
|
||||
"host", "docker", "dockerhost", "k8s",
|
||||
}
|
||||
|
||||
for idx, in := range legacyCases {
|
||||
// Include the index so subtest names stay unique even when two tokens
|
||||
// differ only by separator (e.g. "docker container" vs "docker_container"),
|
||||
// which the test runner would otherwise collapse via space->underscore.
|
||||
name := fmt.Sprintf("legacy_%02d_%q", idx, in)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := alertconfig.CanonicalResourceTypeKeys(in)
|
||||
if got != nil {
|
||||
t.Errorf("CanonicalResourceTypeKeys(%q) = %v, want nil", in, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCanonicalResourceTypeKeysCaseInsensitive verifies that the
|
||||
// case-folding performed inside CanonicalAlertResourceType makes
|
||||
// CanonicalResourceTypeKeys case-insensitive on its input, exercising the
|
||||
// integration between the two functions for representative type families.
|
||||
func TestBranchCovCanonicalResourceTypeKeysCaseInsensitive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{name: "upper VM", in: "VM", want: []string{"vm", "guest"}},
|
||||
{name: "mixed Node", in: "Node", want: []string{"node"}},
|
||||
{name: "upper AGENT-DISK", in: "AGENT-DISK", want: []string{"agent-disk", "agent", "storage"}},
|
||||
{name: "upper STORAGE", in: "STORAGE", want: []string{"storage"}},
|
||||
{name: "title Kubernetes Cluster", in: "Kubernetes Cluster", want: []string{"k8s-cluster", "node"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := alertconfig.CanonicalResourceTypeKeys(tc.in)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("CanonicalResourceTypeKeys(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCanonicalResourceTypeKeysDisplayAliasEquivalence documents and
|
||||
// pins the fact that a spaced display alias (e.g. "kubernetes cluster") and its
|
||||
// hyphenated canonical form ("k8s-cluster") produce identical key sets. This is
|
||||
// because CanonicalAlertResourceType normalizes the spaced form to the
|
||||
// hyphenated form before CanonicalResourceTypeKeys' switch is evaluated. See
|
||||
// the report for the related dead-code observation.
|
||||
func TestBranchCovCanonicalResourceTypeKeysDisplayAliasEquivalence(t *testing.T) {
|
||||
pairs := []struct {
|
||||
display string
|
||||
canonical string
|
||||
}{
|
||||
{"kubernetes cluster", "k8s-cluster"},
|
||||
{"kubernetes node", "k8s-node"},
|
||||
{"kubernetes deployment", "k8s-deployment"},
|
||||
{"kubernetes namespace", "k8s-namespace"},
|
||||
{"truenas system", "truenas-system"},
|
||||
{"truenas pool", "truenas-pool"},
|
||||
{"truenas disk", "truenas-disk"},
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
t.Run(p.display, func(t *testing.T) {
|
||||
fromDisplay := alertconfig.CanonicalResourceTypeKeys(p.display)
|
||||
fromCanonical := alertconfig.CanonicalResourceTypeKeys(p.canonical)
|
||||
if !reflect.DeepEqual(fromDisplay, fromCanonical) {
|
||||
t.Errorf("display %q -> %v, canonical %q -> %v; expected identical key sets",
|
||||
p.display, fromDisplay, p.canonical, fromCanonical)
|
||||
}
|
||||
// And both must be non-nil / non-empty for these valid types.
|
||||
if len(fromDisplay) == 0 {
|
||||
t.Errorf("CanonicalResourceTypeKeys(%q) returned no keys", p.display)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovCanonicalResourceTypeKeysDefaultPassthrough covers the default
|
||||
// switch arm (unknown type returned as the sole key) directly through the
|
||||
// config package, including a type that contains characters that would be a
|
||||
// display alias only if it matched a known arm.
|
||||
func TestBranchCovCanonicalResourceTypeKeysDefaultPassthrough(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{name: "unknown custom type", in: "widget", want: []string{"widget"}},
|
||||
{name: "unknown with dashes", in: "foo-bar-baz", want: []string{"foo-bar-baz"}},
|
||||
{name: "empty after normalize default", in: " ", want: nil},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := alertconfig.CanonicalResourceTypeKeys(tc.in)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("CanonicalResourceTypeKeys(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config"
|
||||
)
|
||||
|
||||
// This file exercises genuinely uncovered branches of the pure/near-pure
|
||||
// normalization helpers in internal/alerts/config/normalize.go. Test names use
|
||||
// the BranchCov prefix so `-run BranchCov` selects them in isolation.
|
||||
|
||||
// floatEq compares two float64 values with a tiny tolerance to avoid surprises
|
||||
// from any future floating-point derivation; the current implementations only
|
||||
// perform exact assignments/subtractions so equality holds exactly.
|
||||
func floatEq(a, b float64) bool {
|
||||
return a == b
|
||||
}
|
||||
|
||||
// htEq compares two HysteresisThreshold values by value.
|
||||
func htEq(a, b alertconfig.HysteresisThreshold) bool {
|
||||
return floatEq(a.Trigger, b.Trigger) && floatEq(a.Clear, b.Clear)
|
||||
}
|
||||
|
||||
// TestBranchCovEnsureValidHysteresis covers every branch of EnsureValidHysteresis:
|
||||
// nil receiver, disabled (Trigger<=0), Clear>=Trigger with positive result,
|
||||
// Clear>=Trigger with negative result clamped to 0, and the no-op valid case.
|
||||
func TestBranchCovEnsureValidHysteresis(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *alertconfig.HysteresisThreshold
|
||||
metric string
|
||||
wantTrigger float64
|
||||
wantClear float64
|
||||
}{
|
||||
{
|
||||
name: "nil threshold returns without panic",
|
||||
input: nil,
|
||||
metric: "nil.metric",
|
||||
},
|
||||
{
|
||||
name: "trigger zero leaves threshold untouched (disabled)",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: 0, Clear: 50},
|
||||
metric: "disabled.zero",
|
||||
wantTrigger: 0,
|
||||
wantClear: 50,
|
||||
},
|
||||
{
|
||||
name: "trigger negative leaves threshold untouched (disabled)",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: -5, Clear: 90},
|
||||
metric: "disabled.negative",
|
||||
wantTrigger: -5,
|
||||
wantClear: 90,
|
||||
},
|
||||
{
|
||||
name: "clear greater than trigger auto-fixes to trigger-5",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: 80, Clear: 90},
|
||||
metric: "cpu",
|
||||
wantTrigger: 80,
|
||||
wantClear: 75,
|
||||
},
|
||||
{
|
||||
name: "clear equals trigger boundary still triggers fix (>=)",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: 80, Clear: 80},
|
||||
metric: "boundary.equal",
|
||||
wantTrigger: 80,
|
||||
wantClear: 75,
|
||||
},
|
||||
{
|
||||
name: "fix clamps to zero when trigger less than 5",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: 3, Clear: 10},
|
||||
metric: "clamp.zero",
|
||||
wantTrigger: 3,
|
||||
wantClear: 0,
|
||||
},
|
||||
{
|
||||
name: "already valid threshold is untouched",
|
||||
input: &alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
metric: "valid",
|
||||
wantTrigger: 85,
|
||||
wantClear: 80,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
alertconfig.EnsureValidHysteresis(tc.input, tc.metric)
|
||||
if tc.input == nil {
|
||||
return
|
||||
}
|
||||
if !floatEq(tc.input.Trigger, tc.wantTrigger) {
|
||||
t.Fatalf("Trigger = %v, want %v", tc.input.Trigger, tc.wantTrigger)
|
||||
}
|
||||
if !floatEq(tc.input.Clear, tc.wantClear) {
|
||||
t.Fatalf("Clear = %v, want %v", tc.input.Clear, tc.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeStorageDefaults covers all three trigger/clear branches
|
||||
// of NormalizeStorageDefaults including the inner clamp-to-zero sub-branch.
|
||||
func TestBranchCovNormalizeStorageDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
trigger float64
|
||||
clear float64
|
||||
wantTrigger float64
|
||||
wantClear float64
|
||||
}{
|
||||
{
|
||||
name: "negative trigger resets to full default pair",
|
||||
trigger: -10,
|
||||
clear: 1234,
|
||||
wantTrigger: 85,
|
||||
wantClear: 80,
|
||||
},
|
||||
{
|
||||
name: "zero trigger forces clear to zero even when clear was positive",
|
||||
trigger: 0,
|
||||
clear: 50,
|
||||
wantTrigger: 0,
|
||||
wantClear: 0,
|
||||
},
|
||||
{
|
||||
name: "positive trigger with non-positive clear derives clear as trigger-5",
|
||||
trigger: 90,
|
||||
clear: 0,
|
||||
wantTrigger: 90,
|
||||
wantClear: 85,
|
||||
},
|
||||
{
|
||||
name: "positive trigger with negative clear derives clear and clamps to zero when trigger<5",
|
||||
trigger: 3,
|
||||
clear: -1,
|
||||
wantTrigger: 3,
|
||||
wantClear: 0,
|
||||
},
|
||||
{
|
||||
name: "already populated valid pair is left untouched",
|
||||
trigger: 95,
|
||||
clear: 88,
|
||||
wantTrigger: 95,
|
||||
wantClear: 88,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
StorageDefault: alertconfig.HysteresisThreshold{
|
||||
Trigger: tc.trigger,
|
||||
Clear: tc.clear,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeStorageDefaults(cfg)
|
||||
got := cfg.StorageDefault
|
||||
if !floatEq(got.Trigger, tc.wantTrigger) {
|
||||
t.Fatalf("Trigger = %v, want %v", got.Trigger, tc.wantTrigger)
|
||||
}
|
||||
if !floatEq(got.Clear, tc.wantClear) {
|
||||
t.Fatalf("Clear = %v, want %v", got.Clear, tc.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeDockerThreshold covers each branch of
|
||||
// NormalizeDockerThreshold, including the early-return Trigger==0 path that
|
||||
// intentionally preserves a positive Clear (unlike NormalizeStorageDefaults).
|
||||
func TestBranchCovNormalizeDockerThreshold(t *testing.T) {
|
||||
const def = 80.0
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in alertconfig.HysteresisThreshold
|
||||
want alertconfig.HysteresisThreshold
|
||||
}{
|
||||
{
|
||||
name: "negative trigger falls back to default then derives clear",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: -1, Clear: 0},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: def, Clear: def - 5},
|
||||
},
|
||||
{
|
||||
name: "zero trigger with negative clear clamps clear to zero and returns early",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 0, Clear: -7},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0},
|
||||
},
|
||||
{
|
||||
name: "zero trigger with positive clear preserves clear (disabled metric)",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 0, Clear: 42},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 0, Clear: 42},
|
||||
},
|
||||
{
|
||||
name: "positive trigger with non-positive clear derives clear",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 90, Clear: 0},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85},
|
||||
},
|
||||
{
|
||||
name: "derived clear clamps to zero when trigger less than 5",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 2, Clear: -1},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 2, Clear: 0},
|
||||
},
|
||||
{
|
||||
name: "clear above trigger gets repaired by EnsureValidHysteresis safety net",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 80, Clear: 95},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 80, Clear: 75},
|
||||
},
|
||||
{
|
||||
name: "valid pair untouched",
|
||||
in: alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
want: alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := alertconfig.NormalizeDockerThreshold(tc.in, def, "docker.test")
|
||||
if !htEq(got, tc.want) {
|
||||
t.Fatalf("NormalizeDockerThreshold(%+v) = %+v, want %+v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeDockerDefaults covers NormalizeDockerDefaults: default
|
||||
// seeding, the ServiceCritGapPct<ServiceWarnGapPct repair, every
|
||||
// StatePoweredOffSeverity NormalizePoweredOffSeverity arm, and the
|
||||
// UpdateAlertDelayHours==0 default.
|
||||
func TestBranchCovNormalizeDockerDefaults(t *testing.T) {
|
||||
t.Run("all zero fields are seeded with canonical defaults", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
d := cfg.DockerDefaults
|
||||
|
||||
// NOTE on actual behavior: NormalizeDockerThreshold treats Trigger==0 as
|
||||
// "disabled" (early return), so a zero-value CPU/Memory/Disk threshold is
|
||||
// NOT seeded with the defaultTrigger passed by NormalizeDockerDefaults.
|
||||
// Only the scalar fields (<= 0 guards) receive defaults. See GLM_REPORT.md
|
||||
// for the suspected source inconsistency this documents.
|
||||
if !htEq(d.CPU, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}) {
|
||||
t.Fatalf("CPU = %+v, want {0 0} (Trigger==0 disabled path, NOT seeded)", d.CPU)
|
||||
}
|
||||
if !htEq(d.Memory, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}) {
|
||||
t.Fatalf("Memory = %+v, want {0 0} (Trigger==0 disabled path, NOT seeded)", d.Memory)
|
||||
}
|
||||
if !htEq(d.Disk, alertconfig.HysteresisThreshold{Trigger: 0, Clear: 0}) {
|
||||
t.Fatalf("Disk = %+v, want {0 0} (Trigger==0 disabled path, NOT seeded)", d.Disk)
|
||||
}
|
||||
if d.RestartCount != 3 {
|
||||
t.Fatalf("RestartCount = %d, want 3", d.RestartCount)
|
||||
}
|
||||
if d.RestartWindow != 300 {
|
||||
t.Fatalf("RestartWindow = %d, want 300", d.RestartWindow)
|
||||
}
|
||||
if d.MemoryWarnPct != 90 {
|
||||
t.Fatalf("MemoryWarnPct = %d, want 90", d.MemoryWarnPct)
|
||||
}
|
||||
if d.MemoryCriticalPct != 95 {
|
||||
t.Fatalf("MemoryCriticalPct = %d, want 95", d.MemoryCriticalPct)
|
||||
}
|
||||
if d.ServiceWarnGapPct != 10 {
|
||||
t.Fatalf("ServiceWarnGapPct = %d, want 10", d.ServiceWarnGapPct)
|
||||
}
|
||||
if d.ServiceCritGapPct != 50 {
|
||||
t.Fatalf("ServiceCritGapPct = %d, want 50", d.ServiceCritGapPct)
|
||||
}
|
||||
if d.StatePoweredOffSeverity != alertconfig.AlertLevelWarning {
|
||||
t.Fatalf("StatePoweredOffSeverity = %q, want %q", d.StatePoweredOffSeverity, alertconfig.AlertLevelWarning)
|
||||
}
|
||||
if d.UpdateAlertDelayHours != 24 {
|
||||
t.Fatalf("UpdateAlertDelayHours = %d, want 24", d.UpdateAlertDelayHours)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative trigger CPU falls back to defaultTrigger and derives clear", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
CPU: alertconfig.HysteresisThreshold{Trigger: -1, Clear: 0},
|
||||
Memory: alertconfig.HysteresisThreshold{Trigger: -1, Clear: 0},
|
||||
Disk: alertconfig.HysteresisThreshold{Trigger: -1, Clear: 0},
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
d := cfg.DockerDefaults
|
||||
if !htEq(d.CPU, alertconfig.HysteresisThreshold{Trigger: 80, Clear: 75}) {
|
||||
t.Fatalf("CPU = %+v, want {80 75}", d.CPU)
|
||||
}
|
||||
if !htEq(d.Memory, alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80}) {
|
||||
t.Fatalf("Memory = %+v, want {85 80}", d.Memory)
|
||||
}
|
||||
if !htEq(d.Disk, alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80}) {
|
||||
t.Fatalf("Disk = %+v, want {85 80}", d.Disk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("critical gap below warning gap is bumped up to warning gap", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
ServiceWarnGapPct: 20,
|
||||
ServiceCritGapPct: 5,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.ServiceWarnGapPct != 20 {
|
||||
t.Fatalf("ServiceWarnGapPct = %d, want 20 (preserved)", cfg.DockerDefaults.ServiceWarnGapPct)
|
||||
}
|
||||
if cfg.DockerDefaults.ServiceCritGapPct != 20 {
|
||||
t.Fatalf("ServiceCritGapPct = %d, want 20 (bumped to warning gap)", cfg.DockerDefaults.ServiceCritGapPct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("critical gap above warning gap is left as configured", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
ServiceWarnGapPct: 10,
|
||||
ServiceCritGapPct: 60,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.ServiceCritGapPct != 60 {
|
||||
t.Fatalf("ServiceCritGapPct = %d, want 60 (preserved)", cfg.DockerDefaults.ServiceCritGapPct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StatePoweredOffSeverity empty defaults to warning", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.StatePoweredOffSeverity != alertconfig.AlertLevelWarning {
|
||||
t.Fatalf("severity = %q, want warning", cfg.DockerDefaults.StatePoweredOffSeverity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StatePoweredOffSeverity critical is preserved through NormalizePoweredOffSeverity", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
StatePoweredOffSeverity: alertconfig.AlertLevelCritical,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.StatePoweredOffSeverity != alertconfig.AlertLevelCritical {
|
||||
t.Fatalf("severity = %q, want critical", cfg.DockerDefaults.StatePoweredOffSeverity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StatePoweredOffSeverity uppercase critical is normalized to critical", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
StatePoweredOffSeverity: alertconfig.AlertLevel("CRITICAL"),
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.StatePoweredOffSeverity != alertconfig.AlertLevelCritical {
|
||||
t.Fatalf("severity = %q, want critical (case-insensitive)", cfg.DockerDefaults.StatePoweredOffSeverity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StatePoweredOffSeverity unrecognized value falls back to warning default arm", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
StatePoweredOffSeverity: alertconfig.AlertLevel("bogus"),
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.StatePoweredOffSeverity != alertconfig.AlertLevelWarning {
|
||||
t.Fatalf("severity = %q, want warning (default arm)", cfg.DockerDefaults.StatePoweredOffSeverity)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UpdateAlertDelayHours zero defaults to 24 and positive preserved", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
if cfg.DockerDefaults.UpdateAlertDelayHours != 24 {
|
||||
t.Fatalf("UpdateAlertDelayHours = %d, want 24", cfg.DockerDefaults.UpdateAlertDelayHours)
|
||||
}
|
||||
|
||||
cfg2 := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{UpdateAlertDelayHours: 48},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg2)
|
||||
if cfg2.DockerDefaults.UpdateAlertDelayHours != 48 {
|
||||
t.Fatalf("UpdateAlertDelayHours = %d, want 48 (preserved)", cfg2.DockerDefaults.UpdateAlertDelayHours)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positive threshold values preserved end to end", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DockerDefaults: alertconfig.DockerThresholdConfig{
|
||||
CPU: alertconfig.HysteresisThreshold{Trigger: 77, Clear: 70},
|
||||
Memory: alertconfig.HysteresisThreshold{Trigger: 88, Clear: 83},
|
||||
Disk: alertconfig.HysteresisThreshold{Trigger: 99, Clear: 94},
|
||||
RestartCount: 7,
|
||||
RestartWindow: 600,
|
||||
MemoryWarnPct: 80,
|
||||
MemoryCriticalPct: 88,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDockerDefaults(cfg)
|
||||
d := cfg.DockerDefaults
|
||||
if !htEq(d.CPU, alertconfig.HysteresisThreshold{Trigger: 77, Clear: 70}) {
|
||||
t.Fatalf("CPU = %+v, want preserved", d.CPU)
|
||||
}
|
||||
if d.RestartCount != 7 {
|
||||
t.Fatalf("RestartCount = %d, want 7", d.RestartCount)
|
||||
}
|
||||
if d.MemoryCriticalPct != 88 {
|
||||
t.Fatalf("MemoryCriticalPct = %d, want 88", d.MemoryCriticalPct)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeDiskFillByType exercises the branch internals of
|
||||
// NormalizeDiskFillByType that are distinct from the sibling-package coverage:
|
||||
// the lower=="" drop arm, the "existing canonical key not overwritten" arm,
|
||||
// and the per-key reset path for a negative clear with positive trigger.
|
||||
func TestBranchCovNormalizeDiskFillByType(t *testing.T) {
|
||||
t.Run("whitespace only key is dropped and not re-added", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
|
||||
" ": {Trigger: 99, Clear: 99},
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
|
||||
if _, exists := cfg.DiskFillByType[" "]; exists {
|
||||
t.Fatalf("whitespace key should be removed, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
if _, exists := cfg.DiskFillByType[""]; exists {
|
||||
t.Fatalf("empty canonical key should not be added, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
for _, k := range []string{"nvme", "sata", "hdd"} {
|
||||
if _, ok := cfg.DiskFillByType[k]; !ok {
|
||||
t.Fatalf("canonical key %q missing, map=%+v", k, cfg.DiskFillByType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-canonical already-lowercase key is left in place", func(t *testing.T) {
|
||||
custom := alertconfig.HysteresisThreshold{Trigger: 70, Clear: 60}
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
|
||||
"external": custom,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
got, ok := cfg.DiskFillByType["external"]
|
||||
if !ok {
|
||||
t.Fatalf("non-canonical lowercase key should survive, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
if !htEq(got, custom) {
|
||||
t.Fatalf("external = %+v, want %+v preserved", got, custom)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercased duplicate of existing canonical key does not overwrite it", func(t *testing.T) {
|
||||
original := alertconfig.HysteresisThreshold{Trigger: 50, Clear: 40}
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
|
||||
"NVME": {Trigger: 1, Clear: 1},
|
||||
"nvme": original,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
if _, exists := cfg.DiskFillByType["NVME"]; exists {
|
||||
t.Fatalf("uppercase NVME key should be removed, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
got := cfg.DiskFillByType["nvme"]
|
||||
if !htEq(got, original) {
|
||||
t.Fatalf("nvme = %+v, want %+v (lowercased dup must not overwrite existing)", got, original)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed-case key lowercased into absent canonical slot is added with value preserved", func(t *testing.T) {
|
||||
custom := alertconfig.HysteresisThreshold{Trigger: 93, Clear: 88}
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
|
||||
"NVMe": custom,
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
if _, exists := cfg.DiskFillByType["NVMe"]; exists {
|
||||
t.Fatalf("mixed-case NVMe should be removed, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
got, ok := cfg.DiskFillByType["nvme"]
|
||||
if !ok {
|
||||
t.Fatalf("lowercased nvme should be added, map=%+v", cfg.DiskFillByType)
|
||||
}
|
||||
if !htEq(got, custom) {
|
||||
t.Fatalf("nvme = %+v, want %+v (operator value preserved through rename)", got, custom)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positive trigger with non-positive clear resets to default", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
DiskFillByType: map[string]alertconfig.HysteresisThreshold{
|
||||
"sata": {Trigger: 88, Clear: 0},
|
||||
"hdd": {Trigger: 80, Clear: -3},
|
||||
},
|
||||
}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
if sata := cfg.DiskFillByType["sata"]; !htEq(sata, alertconfig.HysteresisThreshold{Trigger: 90, Clear: 85}) {
|
||||
t.Fatalf("sata = %+v, want default {90 85}", sata)
|
||||
}
|
||||
if hdd := cfg.DiskFillByType["hdd"]; !htEq(hdd, alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80}) {
|
||||
t.Fatalf("hdd = %+v, want default {85 80}", hdd)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil map seeds canonical defaults", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeDiskFillByType(cfg)
|
||||
want := map[string]alertconfig.HysteresisThreshold{
|
||||
"nvme": {Trigger: 92, Clear: 87},
|
||||
"sata": {Trigger: 90, Clear: 85},
|
||||
"hdd": {Trigger: 85, Clear: 80},
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.DiskFillByType, want) {
|
||||
t.Fatalf("DiskFillByType = %+v, want %+v", cfg.DiskFillByType, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeGeneralSettings covers the default-seeding branch and
|
||||
// the preserved-positive branch for every field.
|
||||
func TestBranchCovNormalizeGeneralSettings(t *testing.T) {
|
||||
t.Run("zero valued config is fully seeded", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeGeneralSettings(cfg)
|
||||
if cfg.MinimumDelta != 2.0 {
|
||||
t.Fatalf("MinimumDelta = %v, want 2.0", cfg.MinimumDelta)
|
||||
}
|
||||
if cfg.SuppressionWindow != 5 {
|
||||
t.Fatalf("SuppressionWindow = %d, want 5", cfg.SuppressionWindow)
|
||||
}
|
||||
if cfg.HysteresisMargin != 5.0 {
|
||||
t.Fatalf("HysteresisMargin = %v, want 5.0", cfg.HysteresisMargin)
|
||||
}
|
||||
if cfg.ObservationWindowHours != 24 {
|
||||
t.Fatalf("ObservationWindowHours = %d, want 24", cfg.ObservationWindowHours)
|
||||
}
|
||||
if cfg.FlappingWindowSeconds != 300 {
|
||||
t.Fatalf("FlappingWindowSeconds = %d, want 300", cfg.FlappingWindowSeconds)
|
||||
}
|
||||
if cfg.FlappingThreshold != 5 {
|
||||
t.Fatalf("FlappingThreshold = %d, want 5", cfg.FlappingThreshold)
|
||||
}
|
||||
if cfg.FlappingCooldownMinutes != 15 {
|
||||
t.Fatalf("FlappingCooldownMinutes = %d, want 15", cfg.FlappingCooldownMinutes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit positive values preserved, negative replaced", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
MinimumDelta: 1.5,
|
||||
SuppressionWindow: 9,
|
||||
HysteresisMargin: 2.5,
|
||||
ObservationWindowHours: 48,
|
||||
FlappingWindowSeconds: 120,
|
||||
FlappingThreshold: -1,
|
||||
FlappingCooldownMinutes: 0,
|
||||
}
|
||||
alertconfig.NormalizeGeneralSettings(cfg)
|
||||
if cfg.MinimumDelta != 1.5 {
|
||||
t.Fatalf("MinimumDelta = %v, want 1.5", cfg.MinimumDelta)
|
||||
}
|
||||
if cfg.SuppressionWindow != 9 {
|
||||
t.Fatalf("SuppressionWindow = %d, want 9", cfg.SuppressionWindow)
|
||||
}
|
||||
if cfg.HysteresisMargin != 2.5 {
|
||||
t.Fatalf("HysteresisMargin = %v, want 2.5", cfg.HysteresisMargin)
|
||||
}
|
||||
if cfg.ObservationWindowHours != 48 {
|
||||
t.Fatalf("ObservationWindowHours = %d, want 48", cfg.ObservationWindowHours)
|
||||
}
|
||||
if cfg.FlappingWindowSeconds != 120 {
|
||||
t.Fatalf("FlappingWindowSeconds = %d, want 120", cfg.FlappingWindowSeconds)
|
||||
}
|
||||
if cfg.FlappingThreshold != 5 {
|
||||
t.Fatalf("FlappingThreshold = %d, want 5 (negative replaced)", cfg.FlappingThreshold)
|
||||
}
|
||||
if cfg.FlappingCooldownMinutes != 15 {
|
||||
t.Fatalf("FlappingCooldownMinutes = %d, want 15 (zero replaced)", cfg.FlappingCooldownMinutes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// expectedTimeThresholdKeys is the canonical set NormalizeTimeThresholds
|
||||
// guarantees to populate (the "all" key is intentionally NOT auto-added).
|
||||
var expectedTimeThresholdKeys = []string{
|
||||
"guest", "node", "storage", "pbs", "agent",
|
||||
"k8s-cluster", "k8s-node", "k8s-deployment", "k8s-namespace", "pod",
|
||||
"truenas-system", "truenas-pool", "truenas-dataset", "truenas-disk",
|
||||
"vmware-host", "vmware-vm", "vmware-datastore", "vmware-network",
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeTimeThresholds covers the nil-init path, the
|
||||
// preserve-existing path, the negative-delay repair path, and the special-cased
|
||||
// "all" key (negative repaired, positive preserved, absent not inserted).
|
||||
func TestBranchCovNormalizeTimeThresholds(t *testing.T) {
|
||||
t.Run("nil TimeThresholds is initialized with all default keys set to 5", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.TimeThresholds == nil {
|
||||
t.Fatal("TimeThresholds should be initialized, got nil")
|
||||
}
|
||||
for _, k := range expectedTimeThresholdKeys {
|
||||
if v, ok := cfg.TimeThresholds[k]; !ok || v != 5 {
|
||||
t.Fatalf("TimeThresholds[%q] = %d ok=%v, want 5", k, v, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := cfg.TimeThresholds["all"]; ok {
|
||||
t.Fatalf("\"all\" key should NOT be auto-added, map=%+v", cfg.TimeThresholds)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing positive delays preserved and missing keys filled", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
TimeThresholds: map[string]int{"guest": 30},
|
||||
}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.TimeThresholds["guest"] != 30 {
|
||||
t.Fatalf("guest = %d, want 30 (preserved)", cfg.TimeThresholds["guest"])
|
||||
}
|
||||
if cfg.TimeThresholds["node"] != 5 {
|
||||
t.Fatalf("node = %d, want 5 (filled)", cfg.TimeThresholds["node"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative per-type delay repaired to 5", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
TimeThresholds: map[string]int{"storage": -12},
|
||||
}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.TimeThresholds["storage"] != 5 {
|
||||
t.Fatalf("storage = %d, want 5 (repaired)", cfg.TimeThresholds["storage"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative all delay repaired while positive all preserved", func(t *testing.T) {
|
||||
t.Run("negative all", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
TimeThresholds: map[string]int{"all": -1},
|
||||
}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.TimeThresholds["all"] != 5 {
|
||||
t.Fatalf("all = %d, want 5 (repaired)", cfg.TimeThresholds["all"])
|
||||
}
|
||||
})
|
||||
t.Run("positive all", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
TimeThresholds: map[string]int{"all": 100},
|
||||
}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.TimeThresholds["all"] != 100 {
|
||||
t.Fatalf("all = %d, want 100 (preserved)", cfg.TimeThresholds["all"])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("nil MetricTimeThresholds collapses to nil", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.NormalizeTimeThresholds(cfg)
|
||||
if cfg.MetricTimeThresholds != nil {
|
||||
t.Fatalf("MetricTimeThresholds = %+v, want nil", cfg.MetricTimeThresholds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovValidateHysteresisThresholds covers the repair of an invalid
|
||||
// value-typed threshold (StorageDefault), a pointer threshold, an unchanged
|
||||
// valid config, and safe handling of nil pointers.
|
||||
func TestBranchCovValidateHysteresisThresholds(t *testing.T) {
|
||||
t.Run("invalid value-typed StorageDefault is repaired in place", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
StorageDefault: alertconfig.HysteresisThreshold{Trigger: 85, Clear: 95},
|
||||
}
|
||||
alertconfig.ValidateHysteresisThresholds(cfg)
|
||||
if cfg.StorageDefault.Clear != 80 {
|
||||
t.Fatalf("StorageDefault.Clear = %v, want 80 (trigger-5)", cfg.StorageDefault.Clear)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid pointer threshold on guest defaults is repaired", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
GuestDefaults: alertconfig.ThresholdConfig{
|
||||
CPU: &alertconfig.HysteresisThreshold{Trigger: 80, Clear: 90},
|
||||
},
|
||||
}
|
||||
alertconfig.ValidateHysteresisThresholds(cfg)
|
||||
if cfg.GuestDefaults.CPU == nil || cfg.GuestDefaults.CPU.Clear != 75 {
|
||||
t.Fatalf("GuestDefaults.CPU.Clear = %v, want 75", cfg.GuestDefaults.CPU)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already valid config is unchanged", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{
|
||||
StorageDefault: alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
GuestDefaults: alertconfig.ThresholdConfig{
|
||||
Memory: &alertconfig.HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
},
|
||||
}
|
||||
alertconfig.ValidateHysteresisThresholds(cfg)
|
||||
if cfg.StorageDefault.Clear != 80 {
|
||||
t.Fatalf("StorageDefault.Clear = %v, want 80 (untouched)", cfg.StorageDefault.Clear)
|
||||
}
|
||||
if cfg.GuestDefaults.Memory == nil || cfg.GuestDefaults.Memory.Clear != 80 {
|
||||
t.Fatalf("GuestDefaults.Memory.Clear = %v, want 80 (untouched)", cfg.GuestDefaults.Memory)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer thresholds do not panic", func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
alertconfig.ValidateHysteresisThresholds(cfg)
|
||||
if cfg.GuestDefaults.CPU != nil {
|
||||
t.Fatalf("GuestDefaults.CPU = %v, want nil", cfg.GuestDefaults.CPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovValidateQuietHoursTimezone covers all four combinations of the
|
||||
// Enabled && Timezone != "" guard plus the error/success outcomes of
|
||||
// time.LoadLocation.
|
||||
func TestBranchCovValidateQuietHoursTimezone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
timezone string
|
||||
wantEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "enabled with invalid timezone gets disabled",
|
||||
enabled: true,
|
||||
timezone: "Nowhere/Invalid",
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "enabled with valid timezone stays enabled",
|
||||
enabled: true,
|
||||
timezone: "America/New_York",
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "enabled with empty timezone is a no-op (stays enabled)",
|
||||
enabled: true,
|
||||
timezone: "",
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "disabled with invalid timezone is a no-op (stays disabled)",
|
||||
enabled: false,
|
||||
timezone: "Nowhere/Invalid",
|
||||
wantEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := &alertconfig.AlertConfig{}
|
||||
cfg.Schedule.QuietHours.Enabled = tc.enabled
|
||||
cfg.Schedule.QuietHours.Timezone = tc.timezone
|
||||
alertconfig.ValidateQuietHoursTimezone(cfg)
|
||||
if cfg.Schedule.QuietHours.Enabled != tc.wantEnabled {
|
||||
t.Fatalf("QuietHours.Enabled = %v, want %v", cfg.Schedule.QuietHours.Enabled, tc.wantEnabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeDockerIgnoredPrefixes covers the empty/nil early
|
||||
// returns, the all-whitespace collapse to nil, case-insensitive dedup that
|
||||
// preserves the first-seen original casing, and surrounding-space trimming.
|
||||
func TestBranchCovNormalizeDockerIgnoredPrefixes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
{name: "nil input returns nil", in: nil, want: nil},
|
||||
{name: "empty input returns nil", in: []string{}, want: nil},
|
||||
{name: "all whitespace entries collapse to nil", in: []string{" ", "", "\t"}, want: nil},
|
||||
{
|
||||
name: "case-insensitive dedup keeps first occurrence original casing",
|
||||
in: []string{"Foo", "foo", "FOO"},
|
||||
want: []string{"Foo"},
|
||||
},
|
||||
{
|
||||
name: "leading and trailing spaces are trimmed but inner case preserved",
|
||||
in: []string{" MyPrefix ", "\tBar\t"},
|
||||
want: []string{"MyPrefix", "Bar"},
|
||||
},
|
||||
{
|
||||
name: "distinct prefixes all retained in first-seen order",
|
||||
in: []string{"alpha", "Beta", "ALPHA"},
|
||||
want: []string{"alpha", "Beta"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := alertconfig.NormalizeDockerIgnoredPrefixes(tc.in)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("len = %d, want %d (got=%v want=%v)", len(got), len(tc.want), got, tc.want)
|
||||
}
|
||||
// Normalize nil vs empty []string{} for comparison after length check.
|
||||
if len(got) == 0 {
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil slice for empty result, got %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("result[%d] = %q, want %q (got=%v)", i, got[i], tc.want[i], got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These tests pin the static provider-registry accessors in ai_providers.go
|
||||
// (SuggestedModelForProvider, SuggestedModelIDSet, IsOpenAICompatibleProvider,
|
||||
// AIProviderFallbackModels, AIProviderDisplayName) which were previously
|
||||
// uncovered. They exercise the not-found / empty / normalization branches each
|
||||
// helper inherits from LookupAIProviderDefinition, plus the per-protocol and
|
||||
// per-catalog arms of the registry data.
|
||||
|
||||
func TestBranchCovSuggestedModelForProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
want string
|
||||
}{
|
||||
// Found-provider arm with a real blessing.
|
||||
{name: "ollama returns blessed patrol model", provider: AIProviderOllama, want: OllamaSuggestedPatrolModel},
|
||||
// LookupAIProviderDefinition normalizes via ToLower(TrimSpace); the
|
||||
// suggestion must still resolve through that normalization.
|
||||
{name: "ollama id case-insensitive", provider: "OLLAMA", want: OllamaSuggestedPatrolModel},
|
||||
{name: "ollama id trims surrounding whitespace", provider: " ollama ", want: OllamaSuggestedPatrolModel},
|
||||
// Found-provider arm where the registry deliberately sets no blessing.
|
||||
{name: "anthropic has no suggestion", provider: AIProviderAnthropic, want: ""},
|
||||
{name: "openai has no suggestion", provider: AIProviderOpenAI, want: ""},
|
||||
{name: "retired quickstart has no suggestion", provider: AIProviderQuickstart, want: ""},
|
||||
// Not-found arm: empty, whitespace-only, unknown, and a lookalike id
|
||||
// that must NOT be treated as a fuzzy match.
|
||||
{name: "empty provider returns empty", provider: "", want: ""},
|
||||
{name: "whitespace-only provider returns empty", provider: " ", want: ""},
|
||||
{name: "unknown provider returns empty", provider: "no-such-provider", want: ""},
|
||||
{name: "lookalike id with trailing colon rejected", provider: "ollama:", want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SuggestedModelForProvider(tt.provider); got != tt.want {
|
||||
t.Fatalf("SuggestedModelForProvider(%q) = %q, want %q", tt.provider, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovSuggestedModelIDSet(t *testing.T) {
|
||||
set := SuggestedModelIDSet()
|
||||
if set == nil {
|
||||
t.Fatal("SuggestedModelIDSet returned a nil map")
|
||||
}
|
||||
|
||||
// Only the Ollama provider contributes a SuggestedModel plus equivalents;
|
||||
// every other provider's empty suggestion is skipped by the `if id != ""`
|
||||
// guard, which is the branch under test here.
|
||||
want := map[string]struct{}{
|
||||
OllamaSuggestedPatrolModel: {},
|
||||
"qwen3:latest": {},
|
||||
}
|
||||
if !reflect.DeepEqual(set, want) {
|
||||
t.Fatalf("SuggestedModelIDSet() = %v, want exactly %v (ollama blessing + equivalents only)", set, want)
|
||||
}
|
||||
|
||||
// Guard against the suggested set being confused with the fallback catalog:
|
||||
// Z.ai exposes "glm-5.2" as a fallback model but has no Patrol blessing, so
|
||||
// it must be absent from the suggestion set.
|
||||
if _, ok := set["glm-5.2"]; ok {
|
||||
t.Fatal(`fallback-catalog model "glm-5.2" leaked into the suggested-model set`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovIsOpenAICompatibleProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
want bool
|
||||
}{
|
||||
// OpenAI-compatible protocol arm (ok && protocol == openai_compatible).
|
||||
{name: "openai", provider: AIProviderOpenAI, want: true},
|
||||
{name: "openrouter gateway", provider: AIProviderOpenRouter, want: true},
|
||||
{name: "deepseek", provider: AIProviderDeepSeek, want: true},
|
||||
{name: "zai", provider: AIProviderZai, want: true},
|
||||
{name: "groq", provider: AIProviderGroq, want: true},
|
||||
{name: "mistral", provider: AIProviderMistral, want: true},
|
||||
{name: "cerebras", provider: AIProviderCerebras, want: true},
|
||||
{name: "together", provider: AIProviderTogether, want: true},
|
||||
{name: "fireworks", provider: AIProviderFireworks, want: true},
|
||||
{name: "openai id case-insensitive", provider: "OpenAI", want: true},
|
||||
|
||||
// Known provider with a non-compatible protocol arm
|
||||
// (ok && protocol != openai_compatible).
|
||||
{name: "anthropic native protocol", provider: AIProviderAnthropic, want: false},
|
||||
{name: "gemini native protocol", provider: AIProviderGemini, want: false},
|
||||
{name: "ollama native protocol", provider: AIProviderOllama, want: false},
|
||||
{name: "codex subscription agent", provider: AIProviderCodexSubscription, want: false},
|
||||
{name: "claude subscription agent", provider: AIProviderClaudeSubscription, want: false},
|
||||
{name: "retired quickstart marker", provider: AIProviderQuickstart, want: false},
|
||||
|
||||
// Not-found arm (ok == false short-circuits the protocol check).
|
||||
{name: "unknown provider", provider: "no-such", want: false},
|
||||
{name: "empty provider", provider: "", want: false},
|
||||
{name: "whitespace-only provider", provider: " ", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsOpenAICompatibleProvider(tt.provider); got != tt.want {
|
||||
t.Fatalf("IsOpenAICompatibleProvider(%q) = %v, want %v", tt.provider, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovAIProviderFallbackModels(t *testing.T) {
|
||||
t.Run("unknown provider returns nil", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels("no-such"); got != nil {
|
||||
t.Fatalf("AIProviderFallbackModels(unknown) = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty provider returns nil", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels(""); got != nil {
|
||||
t.Fatalf(`AIProviderFallbackModels("") = %#v, want nil`, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only provider returns nil", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels(" "); got != nil {
|
||||
t.Fatalf(`AIProviderFallbackModels(" ") = %#v, want nil`, got)
|
||||
}
|
||||
})
|
||||
|
||||
// Known-provider-but-no-catalog arm: the early return fires because
|
||||
// len(def.FallbackModels) == 0 even though the lookup succeeded.
|
||||
t.Run("anthropic has no static fallback catalog", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels(AIProviderAnthropic); got != nil {
|
||||
t.Fatalf("AIProviderFallbackModels(%q) = %#v, want nil", AIProviderAnthropic, got)
|
||||
}
|
||||
})
|
||||
t.Run("ollama has no static fallback catalog", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels(AIProviderOllama); got != nil {
|
||||
t.Fatalf("AIProviderFallbackModels(%q) = %#v, want nil", AIProviderOllama, got)
|
||||
}
|
||||
})
|
||||
t.Run("openrouter relies on gateway catalog not static list", func(t *testing.T) {
|
||||
if got := AIProviderFallbackModels(AIProviderOpenRouter); got != nil {
|
||||
t.Fatalf("AIProviderFallbackModels(%q) = %#v, want nil", AIProviderOpenRouter, got)
|
||||
}
|
||||
})
|
||||
|
||||
// Catalog arm: returns a non-nil copy whose contents match the registry.
|
||||
t.Run("deepseek returns four-entry fallback catalog copy", func(t *testing.T) {
|
||||
got := AIProviderFallbackModels(AIProviderDeepSeek)
|
||||
if got == nil {
|
||||
t.Fatal("expected a non-nil fallback catalog for deepseek")
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("deepseek fallback count = %d, want 4 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].ID != DeepSeekModelV4Flash {
|
||||
t.Fatalf("first deepseek fallback = %q, want %q", got[0].ID, DeepSeekModelV4Flash)
|
||||
}
|
||||
// The two current V4 entries are flagged Notable; the two legacy
|
||||
// aliases are not. Pinning this guards the catalog's shape.
|
||||
notable := 0
|
||||
for _, m := range got {
|
||||
if m.Notable {
|
||||
notable++
|
||||
}
|
||||
}
|
||||
if notable != 2 {
|
||||
t.Fatalf("deepseek notable fallback count = %d, want 2", notable)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zai returns single fallback entry", func(t *testing.T) {
|
||||
got := AIProviderFallbackModels(AIProviderZai)
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Fatalf("zai fallback = %#v, want exactly one entry", got)
|
||||
}
|
||||
if got[0].ID != "glm-5.2" || !got[0].Notable {
|
||||
t.Fatalf("zai fallback[0] = %#v, want {ID:glm-5.2 Notable:true}", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("claude subscription returns two alias entries", func(t *testing.T) {
|
||||
got := AIProviderFallbackModels(AIProviderClaudeSubscription)
|
||||
if got == nil || len(got) != 2 {
|
||||
t.Fatalf("claude subscription fallback = %#v, want exactly two entries", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex subscription returns single fallback entry", func(t *testing.T) {
|
||||
got := AIProviderFallbackModels(AIProviderCodexSubscription)
|
||||
if got == nil || len(got) != 1 {
|
||||
t.Fatalf("codex subscription fallback = %#v, want exactly one entry", got)
|
||||
}
|
||||
})
|
||||
|
||||
// The accessor must hand back an independent copy on every call so that
|
||||
// callers cannot mutate the canonical registry records.
|
||||
t.Run("returned slice is an independent copy", func(t *testing.T) {
|
||||
a := AIProviderFallbackModels(AIProviderDeepSeek)
|
||||
b := AIProviderFallbackModels(AIProviderDeepSeek)
|
||||
if len(a) != 4 || len(b) != 4 {
|
||||
t.Fatalf("precondition: expected 4 deepseek fallbacks, got a=%d b=%d", len(a), len(b))
|
||||
}
|
||||
|
||||
originalFirst := a[0].ID
|
||||
// Grow one returned slice and rewrite an element field on the other.
|
||||
b = append(b, AIProviderModelDefinition{ID: "injected"})
|
||||
a[0] = AIProviderModelDefinition{ID: "mutated"}
|
||||
|
||||
if len(a) != 4 {
|
||||
t.Fatalf("appending to b leaked into a: len(a) = %d, want 4", len(a))
|
||||
}
|
||||
if a[0].ID != "mutated" {
|
||||
t.Fatalf("a[0].ID = %q, want \"mutated\" (independent element expected)", a[0].ID)
|
||||
}
|
||||
|
||||
// A fresh lookup must reflect neither mutation.
|
||||
fresh := AIProviderFallbackModels(AIProviderDeepSeek)
|
||||
if len(fresh) != 4 || fresh[0].ID != originalFirst {
|
||||
t.Fatalf("mutation poisoned the canonical record: fresh = %#v, want first id %q", fresh, originalFirst)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovAIProviderDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
want string
|
||||
}{
|
||||
// Found-provider arm: returns the registry DisplayName, never the raw
|
||||
// input id.
|
||||
{name: "anthropic display name", provider: AIProviderAnthropic, want: "Anthropic"},
|
||||
{name: "openrouter display name", provider: AIProviderOpenRouter, want: "OpenRouter"},
|
||||
{name: "zai display name", provider: AIProviderZai, want: "Z.ai"},
|
||||
{name: "codex subscription display name", provider: AIProviderCodexSubscription, want: "Codex subscription (local)"},
|
||||
{name: "retired quickstart keeps display name", provider: AIProviderQuickstart, want: "Pulse hosted quickstart"},
|
||||
// Lookup normalizes case/whitespace before resolving the id, so the
|
||||
// canonical DisplayName still comes back.
|
||||
{name: "lookup is case-insensitive", provider: "ANTHROPIC", want: "Anthropic"},
|
||||
{name: "lookup trims surrounding whitespace", provider: " anthropic ", want: "Anthropic"},
|
||||
|
||||
// Not-found arm: the raw input is returned VERBATIM (it is not
|
||||
// normalized). This pins the current asymmetric behaviour.
|
||||
{name: "unknown provider returns input verbatim", provider: "WeirdThing", want: "WeirdThing"},
|
||||
{name: "unknown mixed-case input preserved", provider: "SomeCloud", want: "SomeCloud"},
|
||||
{name: "empty input returns empty", provider: "", want: ""},
|
||||
{name: "whitespace-only input returned with whitespace", provider: " ", want: " "},
|
||||
{name: "lookalike id returned verbatim", provider: "ollama:", want: "ollama:"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := AIProviderDisplayName(tt.provider); got != tt.want {
|
||||
t.Fatalf("AIProviderDisplayName(%q) = %q, want %q", tt.provider, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestBranchCovEffectivePollIntervalSecs covers both branches of
|
||||
// AvailabilityTarget.EffectivePollIntervalSecs: the "> 0" arm returns the
|
||||
// configured value, and the <= 0 (zero and negative) arm falls back to the
|
||||
// package default.
|
||||
func TestBranchCovEffectivePollIntervalSecs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{"positive value returned as-is", 45, 45},
|
||||
{"minimum positive value returned", 1, 1},
|
||||
{"zero falls back to default", 0, DefaultAvailabilityPollIntervalSecs},
|
||||
{"negative falls back to default", -5, DefaultAvailabilityPollIntervalSecs},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := AvailabilityTarget{PollIntervalSecs: tt.value}
|
||||
assert.Equal(t, tt.want, target.EffectivePollIntervalSecs())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovEffectiveTimeoutMillis covers both branches of
|
||||
// AvailabilityTarget.EffectiveTimeoutMillis.
|
||||
func TestBranchCovEffectiveTimeoutMillis(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{"positive value returned as-is", 1500, 1500},
|
||||
{"minimum positive value returned", 1, 1},
|
||||
{"zero falls back to default", 0, DefaultAvailabilityTimeoutMillis},
|
||||
{"negative falls back to default", -100, DefaultAvailabilityTimeoutMillis},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := AvailabilityTarget{TimeoutMillis: tt.value}
|
||||
assert.Equal(t, tt.want, target.EffectiveTimeoutMillis())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovEffectiveFailureThreshold covers both branches of
|
||||
// AvailabilityTarget.EffectiveFailureThreshold.
|
||||
func TestBranchCovEffectiveFailureThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{"positive value returned as-is", 7, 7},
|
||||
{"minimum positive value returned", 1, 1},
|
||||
{"zero falls back to default", 0, DefaultAvailabilityFailureThreshold},
|
||||
{"negative falls back to default", -3, DefaultAvailabilityFailureThreshold},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target := AvailabilityTarget{FailureThreshold: tt.value}
|
||||
assert.Equal(t, tt.want, target.EffectiveFailureThreshold())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovDisplayName covers every branch of
|
||||
// AvailabilityTarget.DisplayName: a present (trimmed) name wins; a blank name
|
||||
// falls back to the trimmed address; both blank yields an empty string.
|
||||
func TestBranchCovDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target AvailabilityTarget
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "name present is returned trimmed",
|
||||
target: AvailabilityTarget{Name: " Energy Monitor ", Address: "device.local"},
|
||||
want: "Energy Monitor",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only name falls back to trimmed address",
|
||||
target: AvailabilityTarget{Name: " ", Address: " device.local "},
|
||||
want: "device.local",
|
||||
},
|
||||
{
|
||||
name: "empty name falls back to address",
|
||||
target: AvailabilityTarget{Name: "", Address: "10.0.0.1"},
|
||||
want: "10.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "both blank yields empty string",
|
||||
target: AvailabilityTarget{Name: " ", Address: "\t\n"},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.target.DisplayName())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovValidate exercises every branch of AvailabilityTarget.Validate:
|
||||
// the address-presence check, each arm of the target-kind and protocol
|
||||
// switches (including their defaults), the three numeric bound checks
|
||||
// (including the exact boundary that is accepted), the HTTP/HTTPS URL
|
||||
// validation path (both success and error propagation), and the non-HTTP
|
||||
// host presence/whitespace checks.
|
||||
func TestBranchCovValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target AvailabilityTarget
|
||||
wantErr bool
|
||||
wantErrContains string
|
||||
}{
|
||||
// --- Address presence (first guard). ---
|
||||
{
|
||||
name: "empty address rejected",
|
||||
target: AvailabilityTarget{TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP},
|
||||
wantErr: true,
|
||||
wantErrContains: "address is required",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only address rejected",
|
||||
target: AvailabilityTarget{Address: " ", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP},
|
||||
wantErr: true,
|
||||
wantErrContains: "address is required",
|
||||
},
|
||||
|
||||
// --- TargetKind switch: default arm. ---
|
||||
{
|
||||
name: "unsupported target kind rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetKind("database"), Protocol: AvailabilityProbeICMP},
|
||||
wantErr: true,
|
||||
wantErrContains: "unsupported availability target kind",
|
||||
},
|
||||
{
|
||||
name: "empty target kind hits default arm",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetKind(""), Protocol: AvailabilityProbeICMP},
|
||||
wantErr: true,
|
||||
wantErrContains: "unsupported availability target kind",
|
||||
},
|
||||
|
||||
// --- Protocol switch: ICMP arm. ---
|
||||
{
|
||||
name: "icmp with port set rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 80},
|
||||
wantErr: true,
|
||||
wantErrContains: "icmp availability targets must not set a port",
|
||||
},
|
||||
{
|
||||
name: "icmp machine kind valid happy path",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetMachine, Protocol: AvailabilityProbeICMP, Port: 0, PollIntervalSecs: 30, TimeoutMillis: 1000, FailureThreshold: 3},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "icmp device kind valid happy path",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetDevice, Protocol: AvailabilityProbeICMP, Port: 0, PollIntervalSecs: 30, TimeoutMillis: 1000, FailureThreshold: 3},
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// --- Protocol switch: TCP arm. ---
|
||||
{
|
||||
name: "tcp with valid port accepted",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeTCP, Port: 443},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tcp with zero port rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeTCP, Port: 0},
|
||||
wantErr: true,
|
||||
wantErrContains: "tcp availability targets require a valid port",
|
||||
},
|
||||
{
|
||||
name: "tcp with negative port rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeTCP, Port: -1},
|
||||
wantErr: true,
|
||||
wantErrContains: "tcp availability targets require a valid port",
|
||||
},
|
||||
{
|
||||
name: "tcp with out-of-range high port rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeTCP, Port: 70000},
|
||||
wantErr: true,
|
||||
wantErrContains: "tcp availability targets require a valid port",
|
||||
},
|
||||
|
||||
// --- Protocol switch: HTTP/HTTPS arm. ---
|
||||
{
|
||||
name: "http valid address accepted returns nil at http branch",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeHTTP, Port: 0},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "https valid address accepted returns nil at http branch",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeHTTPS, Port: 0},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "http negative port rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeHTTP, Port: -1},
|
||||
wantErr: true,
|
||||
wantErrContains: "http availability target port must be valid",
|
||||
},
|
||||
{
|
||||
name: "http out-of-range high port rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeHTTP, Port: 70000},
|
||||
wantErr: true,
|
||||
wantErrContains: "http availability target port must be valid",
|
||||
},
|
||||
{
|
||||
name: "http address with non-http scheme rejected via HTTPURL",
|
||||
target: AvailabilityTarget{Address: "ftp://device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeHTTP, Port: 0},
|
||||
wantErr: true,
|
||||
wantErrContains: "http availability targets require http or https scheme",
|
||||
},
|
||||
|
||||
// --- Protocol switch: default arm. ---
|
||||
{
|
||||
name: "unsupported protocol rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeProtocol("udp")},
|
||||
wantErr: true,
|
||||
wantErrContains: "unsupported availability protocol",
|
||||
},
|
||||
{
|
||||
name: "empty protocol hits default arm",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeProtocol("")},
|
||||
wantErr: true,
|
||||
wantErrContains: "unsupported availability protocol",
|
||||
},
|
||||
|
||||
// --- Numeric bound checks (below-min rejected, boundary accepted). ---
|
||||
{
|
||||
name: "poll interval below minimum rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, PollIntervalSecs: 9},
|
||||
wantErr: true,
|
||||
wantErrContains: "availability poll interval must be at least 10 seconds",
|
||||
},
|
||||
{
|
||||
name: "poll interval at minimum boundary accepted",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, PollIntervalSecs: 10},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "timeout below minimum rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, TimeoutMillis: 100},
|
||||
wantErr: true,
|
||||
wantErrContains: "availability timeout must be at least 250 milliseconds",
|
||||
},
|
||||
{
|
||||
name: "timeout at minimum boundary accepted",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, TimeoutMillis: 250},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "failure threshold above maximum rejected",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, FailureThreshold: 11},
|
||||
wantErr: true,
|
||||
wantErrContains: "availability failure threshold must be 10 or less",
|
||||
},
|
||||
{
|
||||
name: "failure threshold at maximum boundary accepted",
|
||||
target: AvailabilityTarget{Address: "device.local", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0, FailureThreshold: 10},
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// --- Non-HTTP host checks (reached only for icmp/tcp protocols). ---
|
||||
{
|
||||
name: "non-http address normalizing to empty host rejected",
|
||||
target: AvailabilityTarget{Address: "[]", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0},
|
||||
wantErr: true,
|
||||
wantErrContains: "address is required",
|
||||
},
|
||||
{
|
||||
name: "non-http address containing whitespace rejected",
|
||||
target: AvailabilityTarget{Address: "host with space", TargetKind: AvailabilityTargetService, Protocol: AvailabilityProbeICMP, Port: 0},
|
||||
wantErr: true,
|
||||
wantErrContains: "must not contain whitespace",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.target.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err, "expected an error for %s", tt.name)
|
||||
if tt.wantErrContains != "" && err != nil {
|
||||
assert.Contains(t, err.Error(), tt.wantErrContains)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err, "expected no error for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This file adds branch-coverage tests for the TrueNAS/VMware credential
|
||||
// masking helpers and the VMware vCenter instance lifecycle methods. It
|
||||
// targets branches that the existing *_test.go files in this package do not
|
||||
// exercise: nil receivers, whitespace-tolerant mask matching, both sides of
|
||||
// every conditional in PreserveMaskedSecrets/Validate/ApplyDefaults/Redacted,
|
||||
// and the <= 0 / empty / whitespace-only boundary inputs.
|
||||
|
||||
func TestBranchCovIsTrueNASSensitiveMask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"exact mask", trueNASSensitiveMask, true},
|
||||
{"empty string", "", false},
|
||||
{"whitespace padded mask still matches", " " + trueNASSensitiveMask + "\t", true},
|
||||
{"newline wrapped mask still matches", "\n" + trueNASSensitiveMask + "\n", true},
|
||||
{"whitespace only input", " \t\n", false},
|
||||
{"unrelated secret value", "api-token-12345", false},
|
||||
{"shorter star run not equal", "****", false},
|
||||
{"longer star run not equal", "**********", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, IsTrueNASSensitiveMask(tt.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovIsVMwareSensitiveMask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"exact mask", vmwareSensitiveMask, true},
|
||||
{"empty string", "", false},
|
||||
{"whitespace padded mask still matches", " " + vmwareSensitiveMask + " ", true},
|
||||
{"whitespace only input", "\t\t", false},
|
||||
{"unrelated secret value", "vmware-password", false},
|
||||
{"substring of mask not equal", "****", false},
|
||||
{"longer star run not equal", "**********", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, IsVMwareSensitiveMask(tt.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovTrueNASPreserveMaskedSecrets(t *testing.T) {
|
||||
t.Run("nil receiver is a no-op and does not panic", func(t *testing.T) {
|
||||
var inst *TrueNASInstance
|
||||
require.NotPanics(t, func() {
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("both fields masked restores both from existing", func(t *testing.T) {
|
||||
inst := TrueNASInstance{APIKey: trueNASSensitiveMask, Password: trueNASSensitiveMask}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
require.Equal(t, "stored-key", inst.APIKey)
|
||||
require.Equal(t, "stored-pass", inst.Password)
|
||||
})
|
||||
|
||||
t.Run("only api key masked leaves password from payload intact", func(t *testing.T) {
|
||||
inst := TrueNASInstance{APIKey: trueNASSensitiveMask, Password: "new-pass"}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
require.Equal(t, "stored-key", inst.APIKey)
|
||||
require.Equal(t, "new-pass", inst.Password)
|
||||
})
|
||||
|
||||
t.Run("only password masked leaves api key from payload intact", func(t *testing.T) {
|
||||
inst := TrueNASInstance{APIKey: "new-key", Password: trueNASSensitiveMask}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
require.Equal(t, "new-key", inst.APIKey)
|
||||
require.Equal(t, "stored-pass", inst.Password)
|
||||
})
|
||||
|
||||
t.Run("neither field masked keeps payload values unchanged", func(t *testing.T) {
|
||||
inst := TrueNASInstance{APIKey: "fresh-key", Password: "fresh-pass"}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
require.Equal(t, "fresh-key", inst.APIKey)
|
||||
require.Equal(t, "fresh-pass", inst.Password)
|
||||
})
|
||||
|
||||
t.Run("masked fields with empty existing restore to empty", func(t *testing.T) {
|
||||
inst := TrueNASInstance{APIKey: trueNASSensitiveMask, Password: trueNASSensitiveMask}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{})
|
||||
require.Equal(t, "", inst.APIKey)
|
||||
require.Equal(t, "", inst.Password)
|
||||
})
|
||||
|
||||
t.Run("whitespace padded mask is treated as the placeholder", func(t *testing.T) {
|
||||
inst := TrueNASInstance{
|
||||
APIKey: " " + trueNASSensitiveMask + " ",
|
||||
Password: "\t" + trueNASSensitiveMask + "\n",
|
||||
}
|
||||
inst.PreserveMaskedSecrets(TrueNASInstance{APIKey: "stored-key", Password: "stored-pass"})
|
||||
require.Equal(t, "stored-key", inst.APIKey)
|
||||
require.Equal(t, "stored-pass", inst.Password)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovVMwarePreserveMaskedSecrets(t *testing.T) {
|
||||
t.Run("nil receiver is a no-op and does not panic", func(t *testing.T) {
|
||||
var v *VMwareVCenterInstance
|
||||
require.NotPanics(t, func() {
|
||||
v.PreserveMaskedSecrets(VMwareVCenterInstance{Password: "stored-secret"})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("masked password restored from existing", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Password: vmwareSensitiveMask}
|
||||
v.PreserveMaskedSecrets(VMwareVCenterInstance{Password: "stored-secret"})
|
||||
require.Equal(t, "stored-secret", v.Password)
|
||||
})
|
||||
|
||||
t.Run("unmasked password kept from payload", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Password: "new-secret"}
|
||||
v.PreserveMaskedSecrets(VMwareVCenterInstance{Password: "stored-secret"})
|
||||
require.Equal(t, "new-secret", v.Password)
|
||||
})
|
||||
|
||||
t.Run("masked password with empty existing restores to empty", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Password: vmwareSensitiveMask}
|
||||
v.PreserveMaskedSecrets(VMwareVCenterInstance{})
|
||||
require.Equal(t, "", v.Password)
|
||||
})
|
||||
|
||||
t.Run("whitespace padded mask is treated as the placeholder", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Password: " " + vmwareSensitiveMask + " "}
|
||||
v.PreserveMaskedSecrets(VMwareVCenterInstance{Password: "stored-secret"})
|
||||
require.Equal(t, "stored-secret", v.Password)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovVMwareValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instance *VMwareVCenterInstance
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "nil instance returns required error",
|
||||
instance: nil,
|
||||
wantError: "vmware vcenter instance is required",
|
||||
},
|
||||
{
|
||||
name: "missing host returns host error",
|
||||
instance: &VMwareVCenterInstance{Username: "admin", Password: "pass"},
|
||||
wantError: "vmware vcenter host is required",
|
||||
},
|
||||
{
|
||||
name: "whitespace only host returns host error",
|
||||
instance: &VMwareVCenterInstance{Host: " ", Username: "admin", Password: "pass"},
|
||||
wantError: "vmware vcenter host is required",
|
||||
},
|
||||
{
|
||||
name: "missing username returns credentials error",
|
||||
instance: &VMwareVCenterInstance{Host: "vc.local", Password: "pass"},
|
||||
wantError: "vmware credentials are required",
|
||||
},
|
||||
{
|
||||
name: "missing password returns credentials error",
|
||||
instance: &VMwareVCenterInstance{Host: "vc.local", Username: "admin"},
|
||||
wantError: "vmware credentials are required",
|
||||
},
|
||||
{
|
||||
name: "whitespace only username returns credentials error",
|
||||
instance: &VMwareVCenterInstance{Host: "vc.local", Username: " ", Password: "pass"},
|
||||
wantError: "vmware credentials are required",
|
||||
},
|
||||
{
|
||||
name: "whitespace only password returns credentials error",
|
||||
instance: &VMwareVCenterInstance{Host: "vc.local", Username: "admin", Password: "\t"},
|
||||
wantError: "vmware credentials are required",
|
||||
},
|
||||
{
|
||||
name: "valid host and credentials pass",
|
||||
instance: &VMwareVCenterInstance{Host: "vc.local", Username: "admin", Password: "pass"},
|
||||
wantError: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.instance.Validate()
|
||||
if tt.wantError == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovVMwareApplyDefaults(t *testing.T) {
|
||||
t.Run("nil receiver is a no-op and does not panic", func(t *testing.T) {
|
||||
var v *VMwareVCenterInstance
|
||||
require.NotPanics(t, func() { v.ApplyDefaults() })
|
||||
})
|
||||
|
||||
t.Run("positive port preserved and explicit single surface kept", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Port: 8443, MonitorHosts: true}
|
||||
v.ApplyDefaults()
|
||||
require.Equal(t, 8443, v.Port)
|
||||
require.False(t, v.MonitorVMs)
|
||||
require.True(t, v.MonitorHosts)
|
||||
require.False(t, v.MonitorDatastores)
|
||||
})
|
||||
|
||||
t.Run("negative port reset to default and legacy scope migrated", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Port: -1}
|
||||
v.ApplyDefaults()
|
||||
require.Equal(t, defaultVMwarePort, v.Port)
|
||||
require.True(t, v.MonitorVMs)
|
||||
require.True(t, v.MonitorHosts)
|
||||
require.True(t, v.MonitorDatastores)
|
||||
})
|
||||
|
||||
t.Run("all surfaces explicitly enabled are left enabled", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{
|
||||
Port: defaultVMwarePort,
|
||||
MonitorVMs: true,
|
||||
MonitorHosts: true,
|
||||
MonitorDatastores: true,
|
||||
}
|
||||
v.ApplyDefaults()
|
||||
require.True(t, v.MonitorVMs)
|
||||
require.True(t, v.MonitorHosts)
|
||||
require.True(t, v.MonitorDatastores)
|
||||
require.Equal(t, defaultVMwarePort, v.Port)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovVMwareRedacted(t *testing.T) {
|
||||
t.Run("nil receiver returns zero value and does not panic", func(t *testing.T) {
|
||||
var v *VMwareVCenterInstance
|
||||
require.NotPanics(t, func() {
|
||||
require.Equal(t, VMwareVCenterInstance{}, v.Redacted())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("empty password is not masked", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Host: "vc.local", Password: ""}
|
||||
got := v.Redacted()
|
||||
require.Equal(t, "", got.Password)
|
||||
})
|
||||
|
||||
t.Run("whitespace only password is not masked", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Host: "vc.local", Password: " "}
|
||||
got := v.Redacted()
|
||||
require.Equal(t, " ", got.Password)
|
||||
})
|
||||
|
||||
t.Run("whitespace padded real password is masked", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Host: "vc.local", Password: " real-secret "}
|
||||
got := v.Redacted()
|
||||
require.Equal(t, vmwareSensitiveMask, got.Password)
|
||||
})
|
||||
|
||||
t.Run("receiver is not mutated by redaction", func(t *testing.T) {
|
||||
v := VMwareVCenterInstance{Host: "vc.local", Username: "admin", Password: "super-secret"}
|
||||
got := v.Redacted()
|
||||
require.Equal(t, "super-secret", v.Password)
|
||||
require.Equal(t, vmwareSensitiveMask, got.Password)
|
||||
require.Equal(t, "admin", got.Username)
|
||||
require.Equal(t, "vc.local", got.Host)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// This file adds branch coverage for the report-branding helpers in
|
||||
// report_branding.go: DecodeReportBrandLogoBase64 and
|
||||
// CanonicalReportBrandLogoFormat. Test functions use the BranchCov prefix so
|
||||
// they can be selected with `-run BranchCov`.
|
||||
|
||||
// TestBranchCovDecodeReportBrandLogoBase64 exercises every branch of
|
||||
// DecodeReportBrandLogoBase64:
|
||||
// - empty / whitespace-only input (returns nil, nil)
|
||||
// - input without a ";base64," data-URL separator (used verbatim)
|
||||
// - input with a ";base64," separator (prefix stripped)
|
||||
// - leading and repeated ";base64," separators
|
||||
// - the base64.StdEncoding success path
|
||||
// - the base64.StdEncoding failure / RawStdEncoding fallback success path
|
||||
// - both encodings failing (final error return)
|
||||
// - the length boundary at exactly ReportBrandLogoBase64MaxLength and one
|
||||
// character over it
|
||||
func TestBranchCovDecodeReportBrandLogoBase64(t *testing.T) {
|
||||
// "AAAAAA" (6 chars, no padding) decodes cleanly under RawStdEncoding but
|
||||
// is rejected by StdEncoding (which expects padding to a multiple of 4),
|
||||
// so it forces the fallback path. "AAA" (3 chars) does the same.
|
||||
const rawFallbackInput = "AAAAAA"
|
||||
// "QUJD" is "ABC" in standard base64 (4 chars, multiple of 4) and succeeds
|
||||
// under StdEncoding on the first try.
|
||||
const stdSuccessInput = "QUJD"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []byte
|
||||
wantErr bool
|
||||
errSub string
|
||||
}{
|
||||
{
|
||||
name: "empty input returns nil nil",
|
||||
input: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "whitespace only trims to empty returns nil nil",
|
||||
input: " \t\n ",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "plain std base64 no separator std success path",
|
||||
input: stdSuccessInput,
|
||||
want: []byte("ABC"),
|
||||
},
|
||||
{
|
||||
name: "plain raw base64 no separator raw fallback path",
|
||||
input: rawFallbackInput,
|
||||
want: []byte{0, 0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "non-base64 garbage both encodings fail",
|
||||
input: "!!!!not-base64!!!!",
|
||||
wantErr: true,
|
||||
errSub: "logoBase64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "five chars not multiple of four both fail",
|
||||
input: "QUJDA",
|
||||
wantErr: true,
|
||||
errSub: "logoBase64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "data url separator strips prefix and decodes",
|
||||
input: "data:image/png;base64," + stdSuccessInput,
|
||||
want: []byte("ABC"),
|
||||
},
|
||||
{
|
||||
name: "data url separator with raw payload uses fallback",
|
||||
input: "data:image/png;base64," + rawFallbackInput,
|
||||
want: []byte{0, 0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "leading base64 separator at index zero",
|
||||
input: ";base64," + stdSuccessInput,
|
||||
want: []byte("ABC"),
|
||||
},
|
||||
{
|
||||
name: "multiple separators only first is stripped second leaks into payload",
|
||||
input: ";base64," + stdSuccessInput + ";base64,BBBB",
|
||||
wantErr: true,
|
||||
errSub: "logoBase64 must be valid base64",
|
||||
},
|
||||
{
|
||||
name: "data url with empty payload decodes to empty non-nil slice",
|
||||
input: "data:image/png;base64,",
|
||||
want: []byte{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := DecodeReportBrandLogoBase64(tc.input)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tc.errSub != "" {
|
||||
assert.Contains(t, err.Error(), tc.errSub)
|
||||
}
|
||||
assert.Nil(t, got)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
|
||||
// Boundary: exactly at the configured max length. Build a valid standard
|
||||
// base64 value whose length equals ReportBrandLogoBase64MaxLength (which is
|
||||
// a multiple of 4) so StdEncoding succeeds and the length check passes.
|
||||
t.Run("length exactly at limit succeeds", func(t *testing.T) {
|
||||
assert.Equal(t, 0, ReportBrandLogoBase64MaxLength%4,
|
||||
"max length must be a multiple of 4 for this boundary case to be well-defined")
|
||||
value := strings.Repeat("A", ReportBrandLogoBase64MaxLength)
|
||||
dec, err := DecodeReportBrandLogoBase64(value)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, dec, ReportBrandLogoBase64MaxLength/4*3)
|
||||
})
|
||||
|
||||
// Boundary: one character over the limit must be rejected by the length
|
||||
// check before any decoding is attempted, regardless of payload validity.
|
||||
t.Run("length one over limit rejected before decode", func(t *testing.T) {
|
||||
value := strings.Repeat("A", ReportBrandLogoBase64MaxLength+1)
|
||||
dec, err := DecodeReportBrandLogoBase64(value)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "logoBase64 must be <= ")
|
||||
assert.Contains(t, err.Error(), "49152 characters")
|
||||
assert.Nil(t, dec)
|
||||
})
|
||||
|
||||
// Boundary: length check is applied to the post-strip value, so a data URL
|
||||
// whose base64 payload alone exceeds the limit must still be rejected.
|
||||
t.Run("oversized payload after data url strip rejected", func(t *testing.T) {
|
||||
payload := strings.Repeat("A", ReportBrandLogoBase64MaxLength+1)
|
||||
dec, err := DecodeReportBrandLogoBase64("data:image/png;base64," + payload)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "logoBase64 must be <= 49152 characters")
|
||||
assert.Nil(t, dec)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovCanonicalReportBrandLogoFormat covers every arm of the switch in
|
||||
// CanonicalReportBrandLogoFormat (including the default rejection branch) and
|
||||
// the leading/trailing whitespace plus case-normalisation performed before the
|
||||
// switch is entered.
|
||||
func TestBranchCovCanonicalReportBrandLogoFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantOk bool
|
||||
}{
|
||||
{name: "empty string accepted as empty canonical", input: "", want: "", wantOk: true},
|
||||
{name: "whitespace only trims to empty accepted", input: " \t ", want: "", wantOk: true},
|
||||
|
||||
{name: "png canonical", input: "png", want: "png", wantOk: true},
|
||||
{name: "png uppercased normalised", input: "PNG", want: "png", wantOk: true},
|
||||
{name: "png mixed case trimmed", input: " PnG ", want: "png", wantOk: true},
|
||||
|
||||
{name: "jpg canonical", input: "jpg", want: "jpg", wantOk: true},
|
||||
{name: "jpeg alias collapses to jpg", input: "jpeg", want: "jpg", wantOk: true},
|
||||
{name: "JPEG uppercased collapses to jpg", input: " JPEG ", want: "jpg", wantOk: true},
|
||||
|
||||
{name: "gif canonical", input: "gif", want: "gif", wantOk: true},
|
||||
{name: "GIF uppercased normalised", input: "GIF", want: "gif", wantOk: true},
|
||||
|
||||
{name: "unknown format webp rejected", input: "webp", want: "", wantOk: false},
|
||||
{name: "unknown format svg rejected", input: "svg", want: "", wantOk: false},
|
||||
{name: "png with trailing digit rejected by default", input: "png2", want: "", wantOk: false},
|
||||
{name: "mime style value rejected by default", input: "image/png", want: "", wantOk: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := CanonicalReportBrandLogoFormat(tc.input)
|
||||
assert.Equal(t, tc.wantOk, ok, "ok mismatch for input %q", tc.input)
|
||||
assert.Equal(t, tc.want, got, "canonical value mismatch for input %q", tc.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBranchCovOrganizationRoleFromAccountRole exercises every switch arm of
|
||||
// OrganizationRoleFromAccountRole, including the strings.ToLower /
|
||||
// strings.TrimSpace normalization that gates the switch, and the default arm
|
||||
// reached by empty, whitespace-only, and unknown inputs.
|
||||
func TestBranchCovOrganizationRoleFromAccountRole(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
role string
|
||||
want OrganizationRole
|
||||
}{
|
||||
// Explicit named arms (canonical spelling).
|
||||
{name: "owner arm", role: "owner", want: OrgRoleOwner},
|
||||
{name: "admin arm", role: "admin", want: OrgRoleAdmin},
|
||||
{name: "tech arm maps to editor", role: "tech", want: OrgRoleEditor},
|
||||
{name: "read_only arm maps to viewer", role: "read_only", want: OrgRoleViewer},
|
||||
|
||||
// ToLower normalization: each named arm must be reached case-insensitively.
|
||||
{name: "OWNER upper hits owner arm", role: "OWNER", want: OrgRoleOwner},
|
||||
{name: "Owner mixed hits owner arm", role: "Owner", want: OrgRoleOwner},
|
||||
{name: "ADMIN upper hits admin arm", role: "ADMIN", want: OrgRoleAdmin},
|
||||
{name: "Tech mixed hits tech arm", role: "Tech", want: OrgRoleEditor},
|
||||
{name: "READ_ONLY upper hits read_only arm", role: "READ_ONLY", want: OrgRoleViewer},
|
||||
|
||||
// TrimSpace normalization: surrounding whitespace must not affect mapping.
|
||||
{name: "leading space owner", role: " owner", want: OrgRoleOwner},
|
||||
{name: "trailing space owner", role: "owner ", want: OrgRoleOwner},
|
||||
{name: "surrounding whitespace admin", role: "\tadmin\n", want: OrgRoleAdmin},
|
||||
{name: "surrounding whitespace read_only", role: " read_only ", want: OrgRoleViewer},
|
||||
|
||||
// Combined case-folding + trimming for each arm.
|
||||
{name: "trimmed upper OWNER", role: " OWNER ", want: OrgRoleOwner},
|
||||
{name: "trimmed mixed Tech", role: " Tech ", want: OrgRoleEditor},
|
||||
|
||||
// Default arm: empty, whitespace-only, and unknown inputs all fall
|
||||
// through to OrgRoleViewer.
|
||||
{name: "empty string default", role: "", want: OrgRoleViewer},
|
||||
{name: "whitespace-only default", role: " \t\n", want: OrgRoleViewer},
|
||||
{name: "unknown role default", role: "billing", want: OrgRoleViewer},
|
||||
{name: "superuser unknown default", role: "superuser", want: OrgRoleViewer},
|
||||
|
||||
// Boundary: "read_only" (with underscore) is a recognized arm, whereas
|
||||
// the visually similar "readonly" (no underscore) is NOT and must
|
||||
// fall through to the default arm. Both yield OrgRoleViewer, but they
|
||||
// exercise different branches.
|
||||
{name: "read_only underscore arm", role: "read_only", want: OrgRoleViewer},
|
||||
{name: "readonly no-underscore hits default", role: "readonly", want: OrgRoleViewer},
|
||||
|
||||
// Sanity: the viewer-equivalent account roles are NOT OrgRoleOwner/Admin/Editor.
|
||||
{name: "read_only does not upgrade", role: "read_only", want: OrgRoleViewer},
|
||||
{name: "unknown does not upgrade", role: "root", want: OrgRoleViewer},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := OrganizationRoleFromAccountRole(tc.role)
|
||||
if got != tc.want {
|
||||
t.Fatalf("OrganizationRoleFromAccountRole(%q) = %q, want %q",
|
||||
tc.role, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBranchCovOrganizationRoleFromAccountRole_DistinctArms verifies that the
|
||||
// four non-default arms each return a distinct (or specifically viewer-bound)
|
||||
// result, so coverage of the read_only -> viewer path is not conflated with
|
||||
// the default -> viewer path. This guards against accidental arm removal.
|
||||
func TestBranchCovOrganizationRoleFromAccountRole_DistinctArms(t *testing.T) {
|
||||
owner := OrganizationRoleFromAccountRole("owner")
|
||||
admin := OrganizationRoleFromAccountRole("admin")
|
||||
editor := OrganizationRoleFromAccountRole("tech")
|
||||
readOnly := OrganizationRoleFromAccountRole("read_only")
|
||||
def := OrganizationRoleFromAccountRole("totally-unknown")
|
||||
|
||||
if owner != OrgRoleOwner {
|
||||
t.Fatalf("owner arm = %q, want %q", owner, OrgRoleOwner)
|
||||
}
|
||||
if admin != OrgRoleAdmin {
|
||||
t.Fatalf("admin arm = %q, want %q", admin, OrgRoleAdmin)
|
||||
}
|
||||
if editor != OrgRoleEditor {
|
||||
t.Fatalf("tech arm = %q, want %q", editor, OrgRoleEditor)
|
||||
}
|
||||
|
||||
// Both read_only and default return OrgRoleViewer by design (read_only is
|
||||
// the least-privileged known account role; unknown roles are downgraded to
|
||||
// least privilege for safety). Assert they agree on the value but exercise
|
||||
// both arms.
|
||||
if readOnly != OrgRoleViewer {
|
||||
t.Fatalf("read_only arm = %q, want %q", readOnly, OrgRoleViewer)
|
||||
}
|
||||
if def != OrgRoleViewer {
|
||||
t.Fatalf("default arm = %q, want %q", def, OrgRoleViewer)
|
||||
}
|
||||
|
||||
// owner/admin/editor must all be distinct from each other and from viewer.
|
||||
seen := map[OrganizationRole]string{}
|
||||
for label, r := range map[string]OrganizationRole{
|
||||
"owner": owner, "admin": admin, "editor": editor, "viewer(read_only)": readOnly,
|
||||
} {
|
||||
if prev, dup := seen[r]; dup {
|
||||
t.Fatalf("role collision: %q and %q both map to %q", prev, label, r)
|
||||
}
|
||||
seen[r] = label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package operationreceipt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file exercises the previously uncovered branches of DecodeQuery and
|
||||
// DecodeQueryResult in internal/operationreceipt/types.go: every error arm
|
||||
// of decodeStrict, the version guards, the per-status switch arms of
|
||||
// DecodeQueryResult (including the default), both sides of each record
|
||||
// nil/state conditional, and ValidateRecord invocation on the non-nil path.
|
||||
|
||||
// withRogueField injects an unknown top-level JSON field into a marshalled
|
||||
// object so we can exercise decodeStrict's DisallowUnknownFields branch
|
||||
// without hand-rolling a long valid payload.
|
||||
func withRogueField(t *testing.T, valid []byte) []byte {
|
||||
t.Helper()
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(valid, &obj); err != nil {
|
||||
t.Fatalf("unmarshal for rogue injection: %v", err)
|
||||
}
|
||||
obj["rogue_field"] = json.RawMessage(`true`)
|
||||
out, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal for rogue injection: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBranchCovDecodeQuery(t *testing.T) {
|
||||
validID := testIdentity("decode-query")
|
||||
validQueryBytes, err := json.Marshal(Query{Version: ProtocolVersion, Identity: validID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal valid query: %v", err)
|
||||
}
|
||||
|
||||
// Identity whose string fields carry surrounding whitespace; after
|
||||
// DecodeQuery runs NormalizeIdentity the result must equal validID.
|
||||
spaceyID := validID
|
||||
spaceyID.AttemptID = " " + validID.AttemptID + "\t"
|
||||
spaceyID.ActionID = " " + validID.ActionID + " "
|
||||
spaceyID.OperationKind = "\t" + validID.OperationKind + " "
|
||||
spaceyID.RequestDigest = " " + validID.RequestDigest + " "
|
||||
spaceyID.AgentID = " " + validID.AgentID + " "
|
||||
spaceyQueryBytes, err := json.Marshal(Query{Version: ProtocolVersion, Identity: spaceyID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal spacey query: %v", err)
|
||||
}
|
||||
|
||||
badVersionBytes, err := json.Marshal(Query{Version: 99, Identity: validID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal bad-version query: %v", err)
|
||||
}
|
||||
|
||||
emptyIdentityBytes, err := json.Marshal(Query{Version: ProtocolVersion, Identity: Identity{}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal empty-identity query: %v", err)
|
||||
}
|
||||
|
||||
zeroVersionBytes, err := json.Marshal(Query{Version: ProtocolVersion, Identity: Identity{
|
||||
AttemptID: "a", ActionID: "b", OperationKind: "k", OperationVersion: 0,
|
||||
RequestDigest: validID.RequestDigest, AgentID: "g",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal zero-version query: %v", err)
|
||||
}
|
||||
|
||||
trailingBytes := append(append([]byte{}, validQueryBytes...), []byte(` {"after":true}`)...)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantOK bool
|
||||
errSub string // asserted (as substring) only when !wantOK
|
||||
wantID Identity
|
||||
checkTrim bool // on success, assert identity equals wantID (post-trim)
|
||||
}{
|
||||
{name: "nil payload rejected", data: nil, wantOK: false, errSub: "payload is empty"},
|
||||
{name: "whitespace-only payload rejected", data: []byte(" \n\t "), wantOK: false, errSub: "payload is empty"},
|
||||
{name: "malformed JSON rejected", data: []byte(`{"version":1`), wantOK: false},
|
||||
{name: "trailing JSON rejected", data: trailingBytes, wantOK: false, errSub: "trailing"},
|
||||
{name: "unknown top-level field rejected", data: withRogueField(t, validQueryBytes), wantOK: false, errSub: "unknown field"},
|
||||
{name: "unsupported version rejected", data: badVersionBytes, wantOK: false, errSub: "unsupported operation query version 99"},
|
||||
{name: "empty identity rejected", data: emptyIdentityBytes, wantOK: false, errSub: "identity fields are required"},
|
||||
{name: "non-positive operation version rejected", data: zeroVersionBytes, wantOK: false, errSub: "operation version must be positive"},
|
||||
{name: "valid query decodes", data: validQueryBytes, wantOK: true, wantID: validID, checkTrim: true},
|
||||
{name: "untrimmed identity fields are normalized", data: spaceyQueryBytes, wantOK: true, wantID: validID, checkTrim: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
q, err := DecodeQuery(tc.data)
|
||||
if !tc.wantOK {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (q=%+v)", q)
|
||||
}
|
||||
if tc.errSub != "" && !strings.Contains(err.Error(), tc.errSub) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tc.errSub)
|
||||
}
|
||||
if q.Version != 0 || q.Identity != (Identity{}) {
|
||||
t.Fatalf("expected zero-value Query on error, got %+v", q)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if q.Version != ProtocolVersion {
|
||||
t.Fatalf("version=%d want=%d", q.Version, ProtocolVersion)
|
||||
}
|
||||
if tc.checkTrim && q.Identity != tc.wantID {
|
||||
t.Fatalf("identity not normalized/round-tripped:\n got %+v\n want %+v", q.Identity, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovDecodeQueryResult(t *testing.T) {
|
||||
id := testIdentity("decode-result")
|
||||
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
acceptedRec := Record{Identity: id, State: StateAccepted, AcceptedAt: now}
|
||||
startedRec := Record{Identity: id, State: StateStarted, AcceptedAt: now, StartedAt: now}
|
||||
interruptedRec := Record{Identity: id, State: StateInterrupted, AcceptedAt: now, StartedAt: now}
|
||||
tombstoneRec := Record{Identity: id, State: StateTombstone, AcceptedAt: now, StartedAt: now, TerminalAt: now}
|
||||
terminalRec := Record{
|
||||
Identity: id,
|
||||
State: StateTerminal,
|
||||
AcceptedAt: now,
|
||||
StartedAt: now,
|
||||
TerminalAt: now,
|
||||
ResultKind: "test.kind",
|
||||
ResultVersion: 1,
|
||||
Result: json.RawMessage(`{}`),
|
||||
}
|
||||
// Record that passes the found_interrupted state check (State==accepted)
|
||||
// but fails ValidateRecord because AcceptedAt is zero.
|
||||
boundsBadRec := Record{Identity: id, State: StateAccepted}
|
||||
|
||||
marshal := func(status QueryStatus, record *Record) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(QueryResult{Version: ProtocolVersion, Status: status, Record: record})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
marshalVersion := func(version int, status QueryStatus, record *Record) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(QueryResult{Version: version, Status: status, Record: record})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
emptyRecord := &Record{}
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
wantOK bool
|
||||
errSub string
|
||||
wantStatus QueryStatus
|
||||
wantState State // checked when non-empty and wantOK
|
||||
wantNilRec bool
|
||||
}{
|
||||
// decodeStrict / version branches.
|
||||
{name: "empty payload rejected", data: nil, wantOK: false, errSub: "payload is empty"},
|
||||
{name: "trailing JSON rejected", data: append(append([]byte{}, marshal(QueryNotFound, nil)...), []byte(" {}")...), wantOK: false, errSub: "trailing"},
|
||||
{name: "unknown field rejected", data: withRogueField(t, marshal(QueryNotFound, nil)), wantOK: false, errSub: "unknown field"},
|
||||
{name: "unsupported version rejected", data: marshalVersion(7, QueryNotFound, nil), wantOK: false, errSub: "unsupported operation query result version 7"},
|
||||
|
||||
// QueryNotFound arm.
|
||||
{name: "not_found without record succeeds", data: marshal(QueryNotFound, nil), wantOK: true, wantStatus: QueryNotFound, wantNilRec: true},
|
||||
{name: "not_found with record rejected", data: marshal(QueryNotFound, emptyRecord), wantOK: false, errSub: "not-found query result cannot include a record"},
|
||||
|
||||
// QueryFoundTerminal arm: nil record, wrong-state record, and valid record.
|
||||
{name: "found_terminal with nil record rejected", data: marshal(QueryFoundTerminal, nil), wantOK: false, errSub: "terminal query result requires terminal record"},
|
||||
{name: "found_terminal with non-terminal record rejected", data: marshal(QueryFoundTerminal, &startedRec), wantOK: false, errSub: "terminal query result requires terminal record"},
|
||||
{name: "found_terminal with valid terminal record succeeds", data: marshal(QueryFoundTerminal, &terminalRec), wantOK: true, wantStatus: QueryFoundTerminal, wantState: StateTerminal},
|
||||
|
||||
// QueryFoundInterrupted arm: nil record, wrong-state record, and each
|
||||
// accepted state.
|
||||
{name: "found_interrupted with nil record rejected", data: marshal(QueryFoundInterrupted, nil), wantOK: false, errSub: "interrupted query result requires nonterminal or tombstone record"},
|
||||
{name: "found_interrupted with terminal record rejected", data: marshal(QueryFoundInterrupted, &terminalRec), wantOK: false, errSub: "interrupted query result requires nonterminal or tombstone record"},
|
||||
{name: "found_interrupted with accepted record succeeds", data: marshal(QueryFoundInterrupted, &acceptedRec), wantOK: true, wantStatus: QueryFoundInterrupted, wantState: StateAccepted},
|
||||
{name: "found_interrupted with started record succeeds", data: marshal(QueryFoundInterrupted, &startedRec), wantOK: true, wantStatus: QueryFoundInterrupted, wantState: StateStarted},
|
||||
{name: "found_interrupted with interrupted record succeeds", data: marshal(QueryFoundInterrupted, &interruptedRec), wantOK: true, wantStatus: QueryFoundInterrupted, wantState: StateInterrupted},
|
||||
{name: "found_interrupted with tombstone record succeeds", data: marshal(QueryFoundInterrupted, &tombstoneRec), wantOK: true, wantStatus: QueryFoundInterrupted, wantState: StateTombstone},
|
||||
|
||||
// Non-nil record path must still run ValidateRecord; a record that
|
||||
// passes the state check but fails validation is rejected.
|
||||
{name: "non-nil record failing ValidateRecord rejected", data: marshal(QueryFoundInterrupted, &boundsBadRec), wantOK: false, errSub: "invalid operation receipt bounds"},
|
||||
|
||||
// default arm: unsupported status.
|
||||
{name: "unsupported status rejected", data: marshal(QueryStatus("bogus"), nil), wantOK: false, errSub: "unsupported operation query status"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := DecodeQueryResult(tc.data)
|
||||
if !tc.wantOK {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (r=%+v)", r)
|
||||
}
|
||||
if tc.errSub != "" && !strings.Contains(err.Error(), tc.errSub) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tc.errSub)
|
||||
}
|
||||
if r.Version != 0 || r.Status != "" || r.Record != nil {
|
||||
t.Fatalf("expected zero-value QueryResult on error, got %+v", r)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if r.Version != ProtocolVersion {
|
||||
t.Fatalf("version=%d want=%d", r.Version, ProtocolVersion)
|
||||
}
|
||||
if r.Status != tc.wantStatus {
|
||||
t.Fatalf("status=%q want=%q", r.Status, tc.wantStatus)
|
||||
}
|
||||
if tc.wantNilRec {
|
||||
if r.Record != nil {
|
||||
t.Fatalf("expected nil record, got %+v", r.Record)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r.Record == nil {
|
||||
t.Fatalf("expected non-nil record")
|
||||
}
|
||||
if tc.wantState != "" && r.Record.State != tc.wantState {
|
||||
t.Fatalf("state=%q want=%q", r.Record.State, tc.wantState)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package securityutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBranchCovHashedStorageName pins HashedStorageName to the published
|
||||
// SHA-256 test vectors (an independent oracle, not a re-implementation of the
|
||||
// function under test) and exercises the edge cases the function's single
|
||||
// expression quietly handles: empty input, multibyte/Unicode input, and the
|
||||
// filename-safety contract of the returned stem.
|
||||
func TestBranchCovHashedStorageName(t *testing.T) {
|
||||
t.Run("known_sha256_vectors", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
want string
|
||||
}{
|
||||
{name: "empty string maps to sha256 of empty input", id: "", want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
|
||||
{name: "nist abc vector", id: "abc", want: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"},
|
||||
{name: "hello vector", id: "hello", want: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
|
||||
{name: "multibyte unicode input is hashed over utf-8 bytes", id: "unicode-→-é-你", want: "2efbbf4012701ef1bdceac2226531cc5ad0f940083dbf7bc75d1d883c0012586"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HashedStorageName(tt.id); got != tt.want {
|
||||
t.Fatalf("HashedStorageName(%q) = %q, want %q", tt.id, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deterministic_and_distinct", func(t *testing.T) {
|
||||
a := HashedStorageName("tenant-a/user-123")
|
||||
b := HashedStorageName("tenant-a/user-123")
|
||||
if a != b {
|
||||
t.Fatalf("HashedStorageName is not deterministic: %q vs %q", a, b)
|
||||
}
|
||||
// Distinct inputs (including the empty/non-empty boundary) must not collide.
|
||||
empty := HashedStorageName("")
|
||||
nonEmpty := HashedStorageName("x")
|
||||
if empty == nonEmpty {
|
||||
t.Fatalf("HashedStorageName collapsed empty and %q to the same digest %q", "x", empty)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filename_safe_fixed_width_lowercase_hex", func(t *testing.T) {
|
||||
for _, id := range []string{"", "a", strings.Repeat("long-", 64), "with/slash", "with..dot"} {
|
||||
got := HashedStorageName(id)
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("HashedStorageName(%q) length = %d, want fixed 64 hex chars", id, len(got))
|
||||
}
|
||||
for i, r := range got {
|
||||
isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')
|
||||
if !isHex {
|
||||
t.Fatalf("HashedStorageName(%q) produced non-lowercase-hex rune %q at %d in %q", id, r, i, got)
|
||||
}
|
||||
}
|
||||
// A storage stem must never smuggle path separators or extensions.
|
||||
if strings.ContainsAny(got, `/\.`) {
|
||||
t.Fatalf("HashedStorageName(%q) produced unsafe filename %q", id, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestBranchCovNormalizeAbsoluteHTTPURL drives every branch of the underlying
|
||||
// NormalizeAbsoluteHTTPURL validator through the in-package wrapper: the
|
||||
// empty-input guard, the url.Parse error path, the scheme/host/userinfo/hostname
|
||||
// rejection arms, and the success path (which preserves query and fragment and
|
||||
// relies on url.Parse to lowercase the scheme while leaving the host cased as
|
||||
// given).
|
||||
func TestBranchCovNormalizeAbsoluteHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantError string
|
||||
}{
|
||||
{name: "empty input rejected", raw: "", wantError: "URL is required"},
|
||||
{name: "whitespace-only input rejected after trim", raw: " \t\n ", wantError: "URL is required"},
|
||||
|
||||
{name: "malformed ipv6 host triggers parse error", raw: "http://[::1", wantError: "invalid URL"},
|
||||
|
||||
{name: "unsupported ftp scheme rejected", raw: "ftp://example.com", wantError: "URL scheme must be http or https"},
|
||||
{name: "scheme-relative url rejected as missing scheme", raw: "//example.com/path", wantError: "URL scheme must be http or https"},
|
||||
{name: "bare path rejected as missing scheme", raw: "example.com", wantError: "URL scheme must be http or https"},
|
||||
|
||||
{name: "empty host on bare scheme rejected", raw: "http://", wantError: "URL host is required"},
|
||||
{name: "empty host with path rejected", raw: "http:///path", wantError: "URL host is required"},
|
||||
|
||||
{name: "userinfo user password rejected", raw: "http://user:pass@example.com", wantError: "URL userinfo is not allowed"},
|
||||
{name: "userinfo user only rejected", raw: "http://user@example.com", wantError: "URL userinfo is not allowed"},
|
||||
|
||||
{name: "port-only host has empty hostname", raw: "http://:8080/path", wantError: "URL hostname is required"},
|
||||
|
||||
{name: "uppercase scheme accepted via url parse lowercasing, host case preserved", raw: "HTTPS://Example.com", want: "https://Example.com"},
|
||||
{name: "leading and trailing whitespace is trimmed before parsing", raw: " https://example.com/path?q=1#frag ", want: "https://example.com/path?q=1#frag"},
|
||||
{name: "success preserves query and fragment unlike base url normalizers", raw: "https://example.com/path?q=1#frag", want: "https://example.com/path?q=1#frag"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeAbsoluteHTTPURL(tt.raw)
|
||||
if tt.wantError != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) = %q, want error containing %q", tt.raw, got, tt.wantError)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantError) {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) error = %q, want substring %q", tt.raw, err.Error(), tt.wantError)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) returned non-nil URL %q alongside error", tt.raw, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) error = %v", tt.raw, err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) returned nil URL", tt.raw)
|
||||
}
|
||||
if got.String() != tt.want {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) = %q, want %q", tt.raw, got.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package securityutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mustParseURL parses raw and fails the test if it cannot be parsed. It is a
|
||||
// small convenience used only to construct *url.URL fixtures for the append/
|
||||
// resolve helpers; it does not re-implement any package logic.
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("mustParseURL(%q): %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizeAbsoluteHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr string // non-empty substring expected when an error is returned
|
||||
wantOK bool // when true, expect success and verify resulting URL
|
||||
wantStr string // expected String() when wantOK
|
||||
wantHost string
|
||||
}{
|
||||
{name: "empty after trim", raw: " ", wantErr: "URL is required"},
|
||||
{name: "whitespace trimmed to success", raw: " https://example.com/a ", wantOK: true, wantStr: "https://example.com/a", wantHost: "example.com"},
|
||||
{name: "parse error invalid escape", raw: "http://host/%zz", wantErr: "invalid URL"},
|
||||
{name: "missing protocol scheme parse error", raw: ":badurl", wantErr: "invalid URL"},
|
||||
{name: "unsupported scheme ftp", raw: "ftp://host", wantErr: "scheme must be http or https"},
|
||||
{name: "empty host", raw: "https://", wantErr: "URL host is required"},
|
||||
{name: "userinfo rejected", raw: "https://user:pw@host", wantErr: "URL userinfo is not allowed"},
|
||||
{name: "port only hostname empty", raw: "https://:443", wantErr: "URL hostname is required"},
|
||||
{name: "https success default port", raw: "https://example.com:443/path", wantOK: true, wantStr: "https://example.com:443/path"},
|
||||
{name: "http success", raw: "http://1.2.3.4/x", wantOK: true, wantStr: "http://1.2.3.4/x"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeAbsoluteHTTPURL(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) = %v, want error containing %q", tt.raw, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) unexpected error: %v", tt.raw, err)
|
||||
}
|
||||
if tt.wantOK {
|
||||
if got == nil {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) returned nil URL", tt.raw)
|
||||
}
|
||||
if got.String() != tt.wantStr {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) String() = %q, want %q", tt.raw, got.String(), tt.wantStr)
|
||||
}
|
||||
if tt.wantHost != "" && got.Hostname() != tt.wantHost {
|
||||
t.Fatalf("NormalizeAbsoluteHTTPURL(%q) Hostname() = %q, want %q", tt.raw, got.Hostname(), tt.wantHost)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizeHTTPBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
defaultScheme string
|
||||
wantErr string
|
||||
wantPath string
|
||||
wantScheme string
|
||||
wantString string
|
||||
wantDefaultApplied bool // verifies the defaultScheme prepend branch fired
|
||||
}{
|
||||
{name: "empty required", raw: " ", defaultScheme: "https", wantErr: "base URL is required"},
|
||||
{name: "default scheme applied", raw: "example.com", defaultScheme: "https", wantScheme: "https", wantPath: "", wantString: "https://example.com", wantDefaultApplied: true},
|
||||
{name: "default scheme applied with path", raw: "example.com/pulse", defaultScheme: "http", wantScheme: "http", wantPath: "/pulse", wantString: "http://example.com/pulse", wantDefaultApplied: true},
|
||||
{name: "default scheme ignored when scheme present", raw: "https://example.com", defaultScheme: "ftp", wantScheme: "https", wantPath: "", wantString: "https://example.com"},
|
||||
{name: "no default scheme and no scheme delegates error", raw: "example.com", defaultScheme: "", wantErr: "scheme must be http or https"},
|
||||
{name: "delegates unsupported scheme", raw: "ftp://host", defaultScheme: "", wantErr: "scheme must be http or https"},
|
||||
{name: "query rejected", raw: "https://host/?q=1", defaultScheme: "", wantErr: "must not include query or fragment"},
|
||||
{name: "fragment rejected", raw: "https://host/#frag", defaultScheme: "", wantErr: "must not include query or fragment"},
|
||||
{name: "root path collapses to empty", raw: "https://host/", defaultScheme: "", wantScheme: "https", wantPath: "", wantString: "https://host"},
|
||||
{name: "dot path collapses to empty", raw: "https://host/.", defaultScheme: "", wantScheme: "https", wantPath: "", wantString: "https://host"},
|
||||
{name: "normal subpath kept", raw: "https://host/a/b", defaultScheme: "", wantScheme: "https", wantPath: "/a/b", wantString: "https://host/a/b"},
|
||||
{name: "host local backslash path rejected", raw: "https://host/\\evil", defaultScheme: "", wantErr: "base URL path must be host-local"},
|
||||
{name: "userinfo rejected via delegation", raw: "https://u@host", defaultScheme: "", wantErr: "URL userinfo is not allowed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeHTTPBaseURL(tt.raw, tt.defaultScheme)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeHTTPBaseURL(%q,%q) = %v, want error containing %q", tt.raw, tt.defaultScheme, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizeHTTPBaseURL(%q,%q) err = %q, want substring %q", tt.raw, tt.defaultScheme, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeHTTPBaseURL(%q,%q) unexpected error: %v", tt.raw, tt.defaultScheme, err)
|
||||
}
|
||||
if got.Scheme != tt.wantScheme {
|
||||
t.Fatalf("Scheme = %q, want %q", got.Scheme, tt.wantScheme)
|
||||
}
|
||||
if got.Path != tt.wantPath {
|
||||
t.Fatalf("Path = %q, want %q", got.Path, tt.wantPath)
|
||||
}
|
||||
if got.String() != tt.wantString {
|
||||
t.Fatalf("String() = %q, want %q", got.String(), tt.wantString)
|
||||
}
|
||||
// RawPath is always cleared by NormalizeHTTPBaseURL.
|
||||
if got.RawPath != "" {
|
||||
t.Fatalf("RawPath = %q, want empty", got.RawPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizeLocalRedirectPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty required", raw: " ", wantErr: "redirect path is required"},
|
||||
{name: "query only path empty", raw: "?foo=bar", wantErr: "redirect must be a local absolute path"},
|
||||
{name: "fragment only path empty", raw: "#frag", wantErr: "redirect must be a local absolute path"},
|
||||
{name: "del control char 0x7f", raw: "/x\x7f", wantErr: "redirect path contains control characters"},
|
||||
{name: "low control char 0x1f", raw: "/x\x1f", wantErr: "redirect path contains control characters"},
|
||||
{name: "parse error invalid escape", raw: "/%zz", wantErr: "invalid redirect path"},
|
||||
{name: "backslash in path rejected", raw: "/a\\b", wantErr: "redirect path must not contain backslashes"},
|
||||
{name: "success preserves query", raw: "/dashboard?next=/home", want: "/dashboard?next=/home"},
|
||||
{name: "success simple path", raw: "/settings/profile", want: "/settings/profile"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeLocalRedirectPath(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeLocalRedirectPath(%q) = %q, want error containing %q", tt.raw, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizeLocalRedirectPath(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeLocalRedirectPath(%q) unexpected error: %v", tt.raw, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("NormalizeLocalRedirectPath(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizePulseHTTPBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr string
|
||||
wantStr string
|
||||
}{
|
||||
{name: "empty required", raw: " ", wantErr: "Pulse URL is required"},
|
||||
{name: "parse error invalid escape", raw: "https://host/%zz", wantErr: "is invalid"},
|
||||
{name: "missing scheme non websocket message", raw: "example.com", wantErr: "must include scheme (https:// or loopback http://)"},
|
||||
{name: "host empty", raw: "https://", wantErr: "must include host"},
|
||||
{name: "hostname empty port only", raw: "https://:443", wantErr: "must include host"},
|
||||
{name: "user credentials rejected", raw: "https://u@host", wantErr: "must not include user credentials"},
|
||||
{name: "query rejected", raw: "https://host/?q=1", wantErr: "must not include query or fragment"},
|
||||
{name: "fragment rejected", raw: "https://host/#f", wantErr: "must not include query or fragment"},
|
||||
{name: "port zero invalid", raw: "https://host:0", wantErr: "invalid port"},
|
||||
{name: "port too high invalid", raw: "https://host:99999", wantErr: "invalid port"},
|
||||
{name: "wss scheme unsupported for http channel", raw: "wss://host", wantErr: "unsupported scheme"},
|
||||
{name: "ws scheme unsupported for http channel", raw: "ws://localhost", wantErr: "unsupported scheme"},
|
||||
{name: "ftp unsupported scheme", raw: "ftp://host", wantErr: "unsupported scheme"},
|
||||
{name: "http non loopback rejected", raw: "http://example.com", wantErr: "must use https unless host is loopback"},
|
||||
{name: "https success normalizes host and trims path", raw: "https://API.Example.COM/Pulse/", wantStr: "https://api.example.com/Pulse"},
|
||||
{name: "http loopback allowed", raw: "http://LocalHost:8080/", wantStr: "http://localhost:8080"},
|
||||
{name: "http ipv4 loopback allowed", raw: "http://127.0.0.1", wantStr: "http://127.0.0.1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizePulseHTTPBaseURL(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) = %v, want error containing %q", tt.raw, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) unexpected error: %v", tt.raw, err)
|
||||
}
|
||||
if got.String() != tt.wantStr {
|
||||
t.Fatalf("NormalizePulseHTTPBaseURL(%q) = %q, want %q", tt.raw, got.String(), tt.wantStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizeSecureHTTPBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr string
|
||||
wantStr string
|
||||
}{
|
||||
{name: "empty required", raw: " ", wantErr: "base URL is required"},
|
||||
{name: "https normalizes host and trims trailing slash", raw: "https://API.Example.COM/Pulse/", wantStr: "https://api.example.com/Pulse"},
|
||||
{name: "http loopback allowed", raw: "http://127.0.0.1/path/", wantStr: "http://127.0.0.1/path"},
|
||||
{name: "http localhost allowed", raw: "http://LocalHost", wantStr: "http://localhost"},
|
||||
{name: "http non loopback rejected", raw: "http://example.com", wantErr: "must use https unless host is loopback"},
|
||||
{name: "delegates bad scheme", raw: "ftp://host", wantErr: "scheme must be http or https"},
|
||||
{name: "clears query and fragment on https", raw: "https://host/path", wantStr: "https://host/path"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeSecureHTTPBaseURL(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeSecureHTTPBaseURL(%q) = %v, want error containing %q", tt.raw, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizeSecureHTTPBaseURL(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeSecureHTTPBaseURL(%q) unexpected error: %v", tt.raw, err)
|
||||
}
|
||||
if got.String() != tt.wantStr {
|
||||
t.Fatalf("NormalizeSecureHTTPBaseURL(%q) = %q, want %q", tt.raw, got.String(), tt.wantStr)
|
||||
}
|
||||
// NormalizeSecureHTTPBaseURL must clear query and fragment.
|
||||
if got.RawQuery != "" || got.Fragment != "" {
|
||||
t.Fatalf("expected cleared query/fragment, got RawQuery=%q Fragment=%q", got.RawQuery, got.Fragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovNormalizePulseWebSocketBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr string
|
||||
wantStr string
|
||||
}{
|
||||
{name: "empty required", raw: " ", wantErr: "Pulse URL is required"},
|
||||
{name: "missing scheme websocket message", raw: "example.com", wantErr: "must include scheme (https://, wss://, or loopback http:// / ws://)"},
|
||||
{name: "https upgraded to wss", raw: "https://LocalHost/", wantStr: "wss://localhost"},
|
||||
{name: "wss success normalizes host", raw: "wss://API.Example.COM/Pulse/", wantStr: "wss://api.example.com/Pulse"},
|
||||
{name: "http loopback downgraded to ws", raw: "http://127.0.0.1/", wantStr: "ws://127.0.0.1"},
|
||||
{name: "ws loopback kept", raw: "ws://LocalHost", wantStr: "ws://localhost"},
|
||||
{name: "http non loopback rejected websocket message", raw: "http://example.com", wantErr: "must use https/wss unless host is loopback"},
|
||||
{name: "ws non loopback rejected", raw: "ws://example.com", wantErr: "must use https/wss unless host is loopback"},
|
||||
{name: "unsupported scheme", raw: "ftp://host", wantErr: "unsupported scheme"},
|
||||
{name: "invalid port high", raw: "wss://host:99999", wantErr: "invalid port"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizePulseWebSocketBaseURL(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) = %v, want error containing %q", tt.raw, got, tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) err = %q, want substring %q", tt.raw, err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) unexpected error: %v", tt.raw, err)
|
||||
}
|
||||
if got.String() != tt.wantStr {
|
||||
t.Fatalf("NormalizePulseWebSocketBaseURL(%q) = %q, want %q", tt.raw, got.String(), tt.wantStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCovAppendURLPath(t *testing.T) {
|
||||
t.Run("nil base returns nil", func(t *testing.T) {
|
||||
if got := AppendURLPath(nil, "x"); got != nil {
|
||||
t.Fatalf("AppendURLPath(nil) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty and slash only segments skipped", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
got := AppendURLPath(base, "", "/", "//", "///")
|
||||
// All segments trim (via strings.Trim of "/") to empty and are skipped; path unchanged.
|
||||
if got.Path != "/api" {
|
||||
t.Fatalf("Path = %q, want %q", got.Path, "/api")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends multiple segments", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
got := AppendURLPath(base, "v1", "/users/", "/42")
|
||||
if got.String() != "https://host/api/v1/users/42" {
|
||||
t.Fatalf("String() = %q", got.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("segments with parent refs collapse", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/a/b")
|
||||
got := AppendURLPath(base, "../c")
|
||||
// path.Join resolves .. -> /a/c
|
||||
if got.Path != "/a/c" {
|
||||
t.Fatalf("Path = %q, want %q", got.Path, "/a/c")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base with empty path gets leading slash", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host")
|
||||
got := AppendURLPath(base, "x")
|
||||
// joined "x" has no leading slash -> normalized to "/x"
|
||||
if got.Path != "/x" {
|
||||
t.Fatalf("Path = %q, want %q", got.Path, "/x")
|
||||
}
|
||||
if got.String() != "https://host/x" {
|
||||
t.Fatalf("String() = %q", got.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no segments and root path collapses to empty", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/")
|
||||
got := AppendURLPath(base)
|
||||
if got.Path != "" {
|
||||
t.Fatalf("Path = %q, want empty", got.Path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rawpath and fragment cleared but base query preserved", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api?keep=1#/frag")
|
||||
got := AppendURLPath(base, "users")
|
||||
// AppendURLPath clears RawPath and Fragment but does NOT clear RawQuery.
|
||||
if got.RawPath != "" {
|
||||
t.Fatalf("RawPath = %q, want empty", got.RawPath)
|
||||
}
|
||||
if got.Fragment != "" {
|
||||
t.Fatalf("Fragment = %q, want empty", got.Fragment)
|
||||
}
|
||||
if got.RawQuery != "keep=1" {
|
||||
t.Fatalf("RawQuery = %q, want %q (base query is preserved)", got.RawQuery, "keep=1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base is not mutated", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_ = AppendURLPath(base, "x", "y")
|
||||
if base.Path != "/api" {
|
||||
t.Fatalf("base.Path mutated to %q", base.Path)
|
||||
}
|
||||
if base.RawPath != "" {
|
||||
t.Fatalf("base.RawPath mutated to %q", base.RawPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBranchCovResolveRelativeURL(t *testing.T) {
|
||||
t.Run("nil base errors", func(t *testing.T) {
|
||||
_, err := ResolveRelativeURL(nil, "/x")
|
||||
if err == nil || !strings.Contains(err.Error(), "base URL is required") {
|
||||
t.Fatalf("err = %v, want base URL is required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty relative after trim errors", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "relative path is required") {
|
||||
t.Fatalf("err = %v, want relative path is required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backslash rejected", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, `/a\b`)
|
||||
if err == nil || !strings.Contains(err.Error(), "must not contain backslashes") {
|
||||
t.Fatalf("err = %v, want backslash rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parse error invalid escape", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, "/%zz")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid relative path") {
|
||||
t.Fatalf("err = %v, want invalid relative path", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absolute url rejected", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, "https://evil/x")
|
||||
if err == nil || !strings.Contains(err.Error(), "must not include scheme or host") {
|
||||
t.Fatalf("err = %v, want scheme/host rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scheme relative host rejected", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, "//evil/x")
|
||||
if err == nil || !strings.Contains(err.Error(), "must not include scheme or host") {
|
||||
t.Fatalf("err = %v, want host rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relative without leading slash rejected", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, err := ResolveRelativeURL(base, "users")
|
||||
if err == nil || !strings.Contains(err.Error(), "must start with '/'") {
|
||||
t.Fatalf("err = %v, want leading slash rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success joins and clears fragment", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
got, err := ResolveRelativeURL(base, "/users")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.String() != "https://host/api/users" {
|
||||
t.Fatalf("String() = %q", got.String())
|
||||
}
|
||||
// No encoding in path -> RawPath collapsed to empty.
|
||||
if got.RawPath != "" {
|
||||
t.Fatalf("RawPath = %q, want empty", got.RawPath)
|
||||
}
|
||||
if got.Fragment != "" {
|
||||
t.Fatalf("Fragment = %q, want empty", got.Fragment)
|
||||
}
|
||||
if got.RawQuery != "" {
|
||||
t.Fatalf("RawQuery = %q, want empty", got.RawQuery)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success propagates relative query", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
got, err := ResolveRelativeURL(base, "/users?active=1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.RawQuery != "active=1" {
|
||||
t.Fatalf("RawQuery = %q, want %q", got.RawQuery, "active=1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("encoded path retains rawpath", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
got, err := ResolveRelativeURL(base, "/us%20ers")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Path != "/api/us ers" {
|
||||
t.Fatalf("Path = %q, want %q", got.Path, "/api/us ers")
|
||||
}
|
||||
if got.RawPath != "/api/us%20ers" {
|
||||
t.Fatalf("RawPath = %q, want %q (retained because it differs from Path)", got.RawPath, "/api/us%20ers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base is not mutated", func(t *testing.T) {
|
||||
base := mustParseURL(t, "https://host/api")
|
||||
_, _ = ResolveRelativeURL(base, "/users")
|
||||
if base.Path != "/api" {
|
||||
t.Fatalf("base.Path mutated to %q", base.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user