chore: Add comprehensive tests for notification and permission handlers

- Implement tests for classifyProviderTestFailure function to cover various error scenarios.
- Enhance notification provider handler tests for token validation, type change rejection, and missing provider ID.
- Add tests for permission helper functions to ensure proper admin authentication checks.
- Expand coverage for utility functions in user handler and docker service tests, including error extraction and socket path handling.
- Introduce a QA report for PR #754 highlighting coverage metrics and security findings related to Gotify and webhook notifications.
This commit is contained in:
GitHub Actions
2026-02-26 02:22:08 +00:00
parent fb69f3da12
commit d89b86675c
7 changed files with 1114 additions and 0 deletions

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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)
})
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}

View File

@@ -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)
})
}
}

View File

@@ -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