27d70a987e
handleCheck's error branch wrote last_check_status via an id-only UPDATE, so an authenticated caller's own valid project id paired with a foreign domain id in the URL could flip a stranger's domain to "error" even though Check itself is project-scoped and would 404/error out first. Add project_id to the WHERE clause (queries/domains.sql + generated db/domains.sql.go), thread projectID through Store/TenantStore/SchedStore SetDomainStatus, and pass pid from context at both call sites in handleCheck plus the scheduler. Also collapse checkDomain's inline status derivation in scheduler.go into a call to service.DeriveStatus, the same helper handleCheck already uses, so there's a single source of truth for "drift vs in_sync" instead of two copies that could drift apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
369 lines
12 KiB
Go
369 lines
12 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
|
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/metrics"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/notify"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
|
)
|
|
|
|
// mockStore is an in-memory SchedStore double.
|
|
type mockStore struct {
|
|
mu sync.Mutex
|
|
|
|
schedules []store.Schedule
|
|
domains map[uuid.UUID][]store.Domain
|
|
status map[uuid.UUID]string
|
|
|
|
touchedProjects []uuid.UUID
|
|
|
|
// driftCount is what CountDriftDomains returns — a canned system-wide
|
|
// count, independent of what this RunOnce's due projects touched.
|
|
driftCount int
|
|
}
|
|
|
|
func newMockStore() *mockStore {
|
|
return &mockStore{
|
|
domains: make(map[uuid.UUID][]store.Domain),
|
|
status: make(map[uuid.UUID]string),
|
|
}
|
|
}
|
|
|
|
func (m *mockStore) ListDueSchedules(ctx context.Context, now time.Time) ([]store.Schedule, error) {
|
|
return m.schedules, nil
|
|
}
|
|
|
|
func (m *mockStore) TouchScheduleRun(ctx context.Context, projectID uuid.UUID, at time.Time) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.touchedProjects = append(m.touchedProjects, projectID)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) {
|
|
return m.domains[projectID], nil
|
|
}
|
|
|
|
func (m *mockStore) GetDomainStatus(ctx context.Context, domainID uuid.UUID) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if st, ok := m.status[domainID]; ok {
|
|
return st, nil
|
|
}
|
|
return StatusUnknown, nil
|
|
}
|
|
|
|
// SetDomainStatus ignores projectID here — this in-memory fake is keyed by
|
|
// domainID alone and isn't exercising the IDOR scoping itself (that's
|
|
// covered at the store layer / API handler level); it exists only to match
|
|
// the SchedStore interface signature.
|
|
func (m *mockStore) SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.status[domainID] = status
|
|
return nil
|
|
}
|
|
|
|
func (m *mockStore) CountDriftDomains(ctx context.Context) (int, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.driftCount, nil
|
|
}
|
|
|
|
// mockChecker returns a preset Changeset or error per domainID, and records
|
|
// which domain IDs it was called with.
|
|
type mockChecker struct {
|
|
mu sync.Mutex
|
|
results map[uuid.UUID]diff.Changeset
|
|
errs map[uuid.UUID]error
|
|
calls []uuid.UUID
|
|
}
|
|
|
|
func (c *mockChecker) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) {
|
|
c.mu.Lock()
|
|
c.calls = append(c.calls, domainID)
|
|
c.mu.Unlock()
|
|
if err, ok := c.errs[domainID]; ok {
|
|
return diff.Changeset{}, err
|
|
}
|
|
return c.results[domainID], nil
|
|
}
|
|
|
|
// mockNotifier records every Event it is asked to Send, and returns a
|
|
// canned set of per-channel results (results defaults to nil, i.e. no
|
|
// channels) so tests can assert on scheduler.checkDomain's per-channel
|
|
// metric recording.
|
|
type mockNotifier struct {
|
|
mu sync.Mutex
|
|
events []notify.Event
|
|
results []notify.ChannelResult
|
|
}
|
|
|
|
func (n *mockNotifier) Send(ctx context.Context, projectID uuid.UUID, ev notify.Event) ([]notify.ChannelResult, error) {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
n.events = append(n.events, ev)
|
|
return n.results, nil
|
|
}
|
|
|
|
func (n *mockNotifier) count() int {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
return len(n.events)
|
|
}
|
|
|
|
func driftChangeset() diff.Changeset {
|
|
return diff.Changeset{Diffs: []diff.RecordDiff{{Kind: diff.Update, Name: "www"}}}
|
|
}
|
|
|
|
func TestRunOnce_NotifiesOnDriftNotOnFirstInSync(t *testing.T) {
|
|
projectID := uuid.New()
|
|
templateID := uuid.New()
|
|
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
domainB := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
|
|
st := newMockStore()
|
|
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
|
st.domains[projectID] = []store.Domain{domainA, domainB}
|
|
|
|
checker := &mockChecker{
|
|
results: map[uuid.UUID]diff.Changeset{
|
|
domainA.ID: driftChangeset(),
|
|
domainB.ID: {},
|
|
},
|
|
}
|
|
notifier := &mockNotifier{}
|
|
m := metrics.New()
|
|
|
|
// CountDriftDomains is the real system-wide count, independent of what
|
|
// this tick touched — set it to something that would NOT match a local
|
|
// per-tick accumulator (only 1 of 2 domains here drifted) to prove the
|
|
// gauge comes from the store call, not a local tally.
|
|
st.driftCount = 7
|
|
|
|
sched := New(st, checker, notifier, m)
|
|
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
if st.status[domainA.ID] != StatusDrift {
|
|
t.Fatalf("domain A status = %q, want drift", st.status[domainA.ID])
|
|
}
|
|
if st.status[domainB.ID] != StatusInSync {
|
|
t.Fatalf("domain B status = %q, want in_sync", st.status[domainB.ID])
|
|
}
|
|
|
|
if got := notifier.count(); got != 1 {
|
|
t.Fatalf("notifications sent = %d, want 1 (only domain A)", got)
|
|
}
|
|
if notifier.events[0].Domain != domainA.ID.String() {
|
|
t.Fatalf("notified domain = %q, want domain A (%s)", notifier.events[0].Domain, domainA.ID)
|
|
}
|
|
if notifier.events[0].Status != StatusDrift {
|
|
t.Fatalf("notified status = %q, want drift", notifier.events[0].Status)
|
|
}
|
|
|
|
if len(st.touchedProjects) != 1 || st.touchedProjects[0] != projectID {
|
|
t.Fatalf("TouchScheduleRun calls = %v, want [%s]", st.touchedProjects, projectID)
|
|
}
|
|
|
|
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusDrift)); got != 1 {
|
|
t.Fatalf("ChecksTotal{drift} = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 {
|
|
t.Fatalf("ChecksTotal{in_sync} = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.DriftDomains); got != float64(st.driftCount) {
|
|
t.Fatalf("DriftDomains gauge = %v, want %d (from CountDriftDomains)", got, st.driftCount)
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_Idempotent_NoRepeatNotifyOnUnchangedDrift(t *testing.T) {
|
|
projectID := uuid.New()
|
|
templateID := uuid.New()
|
|
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
|
|
st := newMockStore()
|
|
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
|
st.domains[projectID] = []store.Domain{domainA}
|
|
|
|
checker := &mockChecker{
|
|
results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()},
|
|
}
|
|
notifier := &mockNotifier{}
|
|
m := metrics.New()
|
|
sched := New(st, checker, notifier, m)
|
|
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("first RunOnce: %v", err)
|
|
}
|
|
if got := notifier.count(); got != 1 {
|
|
t.Fatalf("after first run notifications = %d, want 1", got)
|
|
}
|
|
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("second RunOnce: %v", err)
|
|
}
|
|
if got := notifier.count(); got != 1 {
|
|
t.Fatalf("after second run (drift->drift) notifications = %d, want still 1 (no repeat)", got)
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_CheckError_StatusErrorAndNotify(t *testing.T) {
|
|
projectID := uuid.New()
|
|
templateID := uuid.New()
|
|
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
|
|
st := newMockStore()
|
|
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
|
st.domains[projectID] = []store.Domain{domainA}
|
|
|
|
checker := &mockChecker{
|
|
errs: map[uuid.UUID]error{domainA.ID: errors.New("provider timeout")},
|
|
}
|
|
notifier := &mockNotifier{}
|
|
m := metrics.New()
|
|
sched := New(st, checker, notifier, m)
|
|
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
if st.status[domainA.ID] != StatusError {
|
|
t.Fatalf("domain A status = %q, want error", st.status[domainA.ID])
|
|
}
|
|
if got := notifier.count(); got != 1 {
|
|
t.Fatalf("notifications = %d, want 1 (unknown->error)", got)
|
|
}
|
|
if notifier.events[0].Status != StatusError {
|
|
t.Fatalf("notified status = %q, want error", notifier.events[0].Status)
|
|
}
|
|
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusError)); got != 1 {
|
|
t.Fatalf("ChecksTotal{error} = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestRunOnce_SkipsDomainWithoutTemplate(t *testing.T) {
|
|
projectID := uuid.New()
|
|
templateID := uuid.New()
|
|
domainNoTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: nil}
|
|
domainWithTemplate := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
|
|
st := newMockStore()
|
|
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
|
st.domains[projectID] = []store.Domain{domainNoTemplate, domainWithTemplate}
|
|
|
|
checker := &mockChecker{
|
|
results: map[uuid.UUID]diff.Changeset{domainWithTemplate.ID: {}},
|
|
}
|
|
notifier := &mockNotifier{}
|
|
m := metrics.New()
|
|
sched := New(st, checker, notifier, m)
|
|
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
for _, id := range checker.calls {
|
|
if id == domainNoTemplate.ID {
|
|
t.Fatalf("Checker.Check was called for templateless domain %s, want skipped", id)
|
|
}
|
|
}
|
|
if len(checker.calls) != 1 || checker.calls[0] != domainWithTemplate.ID {
|
|
t.Fatalf("Checker.Check calls = %v, want exactly [%s]", checker.calls, domainWithTemplate.ID)
|
|
}
|
|
|
|
if _, ok := st.status[domainNoTemplate.ID]; ok {
|
|
t.Fatalf("templateless domain status = %q, want no status set (never checked)", st.status[domainNoTemplate.ID])
|
|
}
|
|
if st.status[domainWithTemplate.ID] != StatusInSync {
|
|
t.Fatalf("domain with template status = %q, want in_sync", st.status[domainWithTemplate.ID])
|
|
}
|
|
|
|
if got := notifier.count(); got != 0 {
|
|
t.Fatalf("notifications sent = %d, want 0 (templateless skip is silent, and template domain unknown->in_sync is not news)", got)
|
|
}
|
|
|
|
if got := testutil.ToFloat64(m.ChecksTotal.WithLabelValues(StatusInSync)); got != 1 {
|
|
t.Fatalf("ChecksTotal{in_sync} = %v, want 1 (only the templated domain was checked)", got)
|
|
}
|
|
}
|
|
|
|
// TestRunOnce_RecordsPerChannelNotificationMetrics exercises the fix for the
|
|
// "IncNotification('dispatch', newStatus) unconditionally" bug: the
|
|
// scheduler must record one NotificationsTotal increment per channel result,
|
|
// labeled by that channel's actual type and its actual sent/failed outcome
|
|
// — not a single "dispatch" placeholder blind to per-channel delivery.
|
|
func TestRunOnce_RecordsPerChannelNotificationMetrics(t *testing.T) {
|
|
projectID := uuid.New()
|
|
templateID := uuid.New()
|
|
domainA := store.Domain{ID: uuid.New(), ProjectID: projectID, TemplateID: &templateID}
|
|
|
|
st := newMockStore()
|
|
st.schedules = []store.Schedule{{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: 3600, Enabled: true}}
|
|
st.domains[projectID] = []store.Domain{domainA}
|
|
|
|
checker := &mockChecker{
|
|
results: map[uuid.UUID]diff.Changeset{domainA.ID: driftChangeset()},
|
|
}
|
|
notifier := &mockNotifier{
|
|
results: []notify.ChannelResult{
|
|
{Type: "telegram"},
|
|
{Type: "webhook", Err: errors.New("x")},
|
|
},
|
|
}
|
|
m := metrics.New()
|
|
sched := New(st, checker, notifier, m)
|
|
|
|
// unknown -> drift: shouldNotify is true, so notifier.Send fires once.
|
|
if err := sched.RunOnce(context.Background(), time.Now()); err != nil {
|
|
t.Fatalf("RunOnce: %v", err)
|
|
}
|
|
|
|
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("telegram", "sent")); got != 1 {
|
|
t.Fatalf("NotificationsTotal{telegram,sent} = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(m.NotificationsTotal.WithLabelValues("webhook", "failed")); got != 1 {
|
|
t.Fatalf("NotificationsTotal{webhook,failed} = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestShouldNotify(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
prev string
|
|
new string
|
|
want bool
|
|
}{
|
|
{"unknown->drift notifies", StatusUnknown, StatusDrift, true},
|
|
{"unknown->error notifies", StatusUnknown, StatusError, true},
|
|
{"unknown->in_sync is silent (first sync is not news)", StatusUnknown, StatusInSync, false},
|
|
{"drift->drift does not repeat", StatusDrift, StatusDrift, false},
|
|
{"error->error does not repeat", StatusError, StatusError, false},
|
|
{"drift->in_sync notifies (resolved)", StatusDrift, StatusInSync, true},
|
|
{"in_sync->drift notifies", StatusInSync, StatusDrift, true},
|
|
{"in_sync->error notifies", StatusInSync, StatusError, true},
|
|
{"in_sync->in_sync is silent", StatusInSync, StatusInSync, false},
|
|
{"error->drift notifies (still bad, different bad)", StatusError, StatusDrift, true},
|
|
{"error->in_sync notifies (resolved after failure)", StatusError, StatusInSync, true},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := shouldNotify(tc.prev, tc.new); got != tc.want {
|
|
t.Fatalf("shouldNotify(%q, %q) = %v, want %v", tc.prev, tc.new, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|