rdev/internal/adapter/notify/provisioner_test.go
jordan ddcfe52b5c
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: implement shared notify host model for platform email delivery
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>
2026-02-25 17:04:11 -07:00

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