650 lines
18 KiB
Go
650 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"send/internal/auth"
|
|
"send/internal/config"
|
|
"send/internal/store"
|
|
)
|
|
|
|
// clock is the fixed instant tests start from; s.now is swapped so expiry can
|
|
// be exercised without sleeping.
|
|
var clock = time.Date(2026, 9, 12, 10, 0, 0, 0, time.UTC)
|
|
|
|
type harness struct {
|
|
*Server
|
|
ts *httptest.Server
|
|
dir string
|
|
now time.Time
|
|
token string // a token allowing vanity names
|
|
admin string
|
|
}
|
|
|
|
func newHarness(t *testing.T, tweak func(*config.Config)) *harness {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
cfg := config.Config{}
|
|
fs := config.NewSet("test", "SEND_TEST_")
|
|
cfg.Register(fs)
|
|
if err := fs.Parse(nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg.DataDir = dir
|
|
cfg.MaxSize = 1 << 20
|
|
cfg.MaxExpiry = 72 * time.Hour
|
|
cfg.DefaultExpiry = 72 * time.Hour
|
|
cfg.MinFreeBytes = 0
|
|
cfg.UploadRate = 100000
|
|
cfg.UploadBurst = 100000
|
|
if tweak != nil {
|
|
tweak(&cfg)
|
|
}
|
|
if err := cfg.Normalise(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
st, err := store.Open(cfg.DataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tokens, err := auth.Load(cfg.TokensPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := &harness{dir: dir, now: clock}
|
|
for _, spec := range []struct {
|
|
name string
|
|
admin bool
|
|
dst *string
|
|
}{{"friend", false, &h.token}, {"boss", true, &h.admin}} {
|
|
secret, err := store.NewSecret()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tokens.Add(&auth.Token{
|
|
Name: spec.name, Hash: auth.HashSecret(secret),
|
|
AllowVanity: true, Admin: spec.admin,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
*spec.dst = secret
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv, err := New(&cfg, st, tokens, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv.now = func() time.Time { return h.now }
|
|
|
|
h.Server = srv
|
|
h.ts = httptest.NewServer(srv)
|
|
t.Cleanup(h.ts.Close)
|
|
return h
|
|
}
|
|
|
|
// upload posts a raw body, the way curl does.
|
|
func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *http.Response {
|
|
t.Helper()
|
|
return h.uploadReader(t, bytes.NewReader(body), headers)
|
|
}
|
|
|
|
func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest("POST", h.ts.URL+"/api/upload", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func decode[T any](t *testing.T, resp *http.Response) T {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
var v T
|
|
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
|
|
t.Fatalf("decoding %s response: %v", resp.Status, err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestRoundTrip(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
payload := bytes.Repeat([]byte("godot"), 4096)
|
|
|
|
resp := h.upload(t, payload, map[string]string{
|
|
"Content-Disposition": `attachment; filename="MyGame.zip"`,
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("upload status = %s", resp.Status)
|
|
}
|
|
res := decode[uploadResult](t, resp)
|
|
|
|
if res.Filename != "MyGame.zip" {
|
|
t.Errorf("filename = %q, want MyGame.zip", res.Filename)
|
|
}
|
|
if res.Size != int64(len(payload)) {
|
|
t.Errorf("size = %d, want %d", res.Size, len(payload))
|
|
}
|
|
if res.DeleteToken == "" {
|
|
t.Error("no delete token returned")
|
|
}
|
|
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer get.Body.Close()
|
|
got, _ := io.ReadAll(get.Body)
|
|
if !bytes.Equal(got, payload) {
|
|
t.Errorf("downloaded %d bytes, want %d", len(got), len(payload))
|
|
}
|
|
|
|
// An uploaded file must never come back as something a browser will run.
|
|
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
|
t.Errorf("Content-Type = %q", ct)
|
|
}
|
|
if get.Header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Error("missing nosniff")
|
|
}
|
|
if !strings.Contains(get.Header.Get("Content-Security-Policy"), "sandbox") {
|
|
t.Errorf("CSP = %q", get.Header.Get("Content-Security-Policy"))
|
|
}
|
|
disp, params, err := mime.ParseMediaType(get.Header.Get("Content-Disposition"))
|
|
if err != nil || disp != "attachment" || params["filename"] != "MyGame.zip" {
|
|
t.Errorf("Content-Disposition = %q (%v)", get.Header.Get("Content-Disposition"), err)
|
|
}
|
|
}
|
|
|
|
func TestHTMLUploadIsServedInert(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.upload(t, []byte("<script>alert(1)</script>"), map[string]string{
|
|
"Content-Disposition": `attachment; filename="evil.html"`,
|
|
})
|
|
res := decode[uploadResult](t, resp)
|
|
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer get.Body.Close()
|
|
if ct := get.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
|
t.Errorf("HTML served as %q; it must never be text/html", ct)
|
|
}
|
|
if !strings.HasPrefix(get.Header.Get("Content-Disposition"), "attachment") {
|
|
t.Error("HTML was not served as an attachment")
|
|
}
|
|
}
|
|
|
|
func TestRangeRequest(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
payload := bytes.Repeat([]byte("abcdefgh"), 1024)
|
|
res := decode[uploadResult](t, h.upload(t, payload, nil))
|
|
|
|
req, _ := http.NewRequest("GET", h.ts.URL+"/d/"+res.ID, nil)
|
|
req.Header.Set("Range", "bytes=0-1023")
|
|
get, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer get.Body.Close()
|
|
if get.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %s, want 206", get.Status)
|
|
}
|
|
body, _ := io.ReadAll(get.Body)
|
|
if len(body) != 1024 || !bytes.Equal(body, payload[:1024]) {
|
|
t.Errorf("got %d bytes, want the first 1024", len(body))
|
|
}
|
|
if cr := get.Header.Get("Content-Range"); cr != fmt.Sprintf("bytes 0-1023/%d", len(payload)) {
|
|
t.Errorf("Content-Range = %q", cr)
|
|
}
|
|
}
|
|
|
|
// An oversized body must be refused on bytes actually written, never on a
|
|
// declared length. This sends a chunked body, so there is no Content-Length to
|
|
// consult even if the code wanted to.
|
|
func TestOversizeChunkedUploadIsRefused(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) { c.MaxSize = 4096 })
|
|
|
|
// A plain io.Reader (not a *bytes.Buffer) makes the client use chunked
|
|
// encoding with no declared length.
|
|
body := io.LimitReader(zeroes{}, 1<<20)
|
|
resp := h.uploadReader(t, struct{ io.Reader }{body}, nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %s, want 413", resp.Status)
|
|
}
|
|
if n := h.store.Count(); n != 0 {
|
|
t.Errorf("%d objects stored after a refused upload", n)
|
|
}
|
|
assertNoDebris(t, h.dir)
|
|
}
|
|
|
|
// A body one byte over the cap is refused; exactly at the cap is accepted.
|
|
func TestSizeLimitBoundary(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) { c.MaxSize = 1000 })
|
|
|
|
resp := h.upload(t, bytes.Repeat([]byte("x"), 1000), nil)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("exactly at the limit: status = %s, want 201", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp = h.upload(t, bytes.Repeat([]byte("x"), 1001), nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("one byte over: status = %s, want 413", resp.Status)
|
|
}
|
|
}
|
|
|
|
type zeroes struct{}
|
|
|
|
func (zeroes) Read(p []byte) (int, error) { return len(p), nil }
|
|
|
|
func TestAnonymousCannotClaimVanity(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.upload(t, []byte("hi"), map[string]string{"Vanity": "my-file"})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status = %s, want 403", resp.Status)
|
|
}
|
|
if h.store.Exists("my-file") {
|
|
t.Error("the name was claimed despite the refusal")
|
|
}
|
|
}
|
|
|
|
func TestVanityCollision(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
hdr := map[string]string{
|
|
"Vanity": "my-file",
|
|
"Authorization": "Bearer " + h.token,
|
|
}
|
|
resp := h.upload(t, []byte("first"), hdr)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("first upload: status = %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp = h.upload(t, []byte("second"), hdr)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("second upload: status = %s, want 409", resp.Status)
|
|
}
|
|
|
|
// The first object must be untouched.
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/d/my-file")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer get.Body.Close()
|
|
body, _ := io.ReadAll(get.Body)
|
|
if string(body) != "first" {
|
|
t.Errorf("content = %q, want %q", body, "first")
|
|
}
|
|
}
|
|
|
|
func TestUnknownTokenIsRejected(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
resp := h.upload(t, []byte("hi"), map[string]string{"Authorization": "Bearer nope"})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %s, want 401", resp.Status)
|
|
}
|
|
}
|
|
|
|
// Expiry is enforced on read, not only by the sweeper, which never runs here.
|
|
func TestExpiryIsCheckedOnRead(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("ephemeral"), map[string]string{
|
|
"Expiry": "1h",
|
|
}))
|
|
|
|
status := func(when time.Duration) int {
|
|
h.now = clock.Add(when)
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/d/" + res.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get.Body.Close()
|
|
return get.StatusCode
|
|
}
|
|
|
|
if got := status(59 * time.Minute); got != http.StatusOK {
|
|
t.Fatalf("before expiry: status = %d, want 200", got)
|
|
}
|
|
if got := status(61 * time.Minute); got != http.StatusNotFound {
|
|
t.Fatalf("after expiry: status = %d, want 404", got)
|
|
}
|
|
// The read path also reclaims the space.
|
|
if _, err := os.Stat(filepath.Join(h.dir, "objects", res.ID)); !os.IsNotExist(err) {
|
|
t.Error("expired object was not removed on read")
|
|
}
|
|
}
|
|
|
|
func TestExpiryBeyondLimitIsRefused(t *testing.T) {
|
|
h := newHarness(t, nil) // max 72h for anonymous
|
|
for _, req := range []string{"30d", "never"} {
|
|
resp := h.upload(t, []byte("hi"), map[string]string{"Expiry": req})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("Expiry: %s => status %s, want 400", req, resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestTokenMayOutliveTheAnonymousLimit(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
forever := "never"
|
|
if err := h.tokens.Remove("friend"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret, _ := store.NewSecret()
|
|
if err := h.tokens.Add(&auth.Token{
|
|
Name: "friend", Hash: auth.HashSecret(secret),
|
|
MaxExpiry: &forever, DefaultExpiry: &forever, AllowVanity: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := decode[uploadResult](t, h.upload(t, []byte("keep me"), map[string]string{
|
|
"Authorization": "Bearer " + secret,
|
|
"Expiry": "never",
|
|
}))
|
|
if res.Expires != "" {
|
|
t.Errorf("expires = %q, want empty (never)", res.Expires)
|
|
}
|
|
}
|
|
|
|
func TestPathTraversalIsRejected(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
// As a requested vanity name.
|
|
for _, name := range []string{"../etc/passwd", "..", ".", "/absolute", "a/b", `a\b`, "ok..name"} {
|
|
resp := h.upload(t, []byte("x"), map[string]string{
|
|
"Vanity": name,
|
|
"Authorization": "Bearer " + h.token,
|
|
})
|
|
if resp.StatusCode == http.StatusCreated {
|
|
t.Errorf("vanity %q was accepted", name)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// As a download path. Some of these are normalised away into a redirect to
|
|
// the index, which is harmless; what matters is that no stored bytes are
|
|
// ever served, so the check is for an object response rather than a status.
|
|
for _, path := range []string{
|
|
"/d/..%2f..%2fetc%2fpasswd", "/d/.", "/d/..", "/d/%2e%2e",
|
|
"/d/../tokens.json", "/d/%2e%2e%2ftokens.json", "/d/objects",
|
|
} {
|
|
get, err := h.ts.Client().Get(h.ts.URL + path)
|
|
if err != nil {
|
|
continue // the client itself may refuse to send it, which is fine
|
|
}
|
|
get.Body.Close()
|
|
if get.Header.Get("Content-Disposition") != "" {
|
|
t.Errorf("GET %s served an object", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReservedNamesAreRejected(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
for _, name := range []string{"api", "static", "d", "i", "robots.txt"} {
|
|
resp := h.upload(t, []byte("x"), map[string]string{
|
|
"Vanity": name,
|
|
"Authorization": "Bearer " + h.token,
|
|
})
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("vanity %q => %s, want 400", name, resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestDeleteRequiresTheRightToken(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("delete me"), nil))
|
|
|
|
del := func(token string) int {
|
|
form := strings.NewReader("token=" + token)
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
if got := del("wrong-token"); got != http.StatusForbidden {
|
|
t.Errorf("wrong token => %d, want 403", got)
|
|
}
|
|
if got := del(h.token); got != http.StatusForbidden {
|
|
t.Errorf("a non-owning, non-admin token => %d, want 403", got)
|
|
}
|
|
if got := del(res.DeleteToken); got != http.StatusOK {
|
|
t.Errorf("correct token => %d, want 200", got)
|
|
}
|
|
// The token is single-use because the object it names is gone.
|
|
if got := del(res.DeleteToken); got != http.StatusNotFound {
|
|
t.Errorf("reused token => %d, want 404", got)
|
|
}
|
|
assertNoDebris(t, h.dir)
|
|
}
|
|
|
|
func TestAdminMayDeleteAnything(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
|
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", nil)
|
|
req.Header.Set("Authorization", "Bearer "+h.admin)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("admin delete => %s, want 200", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestMultipartUpload(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
// Order matters: the server needs these before the file part arrives.
|
|
mw.WriteField("token", h.token)
|
|
mw.WriteField("expiry", "2h")
|
|
mw.WriteField("vanity", "from-the-form")
|
|
fw, err := mw.CreateFormFile("file", "notes (draft).txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fw.Write([]byte("hello from a browser"))
|
|
mw.Close()
|
|
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("status = %s", resp.Status)
|
|
}
|
|
res := decode[uploadResult](t, resp)
|
|
if res.ID != "from-the-form" {
|
|
t.Errorf("id = %q, want from-the-form", res.ID)
|
|
}
|
|
if res.Filename != "notes (draft).txt" {
|
|
t.Errorf("filename = %q", res.Filename)
|
|
}
|
|
}
|
|
|
|
// A form post with no Accept: application/json gets the HTML success page, so
|
|
// the no-JS path works.
|
|
func TestFormPostRendersHTML(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
var body bytes.Buffer
|
|
mw := multipart.NewWriter(&body)
|
|
fw, _ := mw.CreateFormFile("file", "thing.bin")
|
|
fw.Write([]byte("data"))
|
|
mw.Close()
|
|
|
|
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
resp, err := h.ts.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
t.Fatalf("Content-Type = %q, want HTML", ct)
|
|
}
|
|
page, _ := io.ReadAll(resp.Body)
|
|
if !strings.Contains(string(page), "Delete token") {
|
|
t.Error("the success page does not show the delete token")
|
|
}
|
|
}
|
|
|
|
func TestBasePathMounting(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) { c.BasePath = "/send" })
|
|
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/send/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer get.Body.Close()
|
|
if get.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /send/ => %s", get.Status)
|
|
}
|
|
page, _ := io.ReadAll(get.Body)
|
|
if !strings.Contains(string(page), `href="/send/static/style.css"`) {
|
|
t.Error("page links do not carry the base path")
|
|
}
|
|
|
|
// The bare prefix redirects to the slashed form.
|
|
noRedirect := *h.ts.Client()
|
|
noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
resp, err := noRedirect.Get(h.ts.URL + "/send")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusMovedPermanently {
|
|
t.Errorf("GET /send => %s, want 301", resp.Status)
|
|
}
|
|
}
|
|
|
|
// An upload that dies mid-flight must leave nothing visible behind, and the
|
|
// sweeper must eventually reclaim the directory.
|
|
func TestAbandonedUploadIsInvisibleAndSwept(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
|
|
up, err := h.store.Reserve("half-done")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
up.Write([]byte("partial"))
|
|
// Deliberately no Commit and no Abort: this is what a killed process leaves.
|
|
|
|
get, err := h.ts.Client().Get(h.ts.URL + "/d/half-done")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
get.Body.Close()
|
|
if get.StatusCode != http.StatusNotFound {
|
|
t.Errorf("an uncommitted object was visible: %s", get.Status)
|
|
}
|
|
|
|
dir := filepath.Join(h.dir, "objects", "half-done")
|
|
old := clock.Add(-48 * time.Hour)
|
|
if err := os.Chtimes(dir, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.store.Sweep(clock)
|
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Error("abandoned upload directory was not swept")
|
|
}
|
|
}
|
|
|
|
func TestQuotaRefusesUploads(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) { c.MaxTotalBytes = 100 })
|
|
|
|
resp := h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("first upload => %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Only 20 bytes of quota remain, so this is truncated to the remainder and
|
|
// refused rather than allowed to overshoot.
|
|
resp = h.upload(t, bytes.Repeat([]byte("x"), 80), nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("over quota => %s, want 413", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestRateLimit(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
c.UploadRate = 1
|
|
c.UploadBurst = 2
|
|
})
|
|
var last *http.Response
|
|
for range 3 {
|
|
if last != nil {
|
|
last.Body.Close()
|
|
}
|
|
last = h.upload(t, []byte("x"), nil)
|
|
}
|
|
defer last.Body.Close()
|
|
if last.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("third upload => %s, want 429", last.Status)
|
|
}
|
|
}
|
|
|
|
// assertNoDebris checks that no object directory was left behind.
|
|
func assertNoDebris(t *testing.T, dir string) {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(filepath.Join(dir, "objects"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
t.Errorf("leftover object directory: %s", e.Name())
|
|
}
|
|
}
|