package api import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/uuid" "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/store" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) const testPID = "00000000-0000-0000-0000-000000000002" // --- mocks --- type mockTenantStore struct { accounts []store.Account createAccounts []struct{ provider, secretEnc, comment string } templates []store.Template createTemplate *store.Template domains []store.Domain createDomains int importDomains []store.Domain importDomainsErr error importCalled bool setDomainTemplateErr error } func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) { m.createAccounts = append(m.createAccounts, struct{ provider, secretEnc, comment string }{prov, secretEnc, comment}) acc := store.Account{ID: uuid.New(), ProjectID: projectID, Provider: prov, SecretEnc: secretEnc, Comment: comment} m.accounts = append(m.accounts, acc) return acc, nil } func (m *mockTenantStore) ListAccounts(context.Context, uuid.UUID) ([]store.Account, error) { return m.accounts, nil } func (m *mockTenantStore) GetAccount(_ context.Context, id, _ uuid.UUID) (store.Account, error) { for _, a := range m.accounts { if a.ID == id { return a, nil } } return store.Account{}, errors.New("account not found") } func (m *mockTenantStore) DeleteAccount(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (m *mockTenantStore) CreateTemplate(_ context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) { tpl := store.Template{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: doc, Version: 1} m.createTemplate = &tpl m.templates = append(m.templates, tpl) return tpl, nil } func (m *mockTenantStore) ListTemplates(context.Context, uuid.UUID) ([]store.Template, error) { return m.templates, nil } func (m *mockTenantStore) UpdateTemplate(_ context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) { return store.Template{ID: id, ProjectID: projectID, Name: name, Doc: doc, Version: 2}, nil } func (m *mockTenantStore) DeleteTemplate(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (m *mockTenantStore) GetTemplate(_ context.Context, id, _ uuid.UUID) (store.Template, error) { for _, t := range m.templates { if t.ID == id { return t, nil } } return store.Template{}, errors.New("template not found") } func (m *mockTenantStore) CreateDomain(_ context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error) { m.createDomains++ d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID} m.domains = append(m.domains, d) return d, nil } func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domain, error) { return m.domains, nil } func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) { if m.setDomainTemplateErr != nil { return store.Domain{}, m.setDomainTemplateErr } for i, d := range m.domains { if d.ID == domainID { m.domains[i].TemplateID = templateID return m.domains[i], nil } } d := store.Domain{ID: domainID, ProjectID: projectID, TemplateID: templateID} m.domains = append(m.domains, d) return d, nil } func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) { m.importCalled = true if m.importDomainsErr != nil { return nil, m.importDomainsErr } out := make([]store.Domain, 0, len(zones)) for _, z := range zones { d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: z.Name, ZoneID: z.ID} out = append(out, d) } m.domains = append(m.domains, out...) m.importDomains = out return out, nil } type mockCipher struct{} func (mockCipher) Encrypt(plaintext []byte) (string, error) { return "ENC(" + string(plaintext) + ")", nil } func (mockCipher) Decrypt(enc string) ([]byte, error) { return []byte(strings.TrimSuffix(strings.TrimPrefix(enc, "ENC("), ")")), nil } type mockRegistry struct { zones []provider.Zone validateErr error } func (r *mockRegistry) ByName(name string) (provider.Provider, error) { return &mockProvider{zones: r.zones, validateErr: r.validateErr}, nil } type mockProvider struct { zones []provider.Zone // validateErr, when set, makes Validate reject the credentials — lets // tests exercise the 400-before-save path of handleCreateAccount. validateErr error } func (mockProvider) Name() string { return "mock" } func (p mockProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) { return p.zones, nil } func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) { return nil, nil } func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error { return nil } func (p mockProvider) Validate(context.Context, provider.Credentials) error { return p.validateErr } // newTenantTestAPI wires a fixed authenticated user who owns whatever // project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see // middleware_test.go) — these tests exercise CRUD behavior past the // RequireAuth/RequireProjectAccess boundary, which has its own dedicated // coverage in middleware_test.go. func newTenantTestAPI() (*API, *mockTenantStore) { ts := &mockTenantStore{} a := &API{ Store: ts, Cipher: mockCipher{}, Reg: &mockRegistry{}, Auth: alwaysOwnedAuthStore(), Sessions: alwaysValidSessions(uuid.New()), } return a, ts } // --- accounts --- // TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates covers the // happy path of the structured-secret contract: secret is a provider-specific // JSON object, Validate accepts it, and the *raw* JSON (not a re-serialized // or unwrapped form) is what gets encrypted and handed to the store. func TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates(t *testing.T) { a, ts := newTenantTestAPI() a.Reg = &mockRegistry{} router := NewRouter(a) body := `{"provider":"selectel","secret":{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"},"comment":"prod"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), "super-secret-token") { t.Fatalf("response leaks plaintext secret: %s", w.Body.String()) } if len(ts.createAccounts) != 1 { t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts)) } got := ts.createAccounts[0].secretEnc if got == `{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"}` { t.Fatalf("store received plaintext secret instead of encrypted value") } wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})` if got != wantEnc { t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc) } var resp accountResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Provider != "selectel" || resp.Comment != "prod" { t.Fatalf("unexpected response: %+v", resp) } } // TestCreateAccount_InvalidCredentials_Returns400BeforeSave covers the // trial-auth gate: when the provider rejects the credentials, the handler // must reject with 400 and a generic message, and must never reach // Store.CreateAccount (so no bad account is persisted). func TestCreateAccount_InvalidCredentials_Returns400BeforeSave(t *testing.T) { a, ts := newTenantTestAPI() a.Reg = &mockRegistry{validateErr: errors.New("identity: invalid password")} router := NewRouter(a) body := `{"provider":"selectel","secret":{"username":"u","password":"wrong","account_id":"123","project_name":"proj"},"comment":"prod"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String()) } if len(ts.createAccounts) != 0 { t.Fatalf("expected CreateAccount not to be called, got %d calls", len(ts.createAccounts)) } if strings.Contains(w.Body.String(), "wrong") || strings.Contains(w.Body.String(), "invalid password") { t.Fatalf("response leaks credential/provider error detail: %s", w.Body.String()) } } func TestListAccounts_NoSecretsInResponse(t *testing.T) { a, ts := newTenantTestAPI() ts.accounts = []store.Account{ {ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(top-secret)", Comment: "one"}, {ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(other-secret)", Comment: "two"}, } router := NewRouter(a) req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/accounts", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), "secret") { t.Fatalf("response leaks secret field: %s", w.Body.String()) } var resp []accountResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp) != 2 { t.Fatalf("expected 2 accounts, got %d", len(resp)) } } func TestDeleteAccount_BadUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } // --- templates --- func TestCreateTemplate_SavesRecords(t *testing.T) { a, ts := newTenantTestAPI() router := NewRouter(a) body := `{"name":"base","records":[{"type":"A","name":"@","ttl":300,"values":["1.2.3.4"]}]}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/templates", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } if ts.createTemplate == nil { t.Fatal("expected CreateTemplate to be called") } if len(ts.createTemplate.Doc.Records) != 1 || ts.createTemplate.Doc.Records[0].Type != "A" { t.Fatalf("unexpected saved doc: %+v", ts.createTemplate.Doc) } var resp templateResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Name != "base" || len(resp.Records) != 1 { t.Fatalf("unexpected response: %+v", resp) } } func TestUpdateTemplate_BadUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) body := `{"name":"x","records":[]}` req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/templates/not-a-uuid", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } // --- domains / import --- func TestImportZones_CreatesDomainPerZone(t *testing.T) { a, ts := newTenantTestAPI() accID := uuid.New() ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} a.Reg = &mockRegistry{zones: []provider.Zone{ {ID: "z1", Name: "example.com"}, {ID: "z2", Name: "example.net"}, }} router := NewRouter(a) req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("status %d body %s", w.Code, w.Body.String()) } if !ts.importCalled { t.Fatal("expected ImportDomains to be called") } if len(ts.importDomains) != 2 { t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains)) } var resp []domainResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp) != 2 { t.Fatalf("expected 2 domains in response, got %d", len(resp)) } } // TestImportZones_AtomicRollbackOnError verifies that when the store fails // to import the batch (e.g. a mid-batch DB error), the handler surfaces a // 500 and — per store.ImportDomains' transactional contract — no partial // set of domains is left behind (modeled here by ImportDomains returning no // domains alongside the error). func TestImportZones_AtomicRollbackOnError(t *testing.T) { a, ts := newTenantTestAPI() accID := uuid.New() ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} ts.importDomainsErr = errors.New("boom: mid-batch failure") a.Reg = &mockRegistry{zones: []provider.Zone{ {ID: "z1", Name: "example.com"}, {ID: "z2", Name: "example.net"}, }} router := NewRouter(a) req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), "boom") { t.Fatalf("internal error details leaked to response: %s", w.Body.String()) } if len(ts.domains) != 0 { t.Fatalf("expected no domains to be created on rollback, got %d", len(ts.domains)) } } func TestImportZones_BadAccountUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid/import", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCreateDomain_BadProjectUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) body := `{"providerAccountId":"` + uuid.New().String() + `","zoneName":"example.com","zoneId":"z1"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/not-a-uuid/domains", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } // TestCreateDomain_AccountNotFoundInProject covers the HIGH tenant-isolation // fix: a providerAccountId that scoped GetAccount can't find within this // project must be rejected before any domain is created — otherwise a // caller could attach a domain to another tenant's provider account. func TestCreateDomain_AccountNotFoundInProject(t *testing.T) { a, ts := newTenantTestAPI() router := NewRouter(a) // ts.accounts is empty, so GetAccount will not find this id. foreignAccID := uuid.New() body := `{"providerAccountId":"` + foreignAccID.String() + `","zoneName":"example.com","zoneId":"z1"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body)) 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()) } if ts.createDomains != 0 { t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains) } } // TestCreateDomain_TemplateNotFoundInProject covers the same isolation fix // for the optional templateId: a template belonging to another project (or // nonexistent) must reject the request before the domain is created. func TestCreateDomain_TemplateNotFoundInProject(t *testing.T) { a, ts := newTenantTestAPI() accID := uuid.New() ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} router := NewRouter(a) foreignTplID := uuid.New() body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + foreignTplID.String() + `"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body)) 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()) } if ts.createDomains != 0 { t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains) } } // TestCreateDomain_HappyPath ensures the tenant-isolation checks don't break // the existing success path: a valid account in-project and no template. func TestCreateDomain_HappyPath(t *testing.T) { a, ts := newTenantTestAPI() accID := uuid.New() ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} router := NewRouter(a) body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String()) } if ts.createDomains != 1 { t.Fatalf("expected 1 CreateDomain call, got %d", ts.createDomains) } var resp domainResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.ZoneName != "example.com" || resp.TemplateID != nil { t.Fatalf("unexpected response: %+v", resp) } } // TestCreateDomain_ValidTemplateInProject ensures a template that scoped // GetTemplate does find (i.e. belongs to this project) is accepted. func TestCreateDomain_ValidTemplateInProject(t *testing.T) { a, ts := newTenantTestAPI() accID := uuid.New() tplID := uuid.New() ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}} ts.templates = []store.Template{{ID: tplID, Name: "base"}} router := NewRouter(a) body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + tplID.String() + `"}` req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String()) } var resp domainResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.TemplateID == nil || *resp.TemplateID != tplID.String() { t.Fatalf("unexpected response: %+v", resp) } } // --- domain template binding (import -> check loop) --- func TestSetDomainTemplate_ValidTemplateId(t *testing.T) { a, ts := newTenantTestAPI() domID := uuid.New() tplID := uuid.New() ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}} router := NewRouter(a) body := `{"templateId":"` + tplID.String() + `"}` req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body %s", w.Code, w.Body.String()) } var resp domainResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.TemplateID == nil || *resp.TemplateID != tplID.String() { t.Fatalf("unexpected response: %+v", resp) } } func TestSetDomainTemplate_BadTemplateUUID(t *testing.T) { a, ts := newTenantTestAPI() domID := uuid.New() ts.domains = []store.Domain{{ID: domID}} router := NewRouter(a) body := `{"templateId":"not-a-uuid"}` req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body)) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String()) } } func TestSetDomainTemplate_TemplateNotFound(t *testing.T) { a, ts := newTenantTestAPI() domID := uuid.New() ts.domains = []store.Domain{{ID: domID}} ts.setDomainTemplateErr = errors.New("template not found in project") router := NewRouter(a) body := `{"templateId":"` + uuid.New().String() + `"}` req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body)) 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()) } } func TestDeleteDomain_BadUUID(t *testing.T) { a, _ := newTenantTestAPI() router := NewRouter(a) req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/domains/not-a-uuid", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } }