feat(api): endpoints to mark and unmark custom records
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
-1
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user