/
t3
/
s3-server
Обзор
Документация
Войти
/
t3
/
s3-server
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
internal/api/http_integration_test.go
504 строки
14 KB
Ivan Shibkikh
auth phase 4
25 июл 2026, 23:19
25 июл 2026, 23:19
02d1c69
Код
Авторство
О чём код?
package api import ( "bytes" "context" "crypto/sha256" "encoding/hex" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitverse.ru/t3/s3-server/internal/config" "gitverse.ru/t3/s3-server/internal/storage/encryption" "gitverse.ru/t3/s3-server/internal/storage/localdisk" "gitverse.ru/t3/s3-server/internal/storage/sqlite" ) // authTransport wraps an http.RoundTripper to add the Bearer token header. type authTransport struct { rt http.RoundTripper token string } func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("Authorization", "Bearer "+t.token) return t.rt.RoundTrip(req) } // setupTestServer creates a fully bootstrapped httptest server with JWT auth. func setupTestServer(t *testing.T) (*httptest.Server, func()) { t.Helper() tmpDir := t.TempDir() metaPath := filepath.Join(tmpDir, "s3.db") disk1 := filepath.Join(tmpDir, "disk1") disk2 := filepath.Join(tmpDir, "disk2") for _, d := range []string{disk1, disk2} { os.MkdirAll(d, 0755) } meta, err := sqlite.New(metaPath) if err != nil { t.Fatal(err) } meta.Init(context.Background()) disk, err := localdisk.New([]string{disk1, disk2}, 1) if err != nil { t.Fatal(err) } disk.Init(context.Background()) key := make([]byte, 32) key[0] = 1 enc, err := encryption.New(disk, key, 64*1024) if err != nil { t.Fatal(err) } // Generate a test RSA key pair and serve JWKS locally. kp := newTestKeyPair(t) jwksServer := kp.testJWKSServer() verifier := testVerifier(jwksServer.URL) cfg := &config.Config{ STS: config.STSConfig{ Issuer: "https://sts.test.local", JWKSURL: jwksServer.URL, RefreshInterval: 0, }, Service: testServiceCfg, Server: config.ServerConfig{ MaxUploadSize: 100 << 20, MaxParts: 10, MaxPartSize: 5 << 30, MinPartSize: 5 << 20, }, } handler := NewHandler(cfg, meta, enc, slog.New(slog.NewJSONHandler(io.Discard, nil))) mux := http.NewServeMux() mux.HandleFunc("GET /", handler.ListBuckets) mux.HandleFunc("PUT /{bucket}", handler.CreateBucket) mux.HandleFunc("DELETE /{bucket}", handler.DeleteBucket) mux.HandleFunc("GET /{bucket}", handler.ListObjects) mux.HandleFunc("PUT /{bucket}/{key...}", handler.handlePut) mux.HandleFunc("GET /{bucket}/{key...}", handler.GetObject) mux.HandleFunc("HEAD /{bucket}/{key...}", handler.HeadObject) mux.HandleFunc("DELETE /{bucket}/{key...}", handler.handleDelete) mux.HandleFunc("POST /{bucket}/{key...}", handler.handlePost) // Presign endpoint. presignHandler := PresignHandler(cfg.Service, slog.New(slog.NewJSONHandler(io.Discard, nil))) mux.Handle("POST /presign", serviceTokenOnlyMiddleware(verifier)(http.HandlerFunc(presignHandler))) authMw := authMiddleware(verifier, cfg.Service) ts := httptest.NewServer(authMw(mux)) // Issue a wildcard Service Token for all tests. token := kp.issueServiceToken("test-user", "test-tenant", []string{"s3:*"}) ts.Client().Transport = &authTransport{rt: http.DefaultTransport, token: token} cleanup := func() { ts.Close() jwksServer.Close() meta.Close() } t.Cleanup(cleanup) return ts, cleanup } func TestHttpPutAndGetObject(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/test-bucket", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } body := []byte("Hello, S3!") req, _ = http.NewRequest("PUT", ts.URL+"/test-bucket/hello.txt", bytes.NewReader(body)) resp, err = client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } req, _ = http.NewRequest("GET", ts.URL+"/test-bucket/hello.txt", nil) resp, err = client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } got, _ := io.ReadAll(resp.Body) if !bytes.Equal(got, body) { t.Fatalf("body mismatch: got %q, want %q", got, body) } } func TestHttpRangeGet(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/range-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() body := []byte("01234567890123456789") req, _ = http.NewRequest("PUT", ts.URL+"/range-bucket/data.bin", bytes.NewReader(body)) resp, _ = client.Do(req) resp.Body.Close() req, _ = http.NewRequest("GET", ts.URL+"/range-bucket/data.bin", nil) req.Header.Set("Range", "bytes=5-14") resp, err := client.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusPartialContent { t.Fatalf("expected 206, got %d", resp.StatusCode) } cr := resp.Header.Get("Content-Range") if !strings.Contains(cr, "bytes 5-14/20") { t.Errorf("unexpected Content-Range: %s", cr) } got, _ := io.ReadAll(resp.Body) if !bytes.Equal(got, body[5:15]) { t.Fatalf("range mismatch: got %q, want %q", got, body[5:15]) } } func TestHttpRangeBeyondSize(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/range2-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() req, _ = http.NewRequest("PUT", ts.URL+"/range2-bucket/data.bin", bytes.NewReader([]byte("hello"))) resp, _ = client.Do(req) resp.Body.Close() req, _ = http.NewRequest("GET", ts.URL+"/range2-bucket/data.bin", nil) req.Header.Set("Range", "bytes=100-200") resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable { t.Fatalf("expected 416, got %d", resp.StatusCode) } } func TestHttpConditionalGet(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/cond-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() body := []byte("conditional-data") req, _ = http.NewRequest("PUT", ts.URL+"/cond-bucket/file.txt", bytes.NewReader(body)) resp, _ = client.Do(req) etag := resp.Header.Get("ETag") resp.Body.Close() req, _ = http.NewRequest("GET", ts.URL+"/cond-bucket/file.txt", nil) req.Header.Set("If-None-Match", etag) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusNotModified { t.Fatalf("expected 304, got %d", resp.StatusCode) } } func TestHttpListBucketsAndObjects(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/list-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() req, _ = http.NewRequest("PUT", ts.URL+"/list-bucket/item.txt", bytes.NewReader([]byte("a"))) resp, _ = client.Do(req) resp.Body.Close() req, _ = http.NewRequest("GET", ts.URL+"/", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(body), "list-bucket") { t.Fatal("expected list-bucket in response") } req, _ = http.NewRequest("GET", ts.URL+"/list-bucket", nil) resp, _ = client.Do(req) body, _ = io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(body), "item.txt") { t.Fatal("expected item.txt in response") } } func TestHttpDeleteObject(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/del-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() req, _ = http.NewRequest("PUT", ts.URL+"/del-bucket/to-del.txt", bytes.NewReader([]byte("bye"))) resp, _ = client.Do(req) resp.Body.Close() req, _ = http.NewRequest("DELETE", ts.URL+"/del-bucket/to-del.txt", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) } req, _ = http.NewRequest("HEAD", ts.URL+"/del-bucket/to-del.txt", nil) resp, _ = client.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("expected 404, got %d", resp.StatusCode) } } func TestHttpOverwriteObject(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/ovw-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() v1 := []byte("version1") req, _ = http.NewRequest("PUT", ts.URL+"/ovw-bucket/key.txt", bytes.NewReader(v1)) resp, _ = client.Do(req) resp.Body.Close() v2 := []byte("version2-longer") req, _ = http.NewRequest("PUT", ts.URL+"/ovw-bucket/key.txt", bytes.NewReader(v2)) resp, _ = client.Do(req) resp.Body.Close() req, _ = http.NewRequest("GET", ts.URL+"/ovw-bucket/key.txt", nil) resp, _ = client.Do(req) got, _ := io.ReadAll(resp.Body) resp.Body.Close() if !bytes.Equal(got, v2) { t.Fatalf("expected v2, got %q", got) } } func TestHttpHeadObject(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/head-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() body := []byte("head-test-data") req, _ = http.NewRequest("PUT", ts.URL+"/head-bucket/file.txt", bytes.NewReader(body)) resp, _ = client.Do(req) etag := resp.Header.Get("ETag") resp.Body.Close() req, _ = http.NewRequest("HEAD", ts.URL+"/head-bucket/file.txt", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if resp.Header.Get("ETag") != etag { t.Errorf("ETag mismatch: %s vs %s", resp.Header.Get("ETag"), etag) } if resp.Header.Get("Content-Length") != "14" { t.Errorf("unexpected Content-Length: %s", resp.Header.Get("Content-Length")) } } func TestHttpMultipartComplete(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/mp-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() req, _ = http.NewRequest("POST", ts.URL+"/mp-bucket/large.bin?uploads", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("initiate failed: %d", resp.StatusCode) } uploadID := extractUploadID(string(body)) if uploadID == "" { t.Fatal("could not extract uploadId") } partSize := 5 * 1024 * 1024 part1Data := bytes.Repeat([]byte{0x41}, partSize) req, _ = http.NewRequest("PUT", ts.URL+"/mp-bucket/large.bin?uploadId="+uploadID+"&partNumber=1", bytes.NewReader(part1Data)) resp, _ = client.Do(req) etag1 := resp.Header.Get("ETag") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("upload part 1 failed: %d", resp.StatusCode) } part2Data := bytes.Repeat([]byte{0x42}, partSize) req, _ = http.NewRequest("PUT", ts.URL+"/mp-bucket/large.bin?uploadId="+uploadID+"&partNumber=2", bytes.NewReader(part2Data)) resp, _ = client.Do(req) etag2 := resp.Header.Get("ETag") resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("upload part 2 failed: %d", resp.StatusCode) } completeXML := `<CompleteMultipartUpload> <Part><PartNumber>1</PartNumber><ETag>` + etag1 + `</ETag></Part> <Part><PartNumber>2</PartNumber><ETag>` + etag2 + `</ETag></Part> </CompleteMultipartUpload>` req, _ = http.NewRequest("POST", ts.URL+"/mp-bucket/large.bin?uploadId="+uploadID, strings.NewReader(completeXML)) req.Header.Set("Content-Type", "application/xml") resp, err = client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("complete failed: %d", resp.StatusCode) } req, _ = http.NewRequest("GET", ts.URL+"/mp-bucket/large.bin", nil) resp, _ = client.Do(req) got, _ := io.ReadAll(resp.Body) resp.Body.Close() compositeHash := sha256.Sum256([]byte(strings.Trim(etag1, `"`) + strings.Trim(etag2, `"`))) expectedETag := `"` + hex.EncodeToString(compositeHash[:]) + `"` if resp.Header.Get("ETag") != expectedETag { t.Errorf("ETag mismatch: got %s, want %s", resp.Header.Get("ETag"), expectedETag) } expectedContent := append([]byte{}, part1Data...) expectedContent = append(expectedContent, part2Data...) if !bytes.Equal(got, expectedContent) { t.Errorf("content mismatch: got %d bytes, want %d bytes", len(got), len(expectedContent)) } } func TestHttpDeleteEmptyBucket(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/empty-bucket", nil) resp, _ := client.Do(req) resp.Body.Close() req, _ = http.NewRequest("DELETE", ts.URL+"/empty-bucket", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("expected 204, got %d", resp.StatusCode) } } func TestHttpBucketOwner(t *testing.T) { ts, _ := setupTestServer(t) client := ts.Client() req, _ := http.NewRequest("PUT", ts.URL+"/owner-bucket", nil) req.Header.Set("X-S3-Owner", "my-team") resp, _ := client.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } req, _ = http.NewRequest("PUT", ts.URL+"/unknown-bucket", nil) resp, _ = client.Do(req) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } req, _ = http.NewRequest("GET", ts.URL+"/", nil) resp, err := client.Do(req) if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() if !strings.Contains(string(body), "<Owner>my-team</Owner>") { t.Fatal("expected <Owner>my-team</Owner> in response") } if !strings.Contains(string(body), "<Owner>unknown</Owner>") { t.Fatal("expected <Owner>unknown</Owner> in response") } } func extractUploadID(xmlStr string) string { start := strings.Index(xmlStr, "<UploadId>") if start < 0 { return "" } start += len("<UploadId>") end := strings.Index(xmlStr[start:], "</UploadId>") if end < 0 { return "" } return xmlStr[start : start+end] }