462 lines
19 KiB
Go
462 lines
19 KiB
Go
package gitea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.maximumdirect.net/releasekit/internal/forge"
|
|
)
|
|
|
|
func TestBackendTagCommitUsesEscapedFullTagAndAuthentication(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got, want := r.RequestURI, "/api/v1/repos/owner/project/tags/v1.2.3%2Frc.1"; got != want {
|
|
t.Errorf("request URI = %q, want %q", got, want)
|
|
}
|
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
|
t.Errorf("authorization = %q, want %q", got, want)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"commit": map[string]string{"sha": "abcdef"}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
commit, err := backend.TagCommit(context.Background(), "v1.2.3/rc.1")
|
|
if err != nil || commit != "abcdef" {
|
|
t.Fatalf("TagCommit() = %q, %v", commit, err)
|
|
}
|
|
}
|
|
|
|
func TestBackendFindReleaseReturnsNeutralNotFound(t *testing.T) {
|
|
server := httptest.NewServer(http.NotFoundHandler())
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
_, err := backend.FindRelease(context.Background(), "v1.2.3")
|
|
var failure *forge.Failure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureNotFound {
|
|
t.Fatalf("FindRelease() error = %v, want neutral not-found failure", err)
|
|
}
|
|
var cause *Failure
|
|
if !errors.As(err, &cause) || cause.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("FindRelease() error cause = %v, want Gitea 404 diagnostic", err)
|
|
}
|
|
}
|
|
|
|
func TestBackendFindReleaseDoesNotRequireWriteResponseFields(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3", Name: "Release"})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if release.Tag != "v1.2.3" || release.Title != "Release" || release.Handle == nil {
|
|
t.Fatalf("FindRelease() = %#v", release)
|
|
}
|
|
}
|
|
|
|
func TestBackendListsMetadataAndOpensCallerOwnedAssetStream(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
|
t.Errorf("authorization = %q, want %q", got, want)
|
|
}
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
|
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "v1.2.3"})
|
|
case "/api/v1/repos/owner/project/releases/12/assets":
|
|
_ = json.NewEncoder(w).Encode([]assetResponse{{ID: 4, Name: "app.bin", Size: 4, BrowserDownloadURL: "/files/app"}})
|
|
case "/files/app":
|
|
_, _ = io.WriteString(w, "data")
|
|
default:
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assets, err := backend.ListAssets(context.Background(), release.Handle)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(assets) != 1 || assets[0].Filename != "app.bin" || assets[0].Size != 4 || assets[0].Handle == nil {
|
|
t.Fatalf("ListAssets() = %#v", assets)
|
|
}
|
|
stream, err := backend.OpenAsset(context.Background(), assets[0].Handle)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, readErr := io.ReadAll(stream)
|
|
closeErr := stream.Close()
|
|
if readErr != nil || closeErr != nil || string(content) != "data" {
|
|
t.Fatalf("OpenAsset() content = %q, read error = %v, close error = %v", content, readErr, closeErr)
|
|
}
|
|
}
|
|
|
|
func TestBackendRejectsAssetHandleFromAnotherBackendType(t *testing.T) {
|
|
backend := NewBackend(testDestination("http://example.test"), nil)
|
|
_, err := backend.OpenAsset(context.Background(), strings.NewReader("asset"))
|
|
var failure *forge.Failure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureInvalid {
|
|
t.Fatalf("OpenAsset() error = %v, want invalid handle", err)
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightUsesAuthenticatedRepositoryReadsAndIgnoresCodePushPermission(t *testing.T) {
|
|
var requests []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("method = %q, want GET", r.Method)
|
|
}
|
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
|
t.Errorf("authorization = %q, want %q", got, want)
|
|
}
|
|
requests = append(requests, r.URL.Path)
|
|
writeRepository := func() {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 31, "name": "project", "full_name": "owner/project",
|
|
"owner": map[string]string{"login": "owner"},
|
|
"permissions": map[string]bool{"admin": false, "push": false, "pull": true},
|
|
})
|
|
}
|
|
switch r.URL.Path {
|
|
case "/api/v1/repos/owner/project", "/api/v1/repositories/31":
|
|
writeRepository()
|
|
default:
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
if err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(requests) != 2 || requests[0] != "/api/v1/repos/owner/project" || requests[1] != "/api/v1/repositories/31" {
|
|
t.Fatalf("preflight requests = %v, want both repository reads", requests)
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightRequiresCredentialWithoutMakingRequests(t *testing.T) {
|
|
var requests int
|
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ }))
|
|
defer server.Close()
|
|
destination := testDestination(server.URL)
|
|
destination.Token = " \t"
|
|
|
|
err := NewBackend(destination, server.Client()).Preflight(context.Background())
|
|
var failure *forge.PreflightFailure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightMissingCredential {
|
|
t.Fatalf("Preflight() error = %v, want missing credential", err)
|
|
}
|
|
if requests != 0 {
|
|
t.Fatalf("made %d requests without a credential", requests)
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightDoesNotAcceptPublicLookupWithoutAuthenticatedAccess(t *testing.T) {
|
|
var requests []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests = append(requests, r.URL.Path)
|
|
if r.URL.Path == "/api/v1/repos/owner/project" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "project", "full_name": "owner/project", "owner": map[string]string{"login": "owner"}})
|
|
return
|
|
}
|
|
http.Error(w, "token rejected", http.StatusUnauthorized)
|
|
}))
|
|
defer server.Close()
|
|
|
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
|
var failure *forge.PreflightFailure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInvalidCredential {
|
|
t.Fatalf("Preflight() error = %v, want invalid credential", err)
|
|
}
|
|
if len(requests) != 2 || requests[1] != "/api/v1/repositories/31" {
|
|
t.Fatalf("preflight requests = %v, want authenticated repository read", requests)
|
|
}
|
|
if strings.Contains(err.Error(), testDestination(server.URL).Token) {
|
|
t.Fatalf("preflight error exposed the credential: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightClassifiesDeniedAndHiddenRepositoriesAsInaccessible(t *testing.T) {
|
|
for _, status := range []int{http.StatusForbidden, http.StatusNotFound} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "repository unavailable", status)
|
|
}))
|
|
defer server.Close()
|
|
|
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
|
var failure *forge.PreflightFailure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightInaccessibleDestination {
|
|
t.Fatalf("Preflight() error = %v, want inaccessible destination", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightClassifiesUnverifiableResponsesAsUnavailable(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
respond func(http.ResponseWriter)
|
|
}{
|
|
{name: "malformed JSON", respond: func(w http.ResponseWriter) { _, _ = io.WriteString(w, "{") }},
|
|
{name: "service failure", respond: func(w http.ResponseWriter) { http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) }},
|
|
{name: "wrong repository identity", respond: func(w http.ResponseWriter) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"id": 31, "name": "other", "full_name": "owner/other", "owner": map[string]string{"login": "owner"}})
|
|
}},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { test.respond(w) }))
|
|
defer server.Close()
|
|
|
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(context.Background())
|
|
var failure *forge.PreflightFailure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable {
|
|
t.Fatalf("Preflight() error = %v, want unavailable", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendPreflightPreservesCancellation(t *testing.T) {
|
|
server := httptest.NewServer(http.NotFoundHandler())
|
|
defer server.Close()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
err := NewBackend(testDestination(server.URL), server.Client()).Preflight(ctx)
|
|
var failure *forge.PreflightFailure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.PreflightUnavailable || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Preflight() error = %v, want unavailable and context cancellation", err)
|
|
}
|
|
}
|
|
|
|
func TestBackendCreateReleaseSendsRequestedFieldsAndAcceptsProtocolAcknowledgment(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases" {
|
|
t.Errorf("request = %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
|
t.Errorf("authorization = %q, want %q", got, want)
|
|
}
|
|
var request createReleaseRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Errorf("decode request: %v", err)
|
|
}
|
|
if request.TagName != "v1.2.3" || request.TargetCommitish != strings.Repeat("a", 40) || request.Name != "Release title" || request.Body != "# Release title\n\nComplete notes." || !request.Draft || !request.Prerelease {
|
|
t.Errorf("release request = %#v", request)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 12, TagName: "server tag", Name: "server title", Body: "server body", Draft: true, Prerelease: true})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
release, evidence, err := backend.CreateRelease(context.Background(), forge.CreateReleaseRequest{
|
|
Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release title",
|
|
Body: "# Release title\n\nComplete notes.", Draft: true, Prerelease: true,
|
|
})
|
|
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
if release.Handle == nil || release.Tag != "server tag" || release.Title != "server title" || release.Body != "server body" || !release.Draft || !release.Prerelease {
|
|
t.Fatalf("CreateRelease() release = %#v", release)
|
|
}
|
|
}
|
|
|
|
func TestBackendCreateReleaseMarksInvalidAcknowledgmentUncertain(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(releaseResponse{TagName: "v1.2.3"})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
|
var failure *forge.Failure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
}
|
|
|
|
func TestBackendCreateReleaseClassifiesConflictAndServerFailure(t *testing.T) {
|
|
for _, test := range []struct {
|
|
status int
|
|
disposition forge.WriteDisposition
|
|
}{
|
|
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
|
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
|
{status: http.StatusServiceUnavailable, disposition: forge.WriteUncertain},
|
|
} {
|
|
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
_, evidence, err := backend.CreateRelease(context.Background(), validCreateReleaseRequest())
|
|
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
|
t.Fatalf("CreateRelease() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendUploadAssetBorrowsReaderAndValidatesAcknowledgment(t *testing.T) {
|
|
var uploaded string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/owner/project/releases/12/assets" || r.URL.Query().Get("name") != "app.bin" {
|
|
t.Errorf("request = %s %s?%s", r.Method, r.URL.Path, r.URL.RawQuery)
|
|
}
|
|
if got, want := r.Header.Get("Authorization"), "token fixture-secret-token"; got != want {
|
|
t.Errorf("authorization = %q, want %q", got, want)
|
|
}
|
|
file, header, err := r.FormFile("attachment")
|
|
if err != nil {
|
|
t.Errorf("attachment: %v", err)
|
|
} else {
|
|
defer file.Close()
|
|
content, readErr := io.ReadAll(file)
|
|
if readErr != nil {
|
|
t.Errorf("read attachment: %v", readErr)
|
|
}
|
|
if header.Filename != "app.bin" {
|
|
t.Errorf("multipart filename = %q", header.Filename)
|
|
}
|
|
uploaded = string(content)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin"})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
reader := &trackedReader{Reader: strings.NewReader("streamed bytes")}
|
|
|
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", reader)
|
|
if err != nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteAccepted}) {
|
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
if uploaded != "streamed bytes" || reader.closed {
|
|
t.Fatalf("uploaded = %q, caller reader closed = %v", uploaded, reader.closed)
|
|
}
|
|
}
|
|
|
|
func TestBackendUploadAssetMarksBadAcknowledgmentUncertain(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "different.bin"})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
|
var failure *forge.Failure
|
|
if !errors.As(err, &failure) || failure.Kind != forge.FailureMalformed || evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) {
|
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
}
|
|
|
|
func TestBackendUploadAssetClassifiesKnownRejectionAndConflict(t *testing.T) {
|
|
for _, test := range []struct {
|
|
status int
|
|
disposition forge.WriteDisposition
|
|
}{
|
|
{status: http.StatusBadRequest, disposition: forge.WriteRejected},
|
|
{status: http.StatusConflict, disposition: forge.WriteConflict},
|
|
{status: http.StatusInternalServerError, disposition: forge.WriteUncertain},
|
|
} {
|
|
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(test.status) }))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
|
|
evidence, err := backend.UploadAsset(context.Background(), releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
|
if err == nil || evidence != (forge.WriteEvidence{Attempted: true, Disposition: test.disposition}) {
|
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBackendUploadAssetPreservesCancellationAsUncertain(t *testing.T) {
|
|
server := httptest.NewServer(http.NotFoundHandler())
|
|
defer server.Close()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
evidence, err := NewBackend(testDestination(server.URL), server.Client()).UploadAsset(ctx, releaseHandle{id: 12}, "app.bin", strings.NewReader("data"))
|
|
if evidence != (forge.WriteEvidence{Attempted: true, Disposition: forge.WriteUncertain}) || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("UploadAsset() evidence = %#v, error = %v", evidence, err)
|
|
}
|
|
}
|
|
|
|
func TestBackendUploadAssetWaitsForMultipartWriterAfterReaderFailure(t *testing.T) {
|
|
readerErr := errors.New("asset reader failed")
|
|
readDone := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/owner/project/releases/tags/v1.2.3" {
|
|
_ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3"})
|
|
return
|
|
}
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_ = json.NewEncoder(w).Encode(assetResponse{ID: 50, Name: "app.bin", Size: 0})
|
|
}))
|
|
defer server.Close()
|
|
backend := NewBackend(testDestination(server.URL), server.Client())
|
|
release, err := backend.FindRelease(context.Background(), "v1.2.3")
|
|
if err != nil {
|
|
t.Fatalf("FindRelease() error = %v", err)
|
|
}
|
|
|
|
evidence, err := backend.UploadAsset(context.Background(), release.Handle, "app.bin", failingUploadReader{err: readerErr, done: readDone})
|
|
select {
|
|
case <-readDone:
|
|
default:
|
|
t.Fatal("UploadAsset() returned before the multipart worker finished reading")
|
|
}
|
|
if !errors.Is(err, readerErr) {
|
|
t.Fatalf("UploadAsset() error = %v, want reader cause %v", err, readerErr)
|
|
}
|
|
if !evidence.Attempted || evidence.Disposition != forge.WriteUncertain {
|
|
t.Fatalf("UploadAsset() evidence = %#v, want uncertain attempted write", evidence)
|
|
}
|
|
}
|
|
|
|
type failingUploadReader struct {
|
|
err error
|
|
done chan struct{}
|
|
}
|
|
|
|
func (reader failingUploadReader) Read([]byte) (int, error) {
|
|
close(reader.done)
|
|
return 0, reader.err
|
|
}
|
|
|
|
func validCreateReleaseRequest() forge.CreateReleaseRequest {
|
|
return forge.CreateReleaseRequest{Tag: "v1.2.3", Commit: strings.Repeat("a", 40), Title: "Release", Body: "# Release"}
|
|
}
|
|
|
|
func testDestination(baseURL string) Destination {
|
|
return Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: "fixture-secret-token"}
|
|
}
|
|
|
|
type trackedReader struct {
|
|
io.Reader
|
|
closed bool
|
|
}
|
|
|
|
func (reader *trackedReader) Close() error {
|
|
reader.closed = true
|
|
return nil
|
|
}
|