Files
goods/api/internal/handler/middleware_db_test.go
T
novaalphastrikeomegaz663 2820823b36
CI / Python (ingestion) (pull_request) Successful in 12s
CI / Migrations (postgres) (pull_request) Successful in 24s
CI / Go (api) (pull_request) Successful in 53s
feat(api): API keys + Redis rate limiting + usage stats
Add an optional API-key layer to the public read-only API. Keys grant
higher per-minute rate limits and attribute usage; anonymous callers are
still allowed at a lower IP-based budget.

- migration 0008_api_key: api_key table (sha256 hash only, plaintext shown once)
- apikey pkg: key generation + hashing
- ratelimit pkg: Redis fixed-window limiter + per-key usage counters; fails open
- public API middleware: X-API-Key / Bearer auth, X-RateLimit-* headers, 429+Retry-After
- admin: issue/list/revoke keys + usage view (API + UI tab)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-20 08:24:07 +00:00

121 lines
3.7 KiB
Go

package handler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/apikey"
"github.com/baicai2026-baicai/goods/api/internal/ratelimit"
"github.com/baicai2026-baicai/goods/api/internal/store"
)
// newRateLimitedHandler builds a handler backed by the test DB and a live Redis
// limiter, plus a freshly issued API key with the given per-minute limit. It
// skips when either backend is unavailable.
func newRateLimitedHandler(t *testing.T, keyLimit int) (h *Handler, plaintextKey string) {
t.Helper()
dsn := os.Getenv("OPENGOODS_DATABASE_URL")
if dsn == "" {
dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable"
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("no database: %v", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
t.Skipf("database not reachable: %v", err)
}
var hasTable bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.api_key') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable {
pool.Close()
t.Skip("migrations not applied (api_key missing)")
}
redisURL := os.Getenv("OPENGOODS_REDIS_URL")
if redisURL == "" {
redisURL = "redis://localhost:6379/0"
}
limiter := ratelimit.New(redisURL)
pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Second)
defer pingCancel()
if err := limiter.Ping(pingCtx); err != nil {
pool.Close()
t.Skipf("redis not reachable: %v", err)
}
key, hash, prefix, err := apikey.Generate()
if err != nil {
pool.Close()
t.Fatal(err)
}
name := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
if _, err := pool.Exec(context.Background(),
`INSERT INTO api_key (name, key_prefix, key_hash, rate_limit_per_min) VALUES ($1,$2,$3,$4)`,
name, prefix, hash, keyLimit); err != nil {
pool.Close()
t.Fatalf("insert api_key: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM api_key WHERE key_hash=$1", hash)
pool.Close()
})
return New(store.New(pool), nil).WithRateLimit(limiter, 60), key
}
func TestRateLimitHeadersAndKeyAuth(t *testing.T) {
h, key := newRateLimitedHandler(t, 100)
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("X-RateLimit-Limit"); got != "100" {
t.Fatalf("X-RateLimit-Limit = %q, want 100 (key limit)", got)
}
if rec.Header().Get("X-RateLimit-Remaining") == "" {
t.Fatal("missing X-RateLimit-Remaining header")
}
}
func TestInvalidKeyRejected(t *testing.T) {
h, _ := newRateLimitedHandler(t, 100)
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", "og_live_thiskeydoesnotexist123456")
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body = %s", rec.Code, rec.Body.String())
}
}
func TestRateLimitExceeded(t *testing.T) {
h, key := newRateLimitedHandler(t, 1)
do := func() int {
req := httptest.NewRequest(http.MethodGet, "/api/"+APIVersion+"/categories", nil)
req.Header.Set("X-API-Key", key)
rec := httptest.NewRecorder()
h.Router().ServeHTTP(rec, req)
return rec.Code
}
if code := do(); code != http.StatusOK {
t.Fatalf("first request status = %d, want 200", code)
}
if code := do(); code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want 429", code)
}
}