7434e5195e
Anonymous callers get a free cumulative quota (1000 calls per IP); once exhausted they get 403 quota_exhausted and must register. Public users can self-register (email+password) to obtain a higher-quota API key, view usage, and regenerate the key. Quota counters live in Redis; the public API stays read-only except for the registration writes. - migration 0011: app_user table + api_key.quota_total + 'registered' tier - ratelimit: IncrTotal/TotalUsed/CopyTotal lifetime counters - middleware: enforce cumulative quota + X-Quota-* headers - store: RegisterUser/Authenticate/RegenerateKey (bcrypt) - handlers: POST /api/v1/register, /account, /account/regenerate - admin: quota_total column + registered tier - public: 'API 密钥' account page + API docs quota section Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
100 lines
3.0 KiB
Go
100 lines
3.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/baicai2026-baicai/goods/api/internal/apikey"
|
|
)
|
|
|
|
func testStore(t *testing.T) *Store {
|
|
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 hasUser bool
|
|
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.app_user') IS NOT NULL").Scan(&hasUser); err != nil || !hasUser {
|
|
pool.Close()
|
|
t.Skip("migrations not applied")
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
return New(pool)
|
|
}
|
|
|
|
func TestRegisterAuthenticateRegenerate(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
email := fmt.Sprintf("user-%d@example.com", time.Now().UnixNano())
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = s.pool.Exec(ctx, "DELETE FROM app_user WHERE lower(email)=lower($1)", email)
|
|
_, _ = s.pool.Exec(ctx, "DELETE FROM api_key WHERE owner_email=$1", email)
|
|
})
|
|
|
|
key, acct, err := s.RegisterUser(ctx, email, "supersecret", 300, 100000)
|
|
if err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if key == "" || acct.KeyPrefix == "" || acct.QuotaTotal != 100000 || acct.RateLimitPerMin != 300 {
|
|
t.Fatalf("unexpected account: %+v key=%q", acct, key)
|
|
}
|
|
|
|
// The issued key resolves via the public auth path with its quota attached.
|
|
k, err := s.APIKeyByHash(ctx, apikey.Hash(key))
|
|
if err != nil {
|
|
t.Fatalf("APIKeyByHash: %v", err)
|
|
}
|
|
if k.QuotaTotal != 100000 || k.RateLimitPerMin != 300 {
|
|
t.Fatalf("key metadata mismatch: %+v", k)
|
|
}
|
|
|
|
// Duplicate email is rejected.
|
|
if _, _, err := s.RegisterUser(ctx, email, "anotherpass", 300, 100000); !errors.Is(err, ErrEmailTaken) {
|
|
t.Fatalf("expected ErrEmailTaken, got %v", err)
|
|
}
|
|
|
|
// Wrong password fails; correct password authenticates.
|
|
if _, err := s.Authenticate(ctx, email, "wrong"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for bad password, got %v", err)
|
|
}
|
|
got, err := s.Authenticate(ctx, email, "supersecret")
|
|
if err != nil {
|
|
t.Fatalf("authenticate: %v", err)
|
|
}
|
|
if got.KeyPrefix != acct.KeyPrefix {
|
|
t.Fatalf("authenticate key prefix = %q want %q", got.KeyPrefix, acct.KeyPrefix)
|
|
}
|
|
|
|
// Regeneration revokes the old key and issues a new one.
|
|
newKey, regen, oldKeyID, err := s.RegenerateKey(ctx, email, "supersecret", 300, 100000)
|
|
if err != nil {
|
|
t.Fatalf("regenerate: %v", err)
|
|
}
|
|
if newKey == key || oldKeyID != acct.KeyID || regen.KeyID == oldKeyID {
|
|
t.Fatalf("regenerate did not rotate key: old=%s new=%+v", oldKeyID, regen)
|
|
}
|
|
if _, err := s.APIKeyByHash(ctx, apikey.Hash(key)); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("old key should be revoked, got %v", err)
|
|
}
|
|
if _, err := s.APIKeyByHash(ctx, apikey.Hash(newKey)); err != nil {
|
|
t.Fatalf("new key should be active: %v", err)
|
|
}
|
|
}
|