diff --git a/backend/internal/repository/proxy_inactive_backup_integration_test.go b/backend/internal/repository/proxy_inactive_backup_integration_test.go new file mode 100644 index 0000000000..e8f9fee39f --- /dev/null +++ b/backend/internal/repository/proxy_inactive_backup_integration_test.go @@ -0,0 +1,40 @@ +//go:build integration + +package repository + +import ( + "github.com/Wei-Shaw/sub2api/internal/service" + "time" +) + +func (s *ProxyExpirySuite) TestSweep_SkipsInactiveBackup() { + for _, chain := range []bool{false, true} { + s.Run(map[bool]string{false: "unresolved", true: "next active backup"}[chain], func() { + now := time.Now() + past := now.Add(-time.Hour) + future := now.Add(time.Hour) + healthy := s.mkProxy("active-tail", service.FallbackModeNone, &future, nil) + backup := s.mkProxy("disabled-backup", service.FallbackModeNone, &future, nil) + disabled, err := s.repo.GetByID(s.ctx, backup) + s.Require().NoError(err) + disabled.Status = "inactive" + if chain { + disabled.FallbackMode = service.FallbackModeProxy + disabled.BackupProxyID = &healthy + } + s.Require().NoError(s.repo.Update(s.ctx, disabled)) + source := s.mkProxy("expired-source", service.FallbackModeProxy, &past, &backup) + account := s.mkAccountWithProxy(source) + _, err = s.repo.SweepExpiredProxies(s.ctx, now) + s.Require().NoError(err) + if chain { + s.Equal(&healthy, s.accountProxyID(account)) + } else { + s.Equal(&source, s.accountProxyID(account)) + } + got, err := s.repo.GetByID(s.ctx, backup) + s.Require().NoError(err) + s.Equal("inactive", got.Status, "fallback traversal must not reactivate disabled proxies") + }) + } +} diff --git a/backend/internal/service/proxy_fallback.go b/backend/internal/service/proxy_fallback.go index aae32d0f32..7986988854 100644 --- a/backend/internal/service/proxy_fallback.go +++ b/backend/internal/service/proxy_fallback.go @@ -27,7 +27,8 @@ func ResolveProxyFallbackTarget(start Proxy, byID map[int64]Proxy, now time.Time if !ok { return nil, false } - if !(&p).IsExpired(now) && p.Status != StatusExpired { + // Disabled nodes may define a fallback, but cannot be selected as targets. + if p.IsActive() && !p.IsExpired(now) { id := p.ID return &id, true } diff --git a/backend/internal/service/proxy_fallback_test.go b/backend/internal/service/proxy_fallback_test.go index c0223a5028..04bf6a0a34 100644 --- a/backend/internal/service/proxy_fallback_test.go +++ b/backend/internal/service/proxy_fallback_test.go @@ -10,7 +10,7 @@ import ( ) func mkProxy(id int64, mode string, backup *int64, expiresInDays *int, now time.Time) Proxy { - p := Proxy{ID: id, FallbackMode: mode, BackupProxyID: backup} + p := Proxy{ID: id, Status: StatusActive, FallbackMode: mode, BackupProxyID: backup} if expiresInDays != nil { t := now.AddDate(0, 0, *expiresInDays) p.ExpiresAt = &t @@ -71,3 +71,35 @@ func TestResolveFallbackTarget(t *testing.T) { require.Nil(t, target) }) } + +func TestResolveFallbackSkipsInactiveBackup(t *testing.T) { + now := time.Now() + for _, mode := range []string{FallbackModeNone, FallbackModeProxy, FallbackModeDirect} { + t.Run(mode, func(t *testing.T) { + source := mkProxy(1, FallbackModeProxy, i64(2), di(-1), now) + disabled := mkProxy(2, mode, i64(3), di(30), now) + disabled.Status = "inactive" + healthy := mkProxy(3, FallbackModeNone, nil, di(30), now) + target, change := ResolveProxyFallbackTarget(source, map[int64]Proxy{1: source, 2: disabled, 3: healthy}, now) + switch mode { + case FallbackModeNone: + require.False(t, change) + require.Nil(t, target) + case FallbackModeProxy: + require.True(t, change) + require.Equal(t, i64(3), target) + case FallbackModeDirect: + require.True(t, change) + require.Nil(t, target) + } + }) + } + t.Run("inactive cycle", func(t *testing.T) { + source := mkProxy(1, FallbackModeProxy, i64(2), di(-1), now) + disabled := mkProxy(2, FallbackModeProxy, i64(1), nil, now) + disabled.Status = "inactive" + target, change := ResolveProxyFallbackTarget(source, map[int64]Proxy{1: source, 2: disabled}, now) + require.False(t, change) + require.Nil(t, target) + }) +}