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) } }