Skip to content

Commit f2463f9

Browse files
author
claude
committed
cloud: backup history endpoints — list + fetch by id
Two new endpoints on /api/cloud/desktop/ that let customers see and restore from previous backups, not just the latest one. GET /api/cloud/desktop/backups Lists the most recent backups for the account, newest first. Cursor-based pagination via ?before=<id> to avoid offset-shifts when new uploads arrive during paging. Default limit 20, max 100. Returns {backups, has_more, next_before}. GET /api/cloud/desktop/backup/{id} Returns the encrypted blob bytes for a specific backup. Ownership enforced: a blob whose account_id doesn't match the authenticated caller returns 404 (not 403) so cross-account ID enumeration can't leak existence information. Route precedence note: /backup/latest (literal) takes precedence over /backup/{id} (wildcard) in Go's http.ServeMux 1.22+. There's a dedicated TestRoutePrecedence_LatestVsID integration test that exercises the real mux to catch regressions if the priority ever changes. Tests added (cloud_backup_history_test.go): happy path with 3 backups, pagination with 25 backups across two pages, ownership enforcement, invalid-id rejection, empty-account graceful return, and the route-precedence test above. blob_key is deliberately excluded from the list response — it's a storage detail, not customer-facing. No schema change. The cloud_backup_blobs table already has everything the list endpoint needs (uploaded_at, size_bytes, sha256_hex, client_version, site_id), and the original upload handler has been writing all of them since v5 landed.
1 parent 160b648 commit f2463f9

4 files changed

Lines changed: 515 additions & 0 deletions

File tree

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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

Comments
 (0)