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 "-" 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)