mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
471 lines
16 KiB
Go
471 lines
16 KiB
Go
package iam_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/iam"
|
|
)
|
|
|
|
// startFakeIAM spins up a minimal httptest server that emulates the IAM
|
|
// endpoints we care about. Handlers are pluggable per-test via setHandler.
|
|
type fakeIAM struct {
|
|
server *httptest.Server
|
|
handlers sync.Map // path → http.HandlerFunc
|
|
calls sync.Map // path → *int64 (call counter)
|
|
}
|
|
|
|
func newFakeIAM(t *testing.T) *fakeIAM {
|
|
t.Helper()
|
|
f := &fakeIAM{}
|
|
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cnt, _ := f.calls.LoadOrStore(r.URL.Path, new(int64))
|
|
atomic.AddInt64(cnt.(*int64), 1)
|
|
if h, ok := f.handlers.Load(r.URL.Path); ok {
|
|
h.(http.HandlerFunc)(w, r)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(f.server.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeIAM) setHandler(path string, h http.HandlerFunc) {
|
|
f.handlers.Store(path, h)
|
|
}
|
|
|
|
func (f *fakeIAM) callCount(path string) int64 {
|
|
v, ok := f.calls.Load(path)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return atomic.LoadInt64(v.(*int64))
|
|
}
|
|
|
|
// writeOK writes a Casdoor-style {status, msg, data} envelope.
|
|
func writeOK(w http.ResponseWriter, data any) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "data": data})
|
|
}
|
|
|
|
// writeErr writes a Casdoor-style status:"error" envelope at HTTP 200.
|
|
// This is the "already exists" path — IAM returns 200, not 409.
|
|
func writeErr(w http.ResponseWriter, msg string) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"status": "error", "msg": msg})
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// LookupByAttribute
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
func TestLookupByAttribute_Hit(t *testing.T) {
|
|
f := newFakeIAM(t)
|
|
// Phone normalization probes multiple candidate formats: the canonical
|
|
// `+16125551234`, the `+`-stripped `16125551234`, and the country-
|
|
// code-stripped `6125551234`. Accept any of the three on the first
|
|
// probe — the SDK's normalization order is its own internal choice
|
|
// and the test should not be load-bearing on that order.
|
|
validProbes := map[string]bool{
|
|
"+16125551234": true,
|
|
"16125551234": true,
|
|
"6125551234": true,
|
|
}
|
|
f.setHandler("/api/get-users", func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.URL.Query().Get("field"); got != "phone" {
|
|
t.Errorf("field: got %q want phone", got)
|
|
}
|
|
if got := r.URL.Query().Get("value"); !validProbes[got] {
|
|
t.Errorf("value: got %q want one of {+16125551234, 16125551234, 6125551234}", got)
|
|
}
|
|
if got := r.URL.Query().Get("owner"); got != "hanzo" {
|
|
t.Errorf("owner: got %q want hanzo", got)
|
|
}
|
|
writeOK(w, []map[string]any{
|
|
{"id": "u-1", "name": "alice", "email": "[email protected]", "owner": "hanzo"},
|
|
})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
out, err := c.LookupByAttribute(context.Background(), "phone", "+16125551234", "", 10)
|
|
if err != nil {
|
|
t.Fatalf("LookupByAttribute: %v", err)
|
|
}
|
|
if len(out) != 1 || out[0].ID != "u-1" {
|
|
t.Fatalf("got %+v, want one user u-1", out)
|
|
}
|
|
}
|
|
|
|
func TestLookupByAttribute_Miss(t *testing.T) {
|
|
f := newFakeIAM(t)
|
|
f.setHandler("/api/get-users", func(w http.ResponseWriter, r *http.Request) {
|
|
writeOK(w, []map[string]any{})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
out, err := c.LookupByAttribute(context.Background(), "email", "[email protected]", "", 10)
|
|
if err != nil {
|
|
t.Fatalf("LookupByAttribute on miss must not error: %v", err)
|
|
}
|
|
if len(out) != 0 {
|
|
t.Fatalf("got %d users, want 0", len(out))
|
|
}
|
|
}
|
|
|
|
func TestLookupByAttribute_PhoneNormalization(t *testing.T) {
|
|
// Verify the three-shape probe for phone: +16125551234 → 16125551234 → 6125551234.
|
|
// The user actually exists under the raw US-national form ("6125551234"),
|
|
// so the first two probes miss and the third hits.
|
|
f := newFakeIAM(t)
|
|
f.setHandler("/api/get-users", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Query().Get("value") {
|
|
case "6125551234":
|
|
writeOK(w, []map[string]any{
|
|
{"id": "u-7", "name": "bob", "email": "[email protected]", "owner": "hanzo"},
|
|
})
|
|
default:
|
|
writeOK(w, []map[string]any{})
|
|
}
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
out, err := c.LookupByAttribute(context.Background(), "phone", "+16125551234", "", 10)
|
|
if err != nil {
|
|
t.Fatalf("LookupByAttribute: %v", err)
|
|
}
|
|
if len(out) != 1 || out[0].ID != "u-7" {
|
|
t.Fatalf("expected normalization to recover u-7, got %+v", out)
|
|
}
|
|
if got := f.callCount("/api/get-users"); got != 3 {
|
|
t.Fatalf("expected 3 probes (raw, no-plus, no-US), got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestLookupByAttribute_RequiresAdminCreds(t *testing.T) {
|
|
c := iam.NewClient("http://unused.invalid")
|
|
_, err := c.LookupByAttribute(context.Background(), "email", "[email protected]", "hanzo", 10)
|
|
if err == nil || !strings.Contains(err.Error(), "admin credentials not configured") {
|
|
t.Fatalf("want admin-credentials error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLookupByAttribute_IAMErrorPropagates(t *testing.T) {
|
|
f := newFakeIAM(t)
|
|
f.setHandler("/api/get-users", func(w http.ResponseWriter, r *http.Request) {
|
|
writeErr(w, "field not supported")
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
_, err := c.LookupByAttribute(context.Background(), "bogus", "x", "", 10)
|
|
if err == nil || !strings.Contains(err.Error(), "field not supported") {
|
|
t.Fatalf("want IAM-side error propagation, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// EnsureUser
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
func TestEnsureUser_Create(t *testing.T) {
|
|
f := newFakeIAM(t)
|
|
var addUserCalls, getUserCalls int64
|
|
f.setHandler("/api/add-user", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&addUserCalls, 1)
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload map[string]any
|
|
_ = json.Unmarshal(body, &payload)
|
|
if payload["email"] != "[email protected]" {
|
|
t.Errorf("email: got %v want [email protected]", payload["email"])
|
|
}
|
|
if payload["owner"] != "hanzo" {
|
|
t.Errorf("owner: got %v want hanzo", payload["owner"])
|
|
}
|
|
writeOK(w, "Affected")
|
|
})
|
|
f.setHandler("/api/get-user", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&getUserCalls, 1)
|
|
writeOK(w, map[string]any{
|
|
"id": "new-id",
|
|
"name": "new",
|
|
"email": "[email protected]",
|
|
"owner": "hanzo",
|
|
})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{
|
|
Email: "[email protected]",
|
|
Name: "new",
|
|
Phone: "+16125550000",
|
|
Type: "normal-user",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EnsureUser create: %v", err)
|
|
}
|
|
if user.ID != "new-id" {
|
|
t.Errorf("user.ID: got %q want new-id", user.ID)
|
|
}
|
|
if atomic.LoadInt64(&addUserCalls) != 1 {
|
|
t.Errorf("add-user calls: got %d want 1", addUserCalls)
|
|
}
|
|
if atomic.LoadInt64(&getUserCalls) != 1 {
|
|
t.Errorf("get-user calls: got %d want 1", getUserCalls)
|
|
}
|
|
}
|
|
|
|
func TestEnsureUser_Idempotent_AlreadyExists(t *testing.T) {
|
|
// IAM/Casdoor returns HTTP 200 with status:"error" + "X already exists"
|
|
// when the user is already present. EnsureUser must treat this as
|
|
// idempotent-replay and resolve the user via GET /api/get-user.
|
|
f := newFakeIAM(t)
|
|
var addUserCalls int64
|
|
f.setHandler("/api/add-user", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&addUserCalls, 1)
|
|
writeErr(w, "user:Email already exists")
|
|
})
|
|
f.setHandler("/api/get-user", func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.URL.Query().Get("email"); got != "[email protected]" {
|
|
t.Errorf("email: got %q want [email protected]", got)
|
|
}
|
|
writeOK(w, map[string]any{
|
|
"id": "existing-id",
|
|
"name": "dup",
|
|
"email": "[email protected]",
|
|
"owner": "hanzo",
|
|
})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "[email protected]", Name: "dup"})
|
|
if err != nil {
|
|
t.Fatalf("EnsureUser on duplicate: %v", err)
|
|
}
|
|
if user.ID != "existing-id" {
|
|
t.Errorf("user.ID: got %q want existing-id", user.ID)
|
|
}
|
|
}
|
|
|
|
func TestEnsureUser_Idempotent_HTTP409(t *testing.T) {
|
|
// Some IAM versions / proxies may return HTTP 409 directly. EnsureUser
|
|
// handles both signals.
|
|
f := newFakeIAM(t)
|
|
f.setHandler("/api/add-user", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusConflict)
|
|
_, _ = w.Write([]byte(`{"status":"error","msg":"already exists"}`))
|
|
})
|
|
f.setHandler("/api/get-user", func(w http.ResponseWriter, r *http.Request) {
|
|
writeOK(w, map[string]any{
|
|
"id": "existing-id",
|
|
"name": "dup",
|
|
"email": "[email protected]",
|
|
"owner": "hanzo",
|
|
})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
user, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "[email protected]"})
|
|
if err != nil {
|
|
t.Fatalf("EnsureUser 409: %v", err)
|
|
}
|
|
if user.ID != "existing-id" {
|
|
t.Errorf("user.ID: got %q want existing-id", user.ID)
|
|
}
|
|
}
|
|
|
|
func TestEnsureUser_PropagatesNonExistsError(t *testing.T) {
|
|
// Errors that are NOT "already exists" must propagate to the caller —
|
|
// not get silently swallowed by the idempotent-replay path.
|
|
f := newFakeIAM(t)
|
|
f.setHandler("/api/add-user", func(w http.ResponseWriter, r *http.Request) {
|
|
writeErr(w, "organization not found")
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
c.SetAdminCreds(iam.AdminCreds{ClientID: "svc", ClientSecret: "shh", Owner: "hanzo"})
|
|
|
|
_, err := c.EnsureUser(context.Background(), iam.EnsureUserSpec{Email: "[email protected]"})
|
|
if err == nil || !strings.Contains(err.Error(), "organization not found") {
|
|
t.Fatalf("want non-exists error to propagate, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Cache: LRU eviction + singleflight coalescing
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
func TestCache_LRU_Evicts_When_Full(t *testing.T) {
|
|
// Configure a tiny cache (cap=2). Validate 3 distinct tokens; the
|
|
// oldest entry (t1) must be evicted, so a re-validation of t1 must
|
|
// hit the upstream IAM again.
|
|
f := newFakeIAM(t)
|
|
var fetchCalls int64
|
|
f.setHandler("/api/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&fetchCalls, 1)
|
|
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
writeUserJSON(w, &iam.User{ID: tok, Email: tok + "@x.com", Name: tok})
|
|
})
|
|
|
|
c := iam.NewClientWithCache(f.server.URL, 2)
|
|
|
|
for _, tok := range []string{"t1", "t2", "t3"} {
|
|
if _, err := c.ValidateToken(tok); err != nil {
|
|
t.Fatalf("ValidateToken(%s): %v", tok, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 3 {
|
|
t.Fatalf("priming: got %d fetches, want 3", got)
|
|
}
|
|
|
|
// t2 and t3 should be cached; t1 should be evicted (LRU).
|
|
for _, tok := range []string{"t2", "t3"} {
|
|
if _, err := c.ValidateToken(tok); err != nil {
|
|
t.Fatalf("ValidateToken(%s) post-fill: %v", tok, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 3 {
|
|
t.Fatalf("cached hits should not refetch: got %d, want 3", got)
|
|
}
|
|
|
|
// t1 should refetch (was evicted).
|
|
if _, err := c.ValidateToken("t1"); err != nil {
|
|
t.Fatalf("ValidateToken(t1) after eviction: %v", err)
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 4 {
|
|
t.Fatalf("t1 should have re-fetched: got %d, want 4", got)
|
|
}
|
|
}
|
|
|
|
func TestCache_Singleflight_Collapses_Concurrent_Validates(t *testing.T) {
|
|
// 100 goroutines validate the SAME token simultaneously. With
|
|
// singleflight, only 1 upstream IAM call should fire — the other 99
|
|
// reuse the inflight result.
|
|
f := newFakeIAM(t)
|
|
var fetchCalls int64
|
|
f.setHandler("/api/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&fetchCalls, 1)
|
|
// Hold long enough that all 100 goroutines have time to land in
|
|
// the singleflight slot before the first fetch completes.
|
|
time.Sleep(50 * time.Millisecond)
|
|
writeUserJSON(w, &iam.User{ID: "u-1", Email: "[email protected]", Name: "u"})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
|
|
const N = 100
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
errs := make(chan error, N)
|
|
wg.Add(N)
|
|
for i := 0; i < N; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := c.ValidateToken("same-token")
|
|
errs <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("concurrent ValidateToken: %v", err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 1 {
|
|
t.Fatalf("singleflight should collapse to 1 call, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestCache_InvalidateToken_Forces_Refetch(t *testing.T) {
|
|
f := newFakeIAM(t)
|
|
var fetchCalls int64
|
|
f.setHandler("/api/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt64(&fetchCalls, 1)
|
|
writeUserJSON(w, &iam.User{ID: "u-1", Email: "[email protected]", Name: "u"})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
if _, err := c.ValidateToken("t"); err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
if _, err := c.ValidateToken("t"); err != nil {
|
|
t.Fatalf("second (cached): %v", err)
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 1 {
|
|
t.Fatalf("cached call should not refetch: got %d", got)
|
|
}
|
|
|
|
c.InvalidateToken("t")
|
|
|
|
if _, err := c.ValidateToken("t"); err != nil {
|
|
t.Fatalf("post-invalidate: %v", err)
|
|
}
|
|
if got := atomic.LoadInt64(&fetchCalls); got != 2 {
|
|
t.Fatalf("post-invalidate must refetch: got %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestCache_FailedFetch_DoesNotPoisonCache(t *testing.T) {
|
|
// A failed upstream fetch must not leave a stale entry. Subsequent
|
|
// successful fetch must hit upstream and succeed.
|
|
f := newFakeIAM(t)
|
|
var calls int64
|
|
f.setHandler("/api/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
|
c := atomic.AddInt64(&calls, 1)
|
|
if c == 1 {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
return
|
|
}
|
|
writeUserJSON(w, &iam.User{ID: "u-1", Email: "[email protected]", Name: "u"})
|
|
})
|
|
|
|
c := iam.NewClient(f.server.URL)
|
|
if _, err := c.ValidateToken("t"); err == nil {
|
|
t.Fatalf("first call must error")
|
|
}
|
|
user, err := c.ValidateToken("t")
|
|
if err != nil {
|
|
t.Fatalf("second call: %v", err)
|
|
}
|
|
if user.ID != "u-1" {
|
|
t.Errorf("user.ID: got %q want u-1", user.ID)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
// writeUserJSON encodes a user as IAM's /api/userinfo response shape
|
|
// (raw user object, not Casdoor envelope).
|
|
func writeUserJSON(w http.ResponseWriter, u *iam.User) {
|
|
_ = json.NewEncoder(w).Encode(u)
|
|
}
|
|
|
|
// guard: make sure the imports compile even if unused in some builds.
|
|
var _ = fmt.Sprintf
|