rdev/internal/adapter/notify/provisioner_test.go
jordan fa0d030def
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: improve notify domain verification reliability and add status endpoints
- Add verifyWithRetry to provisioner: 60s initial DNS propagation delay,
  5 retries with 30s backoff before marking verification as failed
- Add GetNotifyDomainStatus: polls Resend API for domain verification status,
  returns "not_configured" when Resend not set up
- Add VerifyProjectNotify: synchronous re-verification for handler use
- Add getDomainStatus to resendAPI interface + resendClient implementation
- Add NotifyDomainStatus domain struct (host, resend_domain_id, status)
- Guard NOTIFY_RESEND_DOMAIN_ID storage against empty string writes
- New handler: GET /projects/{id}/notify/status (returns verification state)
- New handler: POST /projects/{id}/notify/verify (triggers re-verification)
- Add verify-notify-domain cookbook step to persona-community,
  slackpath-1, and slackpath-4 trees (polls status for up to 6 min)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 16:25:55 -07:00

516 lines
16 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
deleteAccountErr error
listAccountsErr error
// Call counters
createHostCalls int
deleteHostCalls int
createProviderCalls int
createFromAddressCalls int
createAccountCalls int
createSendKeyCalls int
grantHostAccessCalls 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) 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.
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain string, logger *slog.Logger) *Provisioner {
if baseDomain == "" {
baseDomain = "threesix.ai"
}
return &Provisioner{
client: client,
resend: resend,
resendAPIKey: resendAPIKey,
dns: dns,
baseDomain: baseDomain,
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", testLogger())
}
// --- tests ---
func TestCreateProjectNotify_Success(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
dnsRecords: []resendDNSRecord{
{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1; p=..."},
{Record: "MX", Name: "send", Value: "feedback-smtp.us-east-1.amazonses.com", Priority: 10},
},
}
dns := &mockDNS{}
p := newTestProvisioner(admin, resend, dns)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if creds.Host != "mail.happy-fox.test.example" {
t.Errorf("expected host mail.happy-fox.test.example, got %s", creds.Host)
}
if creds.From != "noreply@mail.happy-fox.test.example" {
t.Errorf("expected from noreply@mail.happy-fox.test.example, got %s", creds.From)
}
if creds.APIKey != "notify_send_test_key" {
t.Errorf("expected send key, got %s", creds.APIKey)
}
if creds.ResendDomainID != "resend-domain-id-123" {
t.Errorf("expected resend domain id, got %s", creds.ResendDomainID)
}
if creds.ProjectID != "proj-123" {
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
}
// Verify all steps executed
if admin.createHostCalls != 1 {
t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls)
}
if admin.createProviderCalls != 1 {
t.Errorf("expected 1 createProvider call, got %d", admin.createProviderCalls)
}
if admin.createFromAddressCalls != 1 {
t.Errorf("expected 1 createFromAddress call, got %d", admin.createFromAddressCalls)
}
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 resend.createDomainCalls != 1 {
t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls)
}
// Verify 2 DNS records were upserted
if len(dns.upsertCalls) != 2 {
t.Errorf("expected 2 DNS upserts, got %d", len(dns.upsertCalls))
}
}
func TestCreateProjectNotify_RollsBackOnProviderFailure(t *testing.T) {
admin := &mockAdminClient{
createProviderErr: errors.New("provider setup failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
}
func TestCreateProjectNotify_RollsBackOnFromAddressFailure(t *testing.T) {
admin := &mockAdminClient{
createFromAddressErr: errors.New("from address failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
if admin.deleteAccountCalls != 0 {
t.Errorf("account not yet created, should not delete account")
}
}
func TestCreateProjectNotify_RollsBackOnAccountFailure(t *testing.T) {
admin := &mockAdminClient{
createAccountErr: errors.New("account creation failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
}
func TestCreateProjectNotify_RollsBackOnSendKeyFailure(t *testing.T) {
admin := &mockAdminClient{
createSendKeyErr: errors.New("send key creation failed"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err == nil {
t.Fatal("expected error, got nil")
}
if admin.deleteAccountCalls != 1 {
t.Errorf("expected account rollback, got %d deleteAccount calls", admin.deleteAccountCalls)
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
}
}
func TestCreateProjectNotify_ResendFailureIsNonFatal(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
createDomainErr: errors.New("resend API down"),
}
p := newTestProvisioner(admin, resend, nil)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("resend failure should be non-fatal, got error: %v", err)
}
if creds.ResendDomainID != "" {
t.Errorf("expected empty resend domain id on failure, got %s", creds.ResendDomainID)
}
}
func TestCreateProjectNotify_WithoutResend_SkipsProviderAndDomain(t *testing.T) {
admin := &mockAdminClient{}
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", testLogger())
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("expected no error without resend, got %v", err)
}
if admin.createProviderCalls != 0 {
t.Errorf("createProvider should not be called without ResendAPIKey, got %d calls", admin.createProviderCalls)
}
if creds.ResendDomainID != "" {
t.Errorf("expected no resend domain id without ResendAPIKey, got %s", creds.ResendDomainID)
}
}
func TestCreateProjectNotify_DNSFailureIsNonFatal(t *testing.T) {
admin := &mockAdminClient{}
resend := &mockResendClient{
dnsRecords: []resendDNSRecord{{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1"}},
}
dns := &mockDNS{upsertErr: errors.New("cloudflare down")}
p := newTestProvisioner(admin, resend, dns)
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
if err != nil {
t.Fatalf("DNS failure should be non-fatal, got error: %v", err)
}
// Project still usable; DNS will need manual fix
if creds.Host != "mail.happy-fox.test.example" {
t.Errorf("creds should still be returned on DNS failure, got host %s", creds.Host)
}
}
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)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "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)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "")
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")
}
}
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)
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123")
if err != nil {
t.Fatalf("expected no error when account not found, got %v", err)
}
// Should still attempt host and Resend domain deletion
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 TestCreateProjectNotify_HostUsesBaseDomain(t *testing.T) {
admin := &mockAdminClient{}
p := newProvisionerWithDeps(admin, nil, "", nil, "staging.example.com", testLogger())
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "some-slug")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if creds.Host != "mail.some-slug.staging.example.com" {
t.Errorf("expected host to use baseDomain, got %s", creds.Host)
}
}