#10: merge feature/10-dns-automation into main
This commit is contained in:
commit
c0042ef207
14 files changed
+1043
-2
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
-- Revert is_system column from aliases table
|
||||||
|
ALTER TABLE aliases DROP COLUMN IF EXISTS is_system;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add is_system column to aliases table to track mandatory system security aliases
|
||||||
|
ALTER TABLE aliases ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DnsProvider defines operations for managing remote DNS zone records (e.g. Cloudflare).
|
||||||
|
type DnsProvider interface {
|
||||||
|
GetZoneID(ctx context.Context, domainName string) (string, error)
|
||||||
|
ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error)
|
||||||
|
CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error
|
||||||
|
UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error
|
||||||
|
DeleteRecord(ctx context.Context, zoneID string, recordID string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// SystemAliasRepository manages mandatory system security aliases (postmaster, abuse, dmarc, tlsrpt).
|
||||||
|
type SystemAliasRepository interface {
|
||||||
|
EnsureSystemAliases(ctx context.Context, domainName string, adminEmail string) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncDnsRecordsInput struct {
|
||||||
|
DomainName string
|
||||||
|
MailHost string
|
||||||
|
ServerIPv4 string
|
||||||
|
ServerIPv6 string
|
||||||
|
RsaDkimPubKey string
|
||||||
|
EdDkimPubKey string
|
||||||
|
TlsaHash string
|
||||||
|
DaneEnabled bool
|
||||||
|
AdminEmailAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncDnsRecordsOutput struct {
|
||||||
|
ZoneID string
|
||||||
|
CreatedCount int
|
||||||
|
UpdatedCount int
|
||||||
|
UnchangedCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncDnsRecordsUseCase struct {
|
||||||
|
dnsProvider port.DnsProvider
|
||||||
|
aliasRepo port.SystemAliasRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncDnsRecordsUseCase(dnsProvider port.DnsProvider, aliasRepo port.SystemAliasRepository) *SyncDnsRecordsUseCase {
|
||||||
|
return &SyncDnsRecordsUseCase{
|
||||||
|
dnsProvider: dnsProvider,
|
||||||
|
aliasRepo: aliasRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *SyncDnsRecordsUseCase) Execute(ctx context.Context, input SyncDnsRecordsInput) (*SyncDnsRecordsOutput, error) {
|
||||||
|
if input.DomainName == "" {
|
||||||
|
return nil, fmt.Errorf("domain name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredSpecs := entity.Build13DnsRecordSpecs(
|
||||||
|
input.DomainName,
|
||||||
|
input.MailHost,
|
||||||
|
input.ServerIPv4,
|
||||||
|
input.ServerIPv6,
|
||||||
|
input.RsaDkimPubKey,
|
||||||
|
input.EdDkimPubKey,
|
||||||
|
input.TlsaHash,
|
||||||
|
input.DaneEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
zoneID, err := uc.dnsProvider.GetZoneID(ctx, input.DomainName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get zone id for %s: %w", input.DomainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRecords, err := uc.dnsProvider.ListRecords(ctx, zoneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list dns records for zone %s: %w", zoneID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingMap := make(map[string]entity.DnsRecord)
|
||||||
|
for _, rec := range existingRecords {
|
||||||
|
key := fmt.Sprintf("%s:%s", strings.ToUpper(rec.Type), strings.ToLower(rec.Name))
|
||||||
|
existingMap[key] = rec
|
||||||
|
}
|
||||||
|
|
||||||
|
output := &SyncDnsRecordsOutput{ZoneID: zoneID}
|
||||||
|
|
||||||
|
for _, spec := range desiredSpecs {
|
||||||
|
key := fmt.Sprintf("%s:%s", strings.ToUpper(spec.Type), strings.ToLower(spec.Name))
|
||||||
|
existing, found := existingMap[key]
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
if err := uc.dnsProvider.CreateRecord(ctx, zoneID, spec); err != nil {
|
||||||
|
return nil, fmt.Errorf("create dns record %s %s: %w", spec.Type, spec.Name, err)
|
||||||
|
}
|
||||||
|
output.CreatedCount++
|
||||||
|
} else if !spec.EqualsNormalized(existing) {
|
||||||
|
spec.ID = existing.ID
|
||||||
|
if err := uc.dnsProvider.UpdateRecord(ctx, zoneID, spec); err != nil {
|
||||||
|
return nil, fmt.Errorf("update dns record %s %s: %w", spec.Type, spec.Name, err)
|
||||||
|
}
|
||||||
|
output.UpdatedCount++
|
||||||
|
} else {
|
||||||
|
output.UnchangedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.AdminEmailAddress != "" && uc.aliasRepo != nil {
|
||||||
|
if err := uc.aliasRepo.EnsureSystemAliases(ctx, input.DomainName, input.AdminEmailAddress); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure system aliases: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDnsProvider struct {
|
||||||
|
records map[string]entity.DnsRecord // key -> record
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeDnsProvider() *fakeDnsProvider {
|
||||||
|
return &fakeDnsProvider{records: make(map[string]entity.DnsRecord)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) {
|
||||||
|
return fmt.Sprintf("zone_%s", domainName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
|
||||||
|
var out []entity.DnsRecord
|
||||||
|
for _, r := range f.records {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
|
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
||||||
|
record.ID = fmt.Sprintf("id_%s", key)
|
||||||
|
f.records[key] = record
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
|
key := fmt.Sprintf("%s:%s", strings.ToUpper(record.Type), strings.ToLower(record.Name))
|
||||||
|
f.records[key] = record
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
|
||||||
|
for k, r := range f.records {
|
||||||
|
if r.ID == recordID {
|
||||||
|
delete(f.records, k)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSystemAliasRepository struct {
|
||||||
|
ensured bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSystemAliasRepository) EnsureSystemAliases(_ context.Context, _ string, _ string) error {
|
||||||
|
f.ensured = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncDnsRecordsUseCase_Creates13RecordsAndAliases(t *testing.T) {
|
||||||
|
dns := newFakeDnsProvider()
|
||||||
|
alias := &fakeSystemAliasRepository{}
|
||||||
|
uc := NewSyncDnsRecordsUseCase(dns, alias)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
input := SyncDnsRecordsInput{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mail.example.test",
|
||||||
|
ServerIPv4: "192.0.2.1",
|
||||||
|
ServerIPv6: "2001:db8::1",
|
||||||
|
RsaDkimPubKey: "rsaKey",
|
||||||
|
EdDkimPubKey: "edKey",
|
||||||
|
TlsaHash: "hash123",
|
||||||
|
DaneEnabled: true,
|
||||||
|
AdminEmailAddress: "admin@example.test",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initial Sync -> Creates 13 records
|
||||||
|
out, err := uc.Execute(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.CreatedCount != 13 {
|
||||||
|
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
||||||
|
}
|
||||||
|
if !alias.ensured {
|
||||||
|
t.Error("expected system aliases to be ensured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Second Sync (idempotent) -> Unchanged 13 records
|
||||||
|
out2, err := uc.Execute(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Execute 2 failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out2.UnchangedCount != 13 || out2.CreatedCount != 0 || out2.UpdatedCount != 0 {
|
||||||
|
t.Errorf("expected 13 unchanged, got created=%d, updated=%d, unchanged=%d", out2.CreatedCount, out2.UpdatedCount, out2.UnchangedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DnsRecord struct {
|
||||||
|
ID string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
|
TTL int
|
||||||
|
Priority *int
|
||||||
|
Proxied bool
|
||||||
|
Comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build13DnsRecordSpecs generates the 13 mandatory DNS record specifications per PLAN.md section 7.
|
||||||
|
func Build13DnsRecordSpecs(domainName, mailHost, serverIPv4, serverIPv6, rsaDkimPubKey, edDkimPubKey, tlsaHash string, daneEnabled bool) []DnsRecord {
|
||||||
|
if mailHost == "" {
|
||||||
|
mailHost = fmt.Sprintf("mail.%s", domainName)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := []DnsRecord{
|
||||||
|
// 1. A record for mail host
|
||||||
|
{
|
||||||
|
Type: "A",
|
||||||
|
Name: mailHost,
|
||||||
|
Value: serverIPv4,
|
||||||
|
TTL: 1, // Auto
|
||||||
|
Proxied: false,
|
||||||
|
Comment: "LambdaMail Mail Host A Record",
|
||||||
|
},
|
||||||
|
// 3. MX record
|
||||||
|
{
|
||||||
|
Type: "MX",
|
||||||
|
Name: domainName,
|
||||||
|
Value: mailHost,
|
||||||
|
Priority: intPtr(10),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail Primary MX Record",
|
||||||
|
},
|
||||||
|
// 4. SPF TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: domainName,
|
||||||
|
Value: fmt.Sprintf("v=spf1 mx a:%s -all", mailHost),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail SPF Record",
|
||||||
|
},
|
||||||
|
// 5. RSA DKIM TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("default._domainkey.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=DKIM1; k=rsa; p=%s", rsaDkimPubKey),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail RSA DKIM Key",
|
||||||
|
},
|
||||||
|
// 6. Ed25519 DKIM TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("default-ed._domainkey.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=DKIM1; k=ed25519; p=%s", edDkimPubKey),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail Ed25519 DKIM Key",
|
||||||
|
},
|
||||||
|
// 7. DMARC TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("_dmarc.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=DMARC1; p=quarantine; rua=mailto:dmarc@%s; ruf=mailto:dmarc@%s; fo=1; adkim=s; aspf=s", domainName, domainName),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail DMARC Policy",
|
||||||
|
},
|
||||||
|
// 8. MTA-STS TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("_mta-sts.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=STSv1; id=%d", time.Now().Unix()),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail MTA-STS Policy Version",
|
||||||
|
},
|
||||||
|
// 9. CNAME for mta-sts host (serves policy via HTTPS)
|
||||||
|
{
|
||||||
|
Type: "CNAME",
|
||||||
|
Name: fmt.Sprintf("mta-sts.%s", domainName),
|
||||||
|
Value: mailHost,
|
||||||
|
TTL: 1,
|
||||||
|
Proxied: true,
|
||||||
|
Comment: "LambdaMail MTA-STS Endpoint",
|
||||||
|
},
|
||||||
|
// 10. TLS-RPT TXT record
|
||||||
|
{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("_smtp._tls.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=TLSRPTv1; rua=mailto:tlsrpt@%s", domainName),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail TLS Reporting Destination",
|
||||||
|
},
|
||||||
|
// 12. SRV for IMAPS (RFC 6186)
|
||||||
|
{
|
||||||
|
Type: "SRV",
|
||||||
|
Name: fmt.Sprintf("_imaps._tcp.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("0 1 993 %s", mailHost),
|
||||||
|
TTL: 1,
|
||||||
|
Priority: intPtr(0),
|
||||||
|
Comment: "LambdaMail IMAPS Autoconfig",
|
||||||
|
},
|
||||||
|
// 13. SRV for Submissions
|
||||||
|
{
|
||||||
|
Type: "SRV",
|
||||||
|
Name: fmt.Sprintf("_submissions._tcp.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("0 1 465 %s", mailHost),
|
||||||
|
TTL: 1,
|
||||||
|
Priority: intPtr(0),
|
||||||
|
Comment: "LambdaMail Submissions Autoconfig",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Conditional AAAA record if IPv6 is supplied
|
||||||
|
if serverIPv6 != "" {
|
||||||
|
records = append(records, DnsRecord{
|
||||||
|
Type: "AAAA",
|
||||||
|
Name: mailHost,
|
||||||
|
Value: serverIPv6,
|
||||||
|
TTL: 1,
|
||||||
|
Proxied: false,
|
||||||
|
Comment: "LambdaMail Mail Host AAAA Record",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Conditional TLSA record if DANE is enabled
|
||||||
|
if daneEnabled && tlsaHash != "" {
|
||||||
|
records = append(records, DnsRecord{
|
||||||
|
Type: "TLSA",
|
||||||
|
Name: fmt.Sprintf("_25._tcp.%s", mailHost),
|
||||||
|
Value: fmt.Sprintf("3 1 1 %s", tlsaHash),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail DANE TLSA Record",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// EqualsNormalized compares two DNS records ignoring subtle formatting whitespace/case differences.
|
||||||
|
func (r DnsRecord) EqualsNormalized(other DnsRecord) bool {
|
||||||
|
if r.Type != other.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(r.Name, other.Name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(r.Value) != strings.TrimSpace(other.Value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Proxied != other.Proxied {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild13DnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
|
||||||
|
specs := Build13DnsRecordSpecs(
|
||||||
|
"example.test",
|
||||||
|
"mail.example.test",
|
||||||
|
"192.0.2.1",
|
||||||
|
"2001:db8::1",
|
||||||
|
"rsaPubKeyBase64",
|
||||||
|
"edPubKeyBase64",
|
||||||
|
"hash123",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Should contain 13 records (11 mandatory + 1 AAAA + 1 TLSA)
|
||||||
|
if len(specs) != 13 {
|
||||||
|
t.Fatalf("expected 13 records, got %d", len(specs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MX
|
||||||
|
foundMX := false
|
||||||
|
for _, r := range specs {
|
||||||
|
if r.Type == "MX" && r.Name == "example.test" && r.Value == "mail.example.test" {
|
||||||
|
foundMX = true
|
||||||
|
if r.Priority == nil || *r.Priority != 10 {
|
||||||
|
t.Errorf("MX priority = %v, want 10", r.Priority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMX {
|
||||||
|
t.Error("MX record not found in specs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DMARC
|
||||||
|
foundDmarc := false
|
||||||
|
for _, r := range specs {
|
||||||
|
if r.Type == "TXT" && r.Name == "_dmarc.example.test" {
|
||||||
|
foundDmarc = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundDmarc {
|
||||||
|
t.Error("DMARC record not found in specs")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("e2e test skipped in -short mode")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
runtimeDir := t.TempDir()
|
||||||
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||||
|
Port(54336).
|
||||||
|
RuntimePath(runtimeDir).
|
||||||
|
StartTimeout(120 * time.Second))
|
||||||
|
if err := pg.Start(); err != nil {
|
||||||
|
t.Fatalf("start embedded postgres: %v", err)
|
||||||
|
}
|
||||||
|
defer pg.Stop()
|
||||||
|
|
||||||
|
dbURL := "postgres://postgres:postgres@localhost:54336/postgres?sslmode=disable"
|
||||||
|
pool, err := postgres.NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
root := repoRoot(t)
|
||||||
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration 0001: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
|
t.Fatalf("apply migration 0001: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration 0002: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
|
||||||
|
t.Fatalf("apply migration 0002: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed domain & admin mailbox
|
||||||
|
domainID := uuid.New()
|
||||||
|
adminMailboxID := uuid.New()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'dnstest.example', 'dnstest.example')`, domainID); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'admin', 'admin@dnstest.example', 'hash')`, adminMailboxID, domainID); err != nil {
|
||||||
|
t.Fatalf("seed mailbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasRepo := postgres.NewAliasRepository(pool)
|
||||||
|
fakeDns := &e2eFakeDnsProvider{records: make(map[string]entity.DnsRecord)}
|
||||||
|
|
||||||
|
uc := appusecase.NewSyncDnsRecordsUseCase(fakeDns, aliasRepo)
|
||||||
|
|
||||||
|
input := appusecase.SyncDnsRecordsInput{
|
||||||
|
DomainName: "dnstest.example",
|
||||||
|
MailHost: "mail.dnstest.example",
|
||||||
|
ServerIPv4: "198.51.100.10",
|
||||||
|
ServerIPv6: "2001:db8::10",
|
||||||
|
RsaDkimPubKey: "rsaKeyString",
|
||||||
|
EdDkimPubKey: "edKeyString",
|
||||||
|
TlsaHash: "hashValue",
|
||||||
|
DaneEnabled: true,
|
||||||
|
AdminEmailAddress: "admin@dnstest.example",
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := uc.Execute(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SyncDnsRecords execute: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.CreatedCount != 13 {
|
||||||
|
t.Errorf("CreatedCount = %d, want 13", out.CreatedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 4 system aliases in PostgreSQL (postmaster@, abuse@, dmarc@, tlsrpt@)
|
||||||
|
var aliasCount int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM aliases WHERE domain_id = $1 AND is_system = true`, domainID).Scan(&aliasCount); err != nil || aliasCount != 4 {
|
||||||
|
t.Fatalf("expected 4 system aliases in DB, got count=%d, err=%v", aliasCount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type e2eFakeDnsProvider struct {
|
||||||
|
records map[string]entity.DnsRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *e2eFakeDnsProvider) GetZoneID(_ context.Context, domainName string) (string, error) {
|
||||||
|
return "zone_123", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *e2eFakeDnsProvider) ListRecords(_ context.Context, _ string) ([]entity.DnsRecord, error) {
|
||||||
|
var out []entity.DnsRecord
|
||||||
|
for _, r := range f.records {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *e2eFakeDnsProvider) CreateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
|
k := record.Type + ":" + record.Name
|
||||||
|
f.records[k] = record
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *e2eFakeDnsProvider) UpdateRecord(_ context.Context, _ string, record entity.DnsRecord) error {
|
||||||
|
k := record.Type + ":" + record.Name
|
||||||
|
f.records[k] = record
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *e2eFakeDnsProvider) DeleteRecord(_ context.Context, _ string, recordID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -55,10 +55,18 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
|||||||
root := repoRoot(t)
|
root := repoRoot(t)
|
||||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read migration: %v", err)
|
t.Fatalf("read migration 0001: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
t.Fatalf("apply migration: %v", err)
|
t.Fatalf("apply migration 0001: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration 0002: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
|
||||||
|
t.Fatalf("apply migration 0002: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Seed domain, mailbox, INBOX, Archive
|
// 2. Seed domain, mailbox, INBOX, Archive
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloudflareAdapter struct {
|
||||||
|
apiToken string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
|
||||||
|
return &CloudflareAdapter{
|
||||||
|
apiToken: apiToken,
|
||||||
|
baseURL: "https://api.cloudflare.com/client/v4",
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfZoneResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"result"`
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfRecord struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
Proxied bool `json:"proxied,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfRecordResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result []cfRecord `json:"result"`
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfSingleRecordResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Result cfRecord `json:"result"`
|
||||||
|
Errors []struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
a.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data cfZoneResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return "", fmt.Errorf("decode json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Success || len(data.Result) == 0 {
|
||||||
|
errMsg := "zone not found"
|
||||||
|
if len(data.Errors) > 0 {
|
||||||
|
errMsg = data.Errors[0].Message
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.Result[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
a.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data cfRecordResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Success {
|
||||||
|
return nil, fmt.Errorf("cloudflare API error listing records")
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []entity.DnsRecord
|
||||||
|
for _, r := range data.Result {
|
||||||
|
out = append(out, entity.DnsRecord{
|
||||||
|
ID: r.ID,
|
||||||
|
Type: r.Type,
|
||||||
|
Name: r.Name,
|
||||||
|
Value: r.Content,
|
||||||
|
TTL: r.TTL,
|
||||||
|
Priority: r.Priority,
|
||||||
|
Proxied: r.Proxied,
|
||||||
|
Comment: r.Comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
|
||||||
|
cfBody := cfRecord{
|
||||||
|
Type: record.Type,
|
||||||
|
Name: record.Name,
|
||||||
|
Content: record.Value,
|
||||||
|
TTL: record.TTL,
|
||||||
|
Priority: record.Priority,
|
||||||
|
Proxied: record.Proxied,
|
||||||
|
Comment: record.Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(cfBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data cfSingleRecordResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Success {
|
||||||
|
errMsg := "create record failed"
|
||||||
|
if len(data.Errors) > 0 {
|
||||||
|
errMsg = data.Errors[0].Message
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID)
|
||||||
|
cfBody := cfRecord{
|
||||||
|
Type: record.Type,
|
||||||
|
Name: record.Name,
|
||||||
|
Content: record.Value,
|
||||||
|
TTL: record.TTL,
|
||||||
|
Priority: record.Priority,
|
||||||
|
Proxied: record.Proxied,
|
||||||
|
Comment: record.Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(cfBody)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var data cfSingleRecordResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !data.Success {
|
||||||
|
return fmt.Errorf("cloudflare API error updating record")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setHeaders(req)
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure port interface compliance
|
||||||
|
var _ port.DnsProvider = (*CloudflareAdapter)(nil)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCloudflareAdapter_GetZoneID_List_Create_Update_Delete(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// GET /zones?name=example.test
|
||||||
|
mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
if name == "example.test" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{"id": "zone_12345", "name": "example.test"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"errors": []map[string]string{{"message": "Zone not found"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /zones/zone_12345/dns_records & POST /zones/zone_12345/dns_records
|
||||||
|
mux.HandleFunc("/zones/zone_12345/dns_records", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": "rec_1",
|
||||||
|
"type": "A",
|
||||||
|
"name": "mail.example.test",
|
||||||
|
"content": "192.0.2.1",
|
||||||
|
"ttl": 1,
|
||||||
|
"proxied": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if r.Method == http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"id": "rec_2",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /zones/zone_12345/dns_records/rec_1
|
||||||
|
mux.HandleFunc("/zones/zone_12345/dns_records/rec_1", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"result": map[string]interface{}{"id": "rec_1"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
adapter := NewCloudflareAdapter("test_token")
|
||||||
|
adapter.baseURL = ts.URL
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. GetZoneID
|
||||||
|
zoneID, err := adapter.GetZoneID(ctx, "example.test")
|
||||||
|
if err != nil || zoneID != "zone_12345" {
|
||||||
|
t.Fatalf("GetZoneID: err=%v, zoneID=%s", err, zoneID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ListRecords
|
||||||
|
records, err := adapter.ListRecords(ctx, zoneID)
|
||||||
|
if err != nil || len(records) != 1 || records[0].Name != "mail.example.test" {
|
||||||
|
t.Fatalf("ListRecords: err=%v, records=%+v", err, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. CreateRecord
|
||||||
|
err = adapter.CreateRecord(ctx, zoneID, entity.DnsRecord{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: "example.test",
|
||||||
|
Value: "v=spf1 mx -all",
|
||||||
|
TTL: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. UpdateRecord
|
||||||
|
err = adapter.UpdateRecord(ctx, zoneID, entity.DnsRecord{
|
||||||
|
ID: "rec_1",
|
||||||
|
Type: "A",
|
||||||
|
Name: "mail.example.test",
|
||||||
|
Value: "192.0.2.2",
|
||||||
|
TTL: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateRecord: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AliasRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAliasRepository(pool *pgxpool.Pool) *AliasRepository {
|
||||||
|
return &AliasRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AliasRepository) EnsureSystemAliases(ctx context.Context, domainName, adminEmail string) error {
|
||||||
|
var domainID uuid.UUID
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT id FROM domains WHERE name = $1`, domainName).Scan(&domainID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find domain %s: %w", domainName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemAliasPrefixes := []string{"postmaster", "abuse", "dmarc", "tlsrpt"}
|
||||||
|
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
for _, prefix := range systemAliasPrefixes {
|
||||||
|
sourceAddr := fmt.Sprintf("%s@%s", prefix, domainName)
|
||||||
|
destArray := []string{adminEmail}
|
||||||
|
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO aliases (domain_id, source_address, destination_addresses, is_active, is_system)
|
||||||
|
VALUES ($1, $2, $3, true, true)
|
||||||
|
ON CONFLICT (domain_id, source_address)
|
||||||
|
DO UPDATE SET destination_addresses = EXCLUDED.destination_addresses, is_system = true
|
||||||
|
`, domainID, sourceAddr, destArray)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert system alias %s: %w", sourceAddr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAliasRepository_EnsureSystemAliases(t *testing.T) {
|
||||||
|
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
domainID := uuid.New()
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'dnsalias.test', 'dnsalias.test')`, domainID); err != nil {
|
||||||
|
t.Fatalf("seed domain: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
||||||
|
|
||||||
|
repo := NewAliasRepository(pool)
|
||||||
|
|
||||||
|
if err := repo.EnsureSystemAliases(ctx, "dnsalias.test", "admin@dnsalias.test"); err != nil {
|
||||||
|
t.Fatalf("EnsureSystemAliases: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify postmaster, abuse, dmarc, tlsrpt aliases exist
|
||||||
|
var count int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM aliases WHERE domain_id = $1 AND is_system = true`, domainID).Scan(&count); err != nil || count != 4 {
|
||||||
|
t.Fatalf("expected 4 system aliases in DB, got count=%d, err=%v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user