|
| 1 | +package apiserver |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "net/http/httptest" |
| 8 | + "strings" |
| 9 | + "testing" |
| 10 | + "time" |
| 11 | +) |
| 12 | + |
| 13 | +// TestBackupList_HappyPath: create an account with 3 backups, list |
| 14 | +// them, verify they come back newest-first with correct fields. |
| 15 | +func TestBackupList_HappyPath(t *testing.T) { |
| 16 | + db, cleanup := newTestDB(t) |
| 17 | + defer cleanup() |
| 18 | + |
| 19 | + email := "list@example.com" |
| 20 | + acctObj, err := db.CreateCloudAccount(email, "cloud-multi", "cus_L", "sub_L") |
| 21 | + if err != nil { |
| 22 | + t.Fatalf("create account: %v", err) |
| 23 | + } |
| 24 | + |
| 25 | + svc := NewCloudService(db, &LogMailer{}, &noopBlobs{}, "http://localhost", false) |
| 26 | + siteID, err := svc.resolveOrCreateSite(&CloudAccount{ID: acctObj.ID, Tier: "cloud-multi"}, "default") |
| 27 | + if err != nil { |
| 28 | + t.Fatalf("resolve site: %v", err) |
| 29 | + } |
| 30 | + |
| 31 | + // Insert three blobs spaced by explicit timestamps so ordering is |
| 32 | + // deterministic. |
| 33 | + for i := 1; i <= 3; i++ { |
| 34 | + if _, err := db.conn.Exec(` |
| 35 | + INSERT INTO cloud_backup_blobs |
| 36 | + (account_id, site_id, blob_key, size_bytes, sha256_hex, client_version, uploaded_at) |
| 37 | + VALUES (?, ?, ?, ?, ?, ?, datetime('now', ?)) |
| 38 | + `, acctObj.ID, siteID, fmt.Sprintf("blob-%d", i), 1024*i, fmt.Sprintf("sha%d", i), "v0.2.0", |
| 39 | + fmt.Sprintf("+%d seconds", i)); err != nil { |
| 40 | + t.Fatalf("insert blob %d: %v", i, err) |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + // Seed a bearer license so requireSession resolves the account. |
| 45 | + licenseKey := seedActiveCloudLicense(t, db, email, "cloud-multi") |
| 46 | + |
| 47 | + r := httptest.NewRequest("GET", "/api/cloud/desktop/backups", nil) |
| 48 | + r.Header.Set("Authorization", "Bearer "+licenseKey) |
| 49 | + w := httptest.NewRecorder() |
| 50 | + svc.HandleBackupList(w, r) |
| 51 | + |
| 52 | + if w.Code != 200 { |
| 53 | + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) |
| 54 | + } |
| 55 | + var resp struct { |
| 56 | + Backups []backupListItem `json:"backups"` |
| 57 | + HasMore bool `json:"has_more"` |
| 58 | + NextBefore int64 `json:"next_before"` |
| 59 | + } |
| 60 | + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { |
| 61 | + t.Fatalf("decode: %v (body=%s)", err, w.Body.String()) |
| 62 | + } |
| 63 | + if len(resp.Backups) != 3 { |
| 64 | + t.Fatalf("expected 3 backups, got %d", len(resp.Backups)) |
| 65 | + } |
| 66 | + // Newest first: blob-3 was inserted with the latest offset. |
| 67 | + if resp.Backups[0].Sha256 != "sha3" { |
| 68 | + t.Fatalf("expected sha3 first, got %s", resp.Backups[0].Sha256) |
| 69 | + } |
| 70 | + if resp.Backups[2].Sha256 != "sha1" { |
| 71 | + t.Fatalf("expected sha1 last, got %s", resp.Backups[2].Sha256) |
| 72 | + } |
| 73 | + if resp.HasMore { |
| 74 | + t.Fatalf("expected has_more=false with only 3 blobs, got true") |
| 75 | + } |
| 76 | + // blob_key must NOT leak to the client. |
| 77 | + raw := w.Body.String() |
| 78 | + for i := 1; i <= 3; i++ { |
| 79 | + if strings.Contains(raw, fmt.Sprintf("blob-%d", i)) { |
| 80 | + t.Fatalf("blob_key leaked in list response: %s", raw) |
| 81 | + } |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +// TestBackupList_Pagination: 25 blobs with default limit=20 returns |
| 86 | +// 20 items and has_more=true with a usable next_before cursor. |
| 87 | +func TestBackupList_Pagination(t *testing.T) { |
| 88 | + db, cleanup := newTestDB(t) |
| 89 | + defer cleanup() |
| 90 | + |
| 91 | + email := "paginate@example.com" |
| 92 | + acctObj, err := db.CreateCloudAccount(email, "cloud-single", "cus_P", "sub_P") |
| 93 | + if err != nil { |
| 94 | + t.Fatalf("create account: %v", err) |
| 95 | + } |
| 96 | + svc := NewCloudService(db, &LogMailer{}, &noopBlobs{}, "http://localhost", false) |
| 97 | + siteID, _ := svc.resolveOrCreateSite(&CloudAccount{ID: acctObj.ID, Tier: "cloud-single"}, "default") |
| 98 | + |
| 99 | + for i := 1; i <= 25; i++ { |
| 100 | + if _, err := db.conn.Exec(` |
| 101 | + INSERT INTO cloud_backup_blobs (account_id, site_id, blob_key, size_bytes, sha256_hex) |
| 102 | + VALUES (?, ?, ?, ?, ?) |
| 103 | + `, acctObj.ID, siteID, fmt.Sprintf("k%d", i), 1000, fmt.Sprintf("s%d", i)); err != nil { |
| 104 | + t.Fatalf("seed: %v", err) |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + licenseKey := seedActiveCloudLicense(t, db, email, "cloud-single") |
| 109 | + |
| 110 | + // First page. |
| 111 | + r := httptest.NewRequest("GET", "/api/cloud/desktop/backups", nil) |
| 112 | + r.Header.Set("Authorization", "Bearer "+licenseKey) |
| 113 | + w := httptest.NewRecorder() |
| 114 | + svc.HandleBackupList(w, r) |
| 115 | + |
| 116 | + var page1 struct { |
| 117 | + Backups []backupListItem `json:"backups"` |
| 118 | + HasMore bool `json:"has_more"` |
| 119 | + NextBefore int64 `json:"next_before"` |
| 120 | + } |
| 121 | + if err := json.Unmarshal(w.Body.Bytes(), &page1); err != nil { |
| 122 | + t.Fatalf("decode: %v", err) |
| 123 | + } |
| 124 | + if len(page1.Backups) != 20 { |
| 125 | + t.Fatalf("expected 20 on first page, got %d", len(page1.Backups)) |
| 126 | + } |
| 127 | + if !page1.HasMore { |
| 128 | + t.Fatalf("expected has_more=true with 25 total") |
| 129 | + } |
| 130 | + if page1.NextBefore <= 0 { |
| 131 | + t.Fatalf("expected positive next_before, got %d", page1.NextBefore) |
| 132 | + } |
| 133 | + |
| 134 | + // Second page using next_before cursor. |
| 135 | + r2 := httptest.NewRequest("GET", |
| 136 | + fmt.Sprintf("/api/cloud/desktop/backups?before=%d", page1.NextBefore), nil) |
| 137 | + r2.Header.Set("Authorization", "Bearer "+licenseKey) |
| 138 | + w2 := httptest.NewRecorder() |
| 139 | + svc.HandleBackupList(w2, r2) |
| 140 | + var page2 struct { |
| 141 | + Backups []backupListItem `json:"backups"` |
| 142 | + HasMore bool `json:"has_more"` |
| 143 | + } |
| 144 | + if err := json.Unmarshal(w2.Body.Bytes(), &page2); err != nil { |
| 145 | + t.Fatalf("decode: %v", err) |
| 146 | + } |
| 147 | + if len(page2.Backups) != 5 { |
| 148 | + t.Fatalf("expected 5 on second page, got %d", len(page2.Backups)) |
| 149 | + } |
| 150 | + if page2.HasMore { |
| 151 | + t.Fatalf("expected has_more=false on last page") |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +// TestBackupByID_OwnershipEnforced: account A cannot download account |
| 156 | +// B's backup by guessing its ID. Must 404, not 403 — no enumeration |
| 157 | +// leakage. |
| 158 | +func TestBackupByID_OwnershipEnforced(t *testing.T) { |
| 159 | + db, cleanup := newTestDB(t) |
| 160 | + defer cleanup() |
| 161 | + |
| 162 | + aAcct, _ := db.CreateCloudAccount("a@example.com", "cloud-single", "cus_a", "sub_a") |
| 163 | + bAcct, _ := db.CreateCloudAccount("b@example.com", "cloud-single", "cus_b", "sub_b") |
| 164 | + |
| 165 | + svc := NewCloudService(db, &LogMailer{}, &noopBlobs{}, "http://localhost", false) |
| 166 | + siteA, _ := svc.resolveOrCreateSite(&CloudAccount{ID: aAcct.ID, Tier: "cloud-single"}, "default") |
| 167 | + siteB, _ := svc.resolveOrCreateSite(&CloudAccount{ID: bAcct.ID, Tier: "cloud-single"}, "default") |
| 168 | + |
| 169 | + // A uploads a blob. |
| 170 | + var aBlobID int64 |
| 171 | + res, err := db.conn.Exec(` |
| 172 | + INSERT INTO cloud_backup_blobs (account_id, site_id, blob_key, size_bytes, sha256_hex) |
| 173 | + VALUES (?, ?, ?, ?, ?) |
| 174 | + `, aAcct.ID, siteA, "a-blob", 500, "sha-a") |
| 175 | + if err != nil { |
| 176 | + t.Fatalf("insert: %v", err) |
| 177 | + } |
| 178 | + aBlobID, _ = res.LastInsertId() |
| 179 | + _ = siteB |
| 180 | + |
| 181 | + // B authenticates and tries to fetch A's blob. |
| 182 | + bKey := seedActiveCloudLicense(t, db, "b@example.com", "cloud-single") |
| 183 | + |
| 184 | + r := httptest.NewRequest("GET", fmt.Sprintf("/api/cloud/desktop/backup/%d", aBlobID), nil) |
| 185 | + r.Header.Set("Authorization", "Bearer "+bKey) |
| 186 | + r.SetPathValue("id", fmt.Sprintf("%d", aBlobID)) |
| 187 | + w := httptest.NewRecorder() |
| 188 | + svc.HandleBackupByID(w, r) |
| 189 | + |
| 190 | + if w.Code != 404 { |
| 191 | + t.Fatalf("expected 404 for cross-account fetch, got %d: %s", w.Code, w.Body.String()) |
| 192 | + } |
| 193 | + // The 404 must look identical to a genuinely-missing blob — no |
| 194 | + // "this belongs to another account" leak. |
| 195 | + if strings.Contains(strings.ToLower(w.Body.String()), "another account") || |
| 196 | + strings.Contains(strings.ToLower(w.Body.String()), "forbidden") || |
| 197 | + strings.Contains(strings.ToLower(w.Body.String()), "permission") { |
| 198 | + t.Fatalf("404 body leaks ownership info: %s", w.Body.String()) |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +// TestBackupByID_InvalidID: non-numeric id → 400. |
| 203 | +func TestBackupByID_InvalidID(t *testing.T) { |
| 204 | + db, cleanup := newTestDB(t) |
| 205 | + defer cleanup() |
| 206 | + |
| 207 | + email := "bad@example.com" |
| 208 | + db.CreateCloudAccount(email, "cloud-single", "c", "s") |
| 209 | + licenseKey := seedActiveCloudLicense(t, db, email, "cloud-single") |
| 210 | + |
| 211 | + svc := NewCloudService(db, &LogMailer{}, &noopBlobs{}, "http://localhost", false) |
| 212 | + |
| 213 | + r := httptest.NewRequest("GET", "/api/cloud/desktop/backup/not-a-number", nil) |
| 214 | + r.Header.Set("Authorization", "Bearer "+licenseKey) |
| 215 | + r.SetPathValue("id", "not-a-number") |
| 216 | + w := httptest.NewRecorder() |
| 217 | + svc.HandleBackupByID(w, r) |
| 218 | + |
| 219 | + if w.Code != 400 { |
| 220 | + t.Fatalf("expected 400 for non-numeric id, got %d", w.Code) |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +// TestBackupList_EmptyAccount: new account, no uploads, returns empty |
| 225 | +// list (not an error). |
| 226 | +func TestBackupList_EmptyAccount(t *testing.T) { |
| 227 | + db, cleanup := newTestDB(t) |
| 228 | + defer cleanup() |
| 229 | + |
| 230 | + email := "empty@example.com" |
| 231 | + db.CreateCloudAccount(email, "cloud-single", "c", "s") |
| 232 | + licenseKey := seedActiveCloudLicense(t, db, email, "cloud-single") |
| 233 | + |
| 234 | + svc := NewCloudService(db, &LogMailer{}, &noopBlobs{}, "http://localhost", false) |
| 235 | + // Must create the site first — list joins on cloud_sites. |
| 236 | + acct, _ := db.GetCloudAccountByEmail(email) |
| 237 | + svc.resolveOrCreateSite(acct, "default") |
| 238 | + |
| 239 | + r := httptest.NewRequest("GET", "/api/cloud/desktop/backups", nil) |
| 240 | + r.Header.Set("Authorization", "Bearer "+licenseKey) |
| 241 | + w := httptest.NewRecorder() |
| 242 | + svc.HandleBackupList(w, r) |
| 243 | + |
| 244 | + if w.Code != 200 { |
| 245 | + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) |
| 246 | + } |
| 247 | + var resp struct { |
| 248 | + Backups []backupListItem `json:"backups"` |
| 249 | + } |
| 250 | + json.Unmarshal(w.Body.Bytes(), &resp) |
| 251 | + if len(resp.Backups) != 0 { |
| 252 | + t.Fatalf("expected 0 backups, got %d", len(resp.Backups)) |
| 253 | + } |
| 254 | +} |
| 255 | + |
| 256 | +// --- helpers -------------------------------------------------------- |
| 257 | + |
| 258 | +// noopBlobs is a BlobStore stub for tests that don't actually exercise |
| 259 | +// blob IO. Get returns ErrBlobNotFound so HandleBackupByID's 500 path |
| 260 | +// would be hit only if we accidentally wired a test to call it. |
| 261 | +type noopBlobs struct{} |
| 262 | + |
| 263 | +func (noopBlobs) Put(key string, r io.Reader) (int64, error) { |
| 264 | + return 0, fmt.Errorf("noopBlobs.Put not implemented") |
| 265 | +} |
| 266 | +func (noopBlobs) Get(key string) (io.ReadCloser, error) { |
| 267 | + return nil, ErrBlobNotFound |
| 268 | +} |
| 269 | +func (noopBlobs) Delete(key string) error { return nil } |
| 270 | + |
| 271 | +// seedActiveCloudLicense inserts a matching license row so |
| 272 | +// requireSession's bearer-auth path resolves to the account. |
| 273 | +func seedActiveCloudLicense(t *testing.T, db *SqliteDB, email, tier string) string { |
| 274 | + t.Helper() |
| 275 | + key := fmt.Sprintf("SY-test-%s-%s.sig", tier, email) |
| 276 | + rec := &LicenseRecord{ |
| 277 | + StripeCustomerID: "cus_" + email, |
| 278 | + Product: "stockyard-desktop", |
| 279 | + Tier: tier, |
| 280 | + LicenseKey: key, |
| 281 | + Status: "active", |
| 282 | + Email: email, |
| 283 | + ExpiresAt: time.Now().Add(10 * 365 * 24 * time.Hour), |
| 284 | + } |
| 285 | + if err := db.CreateLicense(rec); err != nil { |
| 286 | + t.Fatalf("seed license: %v", err) |
| 287 | + } |
| 288 | + return key |
| 289 | +} |
| 290 | + |
| 291 | +// TestRoutePrecedence_LatestVsID: Go 1.22+ ServeMux must prefer the |
| 292 | +// literal /backup/latest over the wildcard /backup/{id}. If precedence |
| 293 | +// breaks, "latest" gets parsed as an int64, fails, and the list page |
| 294 | +// UI would silently break. |
| 295 | +// |
| 296 | +// This exercises the real mux wiring in server.go, not the handler |
| 297 | +// directly, because the bug we care about is precisely a routing bug. |
| 298 | +func TestRoutePrecedence_LatestVsID(t *testing.T) { |
| 299 | + db, cleanup := newTestDB(t) |
| 300 | + defer cleanup() |
| 301 | + |
| 302 | + email := "route@example.com" |
| 303 | + db.CreateCloudAccount(email, "cloud-single", "c", "s") |
| 304 | + licenseKey := seedActiveCloudLicense(t, db, email, "cloud-single") |
| 305 | + |
| 306 | + t.Setenv("STOCKYARD_CLOUD_ENABLED", "1") |
| 307 | + srv := NewServer(ServerConfig{Port: 0, AdminKey: ""}, db, nil, nil, &LogMailer{}) |
| 308 | + |
| 309 | + // Make a site + one backup so /latest has something to return. |
| 310 | + svc := srv.desktopCloud |
| 311 | + acct, _ := db.GetCloudAccountByEmail(email) |
| 312 | + siteID, _ := svc.resolveOrCreateSite(acct, "default") |
| 313 | + db.conn.Exec(` |
| 314 | + INSERT INTO cloud_backup_blobs (account_id, site_id, blob_key, size_bytes, sha256_hex) |
| 315 | + VALUES (?, ?, ?, ?, ?) |
| 316 | + `, acct.ID, siteID, "some-key", 100, "somesha") |
| 317 | + |
| 318 | + // Hit /latest via the real mux. We expect the handler to try |
| 319 | + // fetching the blob (will fail — no blob store wired — which is |
| 320 | + // fine; we only care that it's NOT routed to HandleBackupByID). |
| 321 | + r := httptest.NewRequest("GET", "/api/cloud/desktop/backup/latest", nil) |
| 322 | + r.Header.Set("Authorization", "Bearer "+licenseKey) |
| 323 | + w := httptest.NewRecorder() |
| 324 | + srv.mux.ServeHTTP(w, r) |
| 325 | + |
| 326 | + // HandleBackupByID on "latest" would 400 "invalid backup id". |
| 327 | + // HandleBackupLatest without blob store wired returns 503. |
| 328 | + // So 400 = routing bug, 503 or 500 = routing fine. |
| 329 | + if w.Code == 400 && strings.Contains(w.Body.String(), "invalid backup id") { |
| 330 | + t.Fatalf("routing bug: /backup/latest was matched by /backup/{id} — got %s", |
| 331 | + w.Body.String()) |
| 332 | + } |
| 333 | +} |
0 commit comments