ProofedMesh/services/admin-api/internal/attest/pocket_id_token_source_test.go

302 lines
9.1 KiB
Go
Raw Normal View History

package attest
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// mockOIDCServer is an httptest server that serves /.well-known/openid-configuration
// and a client_credentials token endpoint. It records call counts so tests can
// assert on cache behaviour.
type mockOIDCServer struct {
srv *httptest.Server
discoveryCalls atomic.Int32
tokenCalls atomic.Int32
expiresIn int32 // seconds; swap atomically if the test wants to change it between calls
tokenPrefix string // tokens are "<prefix>-<N>" to allow identity assertions
expectedClientID string
expectedSecret string
expectedScope string
expectedAudience string
t *testing.T
}
func newMockOIDCServer(t *testing.T) *mockOIDCServer {
t.Helper()
m := &mockOIDCServer{
expiresIn: 60,
tokenPrefix: "tok",
expectedClientID: "admin-api",
expectedSecret: "s3cret",
t: t,
}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
m.discoveryCalls.Add(1)
if r.Method != http.MethodGet {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": m.srv.URL,
"token_endpoint": m.srv.URL + "/api/oidc/token",
})
})
mux.HandleFunc("/api/oidc/token", func(w http.ResponseWriter, r *http.Request) {
n := m.tokenCalls.Add(1)
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
return
}
id, secret, ok := r.BasicAuth()
if !ok || id != m.expectedClientID || secret != m.expectedSecret {
http.Error(w, `{"error":"invalid_client"}`, http.StatusUnauthorized)
return
}
if got := r.PostForm.Get("grant_type"); got != "client_credentials" {
http.Error(w, `{"error":"unsupported_grant_type"}`, http.StatusBadRequest)
return
}
if m.expectedScope != "" && r.PostForm.Get("scope") != m.expectedScope {
http.Error(w, `{"error":"invalid_scope"}`, http.StatusBadRequest)
return
}
if m.expectedAudience != "" && r.PostForm.Get("audience") != m.expectedAudience {
http.Error(w, `{"error":"invalid_audience"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
resp := pocketIDTokenResponse{
AccessToken: fmt.Sprintf("%s-%d", m.tokenPrefix, n),
TokenType: "Bearer",
ExpiresIn: int(atomic.LoadInt32(&m.expiresIn)),
}
_ = json.NewEncoder(w).Encode(resp)
})
m.srv = httptest.NewServer(mux)
t.Cleanup(m.srv.Close)
return m
}
func TestPocketIDTokenSource_DiscoversAndFetches(t *testing.T) {
m := newMockOIDCServer(t)
src := &PocketIDTokenSource{
Issuer: m.srv.URL,
ClientID: m.expectedClientID,
ClientSecret: m.expectedSecret,
}
tok, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if tok != "tok-1" {
t.Fatalf("token: got %q, want %q", tok, "tok-1")
}
if got := m.discoveryCalls.Load(); got != 1 {
t.Errorf("discovery calls = %d, want 1", got)
}
if got := m.tokenCalls.Load(); got != 1 {
t.Errorf("token calls = %d, want 1", got)
}
}
func TestPocketIDTokenSource_CachesWithinLeeway(t *testing.T) {
m := newMockOIDCServer(t)
now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
src := &PocketIDTokenSource{
Issuer: m.srv.URL,
ClientID: m.expectedClientID,
ClientSecret: m.expectedSecret,
RefreshLeeway: 30 * time.Second,
Now: func() time.Time { return now },
}
// expires_in=60s, leeway=30s → cache valid for ~30s wall time.
for i := 0; i < 3; i++ {
tok, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token call %d: %v", i, err)
}
if tok != "tok-1" {
t.Fatalf("Token call %d: got %q, want cached tok-1", i, tok)
}
}
if got := m.tokenCalls.Load(); got != 1 {
t.Errorf("token endpoint hit %d times, expected 1 cached fetch", got)
}
if got := m.discoveryCalls.Load(); got != 1 {
t.Errorf("discovery hit %d times, expected 1 (sticky)", got)
}
}
func TestPocketIDTokenSource_RefreshesWithinExpiryLeeway(t *testing.T) {
m := newMockOIDCServer(t)
// Pretend the token server issues 60s tokens.
now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
src := &PocketIDTokenSource{
Issuer: m.srv.URL,
ClientID: m.expectedClientID,
ClientSecret: m.expectedSecret,
RefreshLeeway: 30 * time.Second,
Now: func() time.Time { return now },
}
first, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token (first): %v", err)
}
if first != "tok-1" {
t.Fatalf("first token = %q", first)
}
// Advance to one second before the exp-leeway cutoff. Cache must hold.
// exp = 12:01:00, leeway = 30s, so cache is valid while now < 12:00:30.
now = now.Add(29 * time.Second)
same, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token (within leeway): %v", err)
}
if same != "tok-1" {
t.Fatalf("inside leeway expected cached tok-1, got %q", same)
}
// Advance to exp-30s exactly: now+leeway == cachedExpiry → cache
// invalid (strict Before). This is the "verwirft bei exp-30s"
// behaviour from the task spec.
now = now.Add(1 * time.Second)
refreshed, err := src.Token(context.Background())
if err != nil {
t.Fatalf("Token (at exp-leeway): %v", err)
}
if refreshed != "tok-2" {
t.Fatalf("at exp-leeway cutoff expected refreshed tok-2, got %q", refreshed)
}
if got := m.tokenCalls.Load(); got != 2 {
t.Errorf("token endpoint hits = %d, want 2", got)
}
}
func TestPocketIDTokenSource_PassesOptionalScopeAndAudience(t *testing.T) {
m := newMockOIDCServer(t)
m.expectedScope = "trust-api.sign"
m.expectedAudience = "https://trust.basicstack.de"
src := &PocketIDTokenSource{
Issuer: m.srv.URL,
ClientID: m.expectedClientID,
ClientSecret: m.expectedSecret,
Scope: m.expectedScope,
Audience: m.expectedAudience,
}
if _, err := src.Token(context.Background()); err != nil {
t.Fatalf("Token: %v", err)
}
}
func TestPocketIDTokenSource_RejectsBadCredentials(t *testing.T) {
m := newMockOIDCServer(t)
src := &PocketIDTokenSource{
Issuer: m.srv.URL,
ClientID: m.expectedClientID,
ClientSecret: "wrong",
}
_, err := src.Token(context.Background())
if err == nil {
t.Fatal("expected error for bad credentials")
}
if !strings.Contains(err.Error(), "status 401") {
t.Errorf("expected 401 in error, got %v", err)
}
}
func TestPocketIDTokenSource_RejectsMissingConfig(t *testing.T) {
cases := []struct {
name string
src *PocketIDTokenSource
}{
{"no issuer", &PocketIDTokenSource{ClientID: "a", ClientSecret: "b"}},
{"no client id", &PocketIDTokenSource{Issuer: "https://auth", ClientSecret: "b"}},
{"no client secret", &PocketIDTokenSource{Issuer: "https://auth", ClientID: "a"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := tc.src.Token(context.Background()); err == nil {
t.Fatal("expected error, got nil")
}
})
}
}
func TestPocketIDTokenSource_DiscoveryError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "oops", http.StatusInternalServerError)
}))
defer srv.Close()
src := &PocketIDTokenSource{
Issuer: srv.URL,
ClientID: "a",
ClientSecret: "b",
}
_, err := src.Token(context.Background())
if err == nil {
t.Fatal("expected discovery error")
}
if !strings.Contains(err.Error(), "discovery") {
t.Errorf("expected discovery in error, got %v", err)
}
}
func TestPocketIDTokenSource_MissingTokenEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"issuer":"https://auth"}`))
}))
defer srv.Close()
src := &PocketIDTokenSource{
Issuer: srv.URL,
ClientID: "a",
ClientSecret: "b",
}
_, err := src.Token(context.Background())
if err == nil || !strings.Contains(err.Error(), "token_endpoint") {
t.Fatalf("expected missing token_endpoint error, got %v", err)
}
}
func TestPocketIDTokenSource_RejectsNonPositiveExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/.well-known/openid-configuration":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"token_endpoint":%q}`, "http://"+r.Host+"/token")))
case "/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"x","expires_in":0}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
src := &PocketIDTokenSource{
Issuer: srv.URL,
ClientID: "a",
ClientSecret: "b",
}
_, err := src.Token(context.Background())
if err == nil || !strings.Contains(err.Error(), "expires_in") {
t.Fatalf("expected expires_in error, got %v", err)
}
}
// Compile-time check that PocketIDTokenSource satisfies the attest.TokenSource interface.
var _ TokenSource = (*PocketIDTokenSource)(nil)