Files
worker/internal/distworker/notification_test.go
Gleb Tv e987f24903
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
fix(worker): harden control-plane lifecycle
- reconnect safely after token rotation and retry leased results
- reject malformed tasks and remove production cluster debug mutation
- validate environment files and require immutable container images

BREAKING CHANGE: Docker install, deploy, and Compose now require an
immutable repository@sha256 image reference.
2026-07-19 23:11:43 +03:00

408 строки
15 KiB
Go

package distworker
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"rocketgit.ru/rsmon/worker/app/models"
"rocketgit.ru/rsmon/worker/internal/wire"
)
// runnerWithCreds is the smallest fixture that yields a Runner with a
// credentials block already applied (no DB, no init websocket).
func runnerWithCreds(creds *wire.NotificationCredentials) *Runner {
r := NewRunner(&Config{MaxConcurrency: 4})
r.credentialsMu.Lock()
r.credentials = creds
r.credentialsMu.Unlock()
return r
}
// TestExecuteNotification_Email_NoCredentials verifies that the executor
// returns a permanent failure when no SMTP credential is pushed.
func TestExecuteNotification_Email_NoCredentials(t *testing.T) {
r := runnerWithCreds(nil)
report := r.ExecuteNotification(context.Background(), models.Task{
JobID: "job-1",
Payload: []byte(`{
"job_id":"job-1","message_id":1,"notification_id":1,
"method":"email","subject":"x","body_text":"x","body_html":"<p>x</p>",
"contact":{"id":1,"kind":"email","value":"ops@example.com","name":"ops"},
"message_kind":"down"
}`),
})
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
assert.NotNil(t, report.Error)
assert.Equal(t, wire.NotificationResultPermanent, report.Status, "must be permanent so the producer does not loop")
}
func TestClassifyResult_NilEmailErrorDoesNotPanic(t *testing.T) {
status, _, text, retry, err := classifyResult(nil, "")
assert.Equal(t, wire.NotificationResultDelivered, status)
assert.Empty(t, text)
assert.Nil(t, retry)
assert.NoError(t, err)
}
// TestExecuteNotification_UnsupportedMethod covers sms/voice returning
// permanent + unsupported_method.
func TestExecuteNotification_UnsupportedMethod(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
Telegram: []wire.TelegramCredential{{Name: "bot", Token: "123:abc"}},
})
for _, method := range []string{"sms", "voice"} {
t.Run(method, func(t *testing.T) {
report := r.ExecuteNotification(context.Background(), models.Task{
JobID: "job-" + method,
Payload: []byte(`{
"job_id":"job-` + method + `","message_id":1,"notification_id":1,
"method":"` + method + `","subject":"x","body_text":"x","body_html":"<p>x</p>",
"contact":{"id":1,"kind":"` + method + `","value":"x","name":"ops"},
"message_kind":"down"
}`),
})
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
require.NotNil(t, report.Error)
assert.Contains(t, *report.Error, "unsupported_method")
})
}
}
// TestExecuteNotification_UnknownMethod classifies unknown methods as
// permanent so we never loop on bad producer output.
func TestExecuteNotification_UnknownMethod(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{})
report := r.ExecuteNotification(context.Background(), models.Task{
JobID: "job-x",
Payload: []byte(`{
"job_id":"job-x","message_id":1,"notification_id":1,
"method":"pigeon","subject":"x","body_text":"x","body_html":"<p>x</p>",
"contact":{"id":1,"kind":"pigeon","value":"ops@example.com","name":"ops"},
"message_kind":"down"
}`),
})
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
}
// TestExecuteNotification_EmptyPayloadPermanent: a malformed task must be
// rejected permanently so the operator can spot it on the admin page.
func TestExecuteNotification_EmptyPayloadPermanent(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
})
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "job-empty"})
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
require.NotNil(t, report.Error)
}
func TestExecuteNotification_ExpiredDeadlineDoesNotDeliver(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{})
expired := time.Now().Add(-time.Second)
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "expired", Deadline: &expired, Payload: []byte(`{"job_id":"expired","message_id":1,"method":"email","contact":{"id":1,"kind":"email"}}`)})
require.NotNil(t, report.Error)
assert.Contains(t, *report.Error, "deadline expired")
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
}
// TestExecuteNotification_DurationPositive: every report carries a non-zero
// duration_ms even when the work happens instantly. This is what the
// notification_deliveries audit row expects.
func TestExecuteNotification_DurationPositive(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{})
report := r.ExecuteNotification(context.Background(), models.Task{
JobID: "job-d",
Payload: []byte(`{
"job_id":"job-d","message_id":1,"notification_id":1,
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
"message_kind":"down"
}`),
})
assert.GreaterOrEqual(t, report.DurationMs, 0)
}
// TestClassifyResult verifies the error -> status mapping.
func TestClassifyResult(t *testing.T) {
tests := []struct {
name string
err error
wantStatus string
wantRetryAfter *int
}{
{"nil error", nil, wire.NotificationResultDelivered, nil},
{"permanent provider error", errors.New("550 mailbox not found"), wire.NotificationResultPermanent, nil},
{"smtp 421 retryable", errors.New("smtp: 421 try again later"), wire.NotificationResultRetryable, intPtr(30)},
{"smtp 452 retryable", errors.New("452 insufficient storage"), wire.NotificationResultRetryable, intPtr(30)},
{"network timeout retryable", errors.New("dial tcp: i/o timeout"), wire.NotificationResultRetryable, intPtr(15)},
{"tls handshake retryable", errors.New("tls: handshake failure"), wire.NotificationResultRetryable, intPtr(15)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
status, _, errStr, retry, _ := classifyResult(tc.err, "")
assert.Equal(t, tc.wantStatus, status)
if tc.err != nil {
assert.NotEmpty(t, errStr)
}
if tc.wantRetryAfter == nil {
assert.Nil(t, retry)
} else {
require.NotNil(t, retry)
assert.Equal(t, *tc.wantRetryAfter, *retry)
}
})
}
}
// TestWireTelegramToModel_RoundTrip sanity-checks the wire->DB credential
// translation used by the telegram branch.
func TestWireTelegramToModel_RoundTrip(t *testing.T) {
wire := &wire.TelegramCredential{
ID: 1,
Name: "main-bot",
BotName: "rsmon_bot",
Token: "123456:ABCDEFG",
APIURL: "https://api.telegram.org",
}
nc := wireTelegramToModel(wire)
require.NotNil(t, nc)
assert.Equal(t, models.CredentialKindTelegram, nc.Kind)
assert.Equal(t, "main-bot", nc.Name)
require.NotNil(t, nc.BotName)
assert.Equal(t, "rsmon_bot", *nc.BotName)
require.NotNil(t, nc.APIURL)
assert.Equal(t, "https://api.telegram.org", *nc.APIURL)
got, err := nc.GetSecret()
require.NoError(t, err)
assert.Equal(t, "123456:ABCDEFG", got)
}
func TestSelectNotificationCredentialByID(t *testing.T) {
id := int64(2)
smtp, ok := selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
require.True(t, ok)
assert.Equal(t, "second", smtp.Name)
tg, ok := selectTelegramCredential([]wire.TelegramCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
require.True(t, ok)
assert.Equal(t, "second", tg.Name)
missingID := int64(3)
_, ok = selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}}, &missingID)
assert.False(t, ok)
}
// TestEnqueueNotification_BoundedQueue ensures the notifyQueue provides
// backpressure: the channel capacity is queueCapacity() and EnqueueNotification
// blocks once it is full.
func TestEnqueueNotification_BoundedQueue(t *testing.T) {
r := NewRunner(&Config{MaxConcurrency: 4})
r.notifyQueue = make(chan wire.NotificationTask, 4)
for i := 0; i < 4; i++ {
require.True(t, r.EnqueueNotification(wire.NotificationTask{JobID: "x"}))
}
// Channel is full; a non-blocking send must fail. We cannot truly verify
// the blocking case in unit tests, so we just assert the depth counter.
assert.Equal(t, int64(4), r.notifyDepth)
}
// TestExecuteNotification_ReportsJobIDFromPayload verifies the executor
// falls back to the task's JobID when the payload has none.
func TestExecuteNotification_ReportsJobIDFromPayload(t *testing.T) {
r := runnerWithCreds(nil)
report := r.ExecuteNotification(context.Background(), models.Task{
JobID: "outer-job",
Payload: []byte(`{
"job_id":"","message_id":1,"notification_id":1,
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
"message_kind":"down"
}`),
})
assert.Equal(t, "outer-job", report.JobID)
}
func TestDelegatedNotificationAuditCoexistsWithSelfcheckWithoutSecrets(t *testing.T) {
const (
recipient = "ops-private@example.com"
renderedBody = "private rendered body"
credentialSecret = "smtp-password-secret"
hookURL = "https://hooks.example.com/private"
authorization = "Bearer private-authorization"
providerResponse = "private provider response"
)
r := runnerWithCreds(&wire.NotificationCredentials{
SMTP: []wire.SMTPCredential{{Password: credentialSecret}},
})
r.notifyResults = make(chan notifyResultEnvelope, 1)
r.RecordNotification(&NotificationRow{
Kind: "email", Channel: notificationChannelSMTP, Subject: "selfcheck", Body: "main API down", OK: true,
})
r.executeAndForwardNotification(wire.NotificationTask{
JobID: "delegated-job", Method: "sms", Subject: authorization,
BodyText: renderedBody, BodyHTML: providerResponse,
Contact: wire.NotificationContact{Kind: "sms", Value: recipient, Name: hookURL},
})
rows := r.RecentNotifications(10)
require.Len(t, rows, 2)
assert.Equal(t, "selfcheck", rows[0].Subject)
delegated := rows[1]
assert.Equal(t, "delegated-job", delegated.JobID)
assert.Equal(t, "sms", delegated.Method)
assert.Equal(t, wire.NotificationResultPermanent, delegated.Status)
assert.Empty(t, delegated.Subject)
assert.Empty(t, delegated.Body)
assert.Empty(t, delegated.Error)
assert.False(t, delegated.At.IsZero())
stored, err := json.Marshal(delegated)
require.NoError(t, err)
for _, secret := range []string{recipient, renderedBody, credentialSecret, hookURL, authorization, providerResponse} {
assert.NotContains(t, string(stored), secret)
}
}
func TestDelegatedNotificationAuditRecordsOneTerminalRow(t *testing.T) {
const secret = "private-notification-secret"
validTask := func(jobID string) wire.NotificationTask {
return wire.NotificationTask{
JobID: jobID, LeaseToken: "lease", Method: "sms", Subject: secret, BodyText: secret,
Contact: wire.NotificationContact{Value: secret, Name: secret},
}
}
tests := []struct {
name string
invoke func(*Runner)
method string
}{
{
name: "normal result",
invoke: func(r *Runner) { r.executeAndForwardNotification(validTask("normal")) },
method: "sms",
},
{
name: "invalid deadline",
invoke: func(r *Runner) {
task := validTask("invalid-deadline")
deadline := "not-a-timestamp"
task.Deadline = &deadline
r.executeAndForwardNotification(task)
},
method: "sms",
},
{
name: "expired deadline",
invoke: func(r *Runner) {
task := validTask("expired-deadline")
deadline := time.Now().Add(-time.Second).Format(time.RFC3339Nano)
task.Deadline = &deadline
r.executeAndForwardNotification(task)
},
method: "sms",
},
{
name: "malformed payload",
invoke: func(r *Runner) {
r.ExecuteNotification(context.Background(), models.Task{JobID: "malformed-payload", Payload: []byte(`{"subject":"` + secret)})
},
},
{
name: "executor panic",
invoke: func(r *Runner) {
r.notificationExecutor = func(context.Context, models.Task) wire.NotificationResultReport {
panic(secret)
}
r.ExecuteNotification(context.Background(), models.Task{JobID: "panic", Payload: []byte(`{}`)})
},
},
{
name: "malformed envelope",
invoke: func(r *Runner) {
r.enqueueTaskMessage(wire.WorkerMessage{TaskEnvelope: &wire.TaskEnvelope{
Type: "invalid", JobID: "bad-envelope", Notify: ptrNotificationTask(validTask("bad-envelope")),
}})
},
method: "sms",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{})
r.notifyResults = make(chan notifyResultEnvelope, 1)
tc.invoke(r)
rows := r.RecentNotifications(10)
require.Len(t, rows, 1)
assert.Equal(t, wire.NotificationResultPermanent, rows[0].Status)
assert.Equal(t, tc.method, rows[0].Method)
stored, err := json.Marshal(rows[0])
require.NoError(t, err)
assert.NotContains(t, string(stored), secret)
})
}
}
func TestDelegatedNotificationAuditConcurrentRowsAreSecretFree(t *testing.T) {
const attempts = 32
const secret = "concurrent-private-secret"
r := runnerWithCreds(&wire.NotificationCredentials{})
var wg sync.WaitGroup
for i := 0; i < attempts; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
r.ExecuteNotification(context.Background(), models.Task{
JobID: fmt.Sprintf("job-%d", i),
Payload: []byte(`{"job_id":"job-` + fmt.Sprint(i) + `","method":"sms","body_text":"` + secret + `","contact":{"value":"` + secret + `"}}`),
})
}(i)
}
wg.Wait()
rows := r.RecentNotifications(attempts)
require.Len(t, rows, attempts)
for _, row := range rows {
stored, err := json.Marshal(row)
require.NoError(t, err)
assert.NotContains(t, string(stored), secret)
assert.Equal(t, wire.NotificationResultPermanent, row.Status)
}
}
func TestInvalidNotificationDeadlineIsNotExecuted(t *testing.T) {
r := runnerWithCreds(&wire.NotificationCredentials{})
r.notifyResults = make(chan notifyResultEnvelope, 1)
called := false
r.notificationExecutor = func(context.Context, models.Task) wire.NotificationResultReport {
called = true
return wire.NotificationResultReport{Status: wire.NotificationResultDelivered}
}
deadline := "not-a-timestamp"
r.executeAndForwardNotification(wire.NotificationTask{
JobID: "invalid-deadline", LeaseToken: "lease", Method: "sms", Deadline: &deadline,
})
assert.False(t, called)
env := <-r.notifyResults
assert.Equal(t, wire.NotificationResultPermanent, env.report.Status)
require.NotNil(t, env.report.Error)
assert.Equal(t, "invalid notification deadline", *env.report.Error)
require.Len(t, r.RecentNotifications(10), 1)
}
func ptrNotificationTask(task wire.NotificationTask) *wire.NotificationTask { return &task }
// guard against time import being unused if the above compile-time helpers
// are dropped in a future refactor.
var _ = time.Second