Files
sherlock/internal/authn/source_test.go
T
amacocian 59eaf9e47d
Release / release (push) Failing after 6s
SearXNG support
2026-06-14 21:33:22 +02:00

260 lines
7.3 KiB
Go

//go:build unix
package authn
import (
"context"
"sync"
"testing"
"time"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
)
func TestNewTokenSource_RequiresCallback(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic on nil onToken")
}
}()
NewTokenSource(fakekeyring.New(), "gitea",
Config{Issuer: "https://x", ClientID: "y"},
EnsureOptions{LockPath: t.TempDir() + "/lock"}, nil)
}
func TestNewTokenSource_RequiresLockPath(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic on empty LockPath")
}
}()
NewTokenSource(fakekeyring.New(), "gitea",
Config{Issuer: "https://x", ClientID: "y"},
EnsureOptions{}, func(keyring.TokenSet) {})
}
func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
store := fakekeyring.New()
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "fresh",
AccessToken: "at-0",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
})
var got []string
var mu sync.Mutex
cb := func(ts keyring.TokenSet) {
mu.Lock()
got = append(got, ts.AccessToken)
mu.Unlock()
}
src := NewTokenSource(store, "gitea",
Config{Issuer: "https://example.test", ClientID: "y"},
EnsureOptions{LockPath: t.TempDir() + "/lock"}, cb)
ts, err := src.Start(context.Background())
if err != nil {
t.Fatalf("Start: %v", err)
}
if ts.AccessToken != "at-0" {
t.Fatalf("Start returned %q, want at-0", ts.AccessToken)
}
mu.Lock()
defer mu.Unlock()
if len(got) != 1 || got[0] != "at-0" {
t.Fatalf("callback got %v, want [at-0]", got)
}
}
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
stub := newStubAuthentik(t)
store := fakekeyring.New()
// Seed a stale bearer with a still-fresh ID token. This mirrors
// providers that issue short-lived access tokens and longer-lived ID
// tokens; refresh must follow the bearer lifetime because MCPs send
// the access token to services.
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "old",
AccessToken: "at-stale",
RefreshToken: "rt-1",
AccessExpAt: time.Now().Add(-time.Minute),
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid"},
})
tokens := make(chan string, 4)
src := NewTokenSource(store, "gitea",
Config{Issuer: stub.srv.URL, ClientID: "test-client"},
EnsureOptions{LockPath: t.TempDir() + "/lock"},
func(ts keyring.TokenSet) { tokens <- ts.AccessToken })
src.minWait = time.Millisecond // refresh almost immediately
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() { _ = src.Run(ctx); close(done) }()
select {
case got := <-tokens:
if got != "at-refreshed" {
t.Fatalf("renewed token = %q, want at-refreshed", got)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for renewal callback")
}
// The rotated token must be persisted to the wallet too.
stored, err := store.Get("gitea")
if err != nil {
t.Fatalf("Get: %v", err)
}
if stored.AccessToken != "at-refreshed" {
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken)
}
if stored.AccessExpAt.IsZero() {
t.Fatal("wallet access token expiry was not persisted")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not exit after cancel")
}
}
func TestTokenSource_Run_StopsOnCancel(t *testing.T) {
store := fakekeyring.New()
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "fresh",
AccessToken: "at-0",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
})
src := NewTokenSource(store, "gitea",
Config{Issuer: "https://example.test", ClientID: "y"},
EnsureOptions{LockPath: t.TempDir() + "/lock"},
func(keyring.TokenSet) {})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- src.Run(ctx) }()
cancel()
select {
case err := <-done:
if err != context.Canceled {
t.Fatalf("Run returned %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
func TestTokenHolder(t *testing.T) {
h := NewTokenHolder()
if h.AccessToken() != "" {
t.Fatal("empty holder should return empty access token")
}
if _, err := h.Bearer(); err == nil {
t.Fatal("empty holder Bearer should error")
}
h.Set(keyring.TokenSet{AccessToken: "at-1"})
if h.AccessToken() != "at-1" {
t.Fatalf("AccessToken = %q, want at-1", h.AccessToken())
}
if b, err := h.Bearer(); err != nil || b != "Bearer at-1" {
t.Fatalf("Bearer = %q, %v", b, err)
}
h.Set(keyring.TokenSet{AccessToken: "at-2"})
if h.AccessToken() != "at-2" {
t.Fatalf("AccessToken = %q, want at-2 after update", h.AccessToken())
}
}
func TestEnsureStarted_LazyAndIdempotent(t *testing.T) {
store := fakekeyring.New()
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "fresh",
AccessToken: "at-0",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
})
var calls int
var mu sync.Mutex
src := NewTokenSource(store, "gitea",
Config{Issuer: "https://example.test", ClientID: "y"},
EnsureOptions{LockPath: t.TempDir() + "/lock"},
func(keyring.TokenSet) {
mu.Lock()
calls++
mu.Unlock()
}).Lifetime(context.Background())
// Nothing happened yet: no token delivered until EnsureStarted.
mu.Lock()
if calls != 0 {
t.Fatalf("callback fired %d times before EnsureStarted", calls)
}
mu.Unlock()
for i := 0; i < 3; i++ {
if err := src.EnsureStarted(context.Background()); err != nil {
t.Fatalf("EnsureStarted #%d: %v", i, err)
}
}
// Initial auth ran exactly once despite three calls (the renewer may
// add more later, but immediately after Start it's exactly one).
mu.Lock()
got := calls
mu.Unlock()
if got != 1 {
t.Fatalf("initial auth delivered %d tokens, want 1", got)
}
if src.Current().AccessToken != "at-0" {
t.Fatalf("Current = %q, want at-0", src.Current().AccessToken)
}
}
func TestEnsureStarted_RetriesAfterFailure(t *testing.T) {
// Empty wallet + an unreachable issuer makes the first EnsureStarted
// fail (no browser stub). started must stay false so a later call can
// succeed once the wallet is seeded.
store := fakekeyring.New()
src := NewTokenSource(store, "gitea",
Config{Issuer: "https://127.0.0.1:1/nope", ClientID: "y", LoopbackAddrOverride: "127.0.0.1:0"},
EnsureOptions{LockPath: t.TempDir() + "/lock"},
func(keyring.TokenSet) {}).Lifetime(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := src.EnsureStarted(ctx); err == nil {
t.Fatal("expected first EnsureStarted to fail with empty wallet + bad issuer")
}
// Seed a fresh token; now EnsureStarted should succeed (refresh path,
// no issuer contact needed because the token is still fresh).
_ = store.Set("gitea", keyring.TokenSet{
IDToken: "fresh",
AccessToken: "at-late",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
})
if err := src.EnsureStarted(context.Background()); err != nil {
t.Fatalf("EnsureStarted after seeding: %v", err)
}
if src.Current().AccessToken != "at-late" {
t.Fatalf("Current = %q, want at-late", src.Current().AccessToken)
}
}