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 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "net/http"
10 "net/http/httptest"
11 "path/filepath"
12 "strconv"
13 "sync"
14 "testing"
15 "time"
16)
17
18// fakeS3 is just enough S3 for the store: path-style PUT/HEAD/GET(+Range)/
19// DELETE over an in-memory map, counting requests per verb so tests can pin
20// the request economy (#836's lesson: requests ARE the bill).
21type fakeS3 struct {
22 mu sync.Mutex
23 objects map[string][]byte // full path-style key: /bucket/prefix/id
24 gets int
25 puts int
26 heads int
27}
28
29func (f *fakeS3) handler(w http.ResponseWriter, r *http.Request) {
30 f.mu.Lock()
31 defer f.mu.Unlock()
32 switch r.Method {
33 case http.MethodPut:
34 f.puts++
35 b, _ := io.ReadAll(r.Body)
36 f.objects[r.URL.Path] = b
37 w.WriteHeader(http.StatusOK)
38 case http.MethodHead:
39 f.heads++
40 b, ok := f.objects[r.URL.Path]
41 if !ok {
42 w.WriteHeader(http.StatusNotFound)
43 return
44 }
45 w.Header().Set("Content-Length", strconv.Itoa(len(b)))
46 w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
47 w.WriteHeader(http.StatusOK)
48 case http.MethodGet:
49 f.gets++
50 b, ok := f.objects[r.URL.Path]
51 if !ok {
52 w.Header().Set("Content-Type", "application/xml")
53 w.WriteHeader(http.StatusNotFound)
54 fmt.Fprint(w, `<Error><Code>NoSuchKey</Code></Error>`)
55 return
56 }
57 if rng := r.Header.Get("Range"); rng != "" {
58 var start, end int
59 if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err == nil {
60 if end >= len(b) {
61 end = len(b) - 1
62 }
63 w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(b)))
64 w.WriteHeader(http.StatusPartialContent)
65 w.Write(b[start : end+1])
66 return
67 }
68 }
69 w.Write(b)
70 case http.MethodDelete:
71 delete(f.objects, r.URL.Path)
72 w.WriteHeader(http.StatusNoContent)
73 default:
74 w.WriteHeader(http.StatusNotImplemented)
75 }
76}
77
78// newTestS3Store wires an s3BlobStore at a fake endpoint over a real (temp)
79// disk store — the same construction openDB performs when the env is set.
80func newTestS3Store(t *testing.T) (*s3BlobStore, *fakeS3, *diskBlobStore) {
81 t.Helper()
82 fake := &fakeS3{objects: map[string][]byte{}}
83 srv := httptest.NewServer(http.HandlerFunc(fake.handler))
84 t.Cleanup(srv.Close)
85
86 // Static credentials + region so the SDK's signer is satisfied offline.
87 t.Setenv("AWS_ACCESS_KEY_ID", "test")
88 t.Setenv("AWS_SECRET_ACCESS_KEY", "test")
89 t.Setenv("AWS_REGION", "auto")
90
91 disk, err := newDiskBlobStore(filepath.Join(t.TempDir(), "blobs"))
92 if err != nil {
93 t.Fatal(err)
94 }
95 store, err := newS3BlobStore(&s3BlobConfig{
96 Bucket: "test-bucket",
97 Endpoint: srv.URL,
98 }, disk.dir, disk, "space.example.com")
99 if err != nil {
100 t.Fatal(err)
101 }
102 return store, fake, disk
103}
104
105// The blobStore contract (TestDiskBlobStoreRoundTrip's sequence) against
106// implementation #2. Any drift between the two implementations here is a bug
107// in the new one — the contract came first.
108func TestS3BlobStoreRoundTrip(t *testing.T) {
109 store, fake, _ := newTestS3Store(t)
110 payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02")
111
112 w, err := store.create()
113 if err != nil {
114 t.Fatalf("create: %v", err)
115 }
116 if _, err := w.Write(payload[:10]); err != nil {
117 t.Fatalf("write: %v", err)
118 }
119 // Staged bytes are invisible until commit — S3 empty, fallback empty.
120 if _, _, _, err := store.open("blobid_123-abc"); !errors.Is(err, fs.ErrNotExist) {
121 t.Fatalf("open before commit: want fs.ErrNotExist, got %v", err)
122 }
123 if _, err := w.Write(payload[10:]); err != nil {
124 t.Fatalf("write: %v", err)
125 }
126 if err := w.commit("blobid_123-abc"); err != nil {
127 t.Fatalf("commit: %v", err)
128 }
129 if fake.puts != 1 {
130 t.Fatalf("puts = %d, want exactly 1 (the commit)", fake.puts)
131 }
132 // The object landed under the derived per-instance prefix.
133 if _, ok := fake.objects["/test-bucket/space.example.com/blobid_123-abc"]; !ok {
134 t.Fatalf("object key wrong; have %v", keysOf(fake))
135 }
136
137 r, size, modTime, err := store.open("blobid_123-abc")
138 if err != nil {
139 t.Fatalf("open: %v", err)
140 }
141 got, err := io.ReadAll(r)
142 r.Close()
143 if err != nil || !bytes.Equal(got, payload) {
144 t.Fatalf("round-trip mismatch: err=%v got=%q", err, got)
145 }
146 if size != int64(len(payload)) {
147 t.Fatalf("size = %d, want %d", size, len(payload))
148 }
149 if modTime.IsZero() {
150 t.Fatal("zero modtime")
151 }
152
153 if err := store.remove("blobid_123-abc"); err != nil {
154 t.Fatalf("remove: %v", err)
155 }
156 if _, _, _, err := store.open("blobid_123-abc"); !errors.Is(err, fs.ErrNotExist) {
157 t.Fatalf("open after remove: want fs.ErrNotExist, got %v", err)
158 }
159 if err := store.remove("blobid_123-abc"); err != nil {
160 t.Fatalf("remove of missing id: %v", err)
161 }
162}
163
164func keysOf(f *fakeS3) []string {
165 f.mu.Lock()
166 defer f.mu.Unlock()
167 out := make([]string, 0, len(f.objects))
168 for k := range f.objects {
169 out = append(out, k)
170 }
171 return out
172}
173
174// An aborted staged write publishes nothing and leaves no spool file behind.
175func TestS3BlobStoreAbortDiscards(t *testing.T) {
176 store, fake, disk := newTestS3Store(t)
177 w, err := store.create()
178 if err != nil {
179 t.Fatal(err)
180 }
181 if _, err := w.Write([]byte("half an upload")); err != nil {
182 t.Fatal(err)
183 }
184 w.abort()
185 if len(keysOf(fake)) != 0 {
186 t.Fatalf("abort published: %v", keysOf(fake))
187 }
188 entries, err := filepath.Glob(filepath.Join(disk.dir, "*"))
189 if err != nil || len(entries) != 0 {
190 t.Fatalf("spool not cleaned: %v %v", entries, err)
191 }
192}
193
194// Pre-flip blobs live on disk; the s3 store serves and reaps them through its
195// fallback, so a flipped instance never strands its history.
196func TestS3BlobStoreFallbackServesDiskBlobs(t *testing.T) {
197 store, _, disk := newTestS3Store(t)
198 w, err := disk.create()
199 if err != nil {
200 t.Fatal(err)
201 }
202 if _, err := w.Write([]byte("pre-flip bytes")); err != nil {
203 t.Fatal(err)
204 }
205 if err := w.commit("oldblob"); err != nil {
206 t.Fatal(err)
207 }
208
209 r, size, _, err := store.open("oldblob")
210 if err != nil {
211 t.Fatalf("open via fallback: %v", err)
212 }
213 got, _ := io.ReadAll(r)
214 r.Close()
215 if string(got) != "pre-flip bytes" || size != 14 {
216 t.Fatalf("fallback read = %q (%d)", got, size)
217 }
218
219 if err := store.remove("oldblob"); err != nil {
220 t.Fatalf("remove via fallback: %v", err)
221 }
222 if _, _, _, err := disk.open("oldblob"); !errors.Is(err, fs.ErrNotExist) {
223 t.Fatalf("disk bytes survived remove: %v", err)
224 }
225}
226
227// The reader's request economy: a size probe costs nothing, a scrub to the
228// middle of a video costs ONE window fetch, and a sequential read costs
229// ceil(size/window) — never a request per 32KB ServeContent slice.
230func TestS3ReaderRangedWindows(t *testing.T) {
231 store, fake, _ := newTestS3Store(t)
232 payload := bytes.Repeat([]byte("0123456789abcdef"), (s3ReadChunk*3)/16/2) // 1.5 windows
233 w, _ := store.create()
234 w.Write(payload)
235 if err := w.commit("video"); err != nil {
236 t.Fatal(err)
237 }
238
239 r, size, _, err := store.open("video")
240 if err != nil {
241 t.Fatal(err)
242 }
243 defer r.Close()
244 if size != int64(len(payload)) {
245 t.Fatalf("size = %d, want %d", size, len(payload))
246 }
247
248 // http.ServeContent's size probe: seeks only — zero GETs.
249 base := fake.gets
250 if _, err := r.Seek(0, io.SeekEnd); err != nil {
251 t.Fatal(err)
252 }
253 if _, err := r.Seek(0, io.SeekStart); err != nil {
254 t.Fatal(err)
255 }
256 if fake.gets != base {
257 t.Fatalf("seeks cost %d GETs", fake.gets-base)
258 }
259
260 // A Range request landing mid-object (the video scrub): one window.
261 if _, err := r.Seek(int64(s3ReadChunk+100), io.SeekStart); err != nil {
262 t.Fatal(err)
263 }
264 slice := make([]byte, 64<<10)
265 if _, err := io.ReadFull(r, slice); err != nil {
266 t.Fatal(err)
267 }
268 if fake.gets-base != 1 {
269 t.Fatalf("mid-object 64KB read cost %d GETs, want 1", fake.gets-base)
270 }
271 if !bytes.Equal(slice, payload[s3ReadChunk+100:s3ReadChunk+100+64<<10]) {
272 t.Fatal("scrub read returned wrong bytes")
273 }
274
275 // Full sequential read from the top: exactly ceil(1.5 windows) = 2 GETs
276 // (the mid-object window above already cached one of them — reset first).
277 r2, _, _, err := store.open("video")
278 if err != nil {
279 t.Fatal(err)
280 }
281 defer r2.Close()
282 base = fake.gets
283 got, err := io.ReadAll(r2)
284 if err != nil || !bytes.Equal(got, payload) {
285 t.Fatalf("sequential read mismatch: %v", err)
286 }
287 if fake.gets-base != 2 {
288 t.Fatalf("sequential 1.5-window read cost %d GETs, want 2", fake.gets-base)
289 }
290}
291
292// The env switch: unset = disk store untouched; set = s3 store with the
293// prefix derived from the DB basename when none is named.
294func TestS3BlobConfigFromEnv(t *testing.T) {
295 t.Setenv("LCHAT_BLOB_S3_BUCKET", "")
296 if cfg := s3BlobConfigFromEnv(); cfg != nil {
297 t.Fatalf("empty bucket produced config %+v", cfg)
298 }
299 t.Setenv("LCHAT_BLOB_S3_BUCKET", "media")
300 t.Setenv("LCHAT_BLOB_S3_PREFIX", "/custom/")
301 cfg := s3BlobConfigFromEnv()
302 if cfg == nil || cfg.Bucket != "media" || cfg.Prefix != "custom" {
303 t.Fatalf("config = %+v", cfg)
304 }
305}
306