| 1 | package blob |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | ) |
| 15 | |
| 16 | func TestPathUploadStoresExactBytesAndReturnsCreatedThenOK(t *testing.T) { |
| 17 | s := testService(t) |
| 18 | body := []byte("BUD-13 bytes" ) |
| 19 | digest := sha256.Sum256(body) |
| 20 | sha := hex.EncodeToString(digest[:]) |
| 21 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+".txt" , strings.NewReader(string(body))) |
| 22 | request.Header.Set("Content-Type" , "text/plain" ) |
| 23 | first := record(s.Handler(), request) |
| 24 | if first.Code != http.StatusCreated || !strings.Contains(first.Body.String(), sha) { |
| 25 | t.Fatalf("first path upload = %d %s" , first.Code, first.Body.String()) |
| 26 | } |
| 27 | second := record(s.Handler(), httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha, strings.NewReader(string(body)))) |
| 28 | if second.Code != http.StatusOK { |
| 29 | t.Fatalf("duplicate path upload = %d %s" , second.Code, second.Body.String()) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestPathUploadRejectsHashMismatchWithoutPersisting(t *testing.T) { |
| 34 | s := testService(t) |
| 35 | wanted := strings.Repeat("a" , 64) |
| 36 | response := record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, wanted) }, httptest.NewRequest(http.MethodPut, "https://relay.test/" +wanted, strings.NewReader("wrong" ))) |
| 37 | if response.Code != http.StatusConflict { |
| 38 | t.Fatalf("mismatch status = %d, want 409" , response.Code) |
| 39 | } |
| 40 | if _, _, err := s.Get(context.Background(), wanted); err == nil { |
| 41 | t.Fatal("mismatched path upload was persisted" ) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestPathUploadAuthenticatesBeforeReadingBody(t *testing.T) { |
| 46 | s := testService(t) |
| 47 | read := false |
| 48 | s.config.Authorize = func(*http.Request, Action) (string, error) { return "" , errors.New("bad auth" ) } |
| 49 | body := &trackingReader{Reader: strings.NewReader("should not be read" ), read: &read} |
| 50 | sha := strings.Repeat("b" , 64) |
| 51 | response := record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, sha) }, httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha, body)) |
| 52 | if response.Code != http.StatusUnauthorized || read { |
| 53 | t.Fatalf("auth status/read = %d/%v" , response.Code, read) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestPathUploadRejectsRemoteBodyAndMalformedURL(t *testing.T) { |
| 58 | s := testService(t) |
| 59 | sha := strings.Repeat("c" , 64) |
| 60 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+"?url=https%3A%2F%2Fexample.com%2Fblob" , strings.NewReader("body" )) |
| 61 | response := record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, sha) }, request) |
| 62 | if response.Code != http.StatusBadRequest { |
| 63 | t.Fatalf("remote body status = %d, want 400" , response.Code) |
| 64 | } |
| 65 | request = httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+"?url=file%3A%2F%2F%2Fetc%2Fpasswd" , http.NoBody) |
| 66 | response = record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, sha) }, request) |
| 67 | if response.Code != http.StatusBadRequest { |
| 68 | t.Fatalf("malformed URL status = %d, want 400" , response.Code) |
| 69 | } |
| 70 | request = httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+"?url=https%3A%2F%2Fexample.com%2Fx%23fragment" , http.NoBody) |
| 71 | response = record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, sha) }, request) |
| 72 | if response.Code != http.StatusBadRequest { |
| 73 | t.Fatalf("fragment URL status = %d, want 400" , response.Code) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestPathUploadRemoteSourceVerifiesHashAndSupportsHTTP(t *testing.T) { |
| 78 | s := testService(t) |
| 79 | body := []byte("remote BUD-13 bytes" ) |
| 80 | digest := sha256.Sum256(body) |
| 81 | sha := hex.EncodeToString(digest[:]) |
| 82 | s.config.ResolveIP = func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("8.8.8.8" )}, nil } |
| 83 | s.resolve = s.config.ResolveIP |
| 84 | s.config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 85 | return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(body))), Header: http.Header{"Content-Type" : []string{"text/plain" }}, Request: req}, nil |
| 86 | })} |
| 87 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+"?url=http%3A%2F%2Forigin.example%2Ffile.txt" , http.NoBody) |
| 88 | response := record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, sha) }, request) |
| 89 | if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), sha) { |
| 90 | t.Fatalf("remote upload = %d %s" , response.Code, response.Body.String()) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | func TestPathUploadRemoteRejectsPrivateOrigin(t *testing.T) { |
| 95 | s := testService(t) |
| 96 | s.config.ResolveIP = func(context.Context, string) ([]net.IP, error) { return []net.IP{net.ParseIP("127.0.0.1" )}, nil } |
| 97 | s.resolve = s.config.ResolveIP |
| 98 | response := record(func(w http.ResponseWriter, r *http.Request) { s.pathUpload(w, r, strings.Repeat("d" , 64)) }, httptest.NewRequest(http.MethodPut, "https://relay.test/" +strings.Repeat("d" , 64)+"?url=http%3A%2F%2Forigin.example%2Fx" , http.NoBody)) |
| 99 | if response.Code != http.StatusBadRequest { |
| 100 | t.Fatalf("private origin status = %d, want 400" , response.Code) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestPathUploadRemoteUsesRequestContext(t *testing.T) { |
| 105 | s := testService(t) |
| 106 | sha := strings.Repeat("e" , 64) |
| 107 | s.config.ResolveIP = func(ctx context.Context, _ string) ([]net.IP, error) { |
| 108 | return []net.IP{net.ParseIP("8.8.8.8" )}, nil |
| 109 | } |
| 110 | s.resolve = s.config.ResolveIP |
| 111 | s.config.HTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { |
| 112 | return nil, req.Context().Err() |
| 113 | })} |
| 114 | ctx, cancel := context.WithCancel(context.Background()) |
| 115 | cancel() |
| 116 | request := httptest.NewRequest(http.MethodPut, "https://relay.test/" +sha+"?url=https%3A%2F%2Forigin.example%2Ffile" , http.NoBody).WithContext(ctx) |
| 117 | response := record(s.Handler(), request) |
| 118 | if response.Code != http.StatusBadGateway { |
| 119 | t.Fatalf("cancelled remote upload = %d, want 502" , response.Code) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | type trackingReader struct { |
| 124 | io.Reader |
| 125 | read *bool |
| 126 | } |
| 127 | |
| 128 | func (r *trackingReader) Read(p []byte) (int, error) { |
| 129 | *r.read = true |
| 130 | return r.Reader.Read(p) |
| 131 | } |
| 132 | |
| 133 | func TestPathUploadPreservesAccessPurposeAndClaimMetadata(t *testing.T) { |
| 134 | s := testService(t) |
| 135 | body := "named member-only file" |
| 136 | digest := sha256.Sum256([]byte(body)) |
| 137 | hash := hex.EncodeToString(digest[:]) |
| 138 | r := httptest.NewRequest(http.MethodPut, "https://relay.test/" +hash+"?filename=note.txt&path=docs%2Fnote.txt&access=members&purpose=file" , strings.NewReader(body)) |
| 139 | r.Header.Set("Content-Type" , "text/plain" ) |
| 140 | response := record(s.Handler(), r) |
| 141 | if response.Code != http.StatusCreated { |
| 142 | t.Fatalf("upload: %d %s" , response.Code, response.Body.String()) |
| 143 | } |
| 144 | entries, err := s.ListClaimMetadata(t.Context(), "uploader" ) |
| 145 | if err != nil || len(entries) != 1 || entries[0].Purpose != "file" || entries[0].Name != "note.txt" || entries[0].Path != "docs/note.txt" { |
| 146 | t.Fatalf("claim: %+v %v" , entries, err) |
| 147 | } |
| 148 | if access, err := s.BlobAccess(t.Context(), hash); err != nil || access != AccessMembers { |
| 149 | t.Fatalf("access: %q %v" , access, err) |
| 150 | } |
| 151 | } |