All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
516 lines
16 KiB
Go
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)
|
|
}
|
|
}
|