From 47f2bdaed410b15c53c58b206c449583c3d3ce0a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 16 Jul 2026 10:19:51 +0100 Subject: [PATCH] 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. --- ...resolution_recommend_branchcov0716_test.go | 295 +++++++ ..._paths_and_redaction_branchcov0716_test.go | 485 +++++++++++ ...erts_config_identity_branchcov0716_test.go | 214 +++++ ...rts_config_normalize_branchcov0716_test.go | 819 ++++++++++++++++++ ..._ai_providers_static_branchcov0716_test.go | 264 ++++++ .../config_availability_branchcov0716_test.go | 302 +++++++ ...g_credential_masking_branchcov0716_test.go | 279 ++++++ ...nfig_report_branding_branchcov0716_test.go | 190 ++++ .../models/models_roles_branchcov0716_test.go | 110 +++ ...rationreceipt_decode_branchcov0716_test.go | 240 +++++ ...securityutil_storage_branchcov0716_test.go | 129 +++ ...securityutil_httpurl_branchcov0716_test.go | 490 +++++++++++ 12 files changed, 3817 insertions(+) create mode 100644 internal/ai/modelresolution/modelresolution_recommend_branchcov0716_test.go create mode 100644 internal/ai/safety/ai_safety_paths_and_redaction_branchcov0716_test.go create mode 100644 internal/alerts/config/alerts_config_identity_branchcov0716_test.go create mode 100644 internal/alerts/config/alerts_config_normalize_branchcov0716_test.go create mode 100644 internal/config/config_ai_providers_static_branchcov0716_test.go create mode 100644 internal/config/config_availability_branchcov0716_test.go create mode 100644 internal/config/config_credential_masking_branchcov0716_test.go create mode 100644 internal/config/config_report_branding_branchcov0716_test.go create mode 100644 internal/models/models_roles_branchcov0716_test.go create mode 100644 internal/operationreceipt/operationreceipt_decode_branchcov0716_test.go create mode 100644 internal/securityutil/internal_securityutil_storage_branchcov0716_test.go create mode 100644 pkg/securityutil/pkg_securityutil_httpurl_branchcov0716_test.go diff --git a/internal/ai/modelresolution/modelresolution_recommend_branchcov0716_test.go b/internal/ai/modelresolution/modelresolution_recommend_branchcov0716_test.go new file mode 100644 index 000000000..c6f82f22b --- /dev/null +++ b/internal/ai/modelresolution/modelresolution_recommend_branchcov0716_test.go @@ -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") + } +} diff --git a/internal/ai/safety/ai_safety_paths_and_redaction_branchcov0716_test.go b/internal/ai/safety/ai_safety_paths_and_redaction_branchcov0716_test.go new file mode 100644 index 000000000..94bae18ec --- /dev/null +++ b/internal/ai/safety/ai_safety_paths_and_redaction_branchcov0716_test.go @@ -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("/") 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//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) + } + }) +} diff --git a/internal/alerts/config/alerts_config_identity_branchcov0716_test.go b/internal/alerts/config/alerts_config_identity_branchcov0716_test.go new file mode 100644 index 000000000..adae7650d --- /dev/null +++ b/internal/alerts/config/alerts_config_identity_branchcov0716_test.go @@ -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) + } + }) + } +} diff --git a/internal/alerts/config/alerts_config_normalize_branchcov0716_test.go b/internal/alerts/config/alerts_config_normalize_branchcov0716_test.go new file mode 100644 index 000000000..97fd18069 --- /dev/null +++ b/internal/alerts/config/alerts_config_normalize_branchcov0716_test.go @@ -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 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) + } + }) + } +} diff --git a/internal/config/config_credential_masking_branchcov0716_test.go b/internal/config/config_credential_masking_branchcov0716_test.go new file mode 100644 index 000000000..f5e087abf --- /dev/null +++ b/internal/config/config_credential_masking_branchcov0716_test.go @@ -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) + }) +} diff --git a/internal/config/config_report_branding_branchcov0716_test.go b/internal/config/config_report_branding_branchcov0716_test.go new file mode 100644 index 000000000..74a6c3f5a --- /dev/null +++ b/internal/config/config_report_branding_branchcov0716_test.go @@ -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) + }) + } +} diff --git a/internal/models/models_roles_branchcov0716_test.go b/internal/models/models_roles_branchcov0716_test.go new file mode 100644 index 000000000..ab2e2f27e --- /dev/null +++ b/internal/models/models_roles_branchcov0716_test.go @@ -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 + } +} diff --git a/internal/operationreceipt/operationreceipt_decode_branchcov0716_test.go b/internal/operationreceipt/operationreceipt_decode_branchcov0716_test.go new file mode 100644 index 000000000..5a1213d83 --- /dev/null +++ b/internal/operationreceipt/operationreceipt_decode_branchcov0716_test.go @@ -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) + } + }) + } +} diff --git a/internal/securityutil/internal_securityutil_storage_branchcov0716_test.go b/internal/securityutil/internal_securityutil_storage_branchcov0716_test.go new file mode 100644 index 000000000..351392f56 --- /dev/null +++ b/internal/securityutil/internal_securityutil_storage_branchcov0716_test.go @@ -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) + } + }) + } +} diff --git a/pkg/securityutil/pkg_securityutil_httpurl_branchcov0716_test.go b/pkg/securityutil/pkg_securityutil_httpurl_branchcov0716_test.go new file mode 100644 index 000000000..84f51e0e1 --- /dev/null +++ b/pkg/securityutil/pkg_securityutil_httpurl_branchcov0716_test.go @@ -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) + } + }) +}