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
|
// "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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user