package workspaceapps_test

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/coder/coder/v2/codersdk"

	"github.com/go-jose/go-jose/v3"
	"github.com/google/uuid"
	"github.com/stretchr/testify/require"

	"github.com/coder/coder/v2/coderd/coderdtest"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/workspaceapps"
	"github.com/coder/coder/v2/cryptorand"
)

func Test_TokenMatchesRequest(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name  string
		req   workspaceapps.Request
		token workspaceapps.SignedToken
		want  bool
	}{
		{
			name: "OK",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodPath,
					BasePath:          "/app",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "qux",
				},
			},
			want: true,
		},
		{
			name: "NormalizePath",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod: workspaceapps.AccessMethodPath,
					// With trailing slash
					BasePath:          "/app/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "qux",
				},
			},
			want: true,
		},
		{
			name: "DifferentAccessMethod",
			req: workspaceapps.Request{
				AccessMethod: workspaceapps.AccessMethodPath,
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod: workspaceapps.AccessMethodSubdomain,
				},
			},
			want: false,
		},
		{
			name: "DifferentBasePath",
			req: workspaceapps.Request{
				AccessMethod: workspaceapps.AccessMethodPath,
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod: workspaceapps.AccessMethodPath,
					BasePath:     "/app",
				},
			},
			want: false,
		},
		{
			name: "DifferentUsernameOrID",
			req: workspaceapps.Request{
				AccessMethod: workspaceapps.AccessMethodPath,
				BasePath:     "/app",
				UsernameOrID: "foo",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod: workspaceapps.AccessMethodPath,
					BasePath:     "/app",
					UsernameOrID: "bar",
				},
			},
			want: false,
		},
		{
			name: "DifferentWorkspaceNameOrID",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodPath,
					BasePath:          "/app",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "baz",
				},
			},
			want: false,
		},
		{
			name: "DifferentAgentNameOrID",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodPath,
					BasePath:          "/app",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "qux",
				},
			},
			want: false,
		},
		{
			name: "DifferentAppSlugOrPort",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodPath,
					BasePath:          "/app",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "quux",
				},
			},
			want: false,
		},
		{
			name: "SamePrefix",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodSubdomain,
				Prefix:            "dean-was--here---",
				BasePath:          "/",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					Prefix:            "dean--was--here---",
					BasePath:          "/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "quux",
				},
			},
			want: false,
		},
		{
			name: "DifferentPrefix",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodSubdomain,
				Prefix:            "yolo--",
				BasePath:          "/",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					Prefix:            "swag--",
					BasePath:          "/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "quux",
				},
			},
			want: false,
		},
		{
			name: "PortPortocolHTTP",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodSubdomain,
				Prefix:            "yolo--",
				BasePath:          "/",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "8080",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					Prefix:            "yolo--",
					BasePath:          "/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "8080",
				},
			},
			want: true,
		},
		{
			name: "PortPortocolHTTPS",
			req: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodSubdomain,
				Prefix:            "yolo--",
				BasePath:          "/",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "8080s",
			},
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					Prefix:            "yolo--",
					BasePath:          "/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "8080s",
				},
			},
			want: true,
		},
	}

	for _, c := range cases {
		c := c

		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			require.Equal(t, c.want, c.token.MatchesRequest(c.req))
		})
	}
}

func Test_GenerateToken(t *testing.T) {
	t.Parallel()

	t.Run("SetExpiry", func(t *testing.T) {
		t.Parallel()

		tokenStr, err := coderdtest.AppSecurityKey.SignToken(workspaceapps.SignedToken{
			Request: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},

			Expiry:      time.Time{},
			UserID:      uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
			WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
			AgentID:     uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
			AppURL:      "http://127.0.0.1:8080",
		})
		require.NoError(t, err)

		token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
		require.NoError(t, err)

		require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
	})

	future := time.Now().Add(time.Hour)
	cases := []struct {
		name             string
		token            workspaceapps.SignedToken
		parseErrContains string
	}{
		{
			name: "OK1",
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodPath,
					BasePath:          "/app",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "qux",
				},

				Expiry:      future,
				UserID:      uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
				WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
				AgentID:     uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
				AppURL:      "http://127.0.0.1:8080",
			},
		},
		{
			name: "OK2",
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					BasePath:          "/",
					UsernameOrID:      "oof",
					WorkspaceNameOrID: "rab",
					AgentNameOrID:     "zab",
					AppSlugOrPort:     "xuq",
				},

				Expiry:      future,
				UserID:      uuid.MustParse("6fa684a3-11aa-49fd-8512-ab527bd9b900"),
				WorkspaceID: uuid.MustParse("b2d816cc-505c-441d-afdf-dae01781bc0b"),
				AgentID:     uuid.MustParse("6c4396e1-af88-4a8a-91a3-13ea54fc29fb"),
				AppURL:      "http://localhost:9090",
			},
		},
		{
			name: "Expired",
			token: workspaceapps.SignedToken{
				Request: workspaceapps.Request{
					AccessMethod:      workspaceapps.AccessMethodSubdomain,
					BasePath:          "/",
					UsernameOrID:      "foo",
					WorkspaceNameOrID: "bar",
					AgentNameOrID:     "baz",
					AppSlugOrPort:     "qux",
				},

				Expiry:      time.Now().Add(-time.Hour),
				UserID:      uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
				WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
				AgentID:     uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
				AppURL:      "http://127.0.0.1:8080",
			},
			parseErrContains: "token expired",
		},
	}

	for _, c := range cases {
		c := c

		t.Run(c.name, func(t *testing.T) {
			t.Parallel()

			str, err := coderdtest.AppSecurityKey.SignToken(c.token)
			require.NoError(t, err)

			// Tokens aren't deterministic as they have a random nonce, so we
			// can't compare them directly.

			token, err := coderdtest.AppSecurityKey.VerifySignedToken(str)
			if c.parseErrContains != "" {
				require.Error(t, err)
				require.ErrorContains(t, err, c.parseErrContains)
			} else {
				require.NoError(t, err)
				// normalize the expiry
				require.WithinDuration(t, c.token.Expiry, token.Expiry, 10*time.Second)
				c.token.Expiry = token.Expiry
				require.Equal(t, c.token, token)
			}
		})
	}
}

func Test_FromRequest(t *testing.T) {
	t.Parallel()

	t.Run("MultipleTokens", func(t *testing.T) {
		t.Parallel()
		r := httptest.NewRequest("GET", "/", nil)

		// Add an invalid token
		r.AddCookie(&http.Cookie{
			Name:  codersdk.SignedAppTokenCookie,
			Value: "invalid",
		})

		token := workspaceapps.SignedToken{
			Request: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodSubdomain,
				BasePath:          "/",
				UsernameOrID:      "user",
				WorkspaceAndAgent: "workspace/agent",
				WorkspaceNameOrID: "workspace",
				AgentNameOrID:     "agent",
				AppSlugOrPort:     "app",
			},
			Expiry:      time.Now().Add(time.Hour),
			UserID:      uuid.New(),
			WorkspaceID: uuid.New(),
			AgentID:     uuid.New(),
			AppURL:      "/",
		}

		// Add an expired cookie
		expired := token
		expired.Expiry = time.Now().Add(time.Hour * -1)
		expiredStr, err := coderdtest.AppSecurityKey.SignToken(token)
		require.NoError(t, err)
		r.AddCookie(&http.Cookie{
			Name:  codersdk.SignedAppTokenCookie,
			Value: expiredStr,
		})

		// Add a valid token
		validStr, err := coderdtest.AppSecurityKey.SignToken(token)
		require.NoError(t, err)

		r.AddCookie(&http.Cookie{
			Name:  codersdk.SignedAppTokenCookie,
			Value: validStr,
		})

		signed, ok := workspaceapps.FromRequest(r, coderdtest.AppSecurityKey)
		require.True(t, ok, "expected a token to be found")
		// Confirm it is the correct token.
		require.Equal(t, signed.UserID, token.UserID)
	})
}

// The ParseToken fn is tested quite thoroughly in the GenerateToken test as
// well.
func Test_ParseToken(t *testing.T) {
	t.Parallel()

	t.Run("InvalidJWS", func(t *testing.T) {
		t.Parallel()

		token, err := coderdtest.AppSecurityKey.VerifySignedToken("invalid")
		require.Error(t, err)
		require.ErrorContains(t, err, "parse JWS")
		require.Equal(t, workspaceapps.SignedToken{}, token)
	})

	t.Run("VerifySignature", func(t *testing.T) {
		t.Parallel()

		// Create a valid token using a different key.
		var otherKey workspaceapps.SecurityKey
		copy(otherKey[:], coderdtest.AppSecurityKey[:])
		for i := range otherKey {
			otherKey[i] ^= 0xff
		}
		require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)

		tokenStr, err := otherKey.SignToken(workspaceapps.SignedToken{
			Request: workspaceapps.Request{
				AccessMethod:      workspaceapps.AccessMethodPath,
				BasePath:          "/app",
				UsernameOrID:      "foo",
				WorkspaceNameOrID: "bar",
				AgentNameOrID:     "baz",
				AppSlugOrPort:     "qux",
			},

			Expiry:      time.Now().Add(time.Hour),
			UserID:      uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
			WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
			AgentID:     uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
			AppURL:      "http://127.0.0.1:8080",
		})
		require.NoError(t, err)

		// Verify the token is invalid.
		token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
		require.Error(t, err)
		require.ErrorContains(t, err, "verify JWS")
		require.Equal(t, workspaceapps.SignedToken{}, token)
	})

	t.Run("InvalidBody", func(t *testing.T) {
		t.Parallel()

		// Create a signature for an invalid body.
		signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSecurityKey[:64]}, nil)
		require.NoError(t, err)
		signedObject, err := signer.Sign([]byte("hi"))
		require.NoError(t, err)
		serialized, err := signedObject.CompactSerialize()
		require.NoError(t, err)

		token, err := coderdtest.AppSecurityKey.VerifySignedToken(serialized)
		require.Error(t, err)
		require.ErrorContains(t, err, "unmarshal payload")
		require.Equal(t, workspaceapps.SignedToken{}, token)
	})
}

func TestAPIKeyEncryption(t *testing.T) {
	t.Parallel()

	genAPIKey := func(t *testing.T) string {
		id, _ := cryptorand.String(10)
		secret, _ := cryptorand.String(22)

		return fmt.Sprintf("%s-%s", id, secret)
	}

	t.Run("OK", func(t *testing.T) {
		t.Parallel()

		key := genAPIKey(t)
		encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
			APIKey: key,
		})
		require.NoError(t, err)

		decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
		require.NoError(t, err)
		require.Equal(t, key, decryptedKey)
	})

	t.Run("Verifies", func(t *testing.T) {
		t.Parallel()

		t.Run("Expiry", func(t *testing.T) {
			t.Parallel()

			key := genAPIKey(t)
			encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
				APIKey:    key,
				ExpiresAt: dbtime.Now().Add(-1 * time.Hour),
			})
			require.NoError(t, err)

			decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
			require.Error(t, err)
			require.ErrorContains(t, err, "expired")
			require.Empty(t, decryptedKey)
		})

		t.Run("EncryptionKey", func(t *testing.T) {
			t.Parallel()

			// Create a valid token using a different key.
			var otherKey workspaceapps.SecurityKey
			copy(otherKey[:], coderdtest.AppSecurityKey[:])
			for i := range otherKey {
				otherKey[i] ^= 0xff
			}
			require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)

			// Encrypt with the other key.
			key := genAPIKey(t)
			encrypted, err := otherKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
				APIKey: key,
			})
			require.NoError(t, err)

			// Decrypt with the original key.
			decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
			require.Error(t, err)
			require.ErrorContains(t, err, "decrypt API key")
			require.Empty(t, decryptedKey)
		})
	})
}
