Files
ctrld/cmd/cli/api_reload_test.go
T
Cuong Manh Le 25b79d5e73 cli: route organization Internal Domains from managed config
Port the final merged change from !1009 to master, retaining master's
context-aware logger/resolver APIs, proxy decomposition and firewall
refresh handling. Adapt source regression tests and cover master's
refresh handler and transport-level private-address diagnostics.
2026-09-18 13:37:27 +07:00

300 lines
10 KiB
Go

package cli
import (
"context"
"net/netip"
"sync"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// progForRefresh builds a prog that can run a configuration refresh: Firewall
// Mode on, and a buffered reload channel so a refresh that decides ctrld must
// reload does not block on a listener that does not exist in a test.
func progForRefresh() *prog {
p := progWithAllowList()
p.rc = &controld.ResolverConfig{}
p.apiReloadCh = make(chan *ctrld.Config, 1)
return p
}
// refresh runs one configuration refresh through the handler apiConfigReload
// uses, which is the point: a test that called applyAllowedDestinations directly
// would still pass if the refresh path stopped calling it.
func refresh(t *testing.T, p *prog, forced bool, rc *controld.ResolverConfig) {
t.Helper()
p.applyFetchedResolverConfig(context.Background(), discardLogger(), rc, forced, time.Now().Unix())
}
// TestRefreshInternalDomains covers the master-specific refresh handler after
// apiConfigReload delegates to it, including changes with unchanged excludes.
func TestRefreshInternalDomains(t *testing.T) {
for _, forced := range []bool{false, true} {
name := "scheduled"
if forced {
name = "forced"
}
t.Run(name, func(t *testing.T) {
// No platform firewall or service is installed by this fixture.
p := &prog{rc: &controld.ResolverConfig{}, apiReloadCh: make(chan *ctrld.Config, 1)}
p.logger.Store(discardLogger())
for _, step := range []struct {
name string
entries []controld.SplitDNS
reload bool
}{
{"absent", nil, false},
{"add", []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}, true},
{"unchanged", []controld.SplitDNS{{Domain: "CORP.EXAMPLE.", Mode: "os"}}, false},
{"mode", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.2"}}}, true},
{"resolver", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
{"domain", []controld.SplitDNS{{Domain: "office.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
{"remove", nil, true},
{"empty unchanged", []controld.SplitDNS{}, false},
} {
t.Run(step.name, func(t *testing.T) {
rc := &controld.ResolverConfig{SplitDNS: step.entries}
refresh(t, p, forced, rc)
gotReload := false
select {
case cfg := <-p.apiReloadCh:
gotReload = true
if cfg != nil {
t.Fatal("managed change must request config regeneration")
}
default:
}
if gotReload != step.reload {
t.Fatalf("reload = %v, want %v", gotReload, step.reload)
}
if p.rc != rc {
t.Fatal("refresh did not retain the authoritative response")
}
})
}
// Changes to Internal Domains must not override a custom config.
rc := &controld.ResolverConfig{SplitDNS: []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}}
rc.Ctrld.CustomConfig = "custom-config-is-authoritative"
refresh(t, p, false, rc)
select {
case <-p.apiReloadCh:
t.Fatal("Internal Domains overrode unchanged custom config")
default:
}
})
}
}
// TestRefreshAppliesAllowedDestinations drives the real refresh handler for both
// the scheduled and the forced path, in the case where nothing else about the
// configuration changed - no custom config, unchanged exclusions - so the
// refresh takes its early return. That is where an allowed-destination update is
// easiest to lose, because the refresh has no other work to do.
func TestRefreshAppliesAllowedDestinations(t *testing.T) {
direct := netip.MustParseAddr("203.0.113.10")
inRange := netip.MustParseAddr("198.51.100.7")
for _, forced := range []bool{false, true} {
name := "scheduled refresh"
if forced {
name = "forced refresh"
}
t.Run(name, func(t *testing.T) {
p := progForRefresh()
refresh(t, p, forced, &controld.ResolverConfig{
DestinationIPs: []string{"203.0.113.10", "198.51.100.0/24"},
})
if !p.allowList.Contains(direct) || !p.allowList.Contains(inRange) {
t.Fatalf("refresh did not apply the organization list: %s=%v %s=%v",
direct, p.allowList.Contains(direct), inRange, p.allowList.Contains(inRange))
}
select {
case cfg := <-p.apiReloadCh:
t.Fatalf("unchanged configuration signaled a reload (%v)", cfg)
default:
}
// A later refresh withdraws one entry and keeps the other.
refresh(t, p, forced, &controld.ResolverConfig{
DestinationIPs: []string{"203.0.113.10"},
})
if p.allowList.Contains(inRange) {
t.Fatalf("%s still allowed after the refresh that withdrew it", inRange)
}
if !p.allowList.Contains(direct) {
t.Fatalf("%s should still be allowed", direct)
}
// And one clears the list entirely.
refresh(t, p, forced, &controld.ResolverConfig{})
if p.allowList.Contains(direct) {
t.Fatalf("%s still allowed after the refresh that cleared the list", direct)
}
})
}
}
// TestRefreshAppliesAllowedDestinationsWhenReloading covers the other branch of
// the same handler: a refresh that also changes the exclusion list, and so
// signals a ctrld reload, must still apply the destinations - and they must
// survive the allowlist flush that the reload performs.
func TestRefreshAppliesAllowedDestinationsWhenReloading(t *testing.T) {
p := progForRefresh()
p.rc = &controld.ResolverConfig{Exclude: []string{"example.com"}}
direct := netip.MustParseAddr("203.0.113.10")
refresh(t, p, false, &controld.ResolverConfig{
Exclude: []string{"example.com", "example.net"},
DestinationIPs: []string{"203.0.113.10"},
})
select {
case <-p.apiReloadCh:
default:
t.Fatal("exclusion list change did not signal a reload")
}
if !p.allowList.Contains(direct) {
t.Fatalf("%s not allowed after a refresh that reloaded ctrld", direct)
}
}
// TestRefreshKeepsAllowedDestinationsPendingUntilMirrored pins that the refresh
// path reports honestly: while platform enforcement is rejecting the change, the
// refresh leaves it pending and retries it, instead of recording it as applied.
func TestRefreshKeepsAllowedDestinationsPendingUntilMirrored(t *testing.T) {
p := progForRefresh()
var calls []mirrorCall
failing := true
stubMirror(t, &calls, &failing)
rc := &controld.ResolverConfig{DestinationIPs: []string{"203.0.113.10"}}
refresh(t, p, false, rc)
if got := p.pendingDestinations(p.allowList); got != 1 {
t.Fatalf("pendingDestinations = %d after a rejected mirror, want 1", got)
}
refresh(t, p, false, rc)
if len(calls) != 2 {
t.Fatalf("refresh did not retry the rejected change: calls = %v", calls)
}
failing = false
refresh(t, p, false, rc)
if got := p.pendingDestinations(p.allowList); got != 0 {
t.Fatalf("pendingDestinations = %d after the mirror succeeded, want 0", got)
}
}
// stubResolverConfigFetch replaces the refresh loop's API call for the duration
// of a test, and points cdUID at a device so apiConfigReload does not return
// immediately. Each fetch returns the config the test currently wants and
// reports on fetched, which is how a test knows a refresh cycle has run.
func stubResolverConfigFetch(t *testing.T, config func() *controld.ResolverConfig, fetched chan<- struct{}) {
t.Helper()
origFetch, origUID := fetchResolverConfigFn, cdUID
t.Cleanup(func() { fetchResolverConfigFn, cdUID = origFetch, origUID })
cdUID = "test-uid"
fetchResolverConfigFn = func(context.Context, *controld.ResolverConfigRequest, bool) (*controld.ResolverConfig, error) {
rc := config()
select {
case fetched <- struct{}{}:
default:
}
return rc, nil
}
}
// startRefreshLoop runs apiConfigReload in the background and joins it before the
// test finishes. The loop must not outlive the test: it calls through the same
// package-level stubs the next test replaces, so a leaked one would both race
// those globals and act on another test's prog.
func startRefreshLoop(t *testing.T, p *prog) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
p.apiConfigReload()
}()
t.Cleanup(func() {
close(p.stopCh)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("apiConfigReload did not stop")
}
})
}
// waitForCondition polls until cond holds, failing the test if it never does. The
// refresh loop runs in its own goroutine, so its effects land asynchronously.
func waitForCondition(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// TestApiConfigReloadAppliesAllowedDestinations drives apiConfigReload itself -
// the loop that owns the refresh ticker and the forced-reload channel - rather
// than the handler it calls, so the wiring between them is covered too: removing
// the handler call from the loop must fail a test, not just removing the work
// inside the handler.
func TestApiConfigReloadAppliesAllowedDestinations(t *testing.T) {
direct := netip.MustParseAddr("203.0.113.10")
for _, forced := range []bool{true, false} {
name := "forced reload"
if !forced {
name = "refresh ticker"
}
t.Run(name, func(t *testing.T) {
p := progForRefresh()
p.cfg = &ctrld.Config{}
refetch := 1 // seconds; only the ticker path waits for it
p.cfg.Service.RefetchTime = &refetch
p.stopCh = make(chan struct{})
p.apiForceReloadCh = make(chan struct{})
var mu sync.Mutex
destinations := []string{"203.0.113.10"}
fetched := make(chan struct{}, 1)
stubResolverConfigFetch(t, func() *controld.ResolverConfig {
mu.Lock()
defer mu.Unlock()
return &controld.ResolverConfig{DestinationIPs: append([]string(nil), destinations...)}
}, fetched)
startRefreshLoop(t, p)
if forced {
p.apiForceReloadCh <- struct{}{}
}
waitForCondition(t, "the destination to be applied", func() bool {
return p.allowList.Contains(direct)
})
// The organization withdraws it; the next cycle must take it away.
mu.Lock()
destinations = nil
mu.Unlock()
if forced {
p.apiForceReloadCh <- struct{}{}
}
waitForCondition(t, "the withdrawn destination to stop being allowed", func() bool {
return !p.allowList.Contains(direct)
})
})
}
}