diff --git a/backend/internal/api/handlers/notification_coverage_test.go b/backend/internal/api/handlers/notification_coverage_test.go index 23317576..162364dc 100644 --- a/backend/internal/api/handlers/notification_coverage_test.go +++ b/backend/internal/api/handlers/notification_coverage_test.go @@ -378,6 +378,38 @@ func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *te assert.NotContains(t, w.Body.String(), "secret-with-space") } +func TestClassifyProviderTestFailure_NilError(t *testing.T) { + code, category, message := classifyProviderTestFailure(nil) + + assert.Equal(t, "PROVIDER_TEST_FAILED", code) + assert.Equal(t, "dispatch", category) + assert.Equal(t, "Provider test failed", message) +} + +func TestClassifyProviderTestFailure_DefaultStatusCode(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("provider returned status 500")) + + assert.Equal(t, "PROVIDER_TEST_REMOTE_REJECTED", code) + assert.Equal(t, "dispatch", category) + assert.Contains(t, message, "HTTP 500") +} + +func TestClassifyProviderTestFailure_GenericError(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("something completely unexpected")) + + assert.Equal(t, "PROVIDER_TEST_FAILED", code) + assert.Equal(t, "dispatch", category) + assert.Equal(t, "Provider test failed", message) +} + +func TestClassifyProviderTestFailure_InvalidDiscordWebhookURL(t *testing.T) { + code, category, message := classifyProviderTestFailure(errors.New("invalid discord webhook url")) + + assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code) + assert.Equal(t, "validation", category) + assert.Contains(t, message, "Provider URL") +} + func TestClassifyProviderTestFailure_URLValidation(t *testing.T) { code, category, message := classifyProviderTestFailure(errors.New("destination URL validation failed")) @@ -748,3 +780,258 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) { assert.Equal(t, 400, w.Code) } + +func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "template": "minimal", + "token": "secret-token-value", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Preview(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY") +} + +func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "update-type-test", + Name: "Discord Provider", + Type: "discord", + URL: "https://discord.com/api/webhooks/123/abc", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Changed Type Provider", + "type": "gotify", + "url": "https://gotify.example.com", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "update-type-test"}} + c.Request = httptest.NewRequest("PUT", "/providers/update-type-test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_TYPE_IMMUTABLE") +} + +func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "type": "discord", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID") +} + +func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + payload := map[string]any{ + "type": "discord", + "id": "nonexistent-provider", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 404, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND") +} + +func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "empty-url-test", + Name: "Empty URL Provider", + Type: "discord", + URL: "", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "type": "discord", + "id": "empty-url-test", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_CONFIG_MISSING") +} + +func TestIsProviderValidationError_Comprehensive(t *testing.T) { + cases := []struct { + name string + err error + expect bool + }{ + {"nil", nil, false}, + {"invalid_custom_template", errors.New("invalid custom template: missing field"), true}, + {"rendered_template", errors.New("rendered template exceeds maximum"), true}, + {"failed_to_parse", errors.New("failed to parse template: unexpected end"), true}, + {"failed_to_render", errors.New("failed to render template: missing key"), true}, + {"invalid_discord_webhook", errors.New("invalid Discord webhook URL"), true}, + {"unrelated_error", errors.New("database connection failed"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, isProviderValidationError(tc.err)) + }) + } +} + +func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "unsupported-type", + Name: "Custom Provider", + Type: "slack", + URL: "https://hooks.slack.com/test", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Updated Slack Provider", + "url": "https://hooks.slack.com/updated", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "unsupported-type"}} + c.Request = httptest.NewRequest("PUT", "/providers/unsupported-type", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 400, w.Code) + assert.Contains(t, w.Body.String(), "UNSUPPORTED_PROVIDER_TYPE") +} + +func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + existing := models.NotificationProvider{ + ID: "gotify-keep-token", + Name: "Gotify Provider", + Type: "gotify", + URL: "https://gotify.example.com", + Token: "existing-secret-token", + } + require.NoError(t, db.Create(&existing).Error) + + payload := map[string]any{ + "name": "Updated Gotify", + "url": "https://gotify.example.com/new", + "template": "minimal", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Params = gin.Params{{Key: "id", Value: "gotify-keep-token"}} + c.Request = httptest.NewRequest("PUT", "/providers/gotify-keep-token", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Update(c) + + assert.Equal(t, 200, w.Code) + + var updated models.NotificationProvider + require.NoError(t, db.Where("id = ?", "gotify-keep-token").First(&updated).Error) + assert.Equal(t, "existing-secret-token", updated.Token) +} + +func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupNotificationCoverageDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationProviderHandler(svc) + + _ = db.Migrator().DropTable(&models.NotificationProvider{}) + + payload := map[string]any{ + "type": "discord", + "id": "some-provider", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + setAdminContext(c) + c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Test(c) + + assert.Equal(t, 500, w.Code) + assert.Contains(t, w.Body.String(), "PROVIDER_READ_FAILED") +} diff --git a/backend/internal/api/handlers/permission_helpers_test.go b/backend/internal/api/handlers/permission_helpers_test.go index 3113d57a..f9d4fd77 100644 --- a/backend/internal/api/handlers/permission_helpers_test.go +++ b/backend/internal/api/handlers/permission_helpers_test.go @@ -168,3 +168,34 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) { assert.Equal(t, "permissions", audit.EventCategory) assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false)) } + +func TestRequireAuthenticatedAdmin_NoUserID(t *testing.T) { + t.Parallel() + + ctx, rec := newTestContextWithRequest() + result := requireAuthenticatedAdmin(ctx) + assert.False(t, result) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization header required") +} + +func TestRequireAuthenticatedAdmin_UserIDPresentAndAdmin(t *testing.T) { + t.Parallel() + + ctx, _ := newTestContextWithRequest() + ctx.Set("userID", uint(1)) + ctx.Set("role", "admin") + result := requireAuthenticatedAdmin(ctx) + assert.True(t, result) +} + +func TestRequireAuthenticatedAdmin_UserIDPresentButNotAdmin(t *testing.T) { + t.Parallel() + + ctx, rec := newTestContextWithRequest() + ctx.Set("userID", uint(1)) + ctx.Set("role", "user") + result := requireAuthenticatedAdmin(ctx) + assert.False(t, result) + assert.Equal(t, http.StatusForbidden, rec.Code) +} diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 0629c2e6..bdcb24b7 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -3,6 +3,7 @@ package handlers import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" "strconv" @@ -2639,3 +2640,68 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) { db.First(&updatedUser, user.ID) assert.True(t, updatedUser.InviteExpires.After(time.Now())) } + +// ===== Additional coverage for uncovered utility functions ===== + +func TestIsSetupConflictError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"unique constraint failed", errors.New("UNIQUE constraint failed: users.email"), true}, + {"duplicate key", errors.New("duplicate key value violates unique constraint"), true}, + {"database is locked", errors.New("database is locked"), true}, + {"database table is locked", errors.New("database table is locked"), true}, + {"case insensitive", errors.New("UNIQUE CONSTRAINT FAILED"), true}, + {"unrelated error", errors.New("connection refused"), false}, + {"empty error", errors.New(""), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSetupConflictError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMaskSecretForResponse(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"non-empty secret", "my-secret-key", "********"}, + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + {"single char", "x", "********"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := maskSecretForResponse(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRedactInviteURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"non-empty url", "https://example.com/invite/abc123", "[REDACTED]"}, + {"empty string", "", ""}, + {"whitespace only", " ", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactInviteURL(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go index 5a73d0ad..6262c091 100644 --- a/backend/internal/notifications/http_wrapper_test.go +++ b/backend/internal/notifications/http_wrapper_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" neturl "net/url" @@ -497,3 +498,426 @@ func TestBuildSafeRequestURLWithTLSServer(t *testing.T) { t.Fatalf("expected host header %q, got %q", serverURL.Host, hostHeader) } } + +// ===== Additional coverage for uncovered paths ===== + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { + return 0, errors.New("simulated read error") +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestApplyRedirectGuardNilClient(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.applyRedirectGuard(nil) +} + +func TestGuardDestinationNilURL(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + err := wrapper.guardDestination(nil) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected validation failure for nil URL, got: %v", err) + } +} + +func TestGuardDestinationEmptyHostname(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: ""}) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected validation failure for empty hostname, got: %v", err) + } +} + +func TestGuardDestinationUserInfoRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + u := &neturl.URL{Scheme: "https", Host: "example.com", User: neturl.User("admin")} + err := wrapper.guardDestination(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected userinfo rejection, got: %v", err) + } +} + +func TestGuardDestinationFragmentRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + u := &neturl.URL{Scheme: "https", Host: "example.com", Fragment: "section"} + err := wrapper.guardDestination(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected fragment rejection, got: %v", err) + } +} + +func TestGuardDestinationPrivateIPRejection(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + err := wrapper.guardDestination(&neturl.URL{Scheme: "https", Host: "192.168.1.1"}) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private IP rejection, got: %v", err) + } +} + +func TestIsAllowedDestinationIPEdgeCases(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + + tests := []struct { + name string + hostname string + ip net.IP + expected bool + }{ + {"nil IP", "", nil, false}, + {"unspecified", "0.0.0.0", net.IPv4zero, false}, + {"multicast", "224.0.0.1", net.ParseIP("224.0.0.1"), false}, + {"link-local unicast", "169.254.1.1", net.ParseIP("169.254.1.1"), false}, + {"loopback without allowHTTP", "127.0.0.1", net.ParseIP("127.0.0.1"), false}, + {"private 10.x", "10.0.0.1", net.ParseIP("10.0.0.1"), false}, + {"private 172.16.x", "172.16.0.1", net.ParseIP("172.16.0.1"), false}, + {"private 192.168.x", "192.168.1.1", net.ParseIP("192.168.1.1"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := wrapper.isAllowedDestinationIP(tt.hostname, tt.ip) + if result != tt.expected { + t.Fatalf("isAllowedDestinationIP(%q, %v) = %v, want %v", tt.hostname, tt.ip, result, tt.expected) + } + }) + } +} + +func TestIsAllowedDestinationIPLoopbackAllowHTTP(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + + if !wrapper.isAllowedDestinationIP("localhost", net.ParseIP("127.0.0.1")) { + t.Fatal("expected loopback allowed for localhost with allowHTTP") + } + + if wrapper.isAllowedDestinationIP("not-localhost", net.ParseIP("127.0.0.1")) { + t.Fatal("expected loopback rejected for non-localhost hostname") + } +} + +func TestIsLocalDestinationHost(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + {"localhost", true}, + {"LOCALHOST", true}, + {"127.0.0.1", true}, + {"::1", true}, + {"example.com", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := isLocalDestinationHost(tt.host); got != tt.expected { + t.Fatalf("isLocalDestinationHost(%q) = %v, want %v", tt.host, got, tt.expected) + } + }) + } +} + +func TestShouldRetryComprehensive(t *testing.T) { + tests := []struct { + name string + resp *http.Response + err error + expected bool + }{ + {"nil resp nil err", nil, nil, false}, + {"timeout error string", nil, errors.New("operation timeout"), true}, + {"connection error string", nil, errors.New("connection reset"), true}, + {"unrelated error", nil, errors.New("json parse error"), false}, + {"500 response", &http.Response{StatusCode: 500}, nil, true}, + {"502 response", &http.Response{StatusCode: 502}, nil, true}, + {"503 response", &http.Response{StatusCode: 503}, nil, true}, + {"429 response", &http.Response{StatusCode: 429}, nil, true}, + {"200 response", &http.Response{StatusCode: 200}, nil, false}, + {"400 response", &http.Response{StatusCode: 400}, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldRetry(tt.resp, tt.err); got != tt.expected { + t.Fatalf("shouldRetry = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestShouldRetryNetError(t *testing.T) { + netErr := &net.DNSError{Err: "no such host", Name: "example.invalid"} + if !shouldRetry(nil, netErr) { + t.Fatal("expected net.Error to trigger retry via errors.As fallback") + } +} + +func TestReadCappedResponseBodyReadError(t *testing.T) { + _, err := readCappedResponseBody(errReader{}) + if err == nil || !strings.Contains(err.Error(), "read response body") { + t.Fatalf("expected read body error, got: %v", err) + } +} + +func TestReadCappedResponseBodyOversize(t *testing.T) { + oversized := strings.NewReader(strings.Repeat("x", MaxNotifyResponseBodyBytes+10)) + _, err := readCappedResponseBody(oversized) + if err == nil || !strings.Contains(err.Error(), "response payload exceeds") { + t.Fatalf("expected oversize error, got: %v", err) + } +} + +func TestReadCappedResponseBodySuccess(t *testing.T) { + content, err := readCappedResponseBody(strings.NewReader("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(content) != "hello" { + t.Fatalf("expected 'hello', got %q", string(content)) + } +} + +func TestHasDisallowedQueryAuthKeyAllVariants(t *testing.T) { + tests := []struct { + name string + key string + expected bool + }{ + {"token", "token", true}, + {"auth", "auth", true}, + {"apikey", "apikey", true}, + {"api_key", "api_key", true}, + {"TOKEN uppercase", "TOKEN", true}, + {"Api_Key mixed", "Api_Key", true}, + {"safe key", "callback", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := neturl.Values{} + query.Set(tt.key, "secret") + if got := hasDisallowedQueryAuthKey(query); got != tt.expected { + t.Fatalf("hasDisallowedQueryAuthKey with key %q = %v, want %v", tt.key, got, tt.expected) + } + }) + } +} + +func TestHasDisallowedQueryAuthKeyEmptyQuery(t *testing.T) { + if hasDisallowedQueryAuthKey(neturl.Values{}) { + t.Fatal("expected empty query to be safe") + } +} + +func TestNotifyMaxRedirects(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + {"empty", "", 0}, + {"valid 3", "3", 3}, + {"zero", "0", 0}, + {"negative", "-1", 0}, + {"above max", "10", 5}, + {"exactly 5", "5", 5}, + {"invalid", "abc", 0}, + {"whitespace", " 2 ", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CHARON_NOTIFY_MAX_REDIRECTS", tt.envValue) + if got := notifyMaxRedirects(); got != tt.expected { + t.Fatalf("notifyMaxRedirects() = %d, want %d", got, tt.expected) + } + }) + } +} + +func TestResolveAllowedDestinationIPRejectsPrivateIP(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + _, err := wrapper.resolveAllowedDestinationIP("192.168.1.1") + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private IP rejection, got: %v", err) + } +} + +func TestResolveAllowedDestinationIPRejectsLoopback(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + _, err := wrapper.resolveAllowedDestinationIP("127.0.0.1") + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected loopback rejection, got: %v", err) + } +} + +func TestResolveAllowedDestinationIPAllowsPublic(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + ip, err := wrapper.resolveAllowedDestinationIP("1.1.1.1") + if err != nil { + t.Fatalf("expected public IP to be allowed, got: %v", err) + } + if !ip.Equal(net.ParseIP("1.1.1.1")) { + t.Fatalf("expected 1.1.1.1, got %v", ip) + } +} + +func TestBuildSafeRequestURLRejectsPrivateHostname(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = false + u := &neturl.URL{Scheme: "https", Host: "192.168.1.1", Path: "/hook"} + _, _, err := wrapper.buildSafeRequestURL(u) + if err == nil || !strings.Contains(err.Error(), "destination URL validation failed") { + t.Fatalf("expected private host rejection, got: %v", err) + } +} + +func TestWaitBeforeRetryBasic(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + var sleptDuration time.Duration + wrapper.sleep = func(d time.Duration) { sleptDuration = d } + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond + wrapper.retryPolicy.MaxDelay = 1 * time.Second + + wrapper.waitBeforeRetry(1) + if sleptDuration != 100*time.Millisecond { + t.Fatalf("expected 100ms delay for attempt 1, got %v", sleptDuration) + } + + wrapper.waitBeforeRetry(2) + if sleptDuration != 200*time.Millisecond { + t.Fatalf("expected 200ms delay for attempt 2, got %v", sleptDuration) + } +} + +func TestWaitBeforeRetryClampedToMax(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + var sleptDuration time.Duration + wrapper.sleep = func(d time.Duration) { sleptDuration = d } + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.retryPolicy.BaseDelay = 1 * time.Second + wrapper.retryPolicy.MaxDelay = 2 * time.Second + + wrapper.waitBeforeRetry(5) + if sleptDuration != 2*time.Second { + t.Fatalf("expected clamped delay of 2s, got %v", sleptDuration) + } +} + +func TestWaitBeforeRetryDefaultJitter(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.jitterNanos = nil + wrapper.sleep = func(time.Duration) {} + wrapper.retryPolicy.BaseDelay = 100 * time.Millisecond + wrapper.retryPolicy.MaxDelay = 1 * time.Second + wrapper.waitBeforeRetry(1) +} + +func TestHTTPWrapperSendExhaustsRetriesOnTransportError(t *testing.T) { + var calls int32 + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.sleep = func(time.Duration) {} + wrapper.jitterNanos = func(int64) int64 { return 0 } + wrapper.httpClientFactory = func(bool, int) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return nil, errors.New("connection timeout failure") + }), + } + } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: "http://localhost:19999/hook", + Body: []byte(`{"msg":"test"}`), + }) + if err == nil { + t.Fatal("expected error after transport failures") + } + if !strings.Contains(err.Error(), "outbound request failed") { + t.Fatalf("expected outbound request failed message, got: %v", err) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("expected 3 attempts, got %d", got) + } +} + +func TestHTTPWrapperSendExhaustsRetriesOn500(t *testing.T) { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.sleep = func(time.Duration) {} + wrapper.jitterNanos = func(int64) int64 { return 0 } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: server.URL, + Body: []byte(`{"msg":"test"}`), + }) + if err == nil || !strings.Contains(err.Error(), "status 500") { + t.Fatalf("expected 500 status error, got: %v", err) + } + if got := atomic.LoadInt32(&calls); got != 3 { + t.Fatalf("expected 3 attempts for 500 retries, got %d", got) + } +} + +func TestHTTPWrapperSendTransportErrorNoRetry(t *testing.T) { + wrapper := NewNotifyHTTPWrapper() + wrapper.allowHTTP = true + wrapper.retryPolicy.MaxAttempts = 1 + wrapper.httpClientFactory = func(bool, int) *http.Client { + return &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("some unretryable error") + }), + } + } + + _, err := wrapper.Send(context.Background(), HTTPWrapperRequest{ + URL: "http://localhost:19999/hook", + Body: []byte(`{"msg":"test"}`), + }) + if err == nil || !strings.Contains(err.Error(), "outbound request failed") { + t.Fatalf("expected outbound request failed, got: %v", err) + } +} + +func TestSanitizeTransportErrorReasonNetworkUnreachable(t *testing.T) { + result := sanitizeTransportErrorReason(errors.New("connect: network is unreachable")) + if result != "network unreachable" { + t.Fatalf("expected 'network unreachable', got %q", result) + } +} + +func TestSanitizeTransportErrorReasonCertificate(t *testing.T) { + result := sanitizeTransportErrorReason(errors.New("x509: certificate signed by unknown authority")) + if result != "tls handshake failed" { + t.Fatalf("expected 'tls handshake failed', got %q", result) + } +} + +func TestAllowNotifyHTTPOverride(t *testing.T) { + result := allowNotifyHTTPOverride() + if !result { + t.Fatal("expected allowHTTP to be true in test binary") + } +} diff --git a/backend/internal/services/docker_service_test.go b/backend/internal/services/docker_service_test.go index 4e2a955b..fa35e599 100644 --- a/backend/internal/services/docker_service_test.go +++ b/backend/internal/services/docker_service_test.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "fmt" "net" "net/url" "os" @@ -263,3 +264,94 @@ func TestBuildLocalDockerUnavailableDetails_GenericError(t *testing.T) { assert.Contains(t, details, "uid=") assert.Contains(t, details, "gid=") } + +// ===== Additional coverage for uncovered paths ===== + +func TestDockerUnavailableError_NilDetails(t *testing.T) { + var nilErr *DockerUnavailableError + assert.Equal(t, "", nilErr.Details()) +} + +func TestExtractErrno_UrlErrorWrapping(t *testing.T) { + urlErr := &url.Error{Op: "dial", URL: "unix:///var/run/docker.sock", Err: syscall.EACCES} + errno, ok := extractErrno(urlErr) + assert.True(t, ok) + assert.Equal(t, syscall.EACCES, errno) +} + +func TestExtractErrno_SyscallError(t *testing.T) { + scErr := &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED} + errno, ok := extractErrno(scErr) + assert.True(t, ok) + assert.Equal(t, syscall.ECONNREFUSED, errno) +} + +func TestExtractErrno_NilError(t *testing.T) { + _, ok := extractErrno(nil) + assert.False(t, ok) +} + +func TestExtractErrno_NonSyscallError(t *testing.T) { + _, ok := extractErrno(errors.New("some generic error")) + assert.False(t, ok) +} + +func TestExtractErrno_OpErrorWrapping(t *testing.T) { + opErr := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} + errno, ok := extractErrno(opErr) + assert.True(t, ok) + assert.Equal(t, syscall.EPERM, errno) +} + +func TestExtractErrno_NestedUrlSyscallOpError(t *testing.T) { + innerErr := &net.OpError{ + Op: "dial", + Net: "unix", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EACCES}, + } + urlErr := &url.Error{Op: "Get", URL: "unix:///var/run/docker.sock", Err: innerErr} + errno, ok := extractErrno(urlErr) + assert.True(t, ok) + assert.Equal(t, syscall.EACCES, errno) +} + +func TestSocketPathFromDockerHost(t *testing.T) { + tests := []struct { + name string + host string + expected string + }{ + {"unix socket", "unix:///var/run/docker.sock", "/var/run/docker.sock"}, + {"tcp host", "tcp://192.168.1.1:2375", ""}, + {"empty", "", ""}, + {"whitespace unix", " unix:///tmp/docker.sock ", "/tmp/docker.sock"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := socketPathFromDockerHost(tt.host) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildLocalDockerUnavailableDetails_OsErrNotExist(t *testing.T) { + err := fmt.Errorf("wrapped: %w", os.ErrNotExist) + details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") + assert.Contains(t, details, "not found") + assert.Contains(t, details, "/var/run/docker.sock") +} + +func TestBuildLocalDockerUnavailableDetails_NonUnixHost(t *testing.T) { + err := errors.New("cannot connect") + details := buildLocalDockerUnavailableDetails(err, "tcp://192.168.1.1:2375") + assert.Contains(t, details, "Cannot connect") + assert.Contains(t, details, "tcp://192.168.1.1:2375") +} + +func TestBuildLocalDockerUnavailableDetails_EPERMWithStatFail(t *testing.T) { + err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} + details := buildLocalDockerUnavailableDetails(err, "unix:///tmp/nonexistent-eperm.sock") + assert.Contains(t, details, "not accessible") + assert.Contains(t, details, "could not be stat") +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index c4032fb4..47ecc412 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -2538,3 +2538,79 @@ func TestTestProvider_WebhookWorksWhenFlagExplicitlyFalse(t *testing.T) { err := svc.TestProvider(provider) assert.NoError(t, err) } + +func TestUpdateProvider_TypeMutationBlocked(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + existing := models.NotificationProvider{ + ID: "prov-type-mut", + Type: "webhook", + Name: "Original", + URL: "https://example.com/hook", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-type-mut", + Type: "discord", + Name: "Changed", + URL: "https://discord.com/api/webhooks/123/abc", + } + err := svc.UpdateProvider(&update) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot change provider type") +} + +func TestUpdateProvider_GotifyKeepsExistingToken(t *testing.T) { + db := setupNotificationTestDB(t) + svc := NewNotificationService(db) + + existing := models.NotificationProvider{ + ID: "prov-gotify-token", + Type: "gotify", + Name: "My Gotify", + URL: "https://gotify.example.com", + Token: "original-secret-token", + } + require.NoError(t, db.Create(&existing).Error) + + update := models.NotificationProvider{ + ID: "prov-gotify-token", + Type: "gotify", + Name: "My Gotify Updated", + URL: "https://gotify.example.com", + Token: "", + } + err := svc.UpdateProvider(&update) + require.NoError(t, err) + assert.Equal(t, "original-secret-token", update.Token) +} + +func TestGetFeatureFlagValue_FoundSetting(t *testing.T) { + db := setupNotificationTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{})) + svc := NewNotificationService(db) + + tests := []struct { + name string + value string + expected bool + }{ + {"true_string", "true", true}, + {"yes_string", "yes", true}, + {"one_string", "1", true}, + {"false_string", "false", false}, + {"no_string", "no", false}, + {"zero_string", "0", false}, + {"whitespace_true", " True ", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db.Where("key = ?", "test.flag").Delete(&models.Setting{}) + db.Create(&models.Setting{Key: "test.flag", Value: tt.value}) + result := svc.getFeatureFlagValue("test.flag", false) + assert.Equal(t, tt.expected, result, "value=%q", tt.value) + }) + } +} diff --git a/docs/reports/qa_report_pr754.md b/docs/reports/qa_report_pr754.md new file mode 100644 index 00000000..93aafc59 --- /dev/null +++ b/docs/reports/qa_report_pr754.md @@ -0,0 +1,138 @@ +# QA Report — PR #754: Enable and Test Gotify and Custom Webhook Notifications + +**Branch:** `feature/beta-release` +**Date:** 2026-02-25 +**Auditor:** QA Security Agent + +--- + +## Summary + +| # | Check | Result | Details | +|---|-------|--------|---------| +| 1 | Local Patch Coverage Preflight | **WARN** | 79.5% overall (threshold 90%), 78.3% backend (threshold 85%) — advisory only | +| 2 | Backend Coverage ≥ 85% | **PASS** | 87.0% statement / 87.3% line (threshold 87%) | +| 3 | Frontend Coverage ≥ 85% | **PASS** | 88.21% statement / 88.97% line (threshold 85%) | +| 4 | TypeScript Type Check | **PASS** | Zero errors | +| 5 | Pre-commit Hooks | **PASS** | All 15 hooks passed | +| 6a | Trivy Filesystem Scan | **PASS** | 0 CRITICAL/HIGH in project code (findings only in Go module cache) | +| 6b | Docker Image Scan | **WARN** | 1 HIGH in Caddy transitive dep (CVE-2026-25793, nebula v1.9.7 → fixed 1.10.3) | +| 6c | CodeQL (Go + JavaScript) | **PASS** | 0 errors, 0 warnings across both languages | +| 7 | GORM Security Scan | **PASS** | 0 CRITICAL/HIGH (2 INFO suggestions: missing indexes on UserPermittedHost) | +| 8 | Go Vulnerability Check | **PASS** | No vulnerabilities found | + +--- + +## Detailed Findings + +### 1. Local Patch Coverage Preflight + +- **Status:** WARN (advisory, not blocking per policy) +- Overall patch coverage: **79.5%** (threshold: 90%) +- Backend patch coverage: **78.3%** (threshold: 85%) +- Artifacts generated but `test-results/` directory was not persisted at repo root +- **Action:** Consider adding targeted tests for uncovered changed lines in notification service/handler + +### 2. Backend Unit Test Coverage + +- **Status:** PASS +- Statement coverage: **87.0%** +- Line coverage: **87.3%** +- All tests passed (0 failures) + +### 3. Frontend Unit Test Coverage + +- **Status:** PASS +- Statement coverage: **88.21%** +- Branch coverage: **80.58%** +- Function coverage: **85.20%** +- Line coverage: **88.97%** +- All tests passed (0 failures) +- Coverage files generated: `lcov.info`, `coverage-summary.json`, `coverage-final.json` + +### 4. TypeScript Type Check + +- **Status:** PASS +- `tsc --noEmit` completed with zero errors + +### 5. Pre-commit Hooks + +- **Status:** PASS +- All hooks passed: + - fix end of files + - trim trailing whitespace + - check yaml + - check for added large files + - shellcheck + - actionlint (GitHub Actions) + - dockerfile validation + - Go Vet + - golangci-lint (Fast Linters - BLOCKING) + - Check .version matches latest Git tag + - Prevent large files not tracked by LFS + - Prevent committing CodeQL DB artifacts + - Prevent committing data/backups files + - Frontend TypeScript Check + - Frontend Lint (Fix) + +### 6a. Trivy Filesystem Scan + +- **Status:** PASS +- Scanned `backend/` and `frontend/` directories: **0 CRITICAL, 0 HIGH** +- Full workspace scan found 3 CRITICAL + 14 HIGH across Go module cache dependencies (not project code) +- Trivy misconfig scanner crashed (known Trivy bug in ansible parser — nil pointer dereference in `discovery.go:82`). Vuln scanner completed successfully. + +### 6b. Docker Image Scan + +- **Status:** WARN (not blocking — upstream dependency) +- Image: `charon:local` +- **1 HIGH finding:** + - **CVE-2026-25793** — `github.com/slackhq/nebula` v1.9.7 (in `usr/bin/caddy` binary) + - Description: Blocklist evasion via ECDSA Signature Malleability + - Fixed in: v1.10.3 + - Impact: Caddy transitive dependency, not Charon code +- **Remediation:** Upgrade Caddy to a version that pulls nebula ≥ 1.10.3 when available + +### 6c. CodeQL Scans + +- **Status:** PASS +- **Go:** 0 errors, 0 warnings +- **JavaScript:** 0 errors, 0 warnings (347/347 files scanned) +- SARIF outputs: `codeql-results-go.sarif`, `codeql-results-javascript.sarif` + +### 7. GORM Security Scan + +- **Status:** PASS +- Scanned: 41 Go files (2207 lines), 2 seconds +- **0 CRITICAL, 0 HIGH, 0 MEDIUM** +- 2 INFO suggestions: + - `backend/internal/models/user.go:109` — `UserPermittedHost.UserID` missing index + - `backend/internal/models/user.go:110` — `UserPermittedHost.ProxyHostID` missing index + +### 8. Go Vulnerability Check + +- **Status:** PASS +- `govulncheck ./...` — No vulnerabilities found + +--- + +## Gotify Token Security Review + +- No Gotify tokens found in logs, test artifacts, or API examples +- No tokenized URL query parameters exposed in diagnostics or output +- Token handling follows `json:"-"` pattern (verified via `HasToken` computed field approach in PR) + +--- + +## Recommendation + +### GO / NO-GO: **GO** (conditional) + +All blocking gates pass. Two advisory warnings exist: + +1. **Patch coverage** (79.5% overall, 78.3% backend) is below advisory thresholds but not a blocking gate per current policy +2. **Docker image** has 1 HIGH CVE in Caddy's transitive dependency (nebula) — upstream fix required, not actionable in Charon code + +**Conditions:** +- Track nebula CVE-2026-25793 remediation as a follow-up issue when a Caddy update incorporates the fix +- Consider adding targeted tests for uncovered changed lines in notification service/handler to improve patch coverage