| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func testGate(t *testing.T, bits int) *powGate { |
| 12 | t.Helper() |
| 13 | keys, err := newStateKeys([]byte("test-secret")) |
| 14 | if err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | return newPowGate(bits, keys) |
| 18 | } |
| 19 | |
| 20 | func TestPowMintVerify(t *testing.T) { |
| 21 | g := testGate(t, 8) // 8 bits ≈ 256 hashes — instant in tests |
| 22 | challenge, err := g.mint("uid-1") |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | nonce := solvePow(challenge, 8) |
| 27 | |
| 28 | // Wrong account, wrong nonce, then the real thing — which consumes it. |
| 29 | if err := g.verify("uid-2", challenge, nonce); err == nil { |
| 30 | t.Error("someone else's challenge verified") |
| 31 | } |
| 32 | if err := g.verify("uid-1", challenge, "not-the-nonce"); err == nil { |
| 33 | t.Error("bad nonce verified") |
| 34 | } |
| 35 | if err := g.verify("uid-1", challenge, nonce); err != nil { |
| 36 | t.Fatalf("valid solution rejected: %v", err) |
| 37 | } |
| 38 | // Replay: one challenge, one space. |
| 39 | if err := g.verify("uid-1", challenge, nonce); err == nil { |
| 40 | t.Error("replayed challenge verified") |
| 41 | } |
| 42 | |
| 43 | // A tampered challenge never opens. |
| 44 | if err := g.verify("uid-1", challenge+"x", nonce); err == nil { |
| 45 | t.Error("tampered challenge verified") |
| 46 | } |
| 47 | // An expired one is refused even with a valid solution. |
| 48 | stale, err := g.mintAt("uid-1", time.Now().Add(-time.Minute)) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if err := g.verify("uid-1", stale, solvePow(stale, 8)); err == nil { |
| 53 | t.Error("expired challenge verified") |
| 54 | } |
| 55 | |
| 56 | if newPowGate(0, g.keys) != nil { |
| 57 | t.Error("bits 0 should disable the gate") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestLeadingZeroBits(t *testing.T) { |
| 62 | for _, c := range []struct { |
| 63 | in []byte |
| 64 | want int |
| 65 | }{ |
| 66 | {[]byte{0x80}, 0}, {[]byte{0x01}, 7}, {[]byte{0x00, 0xff}, 8}, |
| 67 | {[]byte{0x00, 0x0f}, 12}, {[]byte{0x00, 0x00}, 16}, |
| 68 | } { |
| 69 | if got := leadingZeroBits(c.in); got != c.want { |
| 70 | t.Errorf("leadingZeroBits(%v) = %d, want %d", c.in, got, c.want) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestCreateSpaceRequiresPow(t *testing.T) { |
| 76 | ts, s := newTestServer(t) |
| 77 | uid, token := seedMember(t, s) |
| 78 | cloud, _, _ := fakeCloud(t, http.StatusCreated, map[string]any{ |
| 79 | "host": "h.example.com", "url": "https://h.example.com", "setup_link": "https://h.example.com/join/setup#S", |
| 80 | }) |
| 81 | s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| 82 | s.pow = newPowGate(8, s.keys) |
| 83 | |
| 84 | post := func(body string) *http.Response { |
| 85 | req, _ := http.NewRequest("POST", ts.URL+"/api/spaces", strings.NewReader(body)) |
| 86 | req.Header.Set("Authorization", "Bearer "+token) |
| 87 | resp, err := http.DefaultClient.Do(req) |
| 88 | if err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | t.Cleanup(func() { resp.Body.Close() }) |
| 92 | return resp |
| 93 | } |
| 94 | |
| 95 | // No proof / garbage proof → 403 with the stable codes. |
| 96 | if r := post(""); r.StatusCode != http.StatusForbidden { |
| 97 | t.Fatalf("no proof: %d, want 403", r.StatusCode) |
| 98 | } |
| 99 | if r := post(`{"challenge":"junk","nonce":"1"}`); r.StatusCode != http.StatusForbidden { |
| 100 | t.Fatalf("junk proof: %d, want 403", r.StatusCode) |
| 101 | } |
| 102 | |
| 103 | // The real flow: challenge → solve → create. |
| 104 | creq, _ := http.NewRequest("POST", ts.URL+"/api/spaces/challenge", nil) |
| 105 | creq.Header.Set("Authorization", "Bearer "+token) |
| 106 | cresp, err := http.DefaultClient.Do(creq) |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | defer cresp.Body.Close() |
| 111 | var ch struct { |
| 112 | Bits int `json:"bits"` |
| 113 | Challenge string `json:"challenge"` |
| 114 | } |
| 115 | json.NewDecoder(cresp.Body).Decode(&ch) |
| 116 | if ch.Bits != 8 || ch.Challenge == "" { |
| 117 | t.Fatalf("challenge = %+v", ch) |
| 118 | } |
| 119 | body, _ := json.Marshal(map[string]string{"challenge": ch.Challenge, "nonce": solvePow(ch.Challenge, ch.Bits)}) |
| 120 | if r := post(string(body)); r.StatusCode != http.StatusCreated { |
| 121 | t.Fatalf("solved create: %d, want 201", r.StatusCode) |
| 122 | } |
| 123 | // The same proof can't buy a second space. |
| 124 | if r := post(string(body)); r.StatusCode != http.StatusForbidden { |
| 125 | t.Fatalf("replayed proof: %d, want 403", r.StatusCode) |
| 126 | } |
| 127 | |
| 128 | // Gate off → bits 0 and bodyless creation works (the deployed default). |
| 129 | s.pow = nil |
| 130 | cresp2, err := http.DefaultClient.Do(creq.Clone(creq.Context())) |
| 131 | if err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | defer cresp2.Body.Close() |
| 135 | var off struct { |
| 136 | Bits int `json:"bits"` |
| 137 | } |
| 138 | json.NewDecoder(cresp2.Body).Decode(&off) |
| 139 | if off.Bits != 0 { |
| 140 | t.Errorf("gate off: bits = %d, want 0", off.Bits) |
| 141 | } |
| 142 | if r := post(""); r.StatusCode != http.StatusCreated { |
| 143 | t.Errorf("gate off create: %d, want 201", r.StatusCode) |
| 144 | } |
| 145 | _ = uid |
| 146 | } |
| 147 | |