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
+136
View File
@@ -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)