diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 9088407..7e75e23 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -86,7 +86,11 @@ func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) { recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) if err != nil { log.Printf("api: zone records failed: %v", err) - writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + if errors.Is(err, service.ErrProviderUnavailable) { + writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + } else { + writeErr(w, http.StatusNotFound, "домен не найден") + } return } doc := dto.FromModel(recs) @@ -107,13 +111,17 @@ func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) { dom, err := a.Store.GetDomain(r.Context(), did, pid) if err != nil { log.Printf("api: template-from-zone: get domain failed: %v", err) - writeErr(w, http.StatusInternalServerError, "internal error") + writeErr(w, http.StatusNotFound, "домен не найден") return } recs, err := a.Svc.ZoneRecords(r.Context(), pid, did) if err != nil { log.Printf("api: template-from-zone: zone records failed: %v", err) - writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + if errors.Is(err, service.ErrProviderUnavailable) { + writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера") + } else { + writeErr(w, http.StatusNotFound, "домен не найден") + } return } // Snapshot only managed records — NS/SOA are read-only and never templated. diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go index a54778c..9a82bc2 100644 --- a/internal/api/tenant_test.go +++ b/internal/api/tenant_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -14,6 +15,7 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/provider" + "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) @@ -688,13 +690,13 @@ func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) { } // TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path: -// a GetRecords error from the provider must surface as 502 (bad gateway), -// not a generic 500 or a hung response. +// an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself +// failed) must surface as 502 (bad gateway), not a generic 500 or 404. func TestZoneRecords_ProviderErrorReturns502(t *testing.T) { a, ts := newTenantTestAPI() domID := uuid.New() ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} - a.Svc = &mockCheckApplier{zoneErr: errors.New("provider unreachable")} + a.Svc = &mockCheckApplier{zoneErr: fmt.Errorf("%w: boom", service.ErrProviderUnavailable)} router := NewRouter(a) req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil) @@ -705,3 +707,23 @@ func TestZoneRecords_ProviderErrorReturns502(t *testing.T) { t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String()) } } + +// TestZoneRecords_NotFoundReturns404 covers the domain-resolution-failure +// path: an error that does NOT wrap service.ErrProviderUnavailable (e.g. the +// domain doesn't exist in this project) must surface as 404, not 502 — the +// provider was never even reached. +func TestZoneRecords_NotFoundReturns404(t *testing.T) { + a, ts := newTenantTestAPI() + domID := uuid.New() + ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} + a.Svc = &mockCheckApplier{zoneErr: errors.New("not found")} + router := NewRouter(a) + + req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String()) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index db8efaa..c16e0fd 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -2,6 +2,8 @@ package service import ( "context" + "errors" + "fmt" "github.com/google/uuid" @@ -13,6 +15,13 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) +// ErrProviderUnavailable marks failures that happened while talking to the +// DNS provider itself (network, auth, rate limit, ...), as opposed to +// failures resolving the domain/zone locally (not found, bad credentials +// stored, unknown provider name). Callers use errors.Is against this to +// pick 502 vs 404 without leaking provider error details as "not found". +var ErrProviderUnavailable = errors.New("service: provider unavailable") + // DomainRef is the minimal data the service needs about a domain. type DomainRef struct { ZoneID string @@ -107,7 +116,14 @@ func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uui if err != nil { return nil, err } - return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID) + recs, err := p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID) + if err != nil { + // Only a failure of the provider call itself is "provider unavailable" — + // LoadZone/ByName/Decrypt errors above are local resolution failures + // (e.g. domain not found) and must not be conflated with it. + return nil, fmt.Errorf("%w: %v", ErrProviderUnavailable, err) + } + return recs, nil } // Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.