🏡 index : quine-global/quintodrome.git

package deezer

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"

	"github.com/lestrrat-go/jwx/v3/jwt"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("JWT Authentication", func() {
	var httpClient *fakeHttpClient
	var client *client
	var ctx context.Context

	BeforeEach(func() {
		httpClient = &fakeHttpClient{}
		client = newClient(httpClient)
		ctx = context.Background()
	})

	Describe("getJWT", func() {
		Context("with a valid JWT response", func() {
			It("successfully fetches and caches a JWT token", func() {
				testJWT := createTestJWT(5 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
				})

				token, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token).To(Equal(testJWT))
			})

			It("returns the cached token on subsequent calls", func() {
				testJWT := createTestJWT(5 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
				})

				// First call should fetch from API
				token1, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token1).To(Equal(testJWT))
				Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))

				// Second call should return cached token without hitting API
				httpClient.lastRequest = nil // Clear last request to verify no new request is made
				token2, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token2).To(Equal(testJWT))
				Expect(httpClient.lastRequest).To(BeNil()) // No new request made
			})

			It("parses the JWT expiration time correctly", func() {
				expectedExpiration := time.Now().Add(5 * time.Minute)
				testToken, err := jwt.NewBuilder().
					Expiration(expectedExpiration).
					Build()
				Expect(err).To(BeNil())
				testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
				Expect(err).To(BeNil())

				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
				})

				token, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token).ToNot(BeEmpty())

				// Verify the token is cached until close to expiration
				// The cache should expire 1 minute before the JWT expires
				expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
				Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
			})
		})

		Context("with JWT tokens that expire soon", func() {
			It("rejects tokens that expire in less than 1 minute", func() {
				// Create a token that expires in 30 seconds (less than 1-minute buffer)
				testJWT := createTestJWT(30 * time.Second)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
			})

			It("rejects already expired tokens", func() {
				// Create a token that expired 1 minute ago
				testJWT := createTestJWT(-1 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
			})

			It("accepts tokens that expire in more than 1 minute", func() {
				// Create a token that expires in 2 minutes (just over the 1-minute buffer)
				testJWT := createTestJWT(2 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
				})

				token, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token).ToNot(BeEmpty())
			})
		})

		Context("with invalid responses", func() {
			It("handles HTTP error responses", func() {
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 500,
					Body:       io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
			})

			It("handles malformed JSON responses", func() {
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
			})

			It("handles responses with empty JWT field", func() {
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
			})

			It("handles invalid JWT tokens", func() {
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
				})

				_, err := client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
			})

			It("rejects JWT tokens without expiration", func() {
				// Create a JWT without expiration claim
				testToken, err := jwt.NewBuilder().
					Claim("custom", "value").
					Build()
				Expect(err).To(BeNil())

				// Verify token has no expiration
				_, hasExp := testToken.Expiration()
				Expect(hasExp).To(BeFalse())

				testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
				Expect(err).To(BeNil())

				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
				})

				_, err = client.getJWT(ctx)
				Expect(err).To(HaveOccurred())
				Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
			})
		})

		Context("token caching behavior", func() {
			It("fetches a new token when the cached token expires", func() {
				// First token expires in 5 minutes
				firstJWT := createTestJWT(5 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
				})

				token1, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token1).To(Equal(firstJWT))

				// Manually expire the cached token
				client.jwt.expiresAt = time.Now().Add(-1 * time.Second)

				// Second token with different expiration (10 minutes)
				secondJWT := createTestJWT(10 * time.Minute)
				httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
					StatusCode: 200,
					Body:       io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
				})

				token2, err := client.getJWT(ctx)
				Expect(err).To(BeNil())
				Expect(token2).To(Equal(secondJWT))
				Expect(token2).ToNot(Equal(token1))
			})
		})
	})

	Describe("jwtToken cache", func() {
		var cache *jwtToken

		BeforeEach(func() {
			cache = &jwtToken{}
		})

		It("returns false for expired tokens", func() {
			cache.set("test-token", -1*time.Second) // Already expired
			token, valid := cache.get()
			Expect(valid).To(BeFalse())
			Expect(token).To(BeEmpty())
		})

		It("returns true for valid tokens", func() {
			cache.set("test-token", 4*time.Minute)
			token, valid := cache.get()
			Expect(valid).To(BeTrue())
			Expect(token).To(Equal("test-token"))
		})

		It("is thread-safe for concurrent access", func() {
			wg := sync.WaitGroup{}

			// Writer goroutine
			wg.Go(func() {
				for i := range 100 {
					cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
					time.Sleep(1 * time.Millisecond)
				}
			})

			// Reader goroutine
			wg.Go(func() {
				for range 100 {
					cache.get()
					time.Sleep(1 * time.Millisecond)
				}
			})

			// Wait for both goroutines to complete
			wg.Wait()

			// Verify final state is valid
			token, valid := cache.get()
			Expect(valid).To(BeTrue())
			Expect(token).To(HavePrefix("token-"))
		})
	})
})

// createTestJWT creates a valid JWT token for testing purposes
func createTestJWT(expiresIn time.Duration) string {
	token, err := jwt.NewBuilder().
		Expiration(time.Now().Add(expiresIn)).
		Build()
	if err != nil {
		panic(fmt.Sprintf("failed to create test JWT: %v", err))
	}
	signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
	if err != nil {
		panic(fmt.Sprintf("failed to sign test JWT: %v", err))
	}
	return string(signed)
}