eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/json"
5 "net/http"
6 "os"
7 "path/filepath"
8 "sync"
9 "testing"
10 "time"
11)
12
13func TestStorageBytesForDB(t *testing.T) {
14 dir := t.TempDir()
15 db := filepath.Join(dir, "space.11ch.at.db")
16 write := func(path string, n int) {
17 t.Helper()
18 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(path, make([]byte, n), 0o600); err != nil {
22 t.Fatal(err)
23 }
24 }
25 write(db, 1000)
26 write(db+"-wal", 200)
27 write(filepath.Join(dir, "space.11ch.at-blobs", "aa"), 5000)
28 write(filepath.Join(dir, "space.11ch.at-blobs", "nested", "bb"), 300)
29 // A sibling instance's files must not leak into the count.
30 write(filepath.Join(dir, "other.11ch.at.db"), 9999)
31
32 if got := storageBytesForDB(db); got != 6500 {
33 t.Errorf("storageBytesForDB = %d, want 6500 (db+wal+blobs)", got)
34 }
35 // Missing everything → 0, no error.
36 if got := storageBytesForDB(filepath.Join(dir, "absent.db")); got != 0 {
37 t.Errorf("absent instance = %d, want 0", got)
38 }
39}
40
41func TestMeterOnceRecordsReadings(t *testing.T) {
42 db := openTestCloudDB(t)
43 idA := insertSpaceRow(t, db, "a.11ch.at", 1000)
44 idB := insertSpaceRow(t, db, "b.11ch.at", 1000)
45 // A pending create (NULL host) is never swept.
46 if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil {
47 t.Fatal(err)
48 }
49
50 bytesByHost := map[string]int64{"a.11ch.at": 12345, "b.11ch.at": 0}
51 mux := http.NewServeMux()
52 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
53 h := r.PathValue("host")
54 if h == "b.11ch.at" {
55 w.WriteHeader(http.StatusInternalServerError) // stats down for b
56 return
57 }
58 writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[h], "setup_complete": true, "members": 3})
59 })
60 sock := fakeControl(t, mux.ServeHTTP)
61 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"}
62
63 if metered := s.meterOnce(); metered != 1 {
64 t.Fatalf("metered = %d, want 1 (a recorded, b skipped)", metered)
65 }
66 spaces, err := db.listSpaces("c", "r")
67 if err != nil {
68 t.Fatal(err)
69 }
70 byHost := map[string]Space{}
71 for _, sp := range spaces {
72 byHost[sp.Host] = sp
73 }
74 if got := byHost["a.11ch.at"]; got.StorageBytes != 12345 || got.MeteredAt == 0 || !got.SetupComplete || got.Members != 3 {
75 t.Errorf("a = %+v, want 12345 bytes, claimed-ness recorded, timestamp", got)
76 }
77 if got := byHost["b.11ch.at"]; got.StorageBytes != 0 || got.MeteredAt != 0 || got.SetupComplete {
78 t.Errorf("b = %+v, want untouched (stats error is a skip)", got)
79 }
80 _ = idA
81 _ = idB
82
83 // A later sweep refreshes the reading, never accumulates.
84 bytesByHost["a.11ch.at"] = 200
85 before := time.Now().Unix()
86 s.meterOnce()
87 spaces, _ = db.listSpaces("c", "r")
88 for _, sp := range spaces {
89 if sp.Host == "a.11ch.at" && (sp.StorageBytes != 200 || sp.MeteredAt < before) {
90 t.Errorf("refresh: %+v, want 200 bytes, fresh timestamp", sp)
91 }
92 }
93}
94
95func TestParseByteSizeAndCaps(t *testing.T) {
96 for in, want := range map[string]int64{
97 "25GB": 25e9, "2TB": 2e12, "500MB": 5e8, "1024": 1024, "10 GB": 10e9, "1kb": 1000,
98 } {
99 if got, err := parseByteSize(in); err != nil || got != want {
100 t.Errorf("parseByteSize(%q) = %d, %v; want %d", in, got, err, want)
101 }
102 }
103 for _, bad := range []string{"", "-5GB", "0", "GB", "ten GB"} {
104 if _, err := parseByteSize(bad); err == nil {
105 t.Errorf("parseByteSize(%q) accepted, want error", bad)
106 }
107 }
108
109 caps, _, err := parseStorageCaps("free=10GB, family=25GB,business=2TB")
110 if err != nil || caps["free"] != 10e9 || caps["family"] != 25e9 || caps["business"] != 2e12 {
111 t.Fatalf("caps = %v, %v", caps, err)
112 }
113 if c, sc, err := parseStorageCaps(""); c != nil || sc != nil || err != nil {
114 t.Errorf("empty caps = %v, %v; want nil, nil (enforcement off)", c, err)
115 }
116 if _, _, err := parseStorageCaps("free=banana"); err == nil {
117 t.Error("garbage cap accepted")
118 }
119}
120
121func TestSetInstanceQuota(t *testing.T) {
122 path := filepath.Join(t.TempDir(), "space.env")
123 if err := os.WriteFile(path, []byte("LCHAT_ADMIN_TOKEN=secret\nLCHAT_DB=/data/x.db\n"), 0o600); err != nil {
124 t.Fatal(err)
125 }
126 if err := setInstanceQuota(path, 25e9, "free"); err != nil {
127 t.Fatal(err)
128 }
129 env, err := readEnvFile(path)
130 if err != nil {
131 t.Fatal(err)
132 }
133 // The cap and entitlement are set and every other var survived.
134 if env["LCHAT_BLOB_QUOTA_BYTES"] != "25000000000" || env["LCHAT_ENTITLEMENT"] != "free" ||
135 env["LCHAT_ADMIN_TOKEN"] != "secret" || env["LCHAT_DB"] != "/data/x.db" {
136 t.Fatalf("env after set = %v", env)
137 }
138 // Idempotent update; an empty entitlement leaves the flag alone.
139 if err := setInstanceQuota(path, 10e9, ""); err != nil {
140 t.Fatal(err)
141 }
142 env, _ = readEnvFile(path)
143 if env["LCHAT_BLOB_QUOTA_BYTES"] != "10000000000" || env["LCHAT_ENTITLEMENT"] != "free" {
144 t.Fatalf("env after update = %v", env)
145 }
146 // A billing flip rides the same write; removal at 0 spares the flag.
147 if err := setInstanceQuota(path, 0, "paid"); err != nil {
148 t.Fatal(err)
149 }
150 env, _ = readEnvFile(path)
151 if _, ok := env["LCHAT_BLOB_QUOTA_BYTES"]; ok {
152 t.Fatalf("cap not removed: %v", env)
153 }
154 if env["LCHAT_ENTITLEMENT"] != "paid" {
155 t.Fatalf("entitlement not flipped: %v", env)
156 }
157 // A missing instance surfaces not-exist (the handler's 404).
158 if err := setInstanceQuota(filepath.Join(t.TempDir(), "absent.env"), 1, ""); !os.IsNotExist(err) {
159 t.Errorf("absent env: err = %v, want not-exist", err)
160 }
161}
162
163// quotaControl fakes controld's stats + quota endpoints, recording pushes.
164func quotaControl(t *testing.T, bytesByHost map[string]int64, pushed map[string]int64) string {
165 t.Helper()
166 mux := http.NewServeMux()
167 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
168 writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[r.PathValue("host")]})
169 })
170 mux.HandleFunc("POST /instances/{host}/quota", func(w http.ResponseWriter, r *http.Request) {
171 var req struct {
172 Bytes int64 `json:"bytes"`
173 }
174 json.NewDecoder(r.Body).Decode(&req)
175 pushed[r.PathValue("host")] = req.Bytes
176 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
177 })
178 return fakeControl(t, mux.ServeHTTP)
179}
180
181// TestQuotaPushOnUpgradeAndSweep pins the enforcement loop: a webhook upgrade
182// pushes the tier's cap immediately; the meter sweep reconciles free spaces
183// and downgrades.
184func TestQuotaPushOnUpgradeAndSweep(t *testing.T) {
185 db := openTestCloudDB(t)
186 paidID := insertSpaceRow(t, db, "paid.11ch.at", 1000)
187 insertSpaceRow(t, db, "free.11ch.at", 1000)
188
189 pushed := map[string]int64{}
190 sock := quotaControl(t, map[string]int64{"paid.11ch.at": 1, "free.11ch.at": 1}, pushed)
191 s := &cloudServer{
192 db: db, control: controlClient(sock), controlToken: "t",
193 storageCaps: map[string]int64{"free": 10e9, "family": 25e9},
194 }
195
196 // The upgrade path (what the checkout webhook calls).
197 if err := db.markSpacePaid(paidID, "cus", "sub", "family", ""); err != nil {
198 t.Fatal(err)
199 }
200 s.pushQuotaByID(paidID)
201 if pushed["paid.11ch.at"] != 25e9 {
202 t.Fatalf("upgrade push = %d, want 25GB", pushed["paid.11ch.at"])
203 }
204
205 // The sweep reconciles the free space (never pushed) — and doesn't
206 // re-push the paid one (already at its cap).
207 delete(pushed, "paid.11ch.at")
208 s.meterOnce()
209 if pushed["free.11ch.at"] != 10e9 {
210 t.Fatalf("sweep push = %d, want 10GB", pushed["free.11ch.at"])
211 }
212 if _, again := pushed["paid.11ch.at"]; again {
213 t.Error("sweep re-pushed an already-correct quota")
214 }
215
216 // A lapse (subscription deleted → free) re-applies the free cap.
217 if err := db.setEntitlement(paidID, entFree, 0); err != nil {
218 t.Fatal(err)
219 }
220 s.pushQuotaByID(paidID)
221 if pushed["paid.11ch.at"] != 10e9 {
222 t.Fatalf("lapse push = %d, want back to 10GB", pushed["paid.11ch.at"])
223 }
224
225 // No caps configured → nothing pushes anywhere.
226 pushed2 := map[string]int64{}
227 sock2 := quotaControl(t, map[string]int64{"paid.11ch.at": 1, "free.11ch.at": 1}, pushed2)
228 s2 := &cloudServer{db: db, control: controlClient(sock2), controlToken: "t"}
229 s2.meterOnce()
230 s2.pushQuotaByID(paidID)
231 if len(pushed2) != 0 {
232 t.Errorf("unconfigured caps pushed: %v", pushed2)
233 }
234}
235
236// The quota and meter pushes write DIFFERENT keys to the SAME env file from
237// separate request goroutines; updateInstanceEnv serializes them so neither
238// side's read-before-write snapshot can drop the other's keys. Hammered
239// concurrently, every key family must survive.
240func TestInstanceEnvWritesSerialized(t *testing.T) {
241 envPath := filepath.Join(t.TempDir(), "space.env")
242 if err := writeEnvFile(envPath, map[string]string{"LCHAT_ADMIN_TOKEN": "keepme"}); err != nil {
243 t.Fatal(err)
244 }
245 var wg sync.WaitGroup
246 for i := 0; i < 50; i++ {
247 wg.Add(2)
248 go func() {
249 defer wg.Done()
250 if err := setInstanceQuota(envPath, 12345, "free"); err != nil {
251 t.Error(err)
252 }
253 }()
254 go func() {
255 defer wg.Done()
256 if err := setInstanceMeter(envPath, "https://cloud.example/api/usage", "tok"); err != nil {
257 t.Error(err)
258 }
259 }()
260 }
261 wg.Wait()
262 env, err := readEnvFile(envPath)
263 if err != nil {
264 t.Fatal(err)
265 }
266 for _, key := range []string{"LCHAT_ADMIN_TOKEN", "LCHAT_BLOB_QUOTA_BYTES", "LCHAT_METER_URL", "LCHAT_METER_TOKEN"} {
267 if env[key] == "" {
268 t.Errorf("%s lost in concurrent pushes: %v", key, env)
269 }
270 }
271}
272