Files
OpenFlare/internal/apps/openflare/cloudflare/reconcile_test.go
T
ryan 3edcdb9e9f feat(cloudflare): add DNS pointing integration
Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
2026-08-04 12:32:37 +08:00

219 lines
8.1 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cloudflare
import (
"context"
"errors"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/credential"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type fakeClient struct {
records []DNSRecord
created *RecordInput
updated *RecordInput
deleted []string
deleteErrors map[string]error
}
func (client *fakeClient) VerifyToken(context.Context) error { return nil }
func (client *fakeClient) FindZone(context.Context, string) (*Zone, error) {
return &Zone{ID: "zone-1", Name: "example.com"}, nil
}
func (client *fakeClient) GetRecord(context.Context, string, string) (*DNSRecord, error) {
return nil, errors.New("not found")
}
func (client *fakeClient) ListARecords(context.Context, string, string) ([]DNSRecord, error) {
return client.records, nil
}
func (client *fakeClient) CreateARecord(_ context.Context, _ string, input RecordInput) (*DNSRecord, error) {
client.created = &input
return &DNSRecord{ID: "record-created", Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
}
func (client *fakeClient) UpdateARecord(_ context.Context, _, id string, input RecordInput) (*DNSRecord, error) {
client.updated = &input
return &DNSRecord{ID: id, Name: input.Name, Content: input.Content, Proxied: input.Proxied}, nil
}
func (client *fakeClient) DeleteRecord(_ context.Context, _, recordID string) error {
client.deleted = append(client.deleted, recordID)
return client.deleteErrors[recordID]
}
func setupCloudflareLogicDB(t *testing.T) (context.Context, uint) {
t.Helper()
conn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
if err != nil {
t.Fatalf("gorm.Open() error = %v", err)
}
if err := conn.AutoMigrate(
&model.CFConnection{}, &model.CFPointingGroup{}, &model.CFPointingMember{},
&model.Zone{}, &model.ZoneDomain{}, &model.OpenFlareNode{}, &model.DNSAccount{},
); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
db.SetDB(conn)
t.Cleanup(func() { db.SetDB(nil) })
ctx := context.Background()
sealed, err := credential.Seal(`{"api_token":"test-token"}`)
if err != nil {
t.Fatalf("credential.Seal() error = %v", err)
}
if err := repository.UpsertCFConnection(ctx, &model.CFConnection{Source: model.CFConnectionSourceStandalone, Authorization: sealed, Status: model.CFConnectionStatusReady}); err != nil {
t.Fatalf("UpsertCFConnection() error = %v", err)
}
zone := model.Zone{Domain: "example.com"}
node := model.OpenFlareNode{Name: "edge", NodeID: "node-1", NodeType: "edge_node", IP: "203.0.113.10"}
if err := conn.Create(&zone).Error; err != nil {
t.Fatalf("Create(zone) error = %v", err)
}
if err := conn.Create(&node).Error; err != nil {
t.Fatalf("Create(node) error = %v", err)
}
domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "api.example.com"}
if err := conn.Create(&domain).Error; err != nil {
t.Fatalf("Create(domain) error = %v", err)
}
group := model.CFPointingGroup{Name: "primary", PrimaryNodeID: node.ID, ActiveNodeID: node.ID, DefaultProxied: true, Enabled: true}
if err := conn.Create(&group).Error; err != nil {
t.Fatalf("Create(group) error = %v", err)
}
member := model.CFPointingMember{GroupID: group.ID, ZoneDomainID: domain.ID, Proxied: true, SyncStatus: model.CFMemberSyncPending}
if err := conn.Create(&member).Error; err != nil {
t.Fatalf("Create(member) error = %v", err)
}
return ctx, member.ID
}
func TestReconcileMemberCreatesMissingARecord(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
fake := &fakeClient{}
restore := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restore)
if err := ReconcileMember(ctx, memberID); err != nil {
t.Fatalf("ReconcileMember() error = %v", err)
}
if fake.created == nil || fake.created.Content != "203.0.113.10" || !fake.created.Proxied || fake.created.TTL != 1 {
t.Errorf("CreateARecord input = %+v", fake.created)
}
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
if member.SyncStatus != model.CFMemberSyncOK || member.CFRecordID != "record-created" || member.DesiredIP != "203.0.113.10" {
t.Errorf("reconciled member = %+v", member)
}
}
func TestReconcileMemberRejectsMultipleSameNameARecords(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
fake := &fakeClient{records: []DNSRecord{{ID: "one"}, {ID: "two"}}}
restore := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restore)
if err := ReconcileMember(ctx, memberID); err == nil {
t.Fatal("ReconcileMember() error = nil, want duplicate record error")
}
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
if member.SyncStatus != model.CFMemberSyncError || member.LastError == "" {
t.Errorf("failed member = %+v", member)
}
}
func TestCreateMemberCopiesGroupDefaultProxied(t *testing.T) {
ctx, existingMemberID := setupCloudflareLogicDB(t)
existing, err := repository.GetCFPointingMemberByID(ctx, existingMemberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
group, err := repository.GetCFPointingGroup(ctx, existing.GroupID)
if err != nil {
t.Fatalf("GetCFPointingGroup() error = %v", err)
}
zone := model.Zone{Domain: "example.net"}
if err := db.DB(ctx).Create(&zone).Error; err != nil {
t.Fatalf("Create(zone) error = %v", err)
}
domain := model.ZoneDomain{ZoneID: zone.ID, Domain: "www.example.net"}
if err := db.DB(ctx).Create(&domain).Error; err != nil {
t.Fatalf("Create(domain) error = %v", err)
}
restore := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) { return "task-1", nil })
t.Cleanup(restore)
member, err := CreateMember(ctx, group.ID, MemberCreateInput{ZoneDomainID: domain.ID})
if err != nil {
t.Fatalf("CreateMember() error = %v", err)
}
if !member.Proxied {
t.Error("CreateMember() proxied = false, want group default true")
}
}
func TestDeleteManagedRecordFallsBackWhenCachedRecordIsStale(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{
"cf_zone_id": "zone-1",
"cf_record_id": "stale-record",
}); err != nil {
t.Fatalf("UpdateCFPointingMemberColumns() error = %v", err)
}
fake := &fakeClient{
records: []DNSRecord{{ID: "actual-record"}},
deleteErrors: map[string]error{"stale-record": errors.New("not found")},
}
restore := SetClientFactoryForTest(func(string) Client { return fake })
t.Cleanup(restore)
if err := DeleteManagedRecord(ctx, memberID); err != nil {
t.Fatalf("DeleteManagedRecord() error = %v", err)
}
if len(fake.deleted) != 2 || fake.deleted[0] != "stale-record" || fake.deleted[1] != "actual-record" {
t.Errorf("deleted record IDs = %v, want [stale-record actual-record]", fake.deleted)
}
}
func TestUpdateMemberDoesNotDispatchWhenGroupIsDisabled(t *testing.T) {
ctx, memberID := setupCloudflareLogicDB(t)
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
if err != nil {
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
}
group, err := repository.GetCFPointingGroup(ctx, member.GroupID)
if err != nil {
t.Fatalf("GetCFPointingGroup() error = %v", err)
}
group.Enabled = false
if err = repository.SaveCFPointingGroup(ctx, group); err != nil {
t.Fatalf("SaveCFPointingGroup() error = %v", err)
}
dispatchCount := 0
restore := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
dispatchCount++
return "task-1", nil
})
t.Cleanup(restore)
updated, err := UpdateMember(ctx, group.ID, memberID, MemberUpdateInput{Proxied: false})
if err != nil {
t.Fatalf("UpdateMember() error = %v", err)
}
if updated.SyncStatus != model.CFMemberSyncPending {
t.Errorf("UpdateMember() sync status = %q, want %q", updated.SyncStatus, model.CFMemberSyncPending)
}
if dispatchCount != 0 {
t.Errorf("dispatch count = %d, want 0", dispatchCount)
}
}