Files
dns-autoresolver/internal/api/schedule_test.go
T
vasyansk 7d4bf153d7 feat(api): CRUD расписания/каналов + тест-отправка + история проверок
Task 5 Фазы 3: GET/PUT /schedule (дефолт при отсутствии строки, валидация
interval>=60), POST/GET/DELETE /channels (секрет шифруется Cipher, никогда
не возвращается в ответах), POST /channels/{cid}/test через узкий
TestSender-интерфейс (200/502 без утечки секрета), GET /domains/{did}/history
(сначала GetDomain для project-scoping, затем ListCheckRuns — иначе IDOR
через check_runs, который сам по себе не scoped по project).

Добавлены store.GetDomain (обёртка над существующим sqlc-запросом) и
store.ListCheckRuns (новый запрос + sqlc regen) для поддержки истории.
2026-07-04 13:24:50 +07:00

434 lines
14 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/vasyakrg/dns-autoresolver/internal/store"
)
// --- mock ScheduleStore ---
type mockScheduleStore struct {
schedule store.Schedule
scheduleErr error
upsertCalled bool
upsertInterval int32
upsertEnabled bool
upsertResult store.Schedule
upsertErr error
channels map[uuid.UUID]store.Channel
createChannelIn []struct {
ctype string
config json.RawMessage
secretEnc string
}
createChannelErr error
deleteChannelCalled bool
deletedChannelID uuid.UUID
deleteChannelErr error
domains map[uuid.UUID]store.Domain
checkRuns map[uuid.UUID][]store.CheckRun
listRunsErr error
}
func newMockScheduleStore() *mockScheduleStore {
return &mockScheduleStore{
channels: map[uuid.UUID]store.Channel{},
domains: map[uuid.UUID]store.Domain{},
checkRuns: map[uuid.UUID][]store.CheckRun{},
}
}
func (m *mockScheduleStore) GetSchedule(context.Context, uuid.UUID) (store.Schedule, error) {
if m.scheduleErr != nil {
return store.Schedule{}, m.scheduleErr
}
return m.schedule, nil
}
func (m *mockScheduleStore) UpsertSchedule(_ context.Context, projectID uuid.UUID, interval int32, enabled bool) (store.Schedule, error) {
m.upsertCalled = true
m.upsertInterval = interval
m.upsertEnabled = enabled
if m.upsertErr != nil {
return store.Schedule{}, m.upsertErr
}
if m.upsertResult.ID != uuid.Nil {
return m.upsertResult, nil
}
return store.Schedule{ID: uuid.New(), ProjectID: projectID, IntervalSeconds: interval, Enabled: enabled}, nil
}
func (m *mockScheduleStore) CreateChannel(_ context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (store.Channel, error) {
m.createChannelIn = append(m.createChannelIn, struct {
ctype string
config json.RawMessage
secretEnc string
}{ctype, config, secretEnc})
if m.createChannelErr != nil {
return store.Channel{}, m.createChannelErr
}
ch := store.Channel{ID: uuid.New(), ProjectID: projectID, Type: ctype, Config: config, SecretEnc: secretEnc, Enabled: true}
m.channels[ch.ID] = ch
return ch, nil
}
func (m *mockScheduleStore) ListChannels(context.Context, uuid.UUID) ([]store.Channel, error) {
out := make([]store.Channel, 0, len(m.channels))
for _, c := range m.channels {
out = append(out, c)
}
return out, nil
}
func (m *mockScheduleStore) GetChannel(_ context.Context, id, _ uuid.UUID) (store.Channel, error) {
c, ok := m.channels[id]
if !ok {
return store.Channel{}, errors.New("channel not found")
}
return c, nil
}
func (m *mockScheduleStore) DeleteChannel(_ context.Context, id, _ uuid.UUID) error {
m.deleteChannelCalled = true
m.deletedChannelID = id
if m.deleteChannelErr != nil {
return m.deleteChannelErr
}
delete(m.channels, id)
return nil
}
func (m *mockScheduleStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) {
d, ok := m.domains[id]
if !ok {
return store.Domain{}, errors.New("domain not found")
}
return d, nil
}
func (m *mockScheduleStore) ListCheckRuns(_ context.Context, domainID uuid.UUID) ([]store.CheckRun, error) {
if m.listRunsErr != nil {
return nil, m.listRunsErr
}
return m.checkRuns[domainID], nil
}
// --- mock TestSender ---
type mockTestSender struct {
err error
calledType string
calledConfig json.RawMessage
calledSecret string
called bool
}
func (m *mockTestSender) SendTest(_ context.Context, channelType string, config json.RawMessage, secret string) error {
m.called = true
m.calledType = channelType
m.calledConfig = config
m.calledSecret = secret
return m.err
}
// newScheduleTestAPI wires a fixed authenticated user who owns whatever
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
// middleware_test.go) — these tests exercise schedule/channels/history
// behavior past the RequireAuth/RequireProjectAccess boundary.
func newScheduleTestAPI() (*API, *mockScheduleStore, *mockTestSender) {
ms := newMockScheduleStore()
mts := &mockTestSender{}
a := &API{
Schedule: ms, Dispatch: mts, Cipher: mockCipher{},
Auth: alwaysOwnedAuthStore(), Sessions: alwaysValidSessions(uuid.New()),
}
return a, ms, mts
}
// --- schedule ---
func TestGetSchedule_DefaultWhenNoRow(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
ms.scheduleErr = pgx.ErrNoRows
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/schedule", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp scheduleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.IntervalSeconds != 3600 || resp.Enabled != false {
t.Fatalf("expected default {3600,false}, got %+v", resp)
}
}
func TestGetSchedule_Existing(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
ms.schedule = store.Schedule{ID: uuid.New(), IntervalSeconds: 120, Enabled: true}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/schedule", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp scheduleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.IntervalSeconds != 120 || !resp.Enabled {
t.Fatalf("expected {120,true}, got %+v", resp)
}
}
func TestPutSchedule_RejectsIntervalBelow60(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
router := NewRouter(a)
body := `{"intervalSeconds":59,"enabled":true}`
req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/schedule", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for interval<60, got %d body %s", w.Code, w.Body.String())
}
if ms.upsertCalled {
t.Fatal("UpsertSchedule must not be called when validation fails")
}
}
func TestPutSchedule_Success(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
router := NewRouter(a)
body := `{"intervalSeconds":300,"enabled":true}`
req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/schedule", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if !ms.upsertCalled || ms.upsertInterval != 300 || !ms.upsertEnabled {
t.Fatalf("expected UpsertSchedule(300,true), got called=%v interval=%d enabled=%v", ms.upsertCalled, ms.upsertInterval, ms.upsertEnabled)
}
}
// --- channels ---
func TestCreateChannel_EncryptsSecretAndOmitsFromResponse(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
router := NewRouter(a)
body := `{"type":"telegram","config":{"chat_id":"123"},"secret":"super-bot-token"}`
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "super-bot-token") {
t.Fatalf("response leaks plaintext secret: %s", w.Body.String())
}
if len(ms.createChannelIn) != 1 {
t.Fatalf("expected 1 CreateChannel call, got %d", len(ms.createChannelIn))
}
got := ms.createChannelIn[0]
if got.secretEnc == "" || got.secretEnc == "super-bot-token" || !strings.Contains(got.secretEnc, "super-bot-token") {
// mockCipher.Encrypt wraps as ENC(...) — assert it's the *encrypted* form, not the raw plaintext passed unchanged.
t.Fatalf("expected secret to be passed through cipher.Encrypt, got secretEnc=%q", got.secretEnc)
}
if got.secretEnc == "super-bot-token" {
t.Fatalf("secret was stored unencrypted")
}
var resp channelResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Type != "telegram" || resp.ID == "" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func TestListChannels_NoSecrets(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
ms.channels[uuid.New()] = store.Channel{ID: uuid.New(), Type: "webhook", Config: json.RawMessage(`{"url":"https://example.com"}`), SecretEnc: "ENC(top-secret)", Enabled: true}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/channels", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "top-secret") || strings.Contains(w.Body.String(), "secretEnc") {
t.Fatalf("channel list leaks secret: %s", w.Body.String())
}
}
func TestDeleteChannel(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
cid := uuid.New()
ms.channels[cid] = store.Channel{ID: cid, Type: "webhook"}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/channels/"+cid.String(), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if !ms.deleteChannelCalled || ms.deletedChannelID != cid {
t.Fatalf("expected DeleteChannel(%s), called=%v got=%s", cid, ms.deleteChannelCalled, ms.deletedChannelID)
}
}
func TestDeleteChannel_InvalidUUID(t *testing.T) {
a, _, _ := newScheduleTestAPI()
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/channels/not-a-uuid", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for bad channel uuid, got %d", w.Code)
}
}
func TestTestChannel_Success(t *testing.T) {
a, ms, mts := newScheduleTestAPI()
cid := uuid.New()
ms.channels[cid] = store.Channel{ID: cid, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "ENC(bot-token)"}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+cid.String()+"/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if !mts.called || mts.calledType != "telegram" || mts.calledSecret != "bot-token" {
t.Fatalf("expected SendTest(telegram,...,bot-token), got called=%v type=%s secret=%s", mts.called, mts.calledType, mts.calledSecret)
}
}
func TestTestChannel_SenderError_Returns502WithoutSecret(t *testing.T) {
a, ms, mts := newScheduleTestAPI()
cid := uuid.New()
ms.channels[cid] = store.Channel{ID: cid, Type: "telegram", Config: json.RawMessage(`{"chat_id":"1"}`), SecretEnc: "ENC(bot-token)"}
mts.err = errors.New("telegram: status 401 Unauthorized (token=bot-token)")
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+cid.String()+"/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502 on channel test failure, got %d body %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "bot-token") {
t.Fatalf("error response leaks secret: %s", w.Body.String())
}
}
func TestTestChannel_UnknownChannel_Returns404(t *testing.T) {
a, _, _ := newScheduleTestAPI()
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/channels/"+uuid.New().String()+"/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown channel, got %d", w.Code)
}
}
// --- history ---
func TestDomainHistory_List(t *testing.T) {
a, ms, _ := newScheduleTestAPI()
did := uuid.New()
ms.domains[did] = store.Domain{ID: did}
ms.checkRuns[did] = []store.CheckRun{
{ID: uuid.New(), DomainID: did, Result: json.RawMessage(`{"updates":1,"prunes":0}`)},
{ID: uuid.New(), DomainID: did, Result: json.RawMessage(`{"updates":0,"prunes":0}`)},
}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/history", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp []checkRunResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(resp))
}
}
func TestDomainHistory_InvalidUUID(t *testing.T) {
a, _, _ := newScheduleTestAPI()
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/not-a-uuid/history", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for bad domain uuid, got %d", w.Code)
}
}
// TestDomainHistory_ForeignDomain_Returns404 is the IDOR guard for history:
// check_runs.domain_id has no project scoping of its own, so the handler
// must verify domain ownership via GetDomain before calling ListCheckRuns —
// a domain id the mock doesn't know about (i.e. not in this project) must
// 404 rather than fall through to an unscoped history lookup.
func TestDomainHistory_ForeignDomain_Returns404(t *testing.T) {
a, _, _ := newScheduleTestAPI()
router := NewRouter(a)
did := uuid.New() // never registered in ms.domains
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/history", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for a domain not owned by this project, got %d body %s", w.Code, w.Body.String())
}
}