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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
138
docs/reports/qa_report_pr754.md
Normal file
138
docs/reports/qa_report_pr754.md
Normal 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
|
||||
Reference in New Issue
Block a user