mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
3edcdb9e9f
Implement Cloudflare connection management, pointing groups and members, asynchronous A-record reconciliation, node IP triggers, admin APIs, management pages, migrations, tests, and documentation.
219 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|