Files
bluebird/internal/app/app_test.go
T

791 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"hash"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"golang.org/x/time/rate"
)
type testClient struct {
a *App
h http.Handler
cookies map[string]*http.Cookie
ip string
}
func fixture(t *testing.T, encrypted bool) *testClient {
t.Helper()
c := DefaultConfig()
c.DataDir = t.TempDir()
c.Encryption = encrypted
c.MinFreeBytes = 0
c.DataBudget = 128 << 20
c.FileBudget = 64 << 20
a, e := Open(c)
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { a.Close() })
if e = a.SetAdmin("admin", "test-password"); e != nil {
t.Fatal(e)
}
return &testClient{a: a, h: a.Handler(http.NotFoundHandler()), cookies: map[string]*http.Cookie{}, ip: "203.0.113.10"}
}
func (c *testClient) request(t *testing.T, method, path string, body []byte) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, "http://localhost:8080/api/v1"+path, bytes.NewReader(body))
r.RemoteAddr = c.ip + ":1234"
if method != "GET" && method != "HEAD" {
r.Header.Set("Origin", "http://localhost:8080")
}
for _, cookie := range c.cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
for _, cookie := range w.Result().Cookies() {
if cookie.MaxAge < 0 {
delete(c.cookies, cookie.Name)
} else {
c.cookies[cookie.Name] = cookie
}
}
return w
}
func (c *testClient) json(t *testing.T, method, path string, v any) *httptest.ResponseRecorder {
b, e := json.Marshal(v)
if e != nil {
t.Fatal(e)
}
return c.request(t, method, path, b)
}
func expect(t *testing.T, w *httptest.ResponseRecorder, status int) {
t.Helper()
if w.Code != status {
t.Fatalf("status=%d want=%d body=%s", w.Code, status, w.Body.String())
}
}
func (c *testClient) create(t *testing.T, names []string, data [][]byte, downloads int, renew bool) (string, []string) {
t.Helper()
fs := []ManifestFile{}
for i, d := range data {
fs = append(fs, ManifestFile{Name: names[i], Size: int64(len(d))})
}
w := c.json(t, "POST", "/uploads", Manifest{Files: fs, Days: 7, Downloads: downloads, Renew: renew})
expect(t, w, 201)
var result struct {
ID string `json:"id"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if e := json.Unmarshal(w.Body.Bytes(), &result); e != nil {
t.Fatal(e)
}
ids := []string{}
for _, f := range result.Files {
ids = append(ids, f.ID)
}
return result.ID, ids
}
func (c *testClient) publish(t *testing.T, id string) string {
w := c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{})
expect(t, w, 200)
var out struct {
Code string `json:"code"`
}
json.Unmarshal(w.Body.Bytes(), &out)
if len(out.Code) != 10 {
t.Fatal("missing code")
}
return out.Code
}
func (c *testClient) resolve(t *testing.T, code string) {
w := c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code})
expect(t, w, 200)
}
func (c *testClient) upload(t *testing.T, id, fid string, b []byte) {
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+fid, b), 200)
}
func scalar(t *testing.T, a *App, q string, args ...any) int64 {
t.Helper()
var n int64
if e := a.db.QueryRow(q, args...).Scan(&n); e != nil {
t.Fatal(e)
}
return n
}
func TestDedupIndependentPermissionsAndCleanup(t *testing.T) {
for _, encrypted := range []bool{false, true} {
t.Run(map[bool]string{false: "plain", true: "age"}[encrypted], func(t *testing.T) {
c := fixture(t, encrypted)
data := []byte(strings.Repeat("confidential-content", 1000))
id, files := c.create(t, []string{"a.txt", "b.txt"}, [][]byte{data, data}, 3, true)
for _, f := range files {
c.upload(t, id, f, data)
}
code := c.publish(t, id)
if n := scalar(t, c.a, "SELECT count(*) FROM objects"); n != 1 {
t.Fatalf("dedup objects=%d", n)
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
payload, _ := c.a.root.ReadFile(objectPath(oid))
if encrypted && bytes.Contains(payload, data) {
t.Fatal("plaintext at rest")
}
c.resolve(t, code)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !bytes.Equal(w.Body.Bytes(), data) {
t.Fatal("roundtrip mismatch")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 || scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[1]) != 3 {
t.Fatal("download counters not independent")
}
aExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
bExpiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[1])
if aExpiry-bExpiry != 3*86400 {
t.Fatalf("renewal delta=%d", aExpiry-bExpiry)
}
_, e := c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("deleted shared payload prematurely")
}
_, e = c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[1])
if e != nil {
t.Fatal(e)
}
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("unreferenced object leaked")
}
if _, e = c.a.root.Stat(objectPath(oid)); !os.IsNotExist(e) {
t.Fatal("payload not removed")
}
})
}
}
func TestLimitsAndOversizedBody(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.MaxFiles = 1
p.MaxFileBytes = 4
p.MaxBatchBytes = 4
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 3}, {"b", 1}}}), 400)
expect(t, c.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{"a", 5}}}), 400)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("1234")}, 2, false)
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("12345")), 400)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("oversize object published")
}
expect(t, c.json(t, "POST", "/uploads/"+id+"/publish", struct{}{}), 409)
c.upload(t, id, files[0], []byte("1234"))
c.publish(t, id)
var billed int64
c.a.db.QueryRow("SELECT bytes FROM usage WHERE subject LIKE 'ip:%'").Scan(&billed)
if billed != 8 {
t.Fatalf("retry traffic billed=%d want=8", billed)
}
}
func TestConcurrentLastDownload(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("one")}, 1, true)
c.upload(t, id, files[0], []byte("one"))
c.resolve(t, c.publish(t, id))
cookie := *c.cookies["bb_pickup"]
var wg sync.WaitGroup
codes := make(chan int, 12)
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = "203.0.113.11:1234"
r.AddCookie(&cookie)
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
codes <- w.Code
}()
}
wg.Wait()
close(codes)
success := 0
for n := range codes {
if n == 200 {
success++
}
}
if success != 1 {
t.Fatalf("accepted %d downloads", success)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 0 {
t.Fatal("counter below zero or unconsumed")
}
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != scalar(t, c.a, "SELECT expires FROM parcels WHERE id=?", id) {
t.Fatal("exhausted file renewed")
}
}
func TestConcurrentCapacityReservation(t *testing.T) {
c := fixture(t, false)
c.a.cfg.FileBudget = reserveSize(1024, false) * 2
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
c.a.cfg.Policy.IPDailyUploads = 100
c.a.rateMu.Lock()
c.a.rates["upload:"+c.ip] = &rateEntry{limiter: rate.NewLimiter(rate.Inf, 100), touched: time.Now()}
c.a.rateMu.Unlock()
var wg sync.WaitGroup
statuses := make(chan int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
b, _ := json.Marshal(Manifest{Files: []ManifestFile{{"a", 1024}}})
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/uploads", bytes.NewReader(b))
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
statuses <- w.Code
}()
}
wg.Wait()
close(statuses)
success := 0
for n := range statuses {
if n == 201 {
success++
} else if n != 507 {
t.Fatalf("unexpected %d", n)
}
}
if success != 2 {
t.Fatalf("reserved %d sessions want2", success)
}
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != c.a.cfg.FileBudget {
t.Fatal("reservation ledger inconsistent")
}
}
func TestPasswordOwnershipAndOrigin(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"../../evil.html"}, [][]byte{[]byte("<script>alert(1)</script>")}, 2, false)
other := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: "203.0.113.20"}
expect(t, other.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], []byte("abc")), 404)
c.upload(t, id, files[0], []byte("<script>alert(1)</script>"))
code := c.publish(t, id)
hash, _ := hashPassword("pickup-password")
c.a.db.Exec("UPDATE parcels SET password_hash=? WHERE id=?", hash, id)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code}), 401)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "wrong"}), 404)
expect(t, c.json(t, "POST", "/pickup/resolve", map[string]string{"code": code, "password": "pickup-password"}), 200)
w := c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil)
expect(t, w, 200)
if !strings.HasPrefix(w.Header().Get("Content-Disposition"), "attachment") || w.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatal("active content served inline")
}
r := httptest.NewRequest("POST", "http://localhost:8080/api/v1/parcels/"+id+"/revoke", nil)
r.Header.Set("Origin", "https://evil.example")
r.AddCookie(c.cookies["bb_visitor"])
w = httptest.NewRecorder()
c.h.ServeHTTP(w, r)
expect(t, w, 403)
}
func TestRenewalModesAndLifetimeCeiling(t *testing.T) {
for _, mode := range []string{"add", "sliding"} {
t.Run(mode, func(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 4, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
hard := expiry + 86400
c.a.db.Exec("UPDATE parcels SET renewal_mode=? WHERE id=?", mode, id)
c.a.db.Exec("UPDATE files SET hard_expires=? WHERE id=?", hard, files[0])
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 200)
actual := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
expected := hard
if mode == "sliding" {
expected = expiry
}
if actual != expected {
t.Fatalf("expiry=%d want%d", actual, expected)
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 3 {
t.Fatal("renewal reset downloads")
}
})
}
}
type failWriter struct {
header http.Header
bytes int64
}
func (w *failWriter) Header() http.Header { return w.header }
func (w *failWriter) WriteHeader(int) {}
func (w *failWriter) Write(p []byte) (int, error) { return 0, io.ErrClosedPipe }
func TestInterruptedDownloadDoesNotRenew(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
c.resolve(t, c.publish(t, id))
expiry := scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0])
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_pickup"])
c.h.ServeHTTP(&failWriter{header: make(http.Header)}, r)
if scalar(t, c.a, "SELECT expires FROM files WHERE id=?", files[0]) != expiry {
t.Fatal("failed download renewed")
}
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed begun download not charged")
}
}
func TestBackupRestoreAndMissingKey(t *testing.T) {
c := fixture(t, true)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, true)
c.upload(t, id, files[0], []byte("content"))
code := c.publish(t, id)
config := filepath.Join(t.TempDir(), "config.toml")
os.WriteFile(config, []byte("listen='127.0.0.1:8080'\n"), 0600)
archive := filepath.Join(t.TempDir(), "backup.tar.gz")
if e := c.a.Backup(config, archive); e != nil {
t.Fatal(e)
}
cfg := c.a.cfg
cfg.DataDir = t.TempDir()
if e := Restore(cfg, archive); e != nil {
t.Fatal(e)
}
restored, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
if scalar(t, restored, "SELECT count(*) FROM objects") != 1 {
t.Fatal("restore lost payload")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE state='restored' AND code_hash IS NULL") != 1 {
t.Fatal("old share remains authorized")
}
if scalar(t, restored, "SELECT count(*) FROM parcels WHERE code_hash=?", restored.keyed(code)) != 0 {
t.Fatal("old code restored")
}
restored.Close()
os.Remove(filepath.Join(cfg.DataDir, "secrets", "age.key"))
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("missing encrypted identity silently regenerated")
}
}
func TestRecoveryAndIPPersistence(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"a"}, [][]byte{[]byte("content")}, 3, false)
c.upload(t, id, files[0], []byte("content"))
cfg := c.a.cfg
c.a.Close()
c.a.db = nil
c.a.root = nil
c.a.lock = nil
a, e := Open(cfg)
if e != nil {
t.Fatal(e)
}
defer a.Close()
if scalar(t, a, "SELECT count(*) FROM parcels WHERE state='revoked'") != 1 || scalar(t, a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("partial upload became accessible or leaked")
}
if scalar(t, a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("IP daily counter reset")
}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "203.0.113.21:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8")
if a.remoteIP(r) != "203.0.113.21" {
t.Fatal("untrusted forwarded IP accepted")
}
}
type gatedReader struct {
r *bytes.Reader
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (r *gatedReader) Read(p []byte) (int, error) {
r.once.Do(func() { r.started <- struct{}{}; <-r.release })
return r.r.Read(p)
}
type sinkWriter struct {
header http.Header
status int
hash hash.Hash
count int64
once sync.Once
started chan<- struct{}
release <-chan struct{}
}
func (w *sinkWriter) Header() http.Header { return w.header }
func (w *sinkWriter) WriteHeader(n int) { w.status = n }
func (w *sinkWriter) Write(p []byte) (int, error) {
w.once.Do(func() { w.started <- struct{}{}; <-w.release })
if w.status == 0 {
w.status = 200
}
n, e := w.hash.Write(p)
w.count += int64(n)
return n, e
}
func TestConcurrentStreamingTransfers(t *testing.T) {
c := fixture(t, true)
mib := 8
if v, e := strconv.Atoi(os.Getenv("BLUEBIRD_STRESS_MIB")); e == nil && v >= 1 && v <= 100 {
mib = v
}
c.a.cfg.FileBudget = int64(mib*8+64) << 20
c.a.cfg.DataBudget = c.a.cfg.FileBudget + (64 << 20)
testRuntimeBudgets(t, c.a, c.a.cfg.FileBudget, c.a.cfg.DataBudget)
payload := bytes.Repeat([]byte("transfer"), mib*(1<<17))
clients := make([]*testClient, 8)
ids := make([]string, 8)
files := make([]string, 8)
for i := range clients {
clients[i] = &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: fmt.Sprintf("203.0.113.%d", 40+i)}
id, f := clients[i].create(t, []string{"big.bin"}, [][]byte{payload}, 20, true)
ids[i], files[i] = id, f[0]
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
started := make(chan struct{}, 8)
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
var wg sync.WaitGroup
out := make(chan int, 8)
for i := range clients {
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+ids[i]+"/files/"+files[i], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = clients[i].ip + ":1234"
for _, cookie := range clients[i].cookies {
r.AddCookie(cookie)
}
w := httptest.NewRecorder()
c.h.ServeHTTP(w, r)
out <- w.Code
}(i)
}
for range 8 {
select {
case <-started:
case <-time.After(10 * time.Second):
t.Fatal("8 upload streams did not start together")
}
}
var duringUploads runtime.MemStats
runtime.ReadMemStats(&duringUploads)
uploadStart := time.Now()
releaseOnce.Do(func() { close(release) })
wg.Wait()
uploadDuration := time.Since(uploadStart)
close(out)
for status := range out {
if status != 200 {
t.Fatalf("upload status=%d", status)
}
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 {
t.Fatal("simultaneous identical content not deduplicated")
}
for i := range clients {
clients[i].publish(t, ids[i])
}
var codeHash string
c.a.db.QueryRow("SELECT code_hash FROM parcels WHERE id=?", ids[0]).Scan(&codeHash)
_ = codeHash
code := pickupCode()
c.a.db.Exec("UPDATE parcels SET code_hash=? WHERE id=?", c.a.keyed(code), ids[0])
clients[0].resolve(t, code)
cookie := *clients[0].cookies["bb_pickup"]
downloadStarted := make(chan struct{}, 16)
downloadRelease := make(chan struct{})
var downOnce sync.Once
defer downOnce.Do(func() { close(downloadRelease) })
sinks := make([]*sinkWriter, 16)
for i := range sinks {
sinks[i] = &sinkWriter{header: make(http.Header), hash: sha256.New(), started: downloadStarted, release: downloadRelease}
wg.Add(1)
go func(i int) {
defer wg.Done()
r := httptest.NewRequest("GET", "http://localhost:8080/api/v1/pickup/files/"+files[0]+"/download", nil)
r.RemoteAddr = fmt.Sprintf("203.0.113.%d:1234", 80+i)
r.AddCookie(&cookie)
c.h.ServeHTTP(sinks[i], r)
}(i)
}
for range 16 {
select {
case <-downloadStarted:
case <-time.After(10 * time.Second):
t.Fatal("16 download streams did not start together")
}
}
var duringDownloads runtime.MemStats
runtime.ReadMemStats(&duringDownloads)
downloadStart := time.Now()
downOnce.Do(func() { close(downloadRelease) })
wg.Wait()
downloadDuration := time.Since(downloadStart)
want := sha256.Sum256(payload)
for _, s := range sinks {
if s.status != 200 || s.count != int64(len(payload)) || !bytes.Equal(s.hash.Sum(nil), want[:]) {
t.Fatal("stream output checksum/count mismatch")
}
}
t.Logf("age enabled; 8 simultaneous ×%dMiB uploads: %s (%.1f MiB/s); 16 simultaneous ×%dMiB downloads: %s (%.1f MiB/s); heap delta at barrier: uploads=%.2fMiB downloads=%.2fMiB; in-process HTTP handlers, excludes network/TLS", mib, uploadDuration, float64(mib*8)/uploadDuration.Seconds(), mib, downloadDuration, float64(mib*16)/downloadDuration.Seconds(), float64(int64(duringUploads.Alloc)-int64(before.Alloc))/(1<<20), float64(int64(duringDownloads.Alloc)-int64(before.Alloc))/(1<<20))
}
func TestInjectedCommitFailureAndDeletionFailure(t *testing.T) {
c := fixture(t, true)
payload := []byte("test fault recovery")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, true)
if _, e := c.a.db.Exec("CREATE TRIGGER reject_object_attach BEFORE UPDATE OF object_id ON files WHEN NEW.object_id IS NOT NULL BEGIN SELECT RAISE(ABORT,'injected commit failure'); END"); e != nil {
t.Fatal(e)
}
expect(t, c.request(t, "PUT", "/uploads/"+id+"/files/"+files[0], payload), 500)
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("rolled-back object still registered")
}
entries, e := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects"))
if e != nil {
t.Fatal(e)
}
for _, d := range entries {
sub, _ := os.ReadDir(filepath.Join(c.a.cfg.DataDir, "objects", d.Name()))
if len(sub) > 0 {
t.Fatal("failed commit left uncharged payload")
}
}
c.a.db.Exec("DROP TRIGGER reject_object_attach")
c.upload(t, id, files[0], payload)
c.publish(t, id)
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
if e = c.a.root.Remove(path); e != nil {
t.Fatal(e)
}
if e = c.a.root.Mkdir(path, 0700); e != nil {
t.Fatal(e)
}
if e = c.a.root.WriteFile(path+"/blocker", []byte("x"), 0600); e != nil {
t.Fatal(e)
}
c.a.db.Exec("UPDATE files SET status='deleting' WHERE id=?", files[0])
if e = c.a.Cleanup(); e == nil {
t.Fatal("expected physical deletion failure")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 1 || scalar(t, c.a, "SELECT sum(stored_bytes) FROM objects") <= 0 {
t.Fatal("failed deletion incorrectly released capacity")
}
c.a.root.Remove(path + "/blocker")
if e = c.a.Cleanup(); e != nil {
t.Fatal(e)
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("retry deletion failed")
}
}
func TestDeleteDuringUploadDoesNotRepublish(t *testing.T) {
c := fixture(t, false)
payload := []byte("delete while uploading")
id, files := c.create(t, []string{"a"}, [][]byte{payload}, 3, false)
started := make(chan struct{}, 1)
release := make(chan struct{})
defer func() {
select {
case <-release:
default:
close(release)
}
}()
r := httptest.NewRequest("PUT", "http://localhost:8080/api/v1/uploads/"+id+"/files/"+files[0], &gatedReader{r: bytes.NewReader(payload), started: started, release: release})
r.Header.Set("Origin", "http://localhost:8080")
r.RemoteAddr = c.ip + ":12"
r.AddCookie(c.cookies["bb_visitor"])
done := make(chan *httptest.ResponseRecorder, 1)
go func() { w := httptest.NewRecorder(); c.h.ServeHTTP(w, r); done <- w }()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("stream did not start")
}
expect(t, c.json(t, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}), 200)
expect(t, c.json(t, "POST", "/admin/files/"+files[0]+"/delete", struct{}{}), 200)
_, reserved, _, e := c.a.capacity()
if e != nil || reserved != reserveSize(int64(len(payload)), false) {
t.Fatal("active deletion released upload reservation too early")
}
close(release)
expect(t, <-done, 500)
if scalar(t, c.a, "SELECT count(*) FROM files WHERE status='ready'") != 0 {
t.Fatal("deleted file became ready")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("canceled payload leaked")
}
if e := c.a.Cleanup(); e != nil {
t.Fatal(e)
}
}
func TestAnonymousCookieResetDoesNotResetIPQuota(t *testing.T) {
c := fixture(t, false)
p := c.a.cfg.Policy
p.IPDailyUploads = 1
b, _ := json.Marshal(p)
c.a.db.Exec("UPDATE settings SET json=?", string(b))
c.create(t, []string{"a"}, [][]byte{[]byte("x")}, 1, false)
fresh := &testClient{a: c.a, h: c.h, cookies: map[string]*http.Cookie{}, ip: c.ip}
expect(t, fresh.json(t, "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "a", Size: 1}}}), 429)
if scalar(t, c.a, "SELECT uploads FROM usage WHERE subject LIKE 'ip:%'") != 1 {
t.Fatal("daily quota mutated on rejection")
}
}
func TestFailedRestoreCannotStart(t *testing.T) {
cfg := DefaultConfig()
cfg.DataDir = t.TempDir()
cfg.MinFreeBytes = 0
archive := filepath.Join(t.TempDir(), "broken.tar.gz")
os.WriteFile(archive, []byte("not a backup"), 0600)
if e := Restore(cfg, archive); e == nil {
t.Fatal("invalid archive accepted")
}
if a, e := Open(cfg); e == nil {
a.Close()
t.Fatal("partially restored service can start")
}
}
func TestExclusiveLockAndZeroByteIntegrity(t *testing.T) {
c := fixture(t, true)
if another, e := Open(c.a.cfg); e == nil {
another.Close()
t.Fatal("second process lock succeeded")
}
id, files := c.create(t, []string{"empty"}, [][]byte{{}}, 2, true)
c.upload(t, id, files[0], nil)
c.resolve(t, c.publish(t, id))
expect(t, c.request(t, "HEAD", "/pickup/files/"+files[0]+"/download", nil), 200)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("HEAD consumed count")
}
var oid string
c.a.db.QueryRow("SELECT id FROM objects").Scan(&oid)
path := objectPath(oid)
raw, _ := c.a.root.ReadFile(path)
raw[len(raw)-1] ^= 1
c.a.root.WriteFile(path, raw, 0600)
expect(t, c.request(t, "GET", "/pickup/files/"+files[0]+"/download", nil), 500)
if scalar(t, c.a, "SELECT remaining FROM files WHERE id=?", files[0]) != 2 {
t.Fatal("failed empty-content authentication consumed count")
}
}
func TestSlowUploadIdleDeadline(t *testing.T) {
c := fixture(t, false)
id, files := c.create(t, []string{"slow"}, [][]byte{[]byte("0123456789")}, 2, false)
c.a.cfg.TransferIdleSeconds = 1
c.a.cfg.MaxTransferSeconds = 10
srv := httptest.NewServer(c.h)
defer srv.Close()
c.a.cfg.PublicURL = srv.URL
conn, e := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
if e != nil {
t.Fatal(e)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(5 * time.Second))
_, e = fmt.Fprintf(conn, "PUT /api/v1/uploads/%s/files/%s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\nCookie: bb_visitor=%s\r\nContent-Length: 10\r\nConnection: close\r\n\r\nx", id, files[0], strings.TrimPrefix(srv.URL, "http://"), srv.URL, c.cookies["bb_visitor"].Value)
if e != nil {
t.Fatal(e)
}
start := time.Now()
response, e := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "PUT"})
if e != nil {
t.Fatal(e)
}
defer response.Body.Close()
io.Copy(io.Discard, response.Body)
if response.StatusCode != 400 || time.Since(start) > 4*time.Second {
t.Fatal("idle upload was not rejected promptly")
}
if scalar(t, c.a, "SELECT count(*) FROM objects") != 0 {
t.Fatal("slow partial content became available")
}
}
func TestTrustedProxyChainAndIPv6(t *testing.T) {
c := fixture(t, false)
_, network, _ := net.ParseCIDR("10.0.0.0/8")
c.a.proxies = []*net.IPNet{network}
r := httptest.NewRequest("GET", "http://localhost", nil)
r.RemoteAddr = "10.0.0.1:80"
r.Header.Set("X-Forwarded-For", "8.8.8.8, 203.0.113.9, 10.0.0.2")
if c.a.remoteIP(r) != "203.0.113.9" {
t.Fatal("trusted proxy chain accepted leftmost forged IP")
}
r.RemoteAddr = "[2001:db8:1:2::1]:80"
r.Header.Del("X-Forwarded-For")
first := c.a.remoteIP(r)
r.RemoteAddr = "[2001:db8:1:2::ffff]:80"
if c.a.remoteIP(r) != first {
t.Fatal("IPv6 rotation evaded prefix grouping")
}
}
func testRuntimeBudgets(t *testing.T, a *App, file, data int64) {
t.Helper()
s, e := a.runtimeSettings()
if e != nil {
t.Fatal(e)
}
s.FileBudget = file
s.DataBudget = data
b, _ := json.Marshal(s)
if _, e = a.db.Exec("UPDATE runtime_settings SET json=? WHERE id=1", string(b)); e != nil {
t.Fatal(e)
}
}