@@ -14,6 +14,8 @@ import (
1414 "strings"
1515 "testing"
1616 "time"
17+
18+ "github.com/libops/captcha-protect/internal/helper"
1719)
1820
1921func TestParseIp (t * testing.T ) {
@@ -1629,3 +1631,221 @@ func TestPojChallengeGeneration(t *testing.T) {
16291631 t .Errorf ("Expected PoJ JS URL in challenge page" )
16301632 }
16311633}
1634+
1635+ func TestPerformHealthCheckSuccessResetsFailures (t * testing.T ) {
1636+ config := CreateConfig ()
1637+ config .SiteKey = "test"
1638+ config .SecretKey = "test"
1639+ config .ProtectRoutes = []string {"/" }
1640+ config .CaptchaProvider = "turnstile"
1641+ config .PeriodSeconds = 3600
1642+ config .FailureThreshold = 2
1643+
1644+ ctx , cancel := context .WithCancel (context .Background ())
1645+ defer cancel ()
1646+
1647+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1648+ if err != nil {
1649+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1650+ }
1651+
1652+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1653+ w .WriteHeader (http .StatusOK )
1654+ }))
1655+ defer server .Close ()
1656+
1657+ bc .captchaConfig .js = server .URL
1658+ bc .recordHealthCheckFailure ()
1659+
1660+ bc .performHealthCheck ()
1661+
1662+ bc .mu .RLock ()
1663+ defer bc .mu .RUnlock ()
1664+ if bc .healthCheckFailureCount != 0 {
1665+ t .Fatalf ("expected failure count reset to 0, got %d" , bc .healthCheckFailureCount )
1666+ }
1667+ if bc .circuitState != circuitClosed {
1668+ t .Fatalf ("expected circuit to be closed, got %v" , bc .circuitState )
1669+ }
1670+ }
1671+
1672+ func TestPerformHealthCheckFailurePaths (t * testing.T ) {
1673+ tests := []struct {
1674+ name string
1675+ jsURL string
1676+ status int
1677+ expectErr bool
1678+ }{
1679+ {
1680+ name : "404 considered failure" ,
1681+ status : http .StatusNotFound ,
1682+ },
1683+ {
1684+ name : "503 considered failure" ,
1685+ status : http .StatusServiceUnavailable ,
1686+ },
1687+ {
1688+ name : "invalid URL request creation failure" ,
1689+ jsURL : "://invalid-url" ,
1690+ expectErr : true ,
1691+ },
1692+ }
1693+
1694+ for _ , tt := range tests {
1695+ t .Run (tt .name , func (t * testing.T ) {
1696+ config := CreateConfig ()
1697+ config .SiteKey = "test"
1698+ config .SecretKey = "test"
1699+ config .ProtectRoutes = []string {"/" }
1700+ config .CaptchaProvider = "turnstile"
1701+ config .PeriodSeconds = 3600
1702+ config .FailureThreshold = 1
1703+
1704+ ctx , cancel := context .WithCancel (context .Background ())
1705+ defer cancel ()
1706+
1707+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1708+ if err != nil {
1709+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1710+ }
1711+
1712+ if tt .expectErr {
1713+ bc .captchaConfig .js = tt .jsURL
1714+ } else {
1715+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1716+ w .WriteHeader (tt .status )
1717+ }))
1718+ defer server .Close ()
1719+ bc .captchaConfig .js = server .URL
1720+ }
1721+
1722+ bc .performHealthCheck ()
1723+
1724+ bc .mu .RLock ()
1725+ defer bc .mu .RUnlock ()
1726+ if bc .healthCheckFailureCount != 1 {
1727+ t .Fatalf ("expected failure count 1, got %d" , bc .healthCheckFailureCount )
1728+ }
1729+ if bc .circuitState != circuitOpen {
1730+ t .Fatalf ("expected circuit to open, got %v" , bc .circuitState )
1731+ }
1732+ })
1733+ }
1734+ }
1735+
1736+ func TestVerifyChallengePagePojFallbackUsesOneHourTTL (t * testing.T ) {
1737+ config := CreateConfig ()
1738+ config .SiteKey = "test-key"
1739+ config .SecretKey = "test-secret"
1740+ config .ProtectRoutes = []string {"/" }
1741+ config .CaptchaProvider = "turnstile"
1742+ config .PeriodSeconds = 3600
1743+ config .FailureThreshold = 1
1744+
1745+ ctx , cancel := context .WithCancel (context .Background ())
1746+ defer cancel ()
1747+
1748+ bc , err := NewCaptchaProtect (ctx , nil , config , "test" )
1749+ if err != nil {
1750+ t .Fatalf ("Failed to create CaptchaProtect: %v" , err )
1751+ }
1752+
1753+ // Open the circuit so PoJ becomes active fallback provider.
1754+ bc .recordHealthCheckFailure ()
1755+
1756+ form := url.Values {}
1757+ form .Add ("poj-captcha-response" , "ok" )
1758+ form .Add ("destination" , "%2F" )
1759+ req := httptest .NewRequest (http .MethodPost , "/challenge" , strings .NewReader (form .Encode ()))
1760+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
1761+ rr := httptest .NewRecorder ()
1762+ clientIP := "203.0.113.10"
1763+
1764+ status := bc .verifyChallengePage (rr , req , clientIP )
1765+ if status != http .StatusFound {
1766+ t .Fatalf ("expected status %d, got %d" , http .StatusFound , status )
1767+ }
1768+
1769+ item , found := bc .verifiedCache .Items ()[clientIP ]
1770+ if ! found {
1771+ t .Fatalf ("expected %s to be in verified cache" , clientIP )
1772+ }
1773+
1774+ remaining := time .Until (time .Unix (0 , item .Expiration ))
1775+ if remaining < 50 * time .Minute || remaining > 70 * time .Minute {
1776+ t .Fatalf ("expected PoJ fallback TTL around 1h, got %s" , remaining )
1777+ }
1778+ }
1779+
1780+ func TestGooglebotIPCheckLoopInitialFetchSuccess (t * testing.T ) {
1781+ originalURLs := helper .GoogleCrawlerIPRangeURLs
1782+ defer func () { helper .GoogleCrawlerIPRangeURLs = originalURLs }()
1783+
1784+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1785+ w .Header ().Set ("Content-Type" , "application/json" )
1786+ _ , _ = w .Write ([]byte (`{"prefixes":[{"ipv4Prefix":"203.0.113.0/24"}]}` ))
1787+ }))
1788+ defer server .Close ()
1789+
1790+ helper .GoogleCrawlerIPRangeURLs = []string {server .URL }
1791+
1792+ bc := & CaptchaProtect {
1793+ log : slog .New (slog .NewTextHandler (os .Stdout , nil )),
1794+ httpClient : server .Client (),
1795+ googlebotIPs : helper .NewGooglebotIPs (),
1796+ }
1797+
1798+ ctx , cancel := context .WithCancel (context .Background ())
1799+ done := make (chan struct {})
1800+ go func () {
1801+ bc .googlebotIPCheckLoop (ctx )
1802+ close (done )
1803+ }()
1804+
1805+ deadline := time .Now ().Add (2 * time .Second )
1806+ for time .Now ().Before (deadline ) {
1807+ if bc .googlebotIPs .Contains (net .ParseIP ("203.0.113.10" )) {
1808+ cancel ()
1809+ <- done
1810+ return
1811+ }
1812+ time .Sleep (20 * time .Millisecond )
1813+ }
1814+
1815+ cancel ()
1816+ <- done
1817+ t .Fatal ("expected googlebot IPs to be updated from initial crawler fetch" )
1818+ }
1819+
1820+ func TestGooglebotIPCheckLoopInitialFetchError (t * testing.T ) {
1821+ originalURLs := helper .GoogleCrawlerIPRangeURLs
1822+ defer func () { helper .GoogleCrawlerIPRangeURLs = originalURLs }()
1823+
1824+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , _ * http.Request ) {
1825+ http .Error (w , "boom" , http .StatusInternalServerError )
1826+ }))
1827+ defer server .Close ()
1828+
1829+ helper .GoogleCrawlerIPRangeURLs = []string {server .URL }
1830+
1831+ bc := & CaptchaProtect {
1832+ log : slog .New (slog .NewTextHandler (os .Stdout , nil )),
1833+ httpClient : server .Client (),
1834+ googlebotIPs : helper .NewGooglebotIPs (),
1835+ }
1836+
1837+ ctx , cancel := context .WithCancel (context .Background ())
1838+ done := make (chan struct {})
1839+ go func () {
1840+ bc .googlebotIPCheckLoop (ctx )
1841+ close (done )
1842+ }()
1843+
1844+ time .Sleep (100 * time .Millisecond )
1845+ cancel ()
1846+ <- done
1847+
1848+ if bc .googlebotIPs .Contains (net .ParseIP ("203.0.113.10" )) {
1849+ t .Fatal ("did not expect googlebot IPs to update when initial fetch fails" )
1850+ }
1851+ }
0 commit comments