diff --git a/internal/api/api.go b/internal/api/api.go index cd01cf4..d2ee30f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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) }) }) diff --git a/internal/api/schedule_handlers.go b/internal/api/schedule_handlers.go new file mode 100644 index 0000000..7bc6e52 --- /dev/null +++ b/internal/api/schedule_handlers.go @@ -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) +} diff --git a/internal/api/schedule_test.go b/internal/api/schedule_test.go new file mode 100644 index 0000000..3e2dd13 --- /dev/null +++ b/internal/api/schedule_test.go @@ -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()) + } +} diff --git a/internal/store/db/check_runs.sql.go b/internal/store/db/check_runs.sql.go index 3279246..0040bb8 100644 --- a/internal/store/db/check_runs.sql.go +++ b/internal/store/db/check_runs.sql.go @@ -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 +} diff --git a/internal/store/loader.go b/internal/store/loader.go index 620db85..bc48f64 100644 --- a/internal/store/loader.go +++ b/internal/store/loader.go @@ -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) diff --git a/internal/store/queries/check_runs.sql b/internal/store/queries/check_runs.sql index 3c5ea29..22d51e1 100644 --- a/internal/store/queries/check_runs.sql +++ b/internal/store/queries/check_runs.sql @@ -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; diff --git a/internal/store/tenant.go b/internal/store/tenant.go index 60c15eb..7296f08 100644 --- a/internal/store/tenant.go +++ b/internal/store/tenant.go @@ -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.