From da267b3cef5e867f31ba790bf0df5d4f416ac59d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:10:46 +0700 Subject: [PATCH] feat(api): endpoints to mark and unmark custom records --- internal/api/api.go | 8 +++ internal/api/api_test.go | 2 +- internal/api/dto.go | 15 +++- internal/api/handlers.go | 52 ++++++++++++++ internal/api/tenant_test.go | 136 ++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index c621e20..581f4f3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -55,6 +55,12 @@ type TenantStore interface { // "unknown" until the scheduler's next tick. Scoped by projectID so a // foreign domain ID can never have its status overwritten (IDOR-on-write). SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error + + // AddCustomRecord/DeleteCustomRecord manage the per-domain marks that keep + // a record out of the diff. Scoped by projectID: marking a domain outside + // the caller's project must fail (IDOR-on-write). + AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error + DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error } // Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it. @@ -132,6 +138,8 @@ func NewRouter(a *API) http.Handler { r.Get("/history", a.handleDomainHistory) r.Get("/records", a.handleZoneRecords) r.Post("/template-from-zone", a.handleTemplateFromZone) + r.Post("/customs", a.handleAddCustom) + r.Delete("/customs", a.handleDeleteCustom) }) }) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 94e1c99..2f59986 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -382,7 +382,7 @@ func TestChangesetResponseEmptyMarshalsToArrays(t *testing.T) { t.Fatal(err) } s := string(b) - for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"readOnly":[]`} { + for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"customs":[]`, `"readOnly":[]`} { if !strings.Contains(s, want) { t.Fatalf("expected %s in %s", want, s) } diff --git a/internal/api/dto.go b/internal/api/dto.go index 6d5fa9f..42015f1 100644 --- a/internal/api/dto.go +++ b/internal/api/dto.go @@ -44,6 +44,13 @@ type applyRequest struct { Prunes []string `json:"prunes"` } +// customRequest is the body of POST .../customs — the key travels here +// (never as a path segment) because it contains a space and dots, e.g. +// "CNAME admin.example.com.". +type customRequest struct { + Key string `json:"key"` +} + type recordView struct { Key string `json:"key"` Kind string `json:"kind"` @@ -52,17 +59,19 @@ type recordView struct { Desired []string `json:"desired,omitempty"` Actual []string `json:"actual,omitempty"` ReadOnly bool `json:"readOnly"` + Custom bool `json:"custom"` } type changesetResponse struct { Updates []recordView `json:"updates"` Prunes []recordView `json:"prunes"` + Customs []recordView `json:"customs"` ReadOnly []recordView `json:"readOnly"` InSync int `json:"inSyncCount"` } func toRecordView(d diff.RecordDiff) recordView { - rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly} + rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly, Custom: d.Custom} if d.Desired != nil { rv.Desired = d.Desired.Values } @@ -80,6 +89,7 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse { resp := changesetResponse{ Updates: []recordView{}, Prunes: []recordView{}, + Customs: []recordView{}, ReadOnly: []recordView{}, } for _, d := range cs.Updates() { @@ -88,6 +98,9 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse { for _, d := range cs.Prunes() { resp.Prunes = append(resp.Prunes, toRecordView(d)) } + for _, d := range cs.Customs() { + resp.Customs = append(resp.Customs, toRecordView(d)) + } for _, d := range cs.Diffs { if d.ReadOnly { resp.ReadOnly = append(resp.ReadOnly, toRecordView(d)) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index cc10af0..2dd9933 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -98,6 +98,58 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toChangesetResponse(cs)) } +// handleAddCustom marks an RRset as deliberately kept outside the template: +// it moves from Prunes to its own section, stops counting as drift and can +// never be applied. The key travels in the body, not the path — it contains +// a space and dots ("CNAME admin.example.com."). +func (a *API) handleAddCustom(w http.ResponseWriter, r *http.Request) { + pid, _ := projectIDFrom(r.Context()) + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + var req customRequest + if !decodeBody(w, r, &req) { + return + } + if req.Key == "" { + writeErr(w, http.StatusBadRequest, "key is required") + return + } + if err := a.Store.AddCustomRecord(r.Context(), did, pid, req.Key); err != nil { + // Either the domain doesn't exist or it belongs to another project — + // both are "not found" from this tenant's point of view. + log.Printf("api: add custom record failed: %v", err) + writeErr(w, http.StatusNotFound, "domain not found") + return + } + w.WriteHeader(http.StatusCreated) +} + +// handleDeleteCustom removes the mark, returning the record to the regular +// diff. The key comes as a query parameter for the same reason it comes in +// the body on POST. +func (a *API) handleDeleteCustom(w http.ResponseWriter, r *http.Request) { + pid, _ := projectIDFrom(r.Context()) + did, err := uuid.Parse(chi.URLParam(r, "did")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid domain id") + return + } + key := r.URL.Query().Get("key") + if key == "" { + writeErr(w, http.StatusBadRequest, "key is required") + return + } + if err := a.Store.DeleteCustomRecord(r.Context(), did, pid, key); err != nil { + log.Printf("api: delete custom record failed: %v", err) + writeErr(w, http.StatusNotFound, "domain not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + // handleZoneRecords reads a zone's current records straight from the // provider — no template required, no diff computed. Backs read-only zone // viewing for domains that don't have a template attached (yet). diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index 4f613b0..1db85a6 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -10,7 +10,10 @@ import ( "strings" "testing" + "net/url" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" @@ -49,6 +52,23 @@ type mockTenantStore struct { status string } setDomainStatusErr error + + // addCustomRecordCalls/deleteCustomRecordCalls record every + // Add/DeleteCustomRecord(domainID, projectID, key) call, in order — tests + // assert both the scoping pair and the key the handler forwarded. + addCustomRecordCalls []struct { + domainID uuid.UUID + projectID uuid.UUID + key string + } + addCustomRecordErr error + + deleteCustomRecordCalls []struct { + domainID uuid.UUID + projectID uuid.UUID + key string + } + deleteCustomRecordErr error } func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) { @@ -148,6 +168,24 @@ func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID, projectID return m.setDomainStatusErr } +func (m *mockTenantStore) AddCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error { + m.addCustomRecordCalls = append(m.addCustomRecordCalls, struct { + domainID uuid.UUID + projectID uuid.UUID + key string + }{domainID, projectID, key}) + return m.addCustomRecordErr +} + +func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error { + m.deleteCustomRecordCalls = append(m.deleteCustomRecordCalls, struct { + domainID uuid.UUID + projectID uuid.UUID + key string + }{domainID, projectID, key}) + return m.deleteCustomRecordErr +} + func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) { m.importCalled = true if m.importDomainsErr != nil { @@ -620,6 +658,104 @@ func TestSetDomainTemplate_TemplateNotFound(t *testing.T) { } } +// --- custom records (Task 4: mark/unmark endpoints) --- + +// TestAddCustomRecordReturns201 covers the mark-as-custom endpoint: the key +// travels in the JSON body (it contains a space and dots, e.g. +// "CNAME admin.example.com.", so it can't be a path segment), and the +// handler must scope the store call by (domainID, projectID) exactly as +// received — never trusting the domain id alone. +func TestAddCustomRecordReturns201(t *testing.T) { + a, ts := newTenantTestAPI() + router := NewRouter(a) + + did := uuid.New() + body := `{"key":"CNAME admin.example.com."}` + req := requestWithSessionCookie(http.MethodPost, + "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + if len(ts.addCustomRecordCalls) != 1 { + t.Fatalf("expected 1 AddCustomRecord call, got %d", len(ts.addCustomRecordCalls)) + } + call := ts.addCustomRecordCalls[0] + wantPID := uuid.MustParse(testPID) + if call.domainID != did || call.projectID != wantPID { + t.Fatalf("handler must scope by (domain, project), got (%s, %s)", call.domainID, call.projectID) + } + if call.key != "CNAME admin.example.com." { + t.Fatalf("unexpected key %q", call.key) + } +} + +// TestAddCustomRecordRejectsEmptyKey covers input validation: an empty key +// must be rejected with 400 before the store is ever called. +func TestAddCustomRecordRejectsEmptyKey(t *testing.T) { + a, ts := newTenantTestAPI() + router := NewRouter(a) + + did := uuid.New() + body := `{"key":""}` + req := requestWithSessionCookie(http.MethodPost, + "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on empty key, got %d", w.Code) + } + if len(ts.addCustomRecordCalls) != 0 { + t.Fatal("store must not be called with an empty key") + } +} + +// TestDeleteCustomRecordReturns204 covers the unmark endpoint: the key +// travels as a URL-encoded query parameter and must reach the store +// url-decoded. +func TestDeleteCustomRecordReturns204(t *testing.T) { + a, ts := newTenantTestAPI() + router := NewRouter(a) + + did := uuid.New() + req := requestWithSessionCookie(http.MethodDelete, + "/api/v1/projects/"+testPID+"/domains/"+did.String()+ + "/customs?key="+url.QueryEscape("CNAME admin.example.com."), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) + } + if len(ts.deleteCustomRecordCalls) != 1 || ts.deleteCustomRecordCalls[0].key != "CNAME admin.example.com." { + t.Fatalf("key must be url-decoded, got %+v", ts.deleteCustomRecordCalls) + } +} + +// TestAddCustomRecordForeignDomainReturns404 covers the IDOR-on-write case: +// a domain that doesn't belong to the caller's project is indistinguishable +// from a missing one from this tenant's point of view, so the store's +// pgx.ErrNoRows must surface as 404, not 500. +func TestAddCustomRecordForeignDomainReturns404(t *testing.T) { + a, ts := newTenantTestAPI() + ts.addCustomRecordErr = pgx.ErrNoRows + router := NewRouter(a) + + did := uuid.New() + body := `{"key":"CNAME admin.example.com."}` + req := requestWithSessionCookie(http.MethodPost, + "/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body)) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a domain outside the project, got %d", w.Code) + } +} + func TestDeleteDomain_BadUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a)