@@ -1629,3 +1629,148 @@ func TestPojChallengeGeneration(t *testing.T) {
16291629 t .Errorf ("Expected PoJ JS URL in challenge page" )
16301630 }
16311631}
1632+
1633+ func TestPerformHealthCheckSuccessResetsFailures (t * testing.T ) {
1634+ config := CreateConfig ()
1635+ config .SiteKey = "test"
1636+ config .SecretKey = "test"
1637+ config .ProtectRoutes = []string {"/" }
1638+ config .CaptchaProvider = "turnstile"
1639+ config .PeriodSeconds = 3600
1640+ config .FailureThreshold = 2
1641+
1642+ ctx , cancel := context .WithCancel (context .Background ())
1643+ defer cancel ()
1644+
1645+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1646+ if err != nil {
1647+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1648+ }
1649+
1650+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1651+ w .WriteHeader (http .StatusOK )
1652+ }))
1653+ defer server .Close ()
1654+
1655+ bc .captchaConfig .js = server .URL
1656+ bc .recordHealthCheckFailure ()
1657+
1658+ bc .performHealthCheck ()
1659+
1660+ bc .mu .RLock ()
1661+ defer bc .mu .RUnlock ()
1662+ if bc .healthCheckFailureCount != 0 {
1663+ t .Fatalf ("expected failure count reset to 0, got %d" , bc .healthCheckFailureCount )
1664+ }
1665+ if bc .circuitState != circuitClosed {
1666+ t .Fatalf ("expected circuit to be closed, got %v" , bc .circuitState )
1667+ }
1668+ }
1669+
1670+ func TestPerformHealthCheckFailurePaths (t * testing.T ) {
1671+ tests := []struct {
1672+ name string
1673+ jsURL string
1674+ status int
1675+ expectErr bool
1676+ }{
1677+ {
1678+ name : "404 considered failure" ,
1679+ status : http .StatusNotFound ,
1680+ },
1681+ {
1682+ name : "503 considered failure" ,
1683+ status : http .StatusServiceUnavailable ,
1684+ },
1685+ {
1686+ name : "invalid URL request creation failure" ,
1687+ jsURL : "://invalid-url" ,
1688+ expectErr : true ,
1689+ },
1690+ }
1691+
1692+ for _ , tt := range tests {
1693+ t .Run (tt .name , func (t * testing.T ) {
1694+ config := CreateConfig ()
1695+ config .SiteKey = "test"
1696+ config .SecretKey = "test"
1697+ config .ProtectRoutes = []string {"/" }
1698+ config .CaptchaProvider = "turnstile"
1699+ config .PeriodSeconds = 3600
1700+ config .FailureThreshold = 1
1701+
1702+ ctx , cancel := context .WithCancel (context .Background ())
1703+ defer cancel ()
1704+
1705+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1706+ if err != nil {
1707+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1708+ }
1709+
1710+ if tt .expectErr {
1711+ bc .captchaConfig .js = tt .jsURL
1712+ } else {
1713+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1714+ w .WriteHeader (tt .status )
1715+ }))
1716+ defer server .Close ()
1717+ bc .captchaConfig .js = server .URL
1718+ }
1719+
1720+ bc .performHealthCheck ()
1721+
1722+ bc .mu .RLock ()
1723+ defer bc .mu .RUnlock ()
1724+ if bc .healthCheckFailureCount != 1 {
1725+ t .Fatalf ("expected failure count 1, got %d" , bc .healthCheckFailureCount )
1726+ }
1727+ if bc .circuitState != circuitOpen {
1728+ t .Fatalf ("expected circuit to open, got %v" , bc .circuitState )
1729+ }
1730+ })
1731+ }
1732+ }
1733+
1734+ func TestVerifyChallengePagePojFallbackUsesOneHourTTL (t * testing.T ) {
1735+ config := CreateConfig ()
1736+ config .SiteKey = "test-key"
1737+ config .SecretKey = "test-secret"
1738+ config .ProtectRoutes = []string {"/" }
1739+ config .CaptchaProvider = "turnstile"
1740+ config .PeriodSeconds = 3600
1741+ config .FailureThreshold = 1
1742+
1743+ ctx , cancel := context .WithCancel (context .Background ())
1744+ defer cancel ()
1745+
1746+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1747+ if err != nil {
1748+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1749+ }
1750+
1751+ // Open the circuit so PoJ becomes active fallback provider.
1752+ bc .recordHealthCheckFailure ()
1753+
1754+ form := url.Values {}
1755+ form .Add ("poj-captcha-response" , "ok" )
1756+ form .Add ("destination" , "%2F" )
1757+ req := httptest .NewRequest (http .MethodPost , "/challenge" , strings .NewReader (form .Encode ()))
1758+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
1759+ rr := httptest .NewRecorder ()
1760+ clientIP := "203.0.113.10"
1761+
1762+ status := bc .verifyChallengePage (rr , req , clientIP )
1763+ if status != http .StatusFound {
1764+ t .Fatalf ("expected status %d, got %d" , http .StatusFound , status )
1765+ }
1766+
1767+ item , found := bc .verifiedCache .Items ()[clientIP ]
1768+ if ! found {
1769+ t .Fatalf ("expected %s to be in verified cache" , clientIP )
1770+ }
1771+
1772+ remaining := time .Until (time .Unix (0 , item .Expiration ))
1773+ if remaining < 50 * time .Minute || remaining > 70 * time .Minute {
1774+ t .Fatalf ("expected PoJ fallback TTL around 1h, got %s" , remaining )
1775+ }
1776+ }
0 commit comments