merge 3 wave: worktree-agent-ab476f3616a493a88

This commit is contained in:
2026-07-04 13:32:02 +07:00
7 changed files with 801 additions and 0 deletions
+14
View File
@@ -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)
})
})
+276
View File
@@ -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)
}
+433
View File
@@ -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())
}
}
+29
View File
@@ -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
}
+35
View File
@@ -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)
+3
View File
@@ -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;
+11
View File
@@ -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.