Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Replace per-project notify host provisioning (7-9 API calls + DNS + async Resend verification) with a shared platform host for all *.threesix.ai projects. Under the new model: - CreateProjectNotify: 3 calls only (account + send key + host grant) - No per-project Resend domain, DNS records, or async verification - All *.threesix.ai projects share `threesix.ai` as the platform host - Custom domains still get a dedicated host via ReprovisionNotifyHost Changes: - domain/notify.go: slim NotifyCredentials (no Host/From/ResendDomainID); add NotifyHostCredentials for reprovision return path - port/notify_provisioner.go: update interface signatures and docs - adapter/notify/provisioner.go: rewrite CreateProjectNotify (3 steps); rewrite DeleteProjectNotify (account-only vs full cleanup) - adapter/notify/provisioner_reprovision.go: return *NotifyHostCredentials - adapter/notify/provisioner_test.go: update tests for new model - service/project_infra_crud.go: store only NOTIFY_API_KEY on provision - domain/credential.go: add CredKeyNotifySharedHost/CredKeyNotifySharedFrom - cmd/rdev-api/config.go: add NotifySharedHost/NotifySharedFrom to InfraConfig - service/component.go: add notifySharedHost/notifySharedFrom + WithNotifyDefaults - service/component_deploy.go: inject shared host defaults when no custom host stored - handlers/notify.go: handle shared-host projects in Reprovision guard; add WithSharedNotifyHost builder - cmd/rdev-api/main.go: wire SharedHost to provisioner, component service, and notify handler Bootstrap: NOTIFY_SHARED_HOST=threesix.ai and NOTIFY_SHARED_FROM=noreply@threesix.ai stored in credential store (host id=1 already provisioned with Resend provider). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
572 lines
18 KiB
Go
572 lines
18 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// --- mock implementations ---
|
|
|
|
// mockAdminClient is a controllable implementation of notifyAdminAPI.
|
|
type mockAdminClient struct {
|
|
// accounts simulates the account registry
|
|
accounts []accountResponse
|
|
|
|
// Configurable errors per operation
|
|
createHostErr error
|
|
deleteHostErr error
|
|
createProviderErr error
|
|
createFromAddressErr error
|
|
createAccountErr error
|
|
createSendKeyErr error
|
|
grantHostAccessErr error
|
|
revokeHostAccessErr error
|
|
deleteAccountErr error
|
|
listAccountsErr error
|
|
|
|
// Call counters
|
|
createHostCalls int
|
|
deleteHostCalls int
|
|
createProviderCalls int
|
|
createFromAddressCalls int
|
|
createAccountCalls int
|
|
createSendKeyCalls int
|
|
grantHostAccessCalls int
|
|
revokeHostAccessCalls int
|
|
deleteAccountCalls int
|
|
}
|
|
|
|
func (m *mockAdminClient) createHost(_ context.Context, _, _ string) error {
|
|
m.createHostCalls++
|
|
return m.createHostErr
|
|
}
|
|
|
|
func (m *mockAdminClient) deleteHost(_ context.Context, _ string) error {
|
|
m.deleteHostCalls++
|
|
return m.deleteHostErr
|
|
}
|
|
|
|
func (m *mockAdminClient) createProvider(_ context.Context, _, _ string, _ map[string]string, _, _, _ int) error {
|
|
m.createProviderCalls++
|
|
return m.createProviderErr
|
|
}
|
|
|
|
func (m *mockAdminClient) createFromAddress(_ context.Context, _, _, _ string) error {
|
|
m.createFromAddressCalls++
|
|
return m.createFromAddressErr
|
|
}
|
|
|
|
func (m *mockAdminClient) createAccount(_ context.Context, name string) (*accountResponse, error) {
|
|
m.createAccountCalls++
|
|
if m.createAccountErr != nil {
|
|
return nil, m.createAccountErr
|
|
}
|
|
acct := &accountResponse{ID: "acct-" + name, Name: name, CreatedAt: time.Now()}
|
|
m.accounts = append(m.accounts, *acct)
|
|
return acct, nil
|
|
}
|
|
|
|
func (m *mockAdminClient) createSendKey(_ context.Context, accountID, name string) (*apiKeyResponse, error) {
|
|
m.createSendKeyCalls++
|
|
if m.createSendKeyErr != nil {
|
|
return nil, m.createSendKeyErr
|
|
}
|
|
return &apiKeyResponse{
|
|
ID: 1,
|
|
Key: "notify_send_test_key",
|
|
KeyType: "send",
|
|
AccountID: accountID,
|
|
Name: name,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockAdminClient) grantHostAccess(_ context.Context, _, _ string) error {
|
|
m.grantHostAccessCalls++
|
|
return m.grantHostAccessErr
|
|
}
|
|
|
|
func (m *mockAdminClient) revokeHostAccess(_ context.Context, _, _ string) error {
|
|
m.revokeHostAccessCalls++
|
|
return m.revokeHostAccessErr
|
|
}
|
|
|
|
func (m *mockAdminClient) deleteAccount(_ context.Context, _ string) error {
|
|
m.deleteAccountCalls++
|
|
return m.deleteAccountErr
|
|
}
|
|
|
|
func (m *mockAdminClient) listAccounts(_ context.Context) ([]accountResponse, error) {
|
|
if m.listAccountsErr != nil {
|
|
return nil, m.listAccountsErr
|
|
}
|
|
return m.accounts, nil
|
|
}
|
|
|
|
// mockResendClient is a controllable implementation of resendAPI.
|
|
type mockResendClient struct {
|
|
createDomainErr error
|
|
verifyDomainErr error
|
|
deleteDomainErr error
|
|
|
|
createDomainCalls int
|
|
verifyDomainCalls int
|
|
deleteDomainCalls int
|
|
|
|
domainID string
|
|
dnsRecords []resendDNSRecord
|
|
}
|
|
|
|
func (m *mockResendClient) createDomain(_ context.Context, _, _ string) (string, []resendDNSRecord, error) {
|
|
m.createDomainCalls++
|
|
if m.createDomainErr != nil {
|
|
return "", nil, m.createDomainErr
|
|
}
|
|
id := m.domainID
|
|
if id == "" {
|
|
id = "resend-domain-id-123"
|
|
}
|
|
return id, m.dnsRecords, nil
|
|
}
|
|
|
|
func (m *mockResendClient) verifyDomain(_ context.Context, _ string) error {
|
|
m.verifyDomainCalls++
|
|
return m.verifyDomainErr
|
|
}
|
|
|
|
func (m *mockResendClient) deleteDomain(_ context.Context, _ string) error {
|
|
m.deleteDomainCalls++
|
|
return m.deleteDomainErr
|
|
}
|
|
|
|
func (m *mockResendClient) getDomainStatus(_ context.Context, _ string) (string, error) {
|
|
return "verified", nil
|
|
}
|
|
|
|
// mockDNS is a controllable implementation of port.DNSProvider.
|
|
type mockDNS struct {
|
|
upsertErr error
|
|
deleteByNameErr error
|
|
upsertCalls []domain.DNSRecord
|
|
deleteByNameCalls []struct{ recordType, name string }
|
|
}
|
|
|
|
func (m *mockDNS) UpsertRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
|
|
m.upsertCalls = append(m.upsertCalls, record)
|
|
if m.upsertErr != nil {
|
|
return nil, m.upsertErr
|
|
}
|
|
return &record, nil
|
|
}
|
|
|
|
func (m *mockDNS) DeleteRecordByName(_ context.Context, recordType, name string) error {
|
|
m.deleteByNameCalls = append(m.deleteByNameCalls, struct{ recordType, name string }{recordType, name})
|
|
return m.deleteByNameErr
|
|
}
|
|
|
|
func (m *mockDNS) CreateRecord(_ context.Context, r domain.DNSRecord) (*domain.DNSRecord, error) {
|
|
return &r, nil
|
|
}
|
|
func (m *mockDNS) UpdateRecord(_ context.Context, _ string, r domain.DNSRecord) (*domain.DNSRecord, error) {
|
|
return &r, nil
|
|
}
|
|
func (m *mockDNS) DeleteRecord(_ context.Context, _ string) error { return nil }
|
|
func (m *mockDNS) GetRecord(_ context.Context, _ string) (*domain.DNSRecord, error) { return nil, nil }
|
|
func (m *mockDNS) ListRecords(_ context.Context, _ string) ([]*domain.DNSRecord, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockDNS) FindRecord(_ context.Context, _, _ string) (*domain.DNSRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
}
|
|
|
|
// newProvisionerWithDeps creates a Provisioner with injected dependencies for testing.
|
|
// sharedHost is used as-is; pass a non-empty value for tests that need a working provisioner.
|
|
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain, sharedHost string, logger *slog.Logger) *Provisioner {
|
|
if baseDomain == "" {
|
|
baseDomain = "threesix.ai"
|
|
}
|
|
return &Provisioner{
|
|
client: client,
|
|
resend: resend,
|
|
resendAPIKey: resendAPIKey,
|
|
dns: dns,
|
|
baseDomain: baseDomain,
|
|
sharedHost: sharedHost,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func newTestProvisioner(admin *mockAdminClient, resend *mockResendClient, dns *mockDNS) *Provisioner {
|
|
var r resendAPI
|
|
if resend != nil {
|
|
r = resend
|
|
}
|
|
var d interface {
|
|
UpsertRecord(context.Context, domain.DNSRecord) (*domain.DNSRecord, error)
|
|
DeleteRecordByName(context.Context, string, string) error
|
|
CreateRecord(context.Context, domain.DNSRecord) (*domain.DNSRecord, error)
|
|
UpdateRecord(context.Context, string, domain.DNSRecord) (*domain.DNSRecord, error)
|
|
DeleteRecord(context.Context, string) error
|
|
GetRecord(context.Context, string) (*domain.DNSRecord, error)
|
|
ListRecords(context.Context, string) ([]*domain.DNSRecord, error)
|
|
FindRecord(context.Context, string, string) (*domain.DNSRecord, error)
|
|
}
|
|
if dns != nil {
|
|
d = dns
|
|
}
|
|
return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", "mail.test.example", testLogger())
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestCreateProjectNotify_Success(t *testing.T) {
|
|
admin := &mockAdminClient{}
|
|
p := newTestProvisioner(admin, nil, nil)
|
|
|
|
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if creds.APIKey != "notify_send_test_key" {
|
|
t.Errorf("expected send key notify_send_test_key, got %s", creds.APIKey)
|
|
}
|
|
if creds.ProjectID != "proj-123" {
|
|
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
|
|
}
|
|
if creds.AccountID == "" {
|
|
t.Error("expected non-empty AccountID")
|
|
}
|
|
|
|
// Shared-host model: only account + send key + grant access are called.
|
|
if admin.createAccountCalls != 1 {
|
|
t.Errorf("expected 1 createAccount call, got %d", admin.createAccountCalls)
|
|
}
|
|
if admin.createSendKeyCalls != 1 {
|
|
t.Errorf("expected 1 createSendKey call, got %d", admin.createSendKeyCalls)
|
|
}
|
|
if admin.grantHostAccessCalls != 1 {
|
|
t.Errorf("expected 1 grantHostAccess call, got %d", admin.grantHostAccessCalls)
|
|
}
|
|
|
|
// No per-project host, provider, from-address, Resend domain, or DNS records.
|
|
if admin.createHostCalls != 0 {
|
|
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
|
|
}
|
|
if admin.createProviderCalls != 0 {
|
|
t.Errorf("expected 0 createProvider calls, got %d", admin.createProviderCalls)
|
|
}
|
|
if admin.createFromAddressCalls != 0 {
|
|
t.Errorf("expected 0 createFromAddress calls, got %d", admin.createFromAddressCalls)
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectNotify_NoSharedHost_ReturnsError(t *testing.T) {
|
|
admin := &mockAdminClient{}
|
|
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", "", testLogger())
|
|
|
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
if err == nil {
|
|
t.Fatal("expected error when sharedHost is not configured, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectNotify_RollsBackOnAccountFailure(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
createAccountErr: errors.New("account creation failed"),
|
|
}
|
|
p := newTestProvisioner(admin, nil, nil)
|
|
|
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
// No send key or grant attempted when account creation fails.
|
|
if admin.createSendKeyCalls != 0 {
|
|
t.Errorf("expected 0 createSendKey calls, got %d", admin.createSendKeyCalls)
|
|
}
|
|
if admin.grantHostAccessCalls != 0 {
|
|
t.Errorf("expected 0 grantHostAccess calls, got %d", admin.grantHostAccessCalls)
|
|
}
|
|
// No host was ever created in the shared-host model.
|
|
if admin.createHostCalls != 0 {
|
|
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectNotify_RollsBackOnSendKeyFailure(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
createSendKeyErr: errors.New("send key creation failed"),
|
|
}
|
|
p := newTestProvisioner(admin, nil, nil)
|
|
|
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
// Account was created, so it must be rolled back.
|
|
if admin.deleteAccountCalls != 1 {
|
|
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
|
|
}
|
|
// No host was created, so no host rollback.
|
|
if admin.deleteHostCalls != 0 {
|
|
t.Errorf("expected 0 deleteHost calls, got %d", admin.deleteHostCalls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProjectNotify_Success(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123"},
|
|
},
|
|
}
|
|
resend := &mockResendClient{}
|
|
dns := &mockDNS{}
|
|
p := newTestProvisioner(admin, resend, dns)
|
|
|
|
// perProjectHost is non-empty: delete account + host + Resend domain + DNS records.
|
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if admin.deleteAccountCalls != 1 {
|
|
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
|
|
}
|
|
if admin.deleteHostCalls != 1 {
|
|
t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls)
|
|
}
|
|
if resend.deleteDomainCalls != 1 {
|
|
t.Errorf("expected 1 deleteDomain call, got %d", resend.deleteDomainCalls)
|
|
}
|
|
// 3 DNS records: DKIM TXT, SPF MX, SPF TXT
|
|
if len(dns.deleteByNameCalls) != 3 {
|
|
t.Errorf("expected 3 DNS deleteByName calls, got %d", len(dns.deleteByNameCalls))
|
|
}
|
|
}
|
|
|
|
func TestDeleteProjectNotify_NoResendDomainID_SkipsDomainDeletion(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123"},
|
|
},
|
|
}
|
|
resend := &mockResendClient{}
|
|
p := newTestProvisioner(admin, resend, nil)
|
|
|
|
// perProjectHost set but no resendDomainID: host deleted, Resend domain skipped.
|
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if resend.deleteDomainCalls != 0 {
|
|
t.Errorf("should skip domain deletion when resendDomainID is empty")
|
|
}
|
|
if admin.deleteHostCalls != 1 {
|
|
t.Errorf("expected 1 deleteHost call when perProjectHost is set, got %d", admin.deleteHostCalls)
|
|
}
|
|
}
|
|
|
|
func TestDeleteProjectNotify_NoPerProjectHost_OnlyDeletesAccount(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123"},
|
|
},
|
|
}
|
|
resend := &mockResendClient{}
|
|
dns := &mockDNS{}
|
|
p := newTestProvisioner(admin, resend, dns)
|
|
|
|
// Empty perProjectHost: only the account is deleted (shared host, no custom resources).
|
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "", "")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if admin.deleteAccountCalls != 1 {
|
|
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
|
|
}
|
|
if admin.deleteHostCalls != 0 {
|
|
t.Errorf("expected 0 deleteHost calls when no perProjectHost, got %d", admin.deleteHostCalls)
|
|
}
|
|
if resend.deleteDomainCalls != 0 {
|
|
t.Errorf("expected 0 deleteDomain calls when no perProjectHost, got %d", resend.deleteDomainCalls)
|
|
}
|
|
if len(dns.deleteByNameCalls) != 0 {
|
|
t.Errorf("expected 0 DNS deleteByName calls when no perProjectHost, got %d", len(dns.deleteByNameCalls))
|
|
}
|
|
}
|
|
|
|
func TestDeleteProjectNotify_AccountNotFound_ContinuesCleanup(t *testing.T) {
|
|
// Account doesn't exist (never provisioned or already deleted).
|
|
admin := &mockAdminClient{accounts: []accountResponse{}}
|
|
resend := &mockResendClient{}
|
|
p := newTestProvisioner(admin, resend, nil)
|
|
|
|
// Even without an account, host and Resend domain cleanup must proceed.
|
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
|
|
if err != nil {
|
|
t.Fatalf("expected no error when account not found, got %v", err)
|
|
}
|
|
if admin.deleteHostCalls != 1 {
|
|
t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls)
|
|
}
|
|
if resend.deleteDomainCalls != 1 {
|
|
t.Errorf("expected 1 deleteDomain call, got %d", resend.deleteDomainCalls)
|
|
}
|
|
}
|
|
|
|
func TestGetProjectNotify_NotProvisioned(t *testing.T) {
|
|
admin := &mockAdminClient{}
|
|
p := newTestProvisioner(admin, nil, nil)
|
|
|
|
creds, err := p.GetProjectNotify(context.Background(), "proj-123")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if creds != nil {
|
|
t.Errorf("expected nil for unprovisioned project, got %+v", creds)
|
|
}
|
|
}
|
|
|
|
func TestGetProjectNotify_AlreadyProvisioned(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()},
|
|
},
|
|
}
|
|
p := newTestProvisioner(admin, nil, nil)
|
|
|
|
creds, err := p.GetProjectNotify(context.Background(), "proj-123")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if creds == nil {
|
|
t.Fatal("expected non-nil credentials for provisioned project")
|
|
}
|
|
if creds.AccountID != "acct-001" {
|
|
t.Errorf("expected account id acct-001, got %s", creds.AccountID)
|
|
}
|
|
}
|
|
|
|
func TestReprovisionNotifyHost_Success(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()},
|
|
},
|
|
}
|
|
resend := &mockResendClient{
|
|
domainID: "new-domain-id-456",
|
|
dnsRecords: []resendDNSRecord{
|
|
{Record: "TXT", DNSType: "TXT", Name: "resend._domainkey.mail.myapp", Value: "v=DKIM1;"},
|
|
},
|
|
}
|
|
dns := &mockDNS{}
|
|
p := newTestProvisioner(admin, resend, dns)
|
|
|
|
creds, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
|
|
"mail.old-slug.test.example", "old-domain-id-123", "mail.myapp.test.example")
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if creds.Host != "mail.myapp.test.example" {
|
|
t.Errorf("expected host mail.myapp.test.example, got %s", creds.Host)
|
|
}
|
|
if creds.From != "noreply@mail.myapp.test.example" {
|
|
t.Errorf("expected from noreply@mail.myapp.test.example, got %s", creds.From)
|
|
}
|
|
if creds.ResendDomainID != "new-domain-id-456" {
|
|
t.Errorf("expected resend domain id new-domain-id-456, got %s", creds.ResendDomainID)
|
|
}
|
|
if creds.ProjectID != "proj-123" {
|
|
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
|
|
}
|
|
|
|
// Verify new host was created and old was deleted.
|
|
if admin.createHostCalls != 1 {
|
|
t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls)
|
|
}
|
|
if admin.deleteHostCalls != 1 {
|
|
t.Errorf("expected 1 deleteHost call for old host, got %d", admin.deleteHostCalls)
|
|
}
|
|
if admin.revokeHostAccessCalls != 1 {
|
|
t.Errorf("expected 1 revokeHostAccess call, got %d", admin.revokeHostAccessCalls)
|
|
}
|
|
if admin.grantHostAccessCalls != 1 {
|
|
t.Errorf("expected 1 grantHostAccess call for new host, got %d", admin.grantHostAccessCalls)
|
|
}
|
|
if resend.createDomainCalls != 1 {
|
|
t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls)
|
|
}
|
|
if resend.deleteDomainCalls != 1 {
|
|
t.Errorf("expected 1 deleteDomain call for old domain, got %d", resend.deleteDomainCalls)
|
|
}
|
|
// Old DNS records should have been deleted (DKIM TXT + SPF MX + SPF TXT = 3 calls).
|
|
if len(dns.deleteByNameCalls) != 3 {
|
|
t.Errorf("expected 3 deleteByName calls for old DNS records, got %d", len(dns.deleteByNameCalls))
|
|
}
|
|
}
|
|
|
|
func TestReprovisionNotifyHost_NewHostCreateFails(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
createHostErr: errors.New("host already exists"),
|
|
}
|
|
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
|
|
|
_, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
|
|
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
|
|
if err == nil {
|
|
t.Fatal("expected error when createHost fails")
|
|
}
|
|
}
|
|
|
|
func TestReprovisionNotifyHost_AccountNotFound(t *testing.T) {
|
|
// No accounts in the registry.
|
|
admin := &mockAdminClient{}
|
|
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
|
|
|
_, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
|
|
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
|
|
if err == nil {
|
|
t.Fatal("expected error when account not found")
|
|
}
|
|
}
|
|
|
|
func TestReprovisionNotifyHost_OldDomainDeleteFails_StillSucceeds(t *testing.T) {
|
|
admin := &mockAdminClient{
|
|
accounts: []accountResponse{
|
|
{ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()},
|
|
},
|
|
}
|
|
resend := &mockResendClient{
|
|
domainID: "new-domain-id",
|
|
deleteDomainErr: errors.New("delete failed"),
|
|
}
|
|
p := newTestProvisioner(admin, resend, nil)
|
|
|
|
// Old domain delete failure is non-fatal — reprovision should still succeed.
|
|
creds, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
|
|
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
|
|
if err != nil {
|
|
t.Fatalf("expected success despite old domain delete failure, got %v", err)
|
|
}
|
|
if creds.Host != "mail.new.test.example" {
|
|
t.Errorf("expected new host, got %s", creds.Host)
|
|
}
|
|
}
|