Merge remote-tracking branch 'origin/main' into refactor/permissions-manager

This commit is contained in:
pascal
2026-09-14 13:33:20 +02:00
130 changed files with 4707 additions and 560 deletions
@@ -1,5 +1,13 @@
package domain
import "time"
// ValidationTTL is the time available to validate a custom domain registration.
const ValidationTTL = 48 * time.Hour
// ID identifies a custom domain registration.
type ID string
type Type string
const (
@@ -8,12 +16,13 @@ const (
)
type Domain struct {
ID string `gorm:"unique;primaryKey;autoIncrement"`
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
AccountID string `gorm:"index"`
TargetCluster string // The proxy cluster this domain should be validated against
Type Type `gorm:"-"`
Validated bool
ID string `gorm:"unique;primaryKey;autoIncrement"`
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
AccountID string `gorm:"index"`
TargetCluster string // The proxy cluster this domain should be validated against
Type Type `gorm:"-"`
Validated bool
ValidationExpiresAt *time.Time `gorm:"index"`
// SupportsCustomPorts is populated at query time for free domains from the
// proxy cluster capabilities. Not persisted.
SupportsCustomPorts *bool `gorm:"-"`
@@ -36,7 +45,12 @@ func (d *Domain) EventMeta() map[string]any {
}
}
// Copy returns a copy with an independent validation deadline.
func (d *Domain) Copy() *Domain {
dCopy := *d
if d.ValidationExpiresAt != nil {
expiresAt := *d.ValidationExpiresAt
dCopy.ValidationExpiresAt = &expiresAt
}
return &dCopy
}
@@ -0,0 +1,73 @@
package manager
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
"github.com/netbirdio/netbird/management/server/activity"
)
const (
validationCleanupInterval = 60 * time.Minute
validationCleanupBatch = 100
)
// RunValidationCleanup removes expired registrations on startup and hourly until cancellation.
func (m Manager) RunValidationCleanup(ctx context.Context) {
ticker := time.NewTicker(validationCleanupInterval)
defer ticker.Stop()
for {
m.cleanupExpiredDomains(ctx, time.Now().UTC())
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (m Manager) cleanupExpiredDomains(ctx context.Context, now time.Time) {
var afterID domain.ID
for ctx.Err() == nil {
domains, err := m.store.GetExpiredCustomDomains(ctx, now, afterID, validationCleanupBatch)
if err != nil {
if ctx.Err() == nil {
log.WithContext(ctx).WithError(err).Error("list expired custom domain registrations")
}
return
}
for _, d := range domains {
if ctx.Err() != nil {
return
}
m.deleteExpiredDomain(ctx, d, now)
afterID = domain.ID(d.ID)
}
if len(domains) < validationCleanupBatch {
return
}
}
}
func (m Manager) deleteExpiredDomain(ctx context.Context, d *domain.Domain, now time.Time) {
deleted, err := m.store.DeleteExpiredCustomDomain(ctx, d, now)
if err != nil {
if ctx.Err() == nil {
log.WithContext(ctx).WithFields(log.Fields{"accountID": d.AccountID, "domainID": d.ID}).
WithError(err).Warn("could not expire custom domain registration")
}
return
}
if !deleted {
return
}
meta := d.EventMeta()
if d.ValidationExpiresAt != nil {
meta["validation_expires_at"] = d.ValidationExpiresAt.UTC().Format(time.RFC3339)
}
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID,
activity.CustomDomainValidationExpired, meta)
}
@@ -0,0 +1,274 @@
package manager
import (
"context"
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/mock_server"
nbstore "github.com/netbirdio/netbird/management/server/store"
)
func TestValidateDomain_ExpiredRegistration(t *testing.T) {
env := setupDomainTest(t)
ctx := context.Background()
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "expired.example.com", testCluster)
require.NoError(t, err)
expiresAt := time.Now().Add(-time.Second)
db := env.store.(*nbstore.SqlStore).GetDB()
require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).
Update("validation_expires_at", expiresAt).Error)
env.resolver.set("validation.expired.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored)
assert.False(t, stored.Validated, "an expired registration must not become usable before cleanup runs")
}
func TestCreateDomain_ValidationDeadline(t *testing.T) {
env := setupClockDomainTest(t)
synctest.Test(t, func(t *testing.T) {
ctx := context.Background()
createdAt := time.Now().UTC()
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "pending.example.com", testCluster)
require.NoError(t, err)
require.NotNil(t, d.ValidationExpiresAt)
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
time.Sleep(time.Hour)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
stored := storedDomain(t, env.store, accountA, d.Domain)
require.NotNil(t, stored)
require.NotNil(t, stored.ValidationExpiresAt)
assert.WithinDuration(t, *d.ValidationExpiresAt, *stored.ValidationExpiresAt, 0, "failed validation must not extend the deadline")
})
}
func TestCleanupExpiredDomains_Boundaries(t *testing.T) {
env := setupDomainTest(t)
events := captureDomainEvents(env)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
tests := []struct {
name string
expiresAt time.Time
validated bool
deleted bool
}{
{"expired", now.Add(-time.Second), false, true},
{"deadline", now, false, true},
{"pending", now.Add(time.Second), false, false},
{"validated", now.Add(-time.Hour), true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := createExpiringDomain(t, env, tt.name+".example.com", tt.expiresAt)
if tt.validated {
require.NoError(t, env.store.(*nbstore.SqlStore).GetDB().Model(d).Update("validated", true).Error)
}
env.manager.cleanupExpiredDomains(ctx, now)
stored := storedDomain(t, env.store, accountA, d.Domain)
if !tt.deleted {
assert.NotNil(t, stored, "pending and validated registrations must survive cleanup")
return
}
assert.Nil(t, stored, "expired unused registrations must be removed")
replacement, err := env.manager.CreateDomain(ctx, accountB, accountBUser, d.Domain, testCluster)
require.NoError(t, err)
assert.NotEqual(t, d.ID, replacement.ID, "the released name must receive a fresh registration")
assert.False(t, replacement.Validated, "the new account must validate its own registration")
})
}
got := events.get()
require.Len(t, got, 2, "only successful expiration deletions emit events")
for _, event := range got {
assert.Equal(t, activity.CustomDomainValidationExpired, event.Activity, "use the requested expiration event")
assert.Equal(t, activity.SystemInitiator, event.InitiatorID, "cleanup is attributed to the system")
assert.Equal(t, accountA, event.AccountID, "expiration belongs to the original account")
assert.NotEmpty(t, event.TargetID, "retain the deleted domain ID")
assert.NotEmpty(t, event.Meta["domain"], "retain the deleted domain name")
assert.NotEmpty(t, event.Meta["validation_expires_at"], "include the validation deadline")
}
}
func TestCleanupExpiredDomains_ContinuesPastProtectedBatch(t *testing.T) {
env := setupDomainTest(t)
ctx := context.Background()
now := time.Now().UTC()
for i := range validationCleanupBatch {
d := createExpiringDomain(t, env, fmt.Sprintf("protected-%d.example.com", i), now.Add(-time.Hour))
require.NoError(t, env.store.CreateService(ctx, &rpservice.Service{
ID: fmt.Sprintf("service-%d", i), AccountID: accountA, Domain: "app." + d.Domain,
}))
}
unprotected := createExpiringDomain(t, env, "unused.example.com", now.Add(-time.Hour))
env.manager.cleanupExpiredDomains(ctx, now)
assert.Nil(t, storedDomain(t, env.store, accountA, unprotected.Domain), "protected registrations must not starve later batches")
remaining, err := env.store.ListCustomDomains(ctx, accountA)
require.NoError(t, err)
assert.Len(t, remaining, validationCleanupBatch, "all registrations with dependent services must survive")
}
func TestCleanupExpiredDomains_ConcurrentWorkers(t *testing.T) {
env := setupDomainTest(t)
events := captureDomainEvents(env)
now := time.Now().UTC()
d := createExpiringDomain(t, env, "concurrent.example.com", now.Add(-time.Hour))
var workers sync.WaitGroup
for range 2 {
workers.Go(func() { env.manager.cleanupExpiredDomains(context.Background(), now) })
}
workers.Wait()
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "one worker must remove the expired registration")
assert.Len(t, events.get(), 1, "only the worker that deletes the row may emit the event")
}
func TestRunValidationCleanup_HourlyAndRestart(t *testing.T) {
env := setupClockDomainTest(t)
synctest.Test(t, func(t *testing.T) {
events := captureDomainEvents(env)
now := time.Now().UTC()
startup := createExpiringDomain(t, env, "startup.example.com", now.Add(-time.Hour))
hourly := createExpiringDomain(t, env, "hourly.example.com", now.Add(time.Minute))
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
env.manager.RunValidationCleanup(ctx)
}()
synctest.Wait()
assert.Nil(t, storedDomain(t, env.store, accountA, startup.Domain), "startup must collect overdue registrations")
time.Sleep(59 * time.Minute)
synctest.Wait()
assert.NotNil(t, storedDomain(t, env.store, accountA, hourly.Domain), "cleanup must wait for the 60-minute interval")
time.Sleep(time.Minute)
synctest.Wait()
assert.Nil(t, storedDomain(t, env.store, accountA, hourly.Domain), "the hourly scan must collect expired registrations")
cancel()
<-done
offline := createExpiringDomain(t, env, "offline.example.com", time.Now().UTC().Add(time.Minute))
time.Sleep(2 * time.Hour)
assert.NotNil(t, storedDomain(t, env.store, accountA, offline.Domain), "a stopped worker must not continue deleting")
ctx, cancel = context.WithCancel(context.Background())
done = make(chan struct{})
go func() {
defer close(done)
env.manager.RunValidationCleanup(ctx)
}()
synctest.Wait()
assert.Nil(t, storedDomain(t, env.store, accountA, offline.Domain), "restart must use the persisted deadline")
cancel()
<-done
assert.Len(t, events.get(), 3, "each deletion should emit an expiration event")
})
}
type blockingDomainResolver struct {
started chan struct{}
release chan struct{}
}
func (r blockingDomainResolver) LookupCNAME(context.Context, string) (string, error) {
close(r.started)
<-r.release
return testCluster + ".", nil
}
func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
for _, cleanup := range []bool{false, true} {
t.Run(fmt.Sprintf("cleanup=%t", cleanup), func(t *testing.T) {
env := setupClockDomainTest(t)
synctest.Test(t, func(t *testing.T) {
events := captureDomainEvents(env)
ctx := context.Background()
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "late.example.com", testCluster)
require.NoError(t, err)
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
env.manager.validator.Resolver = resolver
done := make(chan struct{})
go func() {
defer close(done)
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
}()
<-resolver.started
time.Sleep(48 * time.Hour)
if cleanup {
env.manager.cleanupExpiredDomains(ctx, time.Now().UTC())
_, err = env.store.CreateCustomDomain(ctx, accountB, d.Domain, testCluster, false)
require.NoError(t, err)
}
close(resolver.release)
<-done
owner := accountA
if cleanup {
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
owner = accountB
}
stored := storedDomain(t, env.store, owner, d.Domain)
require.NotNil(t, stored)
assert.False(t, stored.Validated, "late validation must not validate either claim")
for _, event := range events.get() {
assert.NotEqual(t, activity.DomainValidated, event.Activity, "a rejected write must not emit a validation event")
}
})
})
}
}
func setupClockDomainTest(t *testing.T) *domainTestEnv {
t.Helper()
// Network driver watchers cannot share cancellation channels across synctest bubbles.
// Store boundary and concurrency tests still exercise the selected database engine.
t.Setenv("NETBIRD_STORE_ENGINE", "sqlite")
return setupDomainTest(t)
}
func createExpiringDomain(t *testing.T, env *domainTestEnv, name string, expiresAt time.Time) *domain.Domain {
t.Helper()
d, err := env.store.CreateCustomDomain(context.Background(), accountA, name, testCluster, false)
require.NoError(t, err)
require.NoError(t, env.store.(*nbstore.SqlStore).GetDB().Model(d).Update("validation_expires_at", expiresAt).Error)
d.ValidationExpiresAt = &expiresAt
return d
}
type domainEvents struct {
mu sync.Mutex
events []*activity.Event
}
func captureDomainEvents(env *domainTestEnv) *domainEvents {
events := &domainEvents{}
env.manager.accountManager = &mock_server.MockAccountManager{
StoreEventFunc: func(_ context.Context, initiator, target, account string, code activity.ActivityDescriber, meta map[string]any) {
if code == activity.DomainAdded {
return
}
events.mu.Lock()
defer events.mu.Unlock()
events.events = append(events.events, &activity.Event{
InitiatorID: initiator, TargetID: target, AccountID: account,
Activity: code.(activity.Activity), Meta: meta,
})
},
}
return events
}
func (e *domainEvents) get() []*activity.Event {
e.mu.Lock()
defer e.mu.Unlock()
return append([]*activity.Event(nil), e.events...)
}
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"strings"
"time"
log "github.com/sirupsen/logrus"
@@ -15,6 +16,7 @@ import (
"github.com/netbirdio/netbird/management/server/activity"
nbstore "github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -29,6 +31,8 @@ type store interface {
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
GetExpiredCustomDomains(ctx context.Context, now time.Time, afterID domain.ID, limit int) ([]*domain.Domain, error)
DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error)
}
type proxyManager interface {
@@ -93,12 +97,13 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
// Add custom domains.
for _, d := range domains {
cd := &domain.Domain{
ID: d.ID,
Domain: d.Domain,
AccountID: accountID,
TargetCluster: d.TargetCluster,
Type: domain.TypeCustom,
Validated: d.Validated,
ID: d.ID,
Domain: d.Domain,
AccountID: accountID,
TargetCluster: d.TargetCluster,
Type: domain.TypeCustom,
Validated: d.Validated,
ValidationExpiresAt: d.ValidationExpiresAt,
}
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
@@ -113,7 +118,17 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
return ret, nil
}
// CreateDomain registers a normalized custom domain and attempts DNS validation.
func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) {
parsed, err := nbdomain.FromString(strings.TrimSuffix(domainName, "."))
if err != nil {
return nil, status.Errorf(status.InvalidArgument, "invalid domain: %v", err)
}
domainName = parsed.PunycodeString()
if !nbdomain.IsValidDomainNoWildcard(domainName) {
return nil, status.Errorf(status.InvalidArgument, "invalid domain format")
}
// Verify the target cluster is in the available clusters for this account
allowList, err := m.getClusterAllowList(ctx, accountID)
if err != nil {
@@ -197,6 +212,14 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
}).WithError(err).Error("get custom domain from store")
return
}
if d.Validated {
return
}
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Debug("custom domain validation window has expired")
return
}
// Validate only against the domain's target cluster
targetCluster := d.TargetCluster
@@ -217,20 +240,21 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
}).Info("validating domain against target cluster")
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).Info("domain validated successfully")
d.Validated = true
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
log.WithFields(log.Fields{
entry := log.WithFields(log.Fields{
"accountID": accountID,
"domainID": domainID,
"domain": d.Domain,
}).WithError(err).Error("update custom domain in store")
}).WithError(err)
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
entry.Debug("custom domain registration is no longer pending validation")
return
}
entry.Error("update custom domain in store")
return
}
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
Info("custom domain validated successfully")
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
} else {
@@ -268,11 +268,8 @@ func TestStore_DuplicateDomainRejectedByIndexAsConflict(t *testing.T) {
assert.Equal(t, status.AlreadyExists, sErr.Type(), "a lost race is a 409, not a 500")
}
// Validation runs asynchronously, so it can finish after the domain was
// deleted and then write a stale row back. gorm's Save falls back to an insert
// when an update affects no rows, which would resurrect the domain as
// validated; UpdateCustomDomain avoids that by selecting explicit columns.
// This pins that behaviour, since dropping the Select would reintroduce it.
// A validation finishing after deletion must reject the stale write, without
// restoring the registration or reporting successful validation.
func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
@@ -287,11 +284,9 @@ func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) {
require.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), "the domain should be gone")
// What an in-flight validation would write once its CNAME check succeeded.
// The write has to succeed for the assertion below to mean anything: a
// rejected write would leave the domain absent for the wrong reason.
stale.Validated = true
_, err = env.store.UpdateCustomDomain(ctx, accountA, stale)
require.NoError(t, err, "the update itself must succeed, so absence is not just a failed write")
require.Error(t, err, "a deleted registration must reject a late validation")
assert.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"),
"a late validation write must not recreate a deleted domain")
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -208,6 +209,14 @@ func (s *stubStore) DeleteCustomDomain(context.Context, string, string) error {
panic("not used in allow-list tests")
}
func (s *stubStore) GetExpiredCustomDomains(context.Context, time.Time, domain.ID, int) ([]*domain.Domain, error) {
panic("not used in allow-list tests")
}
func (s *stubStore) DeleteExpiredCustomDomain(context.Context, *domain.Domain, time.Time) (bool, error) {
panic("not used in allow-list tests")
}
// TestGetClusterAllowList_DedicatedGatewayAddressExcluded pins invariant (B)'s
// chokepoint: a self-addressed settings pin reserves the account's gateway
// address, so it is dropped from the allow list — which, because the
@@ -0,0 +1,83 @@
package manager
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/status"
)
func TestCreateDomain_NormalizesName(t *testing.T) {
for _, tt := range []struct {
name string
input string
canonical string
}{
{"mixed case", "Apps.Example.COM", "apps.example.com"},
{"unicode", "münchen.example.com", "xn--mnchen-3ya.example.com"},
{"trailing dot", "apps.example.com.", "apps.example.com"},
{"underscore", "My_App.example.com", "my_app.example.com"},
} {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
env.resolver.set("validation."+tt.canonical, testCluster)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, tt.input, testCluster)
require.NoError(t, err)
assert.Equal(t, tt.canonical, created.Domain, "the response must use the normalized name")
assert.True(t, created.Validated, "the CNAME lookup must use the normalized name")
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
require.NoError(t, err)
assert.Equal(t, tt.canonical, stored.Domain, "the database must retain the normalized name")
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, tt.canonical, testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "an equivalent name must return a typed conflict")
assert.Equal(t, status.AlreadyExists, sErr.Type(), "normalization must precede the availability check")
})
}
}
func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "Apps.Example.COM.", testCluster)
require.NoError(t, err)
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
env.resolver.set("validation.apps.example.com", testCluster)
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
require.NoError(t, err)
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
assert.True(t, stored.Validated, "later validation must look up the normalized name")
}
func TestCreateDomain_RejectsInvalidName(t *testing.T) {
ctx := context.Background()
env := setupDomainTest(t)
for _, name := range []string{
"", ".", "app..example.com", "app.example.com..", "-app.example.com",
"app%.example.com", "app!.example.com", "*.example.com", "app example.com",
"https://example.com", strings.Repeat("a", 64) + ".example.com",
} {
t.Run(name, func(t *testing.T) {
// A matching DNS response must not make a malformed name acceptable.
env.resolver.set("validation."+name, testCluster)
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, name, testCluster)
require.Error(t, err)
sErr, ok := status.FromError(err)
require.True(t, ok, "invalid names must return a typed client error")
assert.Equal(t, status.InvalidArgument, sErr.Type(), "malformed names must be rejected before storage")
})
}
stored, err := env.store.ListCustomDomains(ctx, accountA)
require.NoError(t, err)
assert.Empty(t, stored, "invalid registration attempts must not reserve any names")
}