package main import ( "encoding/json" "net/http" "strings" "testing" "time" ) func testGate(t *testing.T, bits int) *powGate { t.Helper() keys, err := newStateKeys([]byte("test-secret")) if err != nil { t.Fatal(err) } return newPowGate(bits, keys) } func TestPowMintVerify(t *testing.T) { g := testGate(t, 8) // 8 bits ≈ 256 hashes — instant in tests challenge, err := g.mint("uid-1") if err != nil { t.Fatal(err) } nonce := solvePow(challenge, 8) // Wrong account, wrong nonce, then the real thing — which consumes it. if err := g.verify("uid-2", challenge, nonce); err == nil { t.Error("someone else's challenge verified") } if err := g.verify("uid-1", challenge, "not-the-nonce"); err == nil { t.Error("bad nonce verified") } if err := g.verify("uid-1", challenge, nonce); err != nil { t.Fatalf("valid solution rejected: %v", err) } // Replay: one challenge, one space. if err := g.verify("uid-1", challenge, nonce); err == nil { t.Error("replayed challenge verified") } // A tampered challenge never opens. if err := g.verify("uid-1", challenge+"x", nonce); err == nil { t.Error("tampered challenge verified") } // An expired one is refused even with a valid solution. stale, err := g.mintAt("uid-1", time.Now().Add(-time.Minute)) if err != nil { t.Fatal(err) } if err := g.verify("uid-1", stale, solvePow(stale, 8)); err == nil { t.Error("expired challenge verified") } if newPowGate(0, g.keys) != nil { t.Error("bits 0 should disable the gate") } } func TestLeadingZeroBits(t *testing.T) { for _, c := range []struct { in []byte want int }{ {[]byte{0x80}, 0}, {[]byte{0x01}, 7}, {[]byte{0x00, 0xff}, 8}, {[]byte{0x00, 0x0f}, 12}, {[]byte{0x00, 0x00}, 16}, } { if got := leadingZeroBits(c.in); got != c.want { t.Errorf("leadingZeroBits(%v) = %d, want %d", c.in, got, c.want) } } } func TestCreateSpaceRequiresPow(t *testing.T) { ts, s := newTestServer(t) uid, token := seedMember(t, s) cloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{ "host": "h.example.com", "url": "https://h.example.com", "setup_link": "https://h.example.com/join/setup#S", }) s.cloud = newCloudProvisioner(cloud.URL, "caller-token") s.pow = newPowGate(8, s.keys) post := func(body string) *http.Response { req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // No proof / garbage proof → 403 with the stable codes. if r := post(""); r.StatusCode != http.StatusForbidden { t.Fatalf("no proof: %d, want 403", r.StatusCode) } if r := post(`{"challenge":"junk","nonce":"1"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("junk proof: %d, want 403", r.StatusCode) } // The real flow: challenge → solve → create. creq, _ := http.NewRequest("POST", ts.URL+"/api/spaces/challenge", nil) creq.Header.Set("Authorization", "Bearer "+token) cresp, err := http.DefaultClient.Do(creq) if err != nil { t.Fatal(err) } defer cresp.Body.Close() var ch struct { Bits int `json:"bits"` Challenge string `json:"challenge"` } json.NewDecoder(cresp.Body).Decode(&ch) if ch.Bits != 8 || ch.Challenge == "" { t.Fatalf("challenge = %+v", ch) } body, _ := json.Marshal(map[string]string{"challenge": ch.Challenge, "nonce": solvePow(ch.Challenge, ch.Bits)}) if r := post(string(body)); r.StatusCode != http.StatusCreated { t.Fatalf("solved create: %d, want 201", r.StatusCode) } // The same proof can't buy a second space. if r := post(string(body)); r.StatusCode != http.StatusForbidden { t.Fatalf("replayed proof: %d, want 403", r.StatusCode) } // Gate off → bits 0 and bodyless creation works (the deployed default). s.pow = nil cresp2, err := http.DefaultClient.Do(creq.Clone(creq.Context())) if err != nil { t.Fatal(err) } defer cresp2.Body.Close() var off struct { Bits int `json:"bits"` } json.NewDecoder(cresp2.Body).Decode(&off) if off.Bits != 0 { t.Errorf("gate off: bits = %d, want 0", off.Bits) } if r := post(""); r.StatusCode != http.StatusCreated { t.Errorf("gate off create: %d, want 201", r.StatusCode) } _ = uid }