| 1 | package main |
| 2 | |
| 3 | // The demo gates (demo.go): bucket derivation, adaptive difficulty, the |
| 4 | // bits-sealed-in-the-challenge property, the cooldown semaphore, and the |
| 5 | // endpoint boundary (mandatory PoW, cooldown ≥ TTL, kind=demo forwarding). |
| 6 | |
| 7 | import ( |
| 8 | "bytes" |
| 9 | "encoding/json" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "testing" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | func newDemoTestServer(t *testing.T, cloudURL string) (*httptest.Server, *Server) { |
| 17 | t.Helper() |
| 18 | ts, s := newTestServer(t) |
| 19 | gate, err := newDemoGate(4, s.keys) // 4 bits: solvable in microseconds |
| 20 | if err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | s.demo = gate |
| 24 | if cloudURL != "" { |
| 25 | s.cloud = &cloudProvisioner{url: cloudURL, token: "caller-token", client: http.DefaultClient} |
| 26 | } |
| 27 | return ts, s |
| 28 | } |
| 29 | |
| 30 | // postDemo hits a demo endpoint with a spoofed client IP (the router would |
| 31 | // stamp X-Forwarded-For in production). |
| 32 | func postDemo(t *testing.T, url, path, ip string, payload any) (*http.Response, map[string]any) { |
| 33 | t.Helper() |
| 34 | var body []byte |
| 35 | if payload != nil { |
| 36 | body, _ = json.Marshal(payload) |
| 37 | } |
| 38 | req, _ := http.NewRequest("POST", url+path, bytes.NewReader(body)) |
| 39 | req.Header.Set("X-Forwarded-For", ip) |
| 40 | resp, err := http.DefaultClient.Do(req) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | defer resp.Body.Close() |
| 45 | var m map[string]any |
| 46 | _ = json.NewDecoder(resp.Body).Decode(&m) |
| 47 | return resp, m |
| 48 | } |
| 49 | |
| 50 | func TestDemoBuckets(t *testing.T) { |
| 51 | gate, err := newDemoGate(4, nil) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if gate.bucket("203.0.113.7") != gate.bucket("203.0.113.7") { |
| 56 | t.Fatal("same IPv4 must map to the same bucket") |
| 57 | } |
| 58 | if gate.bucket("203.0.113.7") == gate.bucket("203.0.113.8") { |
| 59 | t.Fatal("distinct IPv4s must map to distinct buckets") |
| 60 | } |
| 61 | // Two addresses inside one /56 delegation share a bucket… |
| 62 | if gate.bucket("2001:db8:1:200::1") != gate.bucket("2001:db8:1:2ff::9") { |
| 63 | t.Fatal("IPv6 addresses in one /56 must share a bucket") |
| 64 | } |
| 65 | // …and a different /56 doesn't. |
| 66 | if gate.bucket("2001:db8:1:200::1") == gate.bucket("2001:db8:1:300::1") { |
| 67 | t.Fatal("distinct /56s must not share a bucket") |
| 68 | } |
| 69 | // A fresh gate (new process) buckets differently: nothing links restarts. |
| 70 | gate2, _ := newDemoGate(4, nil) |
| 71 | if gate.bucket("203.0.113.7") == gate2.bucket("203.0.113.7") { |
| 72 | t.Fatal("buckets must not be stable across processes") |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestDemoBitsEscalation(t *testing.T) { |
| 77 | for _, tc := range []struct{ recent, want int }{ |
| 78 | {0, 18}, {4, 18}, {5, 19}, {9, 19}, {10, 20}, {19, 20}, {20, 21}, {40, 22}, |
| 79 | } { |
| 80 | if got := demoBits(18, tc.recent); got != tc.want { |
| 81 | t.Fatalf("demoBits(18, %d) = %d, want %d", tc.recent, got, tc.want) |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestDemoGateVerify(t *testing.T) { |
| 87 | _, s := newTestServer(t) |
| 88 | gate, err := newDemoGate(4, s.keys) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | bucket := gate.bucket("203.0.113.7") |
| 93 | challenge, err := gate.mint(bucket, 4, time.Now().Add(time.Minute)) |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | nonce := solvePow(challenge, 4) |
| 98 | if err := gate.verify(gate.bucket("198.51.100.1"), challenge, nonce); err == nil { |
| 99 | t.Fatal("another bucket's solution must not verify") |
| 100 | } |
| 101 | if err := gate.verify(bucket, challenge, nonce); err != nil { |
| 102 | t.Fatalf("honest solution rejected: %v", err) |
| 103 | } |
| 104 | if err := gate.verify(bucket, challenge, nonce); err == nil { |
| 105 | t.Fatal("replay must not verify") |
| 106 | } |
| 107 | // The difficulty rides INSIDE the challenge: a challenge minted at 12 bits |
| 108 | // is checked at 12 bits even though the gate's base is 4. |
| 109 | hard, _ := gate.mint(bucket, 12, time.Now().Add(time.Minute)) |
| 110 | weak := solvePow(hard, 4) |
| 111 | if powSolves(hard, weak, 12) { |
| 112 | t.Skip("lucky nonce cleared 12 bits; nothing to assert") |
| 113 | } |
| 114 | if err := gate.verify(bucket, hard, weak); err == nil { |
| 115 | t.Fatal("an under-difficulty nonce must not verify against the sealed bits") |
| 116 | } |
| 117 | // Expired challenges die. |
| 118 | stale, _ := gate.mint(bucket, 4, time.Now().Add(-time.Minute)) |
| 119 | if err := gate.verify(bucket, stale, solvePow(stale, 4)); err == nil { |
| 120 | t.Fatal("expired challenge must not verify") |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestDemoEndpointsDisabled(t *testing.T) { |
| 125 | ts, _ := newTestServer(t) // no demo gate, no cloud |
| 126 | for _, path := range []string{"/api/demo/challenge", "/api/demo"} { |
| 127 | resp, body := postDemo(t, ts.URL, path, "203.0.113.7", nil) |
| 128 | if resp.StatusCode != http.StatusNotImplemented || body["error"] != "unavailable" { |
| 129 | t.Fatalf("%s while disabled: HTTP %d %v", path, resp.StatusCode, body) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestDemoCreateFlow(t *testing.T) { |
| 135 | cloud, captured, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{ |
| 136 | "host": "8oe.11ch.at", "url": "https://8oe.11ch.at", |
| 137 | "setup_link": "https://8oe.11ch.at/join/setup#T", "kind": "demo", |
| 138 | "expires_at": time.Now().Add(24 * time.Hour).Unix(), |
| 139 | }) |
| 140 | ts, _ := newDemoTestServer(t, cloud.URL) |
| 141 | |
| 142 | // PoW is mandatory: no solution, no demo. |
| 143 | resp, body := postDemo(t, ts.URL, "/api/demo", "203.0.113.7", nil) |
| 144 | if resp.StatusCode != http.StatusForbidden || body["error"] != "pow_required" { |
| 145 | t.Fatalf("create without pow: HTTP %d %v", resp.StatusCode, body) |
| 146 | } |
| 147 | |
| 148 | // Challenge → solve → create. |
| 149 | resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil) |
| 150 | if resp.StatusCode != http.StatusOK { |
| 151 | t.Fatalf("challenge: HTTP %d %v", resp.StatusCode, body) |
| 152 | } |
| 153 | challenge, _ := body["challenge"].(string) |
| 154 | bits := int(body["bits"].(float64)) |
| 155 | resp, body = postDemo(t, ts.URL, "/api/demo", "203.0.113.7", |
| 156 | map[string]string{"challenge": challenge, "nonce": solvePow(challenge, bits)}) |
| 157 | if resp.StatusCode != http.StatusCreated || body["host"] != "8oe.11ch.at" { |
| 158 | t.Fatalf("create: HTTP %d %v", resp.StatusCode, body) |
| 159 | } |
| 160 | |
| 161 | // Home forwarded kind=demo with an anonymous one-shot requester. |
| 162 | var sent map[string]string |
| 163 | if err := json.Unmarshal(*capturedBody, &sent); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if sent["kind"] != "demo" || len(sent["requester"]) < 10 { |
| 167 | t.Fatalf("forwarded body = %v", sent) |
| 168 | } |
| 169 | if (*captured).URL.Path != "/api/instances" { |
| 170 | t.Fatalf("forwarded path = %s", (*captured).URL.Path) |
| 171 | } |
| 172 | |
| 173 | // The bucket is now in cooldown: same connection can't mint or create. |
| 174 | resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil) |
| 175 | if resp.StatusCode != http.StatusForbidden || body["error"] != "demo_cooldown" { |
| 176 | t.Fatalf("challenge during cooldown: HTTP %d %v", resp.StatusCode, body) |
| 177 | } |
| 178 | if _, ok := body["retry_at"].(float64); !ok { |
| 179 | t.Fatalf("cooldown carries no retry_at: %v", body) |
| 180 | } |
| 181 | resp, body = postDemo(t, ts.URL, "/api/demo", "203.0.113.7", |
| 182 | map[string]string{"challenge": challenge, "nonce": "0"}) |
| 183 | if resp.StatusCode != http.StatusForbidden || body["error"] != "demo_cooldown" { |
| 184 | t.Fatalf("create during cooldown: HTTP %d %v", resp.StatusCode, body) |
| 185 | } |
| 186 | |
| 187 | // A different connection is unaffected. |
| 188 | if resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "198.51.100.1", nil); resp.StatusCode != http.StatusOK { |
| 189 | t.Fatalf("other bucket's challenge: HTTP %d %v", resp.StatusCode, body) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // A failed cloud create must NOT burn the bucket's cooldown. |
| 194 | func TestDemoFailedCreateKeepsBucketFree(t *testing.T) { |
| 195 | cloud, _, _ := fakeCloud(t, http.StatusServiceUnavailable, map[string]any{ |
| 196 | "error": "demo_busy", "message": "every demo slot is taken right now", |
| 197 | }) |
| 198 | ts, _ := newDemoTestServer(t, cloud.URL) |
| 199 | |
| 200 | _, body := postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil) |
| 201 | challenge, _ := body["challenge"].(string) |
| 202 | resp, body := postDemo(t, ts.URL, "/api/demo", "203.0.113.7", |
| 203 | map[string]string{"challenge": challenge, "nonce": solvePow(challenge, 4)}) |
| 204 | if resp.StatusCode != http.StatusServiceUnavailable || body["error"] != "demo_busy" { |
| 205 | t.Fatalf("busy relay: HTTP %d %v", resp.StatusCode, body) |
| 206 | } |
| 207 | // No cooldown recorded — the next challenge mints fine. |
| 208 | if resp, body = postDemo(t, ts.URL, "/api/demo/challenge", "203.0.113.7", nil); resp.StatusCode != http.StatusOK { |
| 209 | t.Fatalf("challenge after failed create: HTTP %d %v", resp.StatusCode, body) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestDemoCooldownIsExpiryPlusTTL(t *testing.T) { |
| 214 | gate, err := newDemoGate(4, nil) |
| 215 | if err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | now := time.Unix(2_000_000_000, 0) |
| 219 | expires := now.Add(24 * time.Hour).Unix() |
| 220 | gate.record("bucket-a", now, expires) |
| 221 | until := gate.cooldownUntil("bucket-a", now) |
| 222 | if want := expires + 24*3600; until != want { |
| 223 | t.Fatalf("cooldown_until = %d, want expiry+TTL = %d", until, want) |
| 224 | } |
| 225 | // After the cooldown passes, the bucket frees (prune path). |
| 226 | if got := gate.cooldownUntil("bucket-a", time.Unix(until+1, 0)); got != 0 { |
| 227 | t.Fatalf("bucket still blocked after cooldown: %d", got) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | func TestDemoFeaturesFlag(t *testing.T) { |
| 232 | cloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{}) |
| 233 | ts, _ := newDemoTestServer(t, cloud.URL) |
| 234 | resp, err := http.Get(ts.URL + "/api/features") |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | defer resp.Body.Close() |
| 239 | var m map[string]any |
| 240 | _ = json.NewDecoder(resp.Body).Decode(&m) |
| 241 | if m["demo"] != true { |
| 242 | t.Fatalf("features = %v, want demo true", m) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // ---- the claim relay --------------------------------------------------------- |
| 247 | |
| 248 | func TestClaimRelay(t *testing.T) { |
| 249 | cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{ |
| 250 | "host": "8oe.11ch.at", "url": "https://8oe.11ch.at", "entitlement": "free", |
| 251 | }) |
| 252 | ts, s := newDemoTestServer(t, cloud.URL) |
| 253 | uid, linkToken := seedUser(t, s) |
| 254 | |
| 255 | post := func(token string, payload any) (*http.Response, map[string]any) { |
| 256 | t.Helper() |
| 257 | body, _ := json.Marshal(payload) |
| 258 | req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/claim", bytes.NewReader(body)) |
| 259 | if token != "" { |
| 260 | req.Header.Set("Authorization", "Bearer "+token) |
| 261 | } |
| 262 | resp, err := http.DefaultClient.Do(req) |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | defer resp.Body.Close() |
| 267 | var m map[string]any |
| 268 | _ = json.NewDecoder(resp.Body).Decode(&m) |
| 269 | return resp, m |
| 270 | } |
| 271 | |
| 272 | // Auth required; host required. |
| 273 | if resp, _ := post("", map[string]string{"host": "8oe.11ch.at"}); resp.StatusCode != http.StatusUnauthorized { |
| 274 | t.Fatalf("no token: HTTP %d", resp.StatusCode) |
| 275 | } |
| 276 | if resp, body := post(linkToken, map[string]string{}); resp.StatusCode != http.StatusBadRequest || body["error"] != "bad_request" { |
| 277 | t.Fatalf("no host: HTTP %d %v", resp.StatusCode, body) |
| 278 | } |
| 279 | |
| 280 | // Happy path: home forwards {requester: uid} to the cloud's claim route. |
| 281 | resp, body := post(linkToken, map[string]string{"host": "8oe.11ch.at"}) |
| 282 | if resp.StatusCode != http.StatusOK || body["entitlement"] != "free" { |
| 283 | t.Fatalf("claim: HTTP %d %v", resp.StatusCode, body) |
| 284 | } |
| 285 | if (*captured).URL.Path != "/api/instances/8oe.11ch.at/claim" { |
| 286 | t.Fatalf("forwarded path = %s", (*captured).URL.Path) |
| 287 | } |
| 288 | var sent map[string]string |
| 289 | if err := json.Unmarshal(*capturedBody, &sent); err != nil { |
| 290 | t.Fatal(err) |
| 291 | } |
| 292 | if sent["requester"] != uid { |
| 293 | t.Fatalf("forwarded requester = %q, want the signed-in uid", sent["requester"]) |
| 294 | } |
| 295 | |
| 296 | // Stable refusals relay verbatim; the unexpected flattens. |
| 297 | gone, _, _ := fakeCloud(t, http.StatusGone, map[string]any{"error": "demo_expired", "message": "this demo's 24 hours are up"}) |
| 298 | s.cloud = &cloudProvisioner{url: gone.URL, token: "caller-token", client: http.DefaultClient} |
| 299 | if resp, body := post(linkToken, map[string]string{"host": "x.11ch.at"}); resp.StatusCode != http.StatusGone || body["error"] != "demo_expired" { |
| 300 | t.Fatalf("expired relay: HTTP %d %v", resp.StatusCode, body) |
| 301 | } |
| 302 | weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"}) |
| 303 | s.cloud = &cloudProvisioner{url: weird.URL, token: "caller-token", client: http.DefaultClient} |
| 304 | if resp, body := post(linkToken, map[string]string{"host": "x.11ch.at"}); resp.StatusCode != http.StatusBadGateway || body["error"] != "claim_failed" { |
| 305 | t.Fatalf("weird relay: HTTP %d %v", resp.StatusCode, body) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | // ---- the countdown banner's live status read ----------------------------------- |
| 310 | |
| 311 | func TestDemoStatusRelay(t *testing.T) { |
| 312 | cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{ |
| 313 | "demo": true, "expires_at": 1999999999, "internal": "never-relayed", |
| 314 | }) |
| 315 | ts, _ := newDemoTestServer(t, cloud.URL) |
| 316 | |
| 317 | get := func(query string) (*http.Response, map[string]any) { |
| 318 | t.Helper() |
| 319 | resp, err := http.Get(ts.URL + "/api/spaces/demo-status" + query) |
| 320 | if err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | defer resp.Body.Close() |
| 324 | var m map[string]any |
| 325 | _ = json.NewDecoder(resp.Body).Decode(&m) |
| 326 | return resp, m |
| 327 | } |
| 328 | |
| 329 | // No auth needed; host required. |
| 330 | if resp, body := get(""); resp.StatusCode != http.StatusBadRequest || body["error"] != "bad_request" { |
| 331 | t.Fatalf("no host: HTTP %d %v", resp.StatusCode, body) |
| 332 | } |
| 333 | |
| 334 | // Happy path: re-shaped to exactly the banner's two fields, CORS-open (the |
| 335 | // banner fetches from the demo space's own origin), forwarded to the |
| 336 | // cloud's per-host route. |
| 337 | resp, body := get("?host=8OE.11ch.at") |
| 338 | if resp.StatusCode != http.StatusOK || body["demo"] != true { |
| 339 | t.Fatalf("status: HTTP %d %v", resp.StatusCode, body) |
| 340 | } |
| 341 | if exp, _ := body["expires_at"].(float64); int64(exp) != 1999999999 { |
| 342 | t.Fatalf("expires_at = %v", body["expires_at"]) |
| 343 | } |
| 344 | if _, leaked := body["internal"]; leaked { |
| 345 | t.Fatalf("relayed a field beyond the banner's two: %v", body) |
| 346 | } |
| 347 | if resp.Header.Get("Access-Control-Allow-Origin") != "*" { |
| 348 | t.Fatal("missing CORS header — the banner's cross-origin fetch would fail") |
| 349 | } |
| 350 | if (*captured).URL.Path != "/api/instances/8oe.11ch.at/demo-status" { |
| 351 | t.Fatalf("forwarded path = %s (host not lowercased?)", (*captured).URL.Path) |
| 352 | } |
| 353 | |
| 354 | // Repeats inside the cache window never re-hit the provisioner (the read |
| 355 | // auto-fires per tab load/refocus) — and the endpoint sits outside the |
| 356 | // shared ceremony limiter, so a burst can't 429 (drained tokens would |
| 357 | // starve sign-in, since the router funnels every caller into one bucket). |
| 358 | ts4, s := newDemoTestServer(t, cloud.URL) |
| 359 | s.limiter = newRateLimiter(0, 0) // every rateLimited endpoint would 429 |
| 360 | for i := 0; i < 3; i++ { |
| 361 | if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true { |
| 362 | t.Fatalf("read %d: HTTP %d %v", i, resp.StatusCode, body) |
| 363 | } |
| 364 | } |
| 365 | cloud.Close() // cached answers must not need the upstream at all |
| 366 | if resp, body := getFrom(t, ts4, "/api/spaces/demo-status?host=cached.11ch.at"); resp.StatusCode != http.StatusOK || body["demo"] != true { |
| 367 | t.Fatalf("cached read after upstream gone: HTTP %d %v", resp.StatusCode, body) |
| 368 | } |
| 369 | |
| 370 | // A non-demo answer carries no expiry even if the upstream sent one. |
| 371 | claimed, _, _ := fakeCloud(t, http.StatusOK, map[string]any{"demo": false, "expires_at": 123}) |
| 372 | ts2, _ := newDemoTestServer(t, claimed.URL) |
| 373 | resp, body = getFrom(t, ts2, "/api/spaces/demo-status?host=x.11ch.at") |
| 374 | if resp.StatusCode != http.StatusOK || body["demo"] != false { |
| 375 | t.Fatalf("claimed status: HTTP %d %v", resp.StatusCode, body) |
| 376 | } |
| 377 | if _, present := body["expires_at"]; present { |
| 378 | t.Fatalf("claimed status leaked an expiry: %v", body) |
| 379 | } |
| 380 | |
| 381 | // No provisioner wired = no demos, quietly. |
| 382 | ts3, _ := newDemoTestServer(t, "") |
| 383 | resp, body = getFrom(t, ts3, "/api/spaces/demo-status?host=x.11ch.at") |
| 384 | if resp.StatusCode != http.StatusOK || body["demo"] != false { |
| 385 | t.Fatalf("no-cloud status: HTTP %d %v", resp.StatusCode, body) |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | // getFrom fetches a JSON GET off one of the harness servers. |
| 390 | func getFrom(t *testing.T, ts *httptest.Server, path string) (*http.Response, map[string]any) { |
| 391 | t.Helper() |
| 392 | resp, err := http.Get(ts.URL + path) |
| 393 | if err != nil { |
| 394 | t.Fatal(err) |
| 395 | } |
| 396 | defer resp.Body.Close() |
| 397 | var m map[string]any |
| 398 | _ = json.NewDecoder(resp.Body).Decode(&m) |
| 399 | return resp, m |
| 400 | } |
| 401 | |