oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "errors"
8 "log/slog"
9 "net/http"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "gorm.io/gorm"
16)
17
18func activatedContentApp(t *testing.T) (*App, string) {
19 t.Helper()
20 key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{71}, 32))
21 cfg := Config{DBPath: filepath.Join(t.TempDir(), "docs.db"), Env: "development", ContentKey: key}
22 app, err := New(cfg)
23 if err != nil {
24 t.Fatal(err)
25 }
26 if err := app.Close(); err != nil {
27 t.Fatal(err)
28 }
29 cipher, err := newContentCipher(key, "production")
30 if err != nil {
31 t.Fatal(err)
32 }
33 if err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: cfg.DBPath, ContentKey: key, ExpectedFingerprint: cipher.fingerprint}); err != nil {
34 t.Fatal(err)
35 }
36 app, err = New(cfg)
37 if err != nil {
38 t.Fatal(err)
39 }
40 t.Cleanup(func() {
41 if err := app.Close(); err != nil {
42 t.Error(err)
43 }
44 })
45 return app, key
46}
47
48func TestActivatedContentPersistsSealedAndServesPlainUpdates(t *testing.T) {
49 app, _ := activatedContentApp(t)
50 ctx := context.Background()
51 owner := app.TestPerson(t, "owner@example.com")
52 doc, err := app.Store().Create(ctx, owner, "Sealed")
53 if err != nil {
54 t.Fatal(err)
55 }
56 req := app.TestRequest(t, "owner@example.com", http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t)))
57 if rec := serve(app, req); rec.Code != http.StatusNoContent {
58 t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
59 }
60 var row DocUpdate
61 if err := app.store.db.First(&row).Error; err != nil {
62 t.Fatal(err)
63 }
64 if row.ContentEncoding != 1 || bytes.Contains(row.UpdateBin, []byte("First paragraph.")) || bytes.Equal(row.UpdateBin, validUpdate(t)) {
65 t.Fatal("persisted content is not sealed")
66 }
67 for _, suffix := range []string{"/updates", "/updates?raw=1"} {
68 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+suffix, nil))
69 if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), validUpdate(t)) {
70 t.Fatalf("GET %s: %d", suffix, rec.Code)
71 }
72 }
73 h, seq, _, err := app.store.replay(ctx, doc)
74 if err != nil {
75 t.Fatal(err)
76 }
77 defer h.Close(ctx)
78 if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil {
79 t.Fatal(err)
80 }
81 var snap DocSnapshot
82 if err := app.store.db.First(&snap).Error; err != nil {
83 t.Fatal(err)
84 }
85 if snap.ContentEncoding != 1 || bytes.Contains(snap.State, []byte("First paragraph.")) {
86 t.Fatal("snapshot is not sealed")
87 }
88 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil))
89 if rec.Code != 200 || bytes.Equal(rec.Body.Bytes(), snap.State) {
90 t.Fatalf("snapshot GET: %d", rec.Code)
91 }
92 if text, err := app.store.Text(ctx, doc); err != nil || !strings.Contains(text, "First paragraph.") {
93 t.Fatalf("sealed replay text: %q %v", text, err)
94 }
95 replayed, _, _, err := app.store.replay(ctx, doc)
96 if err != nil {
97 t.Fatal(err)
98 }
99 replayed.Close(ctx)
100 row.UpdateBin[len(row.UpdateBin)-1] ^= 1
101 if err := app.store.db.Model(&row).Update("update_bin", row.UpdateBin).Error; err != nil {
102 t.Fatal(err)
103 }
104 rec = serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil))
105 if rec.Code != 500 || strings.Contains(rec.Body.String(), "First paragraph.") || rec.Header().Get("Docs-Seq") != "" {
106 t.Fatal("corrupt update was exposed or acknowledged")
107 }
108}
109
110func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) {
111 app, err := NewForTest(t)
112 if err != nil {
113 t.Fatal(err)
114 }
115 ctx := context.Background()
116 owner := app.TestPerson(t, "owner@example.com")
117 doc, err := app.store.Create(ctx, owner, "Legacy")
118 if err != nil {
119 t.Fatal(err)
120 }
121 h, err := app.core.NewDoc(ctx, doc.GUID, doc.ClientID)
122 if err != nil {
123 t.Fatal(err)
124 }
125 defer h.Close(ctx)
126 if err := h.Apply(ctx, validUpdate(t)); err != nil {
127 t.Fatal(err)
128 }
129 cipher, err := newContentCipher("", "development")
130 if err != nil {
131 t.Fatal(err)
132 }
133 if err := convertContent(ctx, app.store.db, cipher); err != nil {
134 t.Fatal(err)
135 }
136 if seq, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 {
137 t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err)
138 }
139 update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}
140 err = app.store.db.Transaction(func(tx *gorm.DB) error {
141 if err := app.store.prepareContentUpdate(tx, doc.GUID, update); err != nil {
142 return err
143 }
144 return tx.Create(update).Error
145 })
146 if !errors.Is(err, errContentState) {
147 t.Fatalf("legacy update: %v", err)
148 }
149 if err := app.store.writeSnapshot(ctx, doc, h, 1); !errors.Is(err, errContentState) {
150 t.Fatalf("legacy snapshot: %v", err)
151 }
152 var count int64
153 if err := app.store.db.Model(&DocUpdate{}).Count(&count).Error; err != nil || count != 0 {
154 t.Fatalf("legacy write committed: %d %v", count, err)
155 }
156}
157
158func TestContentStartupRejectsMixedStateAndMaintenanceRepairsWithoutMarkerRewrite(t *testing.T) {
159 app, key := activatedContentApp(t)
160 ctx := context.Background()
161 doc, err := app.store.Create(ctx, app.TestPerson(t, "owner@example.com"), "Mixed")
162 if err != nil {
163 t.Fatal(err)
164 }
165 var before contentSealing
166 if err := app.store.db.First(&before).Error; err != nil {
167 t.Fatal(err)
168 }
169 row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}
170 if err := app.store.db.Create(&row).Error; err != nil {
171 t.Fatal(err)
172 }
173 candidate, err := New(app.cfg)
174 if candidate != nil {
175 candidate.Close()
176 }
177 if !errors.Is(err, errContentMixed) {
178 t.Fatalf("mixed boot: %v", err)
179 }
180 if err := convertContent(ctx, app.store.db, app.store.content); err != nil {
181 t.Fatal(err)
182 }
183 var after contentSealing
184 if err := app.store.db.First(&after).Error; err != nil {
185 t.Fatal(err)
186 }
187 if before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) {
188 t.Fatal("repair rewrote activation marker")
189 }
190 if err := app.store.db.First(&row).Error; err != nil {
191 t.Fatal(err)
192 }
193 if row.ContentEncoding != 1 {
194 t.Fatal("repair retained legacy row")
195 }
196 cfg := app.cfg
197 cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{72}, 32))
198 candidate, err = New(cfg)
199 if candidate != nil {
200 candidate.Close()
201 }
202 if !errors.Is(err, errContentKeyMismatch) {
203 t.Fatalf("wrong-key boot: %v", err)
204 }
205 cfg.ContentKey = key
206 candidate, err = New(cfg)
207 if err != nil {
208 t.Fatal(err)
209 }
210 candidate.Close()
211 if err := app.store.db.Delete(&contentSealing{}, 1).Error; err != nil {
212 t.Fatal(err)
213 }
214 candidate, err = New(cfg)
215 if candidate != nil {
216 candidate.Close()
217 }
218 if !errors.Is(err, errContentState) {
219 t.Fatalf("missing-marker boot: %v", err)
220 }
221}
222
223func TestLegacyBootDiagnosesUnactivatedContent(t *testing.T) {
224 var logs bytes.Buffer
225 app, err := NewForTestWith(t, Config{Logger: slog.New(slog.NewJSONHandler(&logs, nil))})
226 if err != nil {
227 t.Fatal(err)
228 }
229 if app.store.content != nil {
230 t.Fatal("legacy boot activated content")
231 }
232 if !strings.Contains(logs.String(), `"level":"ERROR"`) || !strings.Contains(logs.String(), `"content_encoding":0`) {
233 t.Fatalf("legacy diagnosis absent: %s", logs.String())
234 }
235}
236
237func TestContentRepairFailurePreservesActivatedMarkerAndLegacyRows(t *testing.T) {
238 app, _ := activatedContentApp(t)
239 doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Repair")
240 if err != nil {
241 t.Fatal(err)
242 }
243 var before contentSealing
244 if err := app.store.db.First(&before).Error; err != nil {
245 t.Fatal(err)
246 }
247 rows := []DocUpdate{{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}, {DocID: doc.ID, Seq: 2, UpdateBin: validUpdate(t), ContentEncoding: 99, CreatedAt: time.Now().UTC()}}
248 if err := app.store.db.Create(&rows).Error; err != nil {
249 t.Fatal(err)
250 }
251 if err := convertContent(context.Background(), app.store.db, app.store.content); !errors.Is(err, errContentEncoding) {
252 t.Fatalf("repair failure: %v", err)
253 }
254 var after contentSealing
255 if err := app.store.db.First(&after).Error; err != nil {
256 t.Fatal(err)
257 }
258 if before.Encoding != after.Encoding || before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) || before.Scrubbed != after.Scrubbed {
259 t.Fatal("failed repair changed marker")
260 }
261 var row DocUpdate
262 if err := app.store.db.First(&row, rows[0].ID).Error; err != nil {
263 t.Fatal(err)
264 }
265 if row.ContentEncoding != 0 || !bytes.Equal(row.UpdateBin, validUpdate(t)) {
266 t.Fatal("failed repair partially converted legacy row")
267 }
268}
269
270func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.T) {
271 path := filepath.Join(t.TempDir(), "docs.db")
272 cfg := Config{DBPath: path, Env: "production", Origin: "https://docs.example.com", InstanceKey: "independent auth key", Mailer: &recordingMailer{}, AllowedAddresses: "owner@example.com"}
273 app, err := New(cfg)
274 if app != nil {
275 app.Close()
276 }
277 if !errors.Is(err, errContentKey) {
278 t.Fatalf("missing production content key: %v", err)
279 }
280}
281
282func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) {
283 app, _ := activatedContentApp(t)
284 ctx := context.Background()
285 owner := app.TestPerson(t, "owner@example.com")
286 doc, err := app.store.Create(ctx, owner, "Stale")
287 if err != nil {
288 t.Fatal(err)
289 }
290 if _, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); err != nil {
291 t.Fatal(err)
292 }
293 h, seq, _, err := app.store.replay(ctx, doc)
294 if err != nil {
295 t.Fatal(err)
296 }
297 if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil {
298 h.Close(ctx)
299 t.Fatal(err)
300 }
301 h.Close(ctx)
302 app.store.content = nil
303 if _, err := app.store.newestSnapshot(ctx, doc.ID); !errors.Is(err, errContentEncoding) {
304 t.Fatalf("legacy snapshot read: %v", err)
305 }
306 rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil))
307 if rec.Code != http.StatusInternalServerError || rec.Header().Get("Docs-Seq") != "" {
308 t.Fatalf("legacy update GET: %d, seq %q", rec.Code, rec.Header().Get("Docs-Seq"))
309 }
310}
311
312func TestContentStartupRejectsPlaintextSnapshotWithoutLegacyUpdates(t *testing.T) {
313 app, _ := activatedContentApp(t)
314 doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Snapshot")
315 if err != nil {
316 t.Fatal(err)
317 }
318 snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()}
319 if err := app.store.db.Create(&snapshot).Error; err != nil {
320 t.Fatal(err)
321 }
322 candidate, err := New(app.cfg)
323 if candidate != nil {
324 candidate.Close()
325 }
326 if !errors.Is(err, errContentMixed) {
327 t.Fatalf("plaintext snapshot boot: %v", err)
328 }
329}
330