eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package 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
7import (
8 "bytes"
9 "encoding/json"
10 "net/http"
11 "net/http/httptest"
12 "testing"
13 "time"
14)
15
16func 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).
32func 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
50func 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
76func 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
86func 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
124func 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
134func 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.
194func 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
213func 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
231func 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
248func 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
311func 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.
390func 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