feat(api): endpoints to mark and unmark custom records

This commit is contained in:
2026-08-19 18:10:46 +07:00
parent 208e73980d
commit da267b3cef
5 changed files with 211 additions and 2 deletions
+8
View File
@@ -55,6 +55,12 @@ type TenantStore interface {
// "unknown" until the scheduler's next tick. Scoped by projectID so a // "unknown" until the scheduler's next tick. Scoped by projectID so a
// foreign domain ID can never have its status overwritten (IDOR-on-write). // foreign domain ID can never have its status overwritten (IDOR-on-write).
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error 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. // 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("/history", a.handleDomainHistory)
r.Get("/records", a.handleZoneRecords) r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone) r.Post("/template-from-zone", a.handleTemplateFromZone)
r.Post("/customs", a.handleAddCustom)
r.Delete("/customs", a.handleDeleteCustom)
}) })
}) })
+1 -1
View File
@@ -382,7 +382,7 @@ func TestChangesetResponseEmptyMarshalsToArrays(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
s := string(b) s := string(b)
for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"readOnly":[]`} { for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"customs":[]`, `"readOnly":[]`} {
if !strings.Contains(s, want) { if !strings.Contains(s, want) {
t.Fatalf("expected %s in %s", want, s) t.Fatalf("expected %s in %s", want, s)
} }
+14 -1
View File
@@ -44,6 +44,13 @@ type applyRequest struct {
Prunes []string `json:"prunes"` 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 { type recordView struct {
Key string `json:"key"` Key string `json:"key"`
Kind string `json:"kind"` Kind string `json:"kind"`
@@ -52,17 +59,19 @@ type recordView struct {
Desired []string `json:"desired,omitempty"` Desired []string `json:"desired,omitempty"`
Actual []string `json:"actual,omitempty"` Actual []string `json:"actual,omitempty"`
ReadOnly bool `json:"readOnly"` ReadOnly bool `json:"readOnly"`
Custom bool `json:"custom"`
} }
type changesetResponse struct { type changesetResponse struct {
Updates []recordView `json:"updates"` Updates []recordView `json:"updates"`
Prunes []recordView `json:"prunes"` Prunes []recordView `json:"prunes"`
Customs []recordView `json:"customs"`
ReadOnly []recordView `json:"readOnly"` ReadOnly []recordView `json:"readOnly"`
InSync int `json:"inSyncCount"` InSync int `json:"inSyncCount"`
} }
func toRecordView(d diff.RecordDiff) recordView { 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 { if d.Desired != nil {
rv.Desired = d.Desired.Values rv.Desired = d.Desired.Values
} }
@@ -80,6 +89,7 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
resp := changesetResponse{ resp := changesetResponse{
Updates: []recordView{}, Updates: []recordView{},
Prunes: []recordView{}, Prunes: []recordView{},
Customs: []recordView{},
ReadOnly: []recordView{}, ReadOnly: []recordView{},
} }
for _, d := range cs.Updates() { for _, d := range cs.Updates() {
@@ -88,6 +98,9 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
for _, d := range cs.Prunes() { for _, d := range cs.Prunes() {
resp.Prunes = append(resp.Prunes, toRecordView(d)) resp.Prunes = append(resp.Prunes, toRecordView(d))
} }
for _, d := range cs.Customs() {
resp.Customs = append(resp.Customs, toRecordView(d))
}
for _, d := range cs.Diffs { for _, d := range cs.Diffs {
if d.ReadOnly { if d.ReadOnly {
resp.ReadOnly = append(resp.ReadOnly, toRecordView(d)) resp.ReadOnly = append(resp.ReadOnly, toRecordView(d))
+52
View File
@@ -98,6 +98,58 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, toChangesetResponse(cs)) 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 // handleZoneRecords reads a zone's current records straight from the
// provider — no template required, no diff computed. Backs read-only zone // provider — no template required, no diff computed. Backs read-only zone
// viewing for domains that don't have a template attached (yet). // viewing for domains that don't have a template attached (yet).
+136
View File
@@ -10,7 +10,10 @@ import (
"strings" "strings"
"testing" "testing"
"net/url"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/model"
@@ -49,6 +52,23 @@ type mockTenantStore struct {
status string status string
} }
setDomainStatusErr error 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) { 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 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) { func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
m.importCalled = true m.importCalled = true
if m.importDomainsErr != nil { 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) { func TestDeleteDomain_BadUUID(t *testing.T) {
a, _ := newTenantTestAPI() a, _ := newTenantTestAPI()
router := NewRouter(a) router := NewRouter(a)