fix: clear golangci-lint backlog and make lint job blocking
CI / Lint (push) Successful in 2m29s
Build and Publish Container / build-and-publish (push) Successful in 2m58s
CI / Vulnerability scan (push) Successful in 44s
CI / Test (push) Successful in 1m45s

Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').

With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 10:32:51 -07:00
parent d63a440f63
commit eda033ff9b
36 changed files with 279 additions and 203 deletions
+9 -5
View File
@@ -1,3 +1,7 @@
// Package auth provides API key authentication and authorization for the
// notifier service, including key storage backends (in-memory, database,
// and a hybrid cache-plus-database store) and RBAC-style notifier
// authorization.
package auth
import (
@@ -54,8 +58,8 @@ type RateLimiter struct {
mu sync.Mutex
}
// AuthContext holds auth information attached to request context
type AuthContext struct {
// Context holds auth information attached to request context
type Context struct {
APIKey *APIKey
ClientID string
Roles []string
@@ -283,12 +287,12 @@ func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
type authContextKey struct{}
// ContextWithAuth adds auth context to a request context
func ContextWithAuth(ctx context.Context, auth *AuthContext) context.Context {
func ContextWithAuth(ctx context.Context, auth *Context) context.Context {
return context.WithValue(ctx, authContextKey{}, auth)
}
// GetAuthContext retrieves auth context from a request context
func GetAuthContext(ctx context.Context) (*AuthContext, bool) {
auth, ok := ctx.Value(authContextKey{}).(*AuthContext)
func GetAuthContext(ctx context.Context) (*Context, bool) {
auth, ok := ctx.Value(authContextKey{}).(*Context)
return auth, ok
}
+1 -1
View File
@@ -26,7 +26,7 @@ func (a *NotifierAuthz) RegisterRule(notificationType domain.NotificationType, a
}
// IsAuthorized checks if an auth context is authorized to use a specific notifier
func (a *NotifierAuthz) IsAuthorized(auth *AuthContext, notificationType domain.NotificationType, account string) bool {
func (a *NotifierAuthz) IsAuthorized(auth *Context, notificationType domain.NotificationType, account string) bool {
if auth == nil || len(auth.Roles) == 0 {
return false
}
+1 -1
View File
@@ -183,7 +183,7 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
// LoadBootstrapKeyFromEnv checks if a bootstrap key was provided via environment variable
// This allows injecting a pre-generated key via CI/CD
func LoadBootstrapKeyFromEnv(ctx context.Context, keyStore *HybridKeyStore, logger *logging.Logger) error {
func LoadBootstrapKeyFromEnv(_ context.Context, _ *HybridKeyStore, logger *logging.Logger) error {
bootstrapKey := os.Getenv("NOTIFIER_BOOTSTRAP_ADMIN_KEY")
if bootstrapKey == "" {
return nil // Not set, skip
+2 -2
View File
@@ -55,7 +55,7 @@ func (m *GRPCAuthMiddleware) UnaryInterceptor() grpc.UnaryServerInterceptor {
}
// Create auth context and attach to request
authCtx := &AuthContext{
authCtx := &Context{
APIKey: key,
ClientID: key.ClientID,
Roles: key.Roles,
@@ -99,7 +99,7 @@ func (m *GRPCAuthMiddleware) StreamInterceptor() grpc.StreamServerInterceptor {
}
// Create auth context and attach to request
authCtx := &AuthContext{
authCtx := &Context{
APIKey: key,
ClientID: key.ClientID,
Roles: key.Roles,
+4 -4
View File
@@ -128,7 +128,7 @@ func (ks *KeyStoreDB) migrateLegacyPlaintextKeys() error {
if err != nil {
return fmt.Errorf("failed to read legacy keys: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
type legacyRow struct {
id int
@@ -265,7 +265,7 @@ func (ks *KeyStoreDB) ListKeys(ctx context.Context, clientID string) ([]*APIKey,
if err != nil {
return nil, fmt.Errorf("failed to list keys: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var keys []*APIKey
for rows.Next() {
@@ -318,7 +318,7 @@ func (ks *KeyStoreDB) LoadAllKeys(ctx context.Context) ([]*APIKey, error) {
if err != nil {
return nil, fmt.Errorf("failed to load keys: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var keys []*APIKey
for rows.Next() {
@@ -427,7 +427,7 @@ func (ks *KeyStoreDB) auditLogQuery(ctx context.Context, query string, ident str
if err != nil {
return nil, fmt.Errorf("failed to get audit log: %w", err)
}
defer rows.Close()
defer func() { _ = rows.Close() }()
var logs []map[string]interface{}
for rows.Next() {
+1 -1
View File
@@ -68,7 +68,7 @@ func (f *fakeKeyDB) DeactivateKeyByHash(_ context.Context, keyHash string, _ str
return nil
}
func (f *fakeKeyDB) UpdateLastUsed(_ context.Context, keyHash string) error { return nil }
func (f *fakeKeyDB) UpdateLastUsed(_ context.Context, _ string) error { return nil }
func (f *fakeKeyDB) LoadAllKeys(_ context.Context) ([]*APIKey, error) {
var keys []*APIKey
+1 -1
View File
@@ -55,7 +55,7 @@ func (m *RESTAuthMiddleware) Middleware(next http.Handler) http.Handler {
}
// Create auth context and attach to request
authCtx := &AuthContext{
authCtx := &Context{
APIKey: key,
ClientID: key.ClientID,
Roles: key.Roles,
+4 -1
View File
@@ -1,3 +1,6 @@
// Package config loads and validates the notifier service's configuration
// (notifiers, queue, auth, retention, and related settings) from files,
// environment variables, and defaults via viper.
package config
import (
@@ -281,7 +284,7 @@ func (c *Config) Validate() error {
}
if c.Queue.Type == "kafka" && c.Queue.Kafka == nil {
return fmt.Errorf("Kafka queue type selected but no Kafka configuration provided")
return fmt.Errorf("kafka queue type selected but no kafka configuration provided")
}
// Validate at least one notifier is configured
+4 -4
View File
@@ -10,7 +10,7 @@ func TestSanitizeDatabaseURL(t *testing.T) {
input string
expected string
}{
{
{ //nolint:gosec // test fixture URL, not a real credential
name: "PostgreSQL with password",
input: "postgresql://user:password@localhost:5432/dbname",
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
@@ -20,7 +20,7 @@ func TestSanitizeDatabaseURL(t *testing.T) {
input: "postgresql://user@localhost:5432/dbname",
expected: "postgresql://user@localhost:5432/dbname",
},
{
{ //nolint:gosec // test fixture URL, not a real credential
name: "MySQL with special characters in password",
input: "mysql://root:SuperSecret123!@db.example.com:3306/mydb",
expected: "mysql://root:***REDACTED***@db.example.com:3306/mydb",
@@ -40,12 +40,12 @@ func TestSanitizeDatabaseURL(t *testing.T) {
input: "postgresql://localhost:5432/dbname",
expected: "postgresql://localhost:5432/dbname",
},
{
{ //nolint:gosec // test fixture URL, not a real credential
name: "PostgreSQL with password containing colons",
input: "postgresql://user:pass:word@localhost:5432/dbname",
expected: "postgresql://user:***REDACTED***@localhost:5432/dbname",
},
{
{ //nolint:gosec // test fixture URL, not a real credential
name: "PostgreSQL with complex hostname and port",
input: "postgresql://admin:p@ssw0rd!@db-prod.example.com:5432/production",
expected: "postgresql://admin:***REDACTED***@db-prod.example.com:5432/production",
+7
View File
@@ -1,3 +1,6 @@
// Package domain contains the core types shared across the notifier
// service - notifications, queueing primitives, and the notifier
// interfaces that provider implementations satisfy.
package domain
import (
@@ -15,6 +18,7 @@ var (
// Priority defines the urgency level of a notification
type Priority int
// Priority levels, in increasing order of urgency.
const (
PriorityLow Priority = iota
PriorityNormal
@@ -25,6 +29,7 @@ const (
// NotificationType defines the channel through which to send the notification
type NotificationType string
// Supported notification channels.
const (
TypeEmail NotificationType = "email"
TypeSlack NotificationType = "slack"
@@ -35,6 +40,7 @@ const (
// ContentType defines the format of the notification body
type ContentType string
// Supported body content types.
const (
ContentTypeText ContentType = "text"
ContentTypeHTML ContentType = "html"
@@ -43,6 +49,7 @@ const (
// NotificationStatus represents the current state of a notification
type NotificationStatus string
// Notification lifecycle states.
const (
StatusPending NotificationStatus = "pending"
StatusQueued NotificationStatus = "queued"
+6 -1
View File
@@ -1,3 +1,6 @@
// Package logging provides structured logging backed by log/slog, with UTC
// RFC3339 timestamps and a level-gated API compatible with the previous
// *log.Logger-based implementation.
package logging
import (
@@ -19,6 +22,8 @@ type Logger struct {
// LogLevel represents the logging level
type LogLevel int
// Logging levels, in increasing order of severity. DebugLevel is the most
// verbose and ErrorLevel the least.
const (
DebugLevel LogLevel = iota
InfoLevel
@@ -102,7 +107,7 @@ func NewFromOptions(levelStr string, format string, outputPath string) (*Logger,
case "stderr":
output = os.Stderr
default:
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) //nolint:gosec // outputPath is operator-configured (logging.output), not user-controlled input
if err != nil {
return nil, err
}
+15 -15
View File
@@ -12,16 +12,16 @@ import (
func TestNew_TextFormat(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "text.log")
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("unexpected error opening file: %v", err)
}
defer file.Close()
defer func() { _ = file.Close() }()
logger := New(InfoLevel, file)
logger.Info("hello world")
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
@@ -49,7 +49,7 @@ func TestNewFromConfig_JSONOutput(t *testing.T) {
logger.Info("structured message")
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
@@ -77,17 +77,17 @@ func TestNewFromConfig_JSONOutput(t *testing.T) {
func TestLevelFiltering_DebugSuppressedAtInfo(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "level.log")
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("unexpected error opening file: %v", err)
}
defer file.Close()
defer func() { _ = file.Close() }()
logger := New(InfoLevel, file)
logger.Debug("should not appear")
logger.Info("should appear")
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
@@ -112,7 +112,7 @@ func TestNewFromOptions_FileOutput(t *testing.T) {
logger.Info("file message")
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
@@ -136,7 +136,7 @@ func TestNewFromOptions_FormatSelection(t *testing.T) {
}
jsonLogger.Info("json message")
jsonData, err := os.ReadFile(jsonPath)
jsonData, err := os.ReadFile(jsonPath) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read json log file: %v", err)
}
@@ -151,7 +151,7 @@ func TestNewFromOptions_FormatSelection(t *testing.T) {
}
textLogger.Info("text message")
textData, err := os.ReadFile(textPath)
textData, err := os.ReadFile(textPath) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read text log file: %v", err)
}
@@ -170,7 +170,7 @@ func TestNewFromOptions_FormatSelection(t *testing.T) {
}
defaultLogger.Info("default message")
defaultData, err := os.ReadFile(defaultPath)
defaultData, err := os.ReadFile(defaultPath) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read default log file: %v", err)
}
@@ -186,7 +186,7 @@ func TestNewFromOptions_FormatSelection(t *testing.T) {
}
configLogger.Info("config message")
configData, err := os.ReadFile(configPath)
configData, err := os.ReadFile(configPath) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read config log file: %v", err)
}
@@ -198,11 +198,11 @@ func TestNewFromOptions_FormatSelection(t *testing.T) {
func TestSlogAccessor(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "slog.log")
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("unexpected error opening file: %v", err)
}
defer file.Close()
defer func() { _ = file.Close() }()
logger := New(InfoLevel, file)
if logger.Slog() == nil {
@@ -211,7 +211,7 @@ func TestSlogAccessor(t *testing.T) {
logger.Slog().Info("via slog accessor")
data, err := os.ReadFile(path)
data, err := os.ReadFile(path) //nolint:gosec // path is from t.TempDir()
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/logging"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
@@ -72,8 +73,8 @@ func NewCollector(service domain.NotificationService, queue domain.Queue, logger
c.queueDepth,
c.httpRequests,
c.httpDuration,
prometheus.NewGoCollector(),
prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}),
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
return c
+3
View File
@@ -1,3 +1,6 @@
// Package notifier defines the notifier provider interfaces and a factory
// for constructing and looking up configured notifier instances by type
// and account.
package notifier
import (
+5 -5
View File
@@ -127,7 +127,7 @@ func validateCACertPath(caCertPath string) error {
}
// Try to read and parse the certificate
certData, err := os.ReadFile(caCertPath)
certData, err := os.ReadFile(caCertPath) //nolint:gosec // caCertPath is operator-configured (ntfy CA cert path), not user-controlled input
if err != nil {
return fmt.Errorf("failed to read CA certificate file: %w", err)
}
@@ -270,8 +270,8 @@ func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notificati
if body, ok := actionMap["body"].(string); ok {
ntfyAct.Body = body
}
if clear, ok := actionMap["clear"].(bool); ok {
ntfyAct.Clear = clear
if clearAction, ok := actionMap["clear"].(bool); ok {
ntfyAct.Clear = clearAction
}
req.Actions = append(req.Actions, ntfyAct)
}
@@ -302,7 +302,7 @@ func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notificati
// sendToTopic sends a notification to a specific ntfy topic
func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error {
url := fmt.Sprintf("%s", n.config.ServerURL)
url := n.config.ServerURL
jsonData, err := json.Marshal(req)
if err != nil {
@@ -327,7 +327,7 @@ func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error
if err != nil {
return fmt.Errorf("failed to send ntfy notification: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode)
+8 -8
View File
@@ -48,7 +48,7 @@ func TestNewNtfyNotifierWithDefaultCA(t *testing.T) {
func TestNewNtfyNotifierWithCustomCA(t *testing.T) {
// Create a temporary CA certificate file
certPath := createTempCACert(t)
defer os.Remove(certPath)
defer func() { _ = os.Remove(certPath) }()
config := &NtfyConfig{
ServerURL: "https://self-signed.example.com",
@@ -95,13 +95,13 @@ func TestValidateCACertPathInvalidFormat(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
defer func() { _ = os.Remove(tmpFile.Name()) }()
// Write invalid content (not PEM format)
if _, err := tmpFile.WriteString("This is not a valid certificate"); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
tmpFile.Close()
_ = tmpFile.Close()
config := &NtfyConfig{
ServerURL: "https://ntfy.sh",
@@ -128,7 +128,7 @@ func TestValidateCACertPathIsDirectory(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tmpDir)
defer func() { _ = os.RemoveAll(tmpDir) }()
config := &NtfyConfig{
ServerURL: "https://ntfy.sh",
@@ -210,7 +210,7 @@ func TestTLSConfigNeverSkipsVerification(t *testing.T) {
func TestCustomCACertLoading(t *testing.T) {
// Create a temporary CA certificate
certPath := createTempCACert(t)
defer os.Remove(certPath)
defer func() { _ = os.Remove(certPath) }()
config := &NtfyConfig{
ServerURL: "https://self-signed.example.com",
@@ -257,8 +257,8 @@ func TestEmptyCertFileError(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
tmpFile.Close()
defer func() { _ = os.Remove(tmpFile.Name()) }()
_ = tmpFile.Close()
err = validateCACertPath(tmpFile.Name())
if err == nil {
@@ -274,7 +274,7 @@ func createTempCACert(t *testing.T) string {
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer tmpFile.Close()
defer func() { _ = tmpFile.Close() }()
// Generate a self-signed certificate for testing
certPEM := generateSelfSignedCert(t)
+4 -4
View File
@@ -55,12 +55,12 @@ type slackTextBlock struct {
// NewSlackNotifier creates a new Slack notifier
func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) {
if config == nil {
return nil, fmt.Errorf("Slack config is required")
return nil, fmt.Errorf("slack config is required")
}
// Either webhook URL or token is required
if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 {
return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required")
return nil, fmt.Errorf("slack webhook URL, token, or channel webhooks are required")
}
return &SlackNotifier{
@@ -201,10 +201,10 @@ func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg
if err != nil {
return fmt.Errorf("failed to send Slack notification: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Slack API returned status: %d", resp.StatusCode)
return fmt.Errorf("slack API returned status: %d", resp.StatusCode)
}
return nil
+10 -10
View File
@@ -148,13 +148,13 @@ func sendMailImplicitTLS(addr, serverName string, auth smtp.Auth, from string, r
if err != nil {
return fmt.Errorf("failed to establish TLS connection to %s: %w", addr, err)
}
defer conn.Close()
defer func() { _ = conn.Close() }()
client, err := smtp.NewClient(conn, serverName)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
defer func() { _ = client.Close() }()
if auth != nil {
if ok, _ := client.Extension("AUTH"); ok {
@@ -224,23 +224,23 @@ func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
fromHeader = fmt.Sprintf("%s <%s>", encodeHeaderValue(s.config.FromName), s.config.From)
}
builder.WriteString(fmt.Sprintf("From: %s\r\n", fromHeader))
fmt.Fprintf(&builder, "From: %s\r\n", fromHeader)
// Add To header (optional if only BCC is specified)
if len(notification.Recipients) > 0 {
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
fmt.Fprintf(&builder, "To: %s\r\n", strings.Join(notification.Recipients, ", "))
}
// Add CC header (optional)
if len(notification.CC) > 0 {
builder.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(notification.CC, ", ")))
fmt.Fprintf(&builder, "Cc: %s\r\n", strings.Join(notification.CC, ", "))
}
// Note: BCC is intentionally NOT included in headers (that's the point of BCC!)
// Subject is fully attacker-controlled, so it is always run through RFC 2047 encoding.
// This neutralizes embedded CR/LF (and non-ASCII) instead of interpolating it raw.
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", encodeHeaderValue(notification.Subject)))
fmt.Fprintf(&builder, "Subject: %s\r\n", encodeHeaderValue(notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n")
switch {
@@ -275,24 +275,24 @@ func isHTMLContent(notification *domain.Notification) bool {
func (s *SMTPNotifier) buildMultipartMessage(builder *strings.Builder, plainText, htmlBody string) {
boundary := generateBoundary()
builder.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
fmt.Fprintf(builder, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)
builder.WriteString("\r\n")
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
fmt.Fprintf(builder, "--%s\r\n", boundary)
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n")
builder.WriteString(plainText)
builder.WriteString("\r\n\r\n")
builder.WriteString(fmt.Sprintf("--%s\r\n", boundary))
fmt.Fprintf(builder, "--%s\r\n", boundary)
builder.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
builder.WriteString("Content-Transfer-Encoding: 7bit\r\n")
builder.WriteString("\r\n")
builder.WriteString(htmlBody)
builder.WriteString("\r\n\r\n")
builder.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
fmt.Fprintf(builder, "--%s--\r\n", boundary)
}
// detectContentType auto-detects if the body is HTML
+8 -5
View File
@@ -1,3 +1,6 @@
// Package queue provides domain.Queue implementations used to buffer
// notifications between submission and delivery, including an in-memory
// LocalQueue with optional disk persistence.
package queue
import (
@@ -129,7 +132,7 @@ func (lq *LocalQueue) Dequeue(ctx context.Context) (*domain.QueueMessage, error)
}
// Ack acknowledges successful processing of a message
func (lq *LocalQueue) Ack(ctx context.Context, messageID string) error {
func (lq *LocalQueue) Ack(_ context.Context, messageID string) error {
lq.mu.Lock()
defer lq.mu.Unlock()
@@ -188,14 +191,14 @@ func (lq *LocalQueue) Nack(ctx context.Context, messageID string, requeue bool)
}
// Size returns the current number of messages in the queue
func (lq *LocalQueue) Size(ctx context.Context) (int64, error) {
func (lq *LocalQueue) Size(_ context.Context) (int64, error) {
lq.mu.RLock()
defer lq.mu.RUnlock()
return int64(len(lq.queue)), nil
}
// Purge removes all messages from the queue
func (lq *LocalQueue) Purge(ctx context.Context) error {
func (lq *LocalQueue) Purge(_ context.Context) error {
lq.mu.Lock()
defer lq.mu.Unlock()
@@ -238,7 +241,7 @@ func (lq *LocalQueue) Close() error {
}
// HealthCheck verifies the queue is operational
func (lq *LocalQueue) HealthCheck(ctx context.Context) error {
func (lq *LocalQueue) HealthCheck(_ context.Context) error {
lq.mu.RLock()
defer lq.mu.RUnlock()
@@ -260,7 +263,7 @@ func (lq *LocalQueue) persistToDiskSync() error {
return fmt.Errorf("failed to marshal queue state: %w", err)
}
if err := os.WriteFile(lq.persistPath, data, 0644); err != nil {
if err := os.WriteFile(lq.persistPath, data, 0600); err != nil {
return fmt.Errorf("failed to write queue state: %w", err)
}
+24 -10
View File
@@ -1,3 +1,7 @@
// Package service implements the core notification service: queueing,
// worker-pool delivery with retry/backoff, in-memory notification tracking,
// retention cleanup, and multi-tenant access control on top of the
// domain and auth packages.
package service
import (
@@ -166,14 +170,12 @@ func (s *NotificationService) performCleanup() {
// Track which notifications to delete
var toDelete []string
var allNotifications []*domain.Notification
// First pass: identify expired notifications and collect all for sorting
// First pass: identify expired notifications
for id, notification := range s.notifications {
if notification.CreatedAt.Before(expiredBefore) {
toDelete = append(toDelete, id)
}
allNotifications = append(allNotifications, notification)
}
// Delete expired notifications
@@ -218,7 +220,7 @@ func (s *NotificationService) performCleanup() {
}
// worker processes notifications from the queue
func (s *NotificationService) worker(ctx context.Context, id int) {
func (s *NotificationService) worker(ctx context.Context, _ int) {
defer s.wg.Done()
for {
@@ -284,7 +286,9 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
notification.ID, notification.Type, account, err)
notification.Status = domain.StatusFailed
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)
s.queue.Nack(ctx, msg.ID, false)
if nackErr := s.queue.Nack(ctx, msg.ID, false); nackErr != nil {
s.logger.Warnf("failed to nack message id=%s: %v", msg.ID, nackErr)
}
s.updateNotification(notification)
return
}
@@ -313,13 +317,17 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
notification.Status = domain.StatusFailed
s.logger.Errorf("Notification send failed permanently - id=%s, type=%s, account=%s, recipients=%v, attempts=%d, error=%s",
notification.ID, notification.Type, account, notification.Recipients, notification.RetryCount, notification.LastError)
s.queue.Nack(ctx, msg.ID, false) // Don't requeue
if nackErr := s.queue.Nack(ctx, msg.ID, false); nackErr != nil { // Don't requeue
s.logger.Warnf("failed to nack message id=%s: %v", msg.ID, nackErr)
}
}
} else {
notification.Status = domain.StatusSent
now := time.Now()
notification.SentAt = &now
s.queue.Ack(ctx, msg.ID)
if ackErr := s.queue.Ack(ctx, msg.ID); ackErr != nil {
s.logger.Warnf("failed to ack message id=%s: %v", msg.ID, ackErr)
}
s.logger.Infof("Notification sent successfully - id=%s, type=%s, account=%s, recipients=%v",
notification.ID, notification.Type, account, notification.Recipients)
}
@@ -335,7 +343,9 @@ func (s *NotificationService) processNotification(ctx context.Context, msg *doma
func (s *NotificationService) scheduleRetry(ctx context.Context, msg *domain.QueueMessage, notification *domain.Notification) {
delay := s.retryDelay(notification.RetryCount)
if delay <= 0 {
s.queue.Nack(ctx, msg.ID, true) // Requeue immediately
if err := s.queue.Nack(ctx, msg.ID, true); err != nil { // Requeue immediately
s.logger.Warnf("failed to nack message id=%s: %v", msg.ID, err)
}
return
}
@@ -348,7 +358,9 @@ func (s *NotificationService) scheduleRetry(ctx context.Context, msg *domain.Que
select {
case <-timer.C:
s.queue.Nack(ctx, msg.ID, true) // Requeue after backoff
if err := s.queue.Nack(ctx, msg.ID, true); err != nil { // Requeue after backoff
s.logger.Warnf("failed to nack message id=%s: %v", msg.ID, err)
}
case <-ctx.Done():
s.abandonRetry(msg, notification)
case <-s.stopChan:
@@ -362,7 +374,9 @@ func (s *NotificationService) scheduleRetry(ctx context.Context, msg *domain.Que
// retry is still pending, so the notification isn't left stuck in "retrying"
// forever and no goroutine lingers past shutdown.
func (s *NotificationService) abandonRetry(msg *domain.QueueMessage, notification *domain.Notification) {
s.queue.Nack(context.Background(), msg.ID, false) // Don't requeue
if err := s.queue.Nack(context.Background(), msg.ID, false); err != nil { // Don't requeue
s.logger.Warnf("failed to nack message id=%s: %v", msg.ID, err)
}
notification.Status = domain.StatusFailed
s.updateNotification(notification)
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestConcurrentSendGetListNoRace(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
const numSenders = 8
const sendsPerSender = 25
+12 -11
View File
@@ -58,7 +58,7 @@ func TestTTLBasedCleanup(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create old notification (created 2 seconds ago)
oldTime := time.Now().Add(-2 * time.Second)
@@ -128,7 +128,7 @@ func TestMaxSizeEnforcement(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create 10 notifications
for i := 0; i < 10; i++ {
@@ -179,7 +179,7 @@ func TestCleanupRemovesOldestFirst(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create notifications with distinct times
baseTime := time.Now()
@@ -234,7 +234,7 @@ func TestCleanupDisabled(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create old notification
oldTime := time.Now().Add(-2 * time.Second)
@@ -278,7 +278,7 @@ func TestCleanupConcurrency(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create some initial notifications
for i := 0; i < 10; i++ {
@@ -404,10 +404,11 @@ func TestCleanupGracefulShutdown(t *testing.T) {
t.Errorf("Stop failed: %v", stopErr)
}
// Verify notifications are still intact after graceful shutdown
stats, err := svc.GetStats(context.Background())
if err == nil && stats.TotalSent > 0 {
// This is expected - notifications should persist through shutdown
// Verify notifications are still intact after graceful shutdown - it's
// expected that notifications persist through shutdown, so there's
// nothing further to assert beyond GetStats succeeding.
if stats, err := svc.GetStats(context.Background()); err == nil {
t.Logf("stats after graceful shutdown: sent=%d", stats.TotalSent)
}
}
@@ -432,7 +433,7 @@ func TestCleanupWithMixedNotificationStatuses(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
oldTime := time.Now().Add(-2 * time.Second)
@@ -499,7 +500,7 @@ func TestCleanupPerformance(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
// Create 5000 old notifications
startTime := time.Now()
+7 -7
View File
@@ -20,7 +20,7 @@ type alwaysFailNotifier struct {
calls []time.Time
}
func (n *alwaysFailNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
func (n *alwaysFailNotifier) Send(_ context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
n.mu.Lock()
n.calls = append(n.calls, time.Now())
n.mu.Unlock()
@@ -35,7 +35,7 @@ func (n *alwaysFailNotifier) Send(ctx context.Context, notification *domain.Noti
func (n *alwaysFailNotifier) Type() domain.NotificationType { return domain.TypeStdout }
func (n *alwaysFailNotifier) Validate(notification *domain.Notification) error { return nil }
func (n *alwaysFailNotifier) Validate(_ *domain.Notification) error { return nil }
func (n *alwaysFailNotifier) Close() error { return nil }
@@ -71,7 +71,7 @@ func createFailingTestService(t *testing.T, fail domain.Notifier) *NotificationS
// waitForStatus polls GetNotification until it observes the notification in
// the given status, or fails the test after timeout.
func waitForStatus(t *testing.T, svc *NotificationService, ctx context.Context, id string, status domain.NotificationStatus, timeout time.Duration) *domain.Notification {
func waitForStatus(ctx context.Context, t *testing.T, svc *NotificationService, id string, status domain.NotificationStatus, timeout time.Duration) *domain.Notification {
t.Helper()
deadline := time.Now().Add(timeout)
@@ -102,7 +102,7 @@ func TestRetryBackoffExponentialDelaysRequeue(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
notification := &domain.Notification{
ID: "backoff-exponential-1",
@@ -116,7 +116,7 @@ func TestRetryBackoffExponentialDelaysRequeue(t *testing.T) {
t.Fatalf("Send failed: %v", err)
}
waitForStatus(t, svc, ctx, notification.ID, domain.StatusFailed, 5*time.Second)
waitForStatus(ctx, t, svc, notification.ID, domain.StatusFailed, 5*time.Second)
calls := fail.callTimes()
if len(calls) != 3 {
@@ -152,7 +152,7 @@ func TestRetryBackoffNoneIsImmediate(t *testing.T) {
if err := svc.Start(ctx); err != nil {
t.Fatalf("Failed to start service: %v", err)
}
defer svc.Stop()
defer func() { _ = svc.Stop() }()
notification := &domain.Notification{
ID: "backoff-none-1",
@@ -167,7 +167,7 @@ func TestRetryBackoffNoneIsImmediate(t *testing.T) {
t.Fatalf("Send failed: %v", err)
}
waitForStatus(t, svc, ctx, notification.ID, domain.StatusFailed, 2*time.Second)
waitForStatus(ctx, t, svc, notification.ID, domain.StatusFailed, 2*time.Second)
elapsed := time.Since(start)
if elapsed > 1*time.Second {
+2 -2
View File
@@ -10,11 +10,11 @@ import (
"github.com/igodwin/notifier/internal/domain"
)
// ctxForClient builds a context carrying an auth.AuthContext for the given
// ctxForClient builds a context carrying an auth.Context for the given
// client and roles, as REST/gRPC middleware would attach after authenticating
// a request.
func ctxForClient(clientID string, roles ...string) context.Context {
return auth.ContextWithAuth(context.Background(), &auth.AuthContext{
return auth.ContextWithAuth(context.Background(), &auth.Context{
ClientID: clientID,
Roles: roles,
})