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) для поддержки истории.
This commit is contained in:
@@ -85,6 +85,8 @@ type API struct {
|
||||
Reg ProviderRegistry
|
||||
Auth AuthStore
|
||||
Sessions SessionManager
|
||||
Schedule ScheduleStore
|
||||
Dispatch TestSender
|
||||
}
|
||||
|
||||
func NewRouter(a *API) http.Handler {
|
||||
@@ -114,6 +116,18 @@ func NewRouter(a *API) http.Handler {
|
||||
r.Post("/apply", a.handleApply)
|
||||
r.Patch("/", a.handleSetDomainTemplate)
|
||||
r.Delete("/", a.handleDeleteDomain)
|
||||
r.Get("/history", a.handleDomainHistory)
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/schedule", a.handleGetSchedule)
|
||||
r.Put("/schedule", a.handlePutSchedule)
|
||||
r.Route("/channels", func(r chi.Router) {
|
||||
r.Post("/", a.handleCreateChannel)
|
||||
r.Get("/", a.handleListChannels)
|
||||
r.Route("/{cid}", func(r chi.Router) {
|
||||
r.Delete("/", a.handleDeleteChannel)
|
||||
r.Post("/test", a.handleTestChannel)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||
)
|
||||
|
||||
// ScheduleStore is the persistence surface the schedule/channels/history
|
||||
// handlers depend on. *store.Store satisfies it directly via its thin
|
||||
// wrapper methods (see internal/store/tenant.go, internal/store/loader.go);
|
||||
// tests can supply their own mock.
|
||||
type ScheduleStore interface {
|
||||
GetSchedule(ctx context.Context, projectID uuid.UUID) (store.Schedule, error)
|
||||
UpsertSchedule(ctx context.Context, projectID uuid.UUID, interval int32, enabled bool) (store.Schedule, error)
|
||||
|
||||
CreateChannel(ctx context.Context, projectID uuid.UUID, ctype string, config json.RawMessage, secretEnc string) (store.Channel, error)
|
||||
ListChannels(ctx context.Context, projectID uuid.UUID) ([]store.Channel, error)
|
||||
GetChannel(ctx context.Context, id, projectID uuid.UUID) (store.Channel, error)
|
||||
DeleteChannel(ctx context.Context, id, projectID uuid.UUID) error
|
||||
|
||||
// GetDomain verifies domainID belongs to projectID — required before
|
||||
// ListCheckRuns, which is not itself scoped by project.
|
||||
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
||||
ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]store.CheckRun, error)
|
||||
}
|
||||
|
||||
// TestSender sends a one-off test notification through a single
|
||||
// notification channel (POST /channels/{cid}/test). It's a narrow surface
|
||||
// deliberately decoupled from notify.Dispatcher (which fans a single event
|
||||
// out to every enabled channel of a project by project ID, not a single
|
||||
// channel by ID) — cmd/server wiring (Task 6) supplies a concrete adapter
|
||||
// over internal/notify's Notifiers; tests supply a mock.
|
||||
type TestSender interface {
|
||||
SendTest(ctx context.Context, channelType string, config json.RawMessage, secret string) error
|
||||
}
|
||||
|
||||
const minScheduleIntervalSeconds = 60
|
||||
|
||||
// defaultScheduleResponse is what GET /schedule returns when the project has
|
||||
// never created a schedule row (store.GetSchedule returns pgx.ErrNoRows).
|
||||
var defaultScheduleResponse = scheduleResponse{IntervalSeconds: 3600, Enabled: false}
|
||||
|
||||
type scheduleRequest struct {
|
||||
IntervalSeconds int32 `json:"intervalSeconds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type scheduleResponse struct {
|
||||
IntervalSeconds int32 `json:"intervalSeconds"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
|
||||
}
|
||||
|
||||
func toScheduleResponse(s store.Schedule) scheduleResponse {
|
||||
return scheduleResponse{IntervalSeconds: s.IntervalSeconds, Enabled: s.Enabled, LastRunAt: s.LastRunAt}
|
||||
}
|
||||
|
||||
// --- schedule ---
|
||||
|
||||
func (a *API) handleGetSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
sc, err := a.Schedule.GetSchedule(r.Context(), pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeJSON(w, http.StatusOK, defaultScheduleResponse)
|
||||
return
|
||||
}
|
||||
log.Printf("api: get schedule failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toScheduleResponse(sc))
|
||||
}
|
||||
|
||||
func (a *API) handlePutSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
var req scheduleRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.IntervalSeconds < minScheduleIntervalSeconds {
|
||||
writeErr(w, http.StatusBadRequest, "intervalSeconds must be >= 60")
|
||||
return
|
||||
}
|
||||
sc, err := a.Schedule.UpsertSchedule(r.Context(), pid, req.IntervalSeconds, req.Enabled)
|
||||
if err != nil {
|
||||
log.Printf("api: upsert schedule failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toScheduleResponse(sc))
|
||||
}
|
||||
|
||||
// --- channels ---
|
||||
|
||||
type channelRequest struct {
|
||||
Type string `json:"type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// channelResponse deliberately excludes the secret (plaintext or encrypted) —
|
||||
// bot tokens/webhook signing keys must never reach an API response.
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func toChannelResponse(c store.Channel) channelResponse {
|
||||
return channelResponse{ID: c.ID.String(), Type: c.Type, Config: c.Config, Enabled: c.Enabled}
|
||||
}
|
||||
|
||||
func (a *API) handleCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
var req channelRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Type == "" {
|
||||
writeErr(w, http.StatusBadRequest, "type is required")
|
||||
return
|
||||
}
|
||||
if len(req.Config) == 0 {
|
||||
req.Config = json.RawMessage("{}")
|
||||
}
|
||||
secretEnc := ""
|
||||
if req.Secret != "" {
|
||||
enc, err := a.Cipher.Encrypt([]byte(req.Secret))
|
||||
if err != nil {
|
||||
log.Printf("api: encrypt channel secret failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
secretEnc = enc
|
||||
}
|
||||
ch, err := a.Schedule.CreateChannel(r.Context(), pid, req.Type, req.Config, secretEnc)
|
||||
if err != nil {
|
||||
log.Printf("api: create channel failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toChannelResponse(ch))
|
||||
}
|
||||
|
||||
func (a *API) handleListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
chs, err := a.Schedule.ListChannels(r.Context(), pid)
|
||||
if err != nil {
|
||||
log.Printf("api: list channels failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
resp := make([]channelResponse, 0, len(chs))
|
||||
for _, c := range chs {
|
||||
resp = append(resp, toChannelResponse(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "cid"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
if err := a.Schedule.DeleteChannel(r.Context(), cid, pid); err != nil {
|
||||
log.Printf("api: delete channel failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleTestChannel sends a one-off test notification through a single
|
||||
// channel so a user can verify their bot_token/chat_id or webhook URL work
|
||||
// before enabling the schedule. The channel's secret is decrypted only in
|
||||
// memory to make the outbound call — it's never echoed back, and a failure
|
||||
// from the remote channel (bad token, unreachable webhook) is reported as
|
||||
// 502 without including any secret material in the error body.
|
||||
func (a *API) handleTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
cid, err := uuid.Parse(chi.URLParam(r, "cid"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
ch, err := a.Schedule.GetChannel(r.Context(), cid, pid)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
secret := ""
|
||||
if ch.SecretEnc != "" {
|
||||
dec, err := a.Cipher.Decrypt(ch.SecretEnc)
|
||||
if err != nil {
|
||||
log.Printf("api: decrypt channel secret failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
secret = string(dec)
|
||||
}
|
||||
if err := a.Dispatch.SendTest(r.Context(), ch.Type, ch.Config, secret); err != nil {
|
||||
log.Printf("api: test channel %s failed: %v", cid, err)
|
||||
writeErr(w, http.StatusBadGateway, "channel test failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// --- history ---
|
||||
|
||||
type checkRunResponse struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
func toCheckRunResponse(c store.CheckRun) checkRunResponse {
|
||||
return checkRunResponse{ID: c.ID.String(), CreatedAt: c.CreatedAt, Result: c.Result}
|
||||
}
|
||||
|
||||
// handleDomainHistory returns the most recent check_runs for a domain.
|
||||
// check_runs.domain_id has no project scoping of its own, so this handler
|
||||
// must first confirm the domain belongs to the caller's project (GetDomain)
|
||||
// before listing its history — otherwise a caller could enumerate another
|
||||
// tenant's domain IDs to read their check history (IDOR).
|
||||
func (a *API) handleDomainHistory(w http.ResponseWriter, r *http.Request) {
|
||||
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
||||
// validated it before this handler ever runs.
|
||||
pid, _ := projectIDFrom(r.Context())
|
||||
did, err := uuid.Parse(chi.URLParam(r, "did"))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid domain id")
|
||||
return
|
||||
}
|
||||
if _, err := a.Schedule.GetDomain(r.Context(), did, pid); err != nil {
|
||||
writeErr(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
runs, err := a.Schedule.ListCheckRuns(r.Context(), did)
|
||||
if err != nil {
|
||||
log.Printf("api: list check runs failed: %v", err)
|
||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
resp := make([]checkRunResponse, 0, len(runs))
|
||||
for _, cr := range runs {
|
||||
resp = append(resp, toCheckRunResponse(cr))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -34,3 +34,32 @@ func (q *Queries) CreateCheckRun(ctx context.Context, arg CreateCheckRunParams)
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listCheckRuns = `-- name: ListCheckRuns :many
|
||||
SELECT id, domain_id, result, created_at FROM check_runs WHERE domain_id = $1 ORDER BY created_at DESC LIMIT 50
|
||||
`
|
||||
|
||||
func (q *Queries) ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]CheckRun, error) {
|
||||
rows, err := q.db.Query(ctx, listCheckRuns, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CheckRun
|
||||
for rows.Next() {
|
||||
var i CheckRun
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.DomainID,
|
||||
&i.Result,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -51,6 +52,40 @@ func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Ch
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckRun is a provider-neutral summary of a past check/apply run, returned
|
||||
// by ListCheckRuns for the domain history endpoint (Фаза 3).
|
||||
type CheckRun struct {
|
||||
ID uuid.UUID
|
||||
DomainID uuid.UUID
|
||||
Result json.RawMessage
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func checkRunFromDB(c db.CheckRun) CheckRun {
|
||||
return CheckRun{
|
||||
ID: c.ID,
|
||||
DomainID: c.DomainID,
|
||||
Result: json.RawMessage(c.Result),
|
||||
CreatedAt: c.CreatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// ListCheckRuns returns the most recent check_runs rows for a domain (newest
|
||||
// first, capped at 50). Not scoped by project itself — callers must verify
|
||||
// the domain belongs to the caller's project first (e.g. via GetDomain)
|
||||
// since check_runs only references domain_id.
|
||||
func (s *Store) ListCheckRuns(ctx context.Context, domainID uuid.UUID) ([]CheckRun, error) {
|
||||
rows, err := s.q.ListCheckRuns(ctx, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CheckRun, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, checkRunFromDB(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// compile-time interface checks
|
||||
var _ service.Loader = (*Store)(nil)
|
||||
var _ service.Recorder = (*Store)(nil)
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
INSERT INTO check_runs (id, domain_id, result)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListCheckRuns :many
|
||||
SELECT * FROM check_runs WHERE domain_id = $1 ORDER BY created_at DESC LIMIT 50;
|
||||
|
||||
@@ -176,6 +176,17 @@ func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
|
||||
return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID})
|
||||
}
|
||||
|
||||
// GetDomain is a scoped lookup used to verify a domain belongs to projectID
|
||||
// before it's referenced elsewhere (e.g. history — check_runs isn't itself
|
||||
// scoped by project, so callers must confirm domain ownership first).
|
||||
func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain, error) {
|
||||
d, err := s.q.GetDomain(ctx, db.GetDomainParams{ID: id, ProjectID: projectID})
|
||||
if err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
return domainFromDB(d), nil
|
||||
}
|
||||
|
||||
// ImportDomains creates one domain per zone inside a single transaction: if
|
||||
// any zone fails to be created, the whole batch is rolled back so callers
|
||||
// never observe a partially-imported set of domains.
|
||||
|
||||
Reference in New Issue
Block a user