/*
   Copyright The Soci Snapshotter Authors.

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

package resolver

import (
	"bytes"
	"errors"
	"io"
	"net/http"
	"net/url"
	"strings"
	"testing"

	"github.com/containerd/containerd/remotes/docker"
)

const (
	// mockURL is a fake URL modeling soci-snapshotter fetching content from S3.
	mockURL = "https://s3.us-east-1.amazonaws.com/981ebdad55863b3631dce86a228a3ea230dc87673a06a7d216b1275d4dd707c9/12d7153d7eee2fd595a25e5378384f1ae4b6a1658298a54c5bd3f951ec50b7cb"

	// mockQuery is a fake HTTP query with sensitive information which should be redacted.
	mockQuery = "?username=admin&password=admin"

	// redactedQuery is the expected result of redacting mockQuery.
	// The query values will be sorted by key as a side-effect of encoding the URL query string back into the URL.
	// See https://pkg.go.dev/net/url#Values.Encode
	redactedQuery = "?password=redacted&username=redacted"
)

func TestHandleHTTPErrorRedactsHTTPQueries(t *testing.T) {
	createHTTPResponse := func(path string, query string) *http.Response {
		url, err := url.Parse(path + query)
		if err != nil {
			panic(err)
		}
		return &http.Response{
			Body: &mockBody{},
			Request: &http.Request{
				Method: "GET",
				URL:    url,
			},
		}
	}

	testcases := []struct {
		Name        string
		Description string
		Response    *http.Response
		Err         error
		Attempts    int
		Assert      func(*testing.T, *http.Response, error)
	}{
		{
			Name:        "NilResponseOrError",
			Description: "Handler should gracefully handle a nil response or error",
			Response:    nil,
			Err:         nil,
			Attempts:    10,
			Assert: func(t *testing.T, response *http.Response, err error) {
				if response != nil {
					t.Fatalf("Expected nil response, got '%v'", response)
				}

				const expected = "unknown \"unknown\": giving up request after 10 attempt(s)"
				if strings.Compare(expected, err.Error()) != 0 {
					t.Fatalf("Expected '%s', got '%s'", expected, err.Error())
				}
			},
		},
		{
			Name:        "RedactURLInResponse",
			Description: "Handler should redact HTTP queries in response",
			Response:    createHTTPResponse(mockURL, mockQuery),
			Err:         errors.New("connect: connection refused"),
			Attempts:    10,
			Assert: func(t *testing.T, response *http.Response, err error) {
				if response != nil {
					t.Fatalf("Expected nil response, got '%v'", response)
				}

				const expected = "GET \"" + mockURL + redactedQuery + "\": giving up request after 10 attempt(s): connect: connection refused"
				if strings.Compare(expected, err.Error()) != 0 {
					t.Fatalf("Expected '%s', got '%s'", expected, err.Error())
				}
			},
		},
		{
			Name:        "RedactURLInError",
			Description: "Handler should redact HTTP queries in error",
			Response:    createHTTPResponse(mockURL, ""),
			Err: &url.Error{
				Op:  "GET",
				URL: mockURL + mockQuery,
				Err: errors.New("connect: connection refused"),
			},
			Attempts: 10,
			Assert: func(t *testing.T, response *http.Response, err error) {
				if response != nil {
					t.Fatalf("Expected nil response, got '%v'", response)
				}

				const expected = "GET \"" + mockURL + "\": giving up request after 10 attempt(s): GET \"" + mockURL + redactedQuery + "\": connect: connection refused"
				if strings.Compare(expected, err.Error()) != 0 {
					t.Fatalf("Expected '%s', got '%s'", expected, err.Error())
				}
			},
		},
	}

	for _, testcase := range testcases {
		t.Run(testcase.Name, func(t *testing.T) {
			response, err := handleHTTPError(testcase.Response, testcase.Err, testcase.Attempts)
			testcase.Assert(t, response, err)
		})
	}
}

func TestHandleHTTPErrorReadsAndClosesResponseBody(t *testing.T) {
	body := &mockBody{}
	response := &http.Response{
		Body: body,
	}
	err := errors.New("connect: connection refused")

	_, _ = handleHTTPError(response, err, 0)

	if !body.WasRead {
		t.Fatalf("The response body was not read by handler")
	}

	if !body.Closed {
		t.Fatalf("The response body was not closed by handler")
	}
}

type mockBody struct {
	Closed  bool
	WasRead bool
}

func (b *mockBody) Read(_ []byte) (int, error) {
	b.WasRead = true
	return 0, io.EOF
}

func (b *mockBody) Close() error {
	b.Closed = true
	return nil
}

func TestAuthentication(t *testing.T) {
	ecrForbiddenResponse, _ := docker.Errors([]error{docker.ErrorCodeDenied.WithMessage(ecrTokenExpiredResponseMessage)}).MarshalJSON()
	normalForbiddenResponse, _ := docker.Errors([]error{docker.ErrorCodeDenied}).MarshalJSON()
	unauthorizedResponse, _ := docker.Errors([]error{docker.ErrorCodeUnauthorized}).MarshalJSON()
	testCases := []struct {
		name               string
		performAuth        bool
		response           *http.Response
		expectedAuthHeader string
	}{
		{
			name:        "Authenticate on 403 with token expiry.",
			performAuth: true,
			response: &http.Response{
				StatusCode: http.StatusForbidden,
				Body:       io.NopCloser(bytes.NewReader(ecrForbiddenResponse)),
			},
			expectedAuthHeader: "Basic realm=\"\",service=\"ecr.amazonaws.com\"",
		},
		{
			name:        "Do not authenticate on 403 without token expiry.",
			performAuth: false,
			response: &http.Response{
				StatusCode: http.StatusForbidden,
				Body:       io.NopCloser(bytes.NewReader(normalForbiddenResponse)),
			},
		},
		{
			name:        "Authenticate on 401.",
			performAuth: true,
			response: &http.Response{
				StatusCode: http.StatusUnauthorized,
				Body:       io.NopCloser(bytes.NewReader(unauthorizedResponse)),
			},
		},
		{
			name:        "Do not authenticate on 200.",
			performAuth: false,
			response: &http.Response{
				StatusCode: http.StatusOK,
				Body:       io.NopCloser(bytes.NewReader([]byte("data"))),
			},
		},
	}
	for _, tc := range testCases {
		shouldPerformAuthentication := shouldAuthenticate(tc.response)
		if tc.performAuth != shouldPerformAuthentication {
			t.Fatalf("failed test case: %s: expected auth: %v; got auth: %v",
				tc.name, tc.performAuth, shouldPerformAuthentication)
		}

		if tc.expectedAuthHeader != "" {
			authHeader, exists := tc.response.Header["Www-Authenticate"]
			if !exists {
				t.Fatalf("failed test case: %s: expected Www-Authenticate header: %s; got nothing",
					tc.name, tc.expectedAuthHeader)
			}
			if authHeader[0] != tc.expectedAuthHeader {
				t.Fatalf("failed test case: %s: expected Www-Authenticate header: %s; got %s",
					tc.name, tc.expectedAuthHeader, authHeader[0])
			}
		}

	}
}
