feat(api): add admin endpoint to issue keys for masters

Add `POST /admin/masters/{id}/keys` allowing admins to issue child keys
on behalf of a master. Introduce an `issued_by` field in the Key model
to audit whether a key was issued by the master or an admin.

Refactor master service to use typed errors for consistent HTTP status
mapping and ensure validation logic (active status, group check) is
shared.
This commit is contained in:
zenfun
2025-12-15 15:59:33 +08:00
parent 11f6e81798
commit aa69ce3659
8 changed files with 232 additions and 3 deletions

View File

@@ -0,0 +1,85 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/ez-api/foundation/tokenhash"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAdmin_IssueChildKeyForMaster_IssuedByAdminAndSynced(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Master{}, &model.Key{}); err != nil {
t.Fatalf("migrate: %v", err)
}
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
syncService := service.NewSyncService(rdb)
masterService := service.NewMasterService(db)
adminHandler := NewAdminHandler(masterService, syncService)
m, _, err := masterService.CreateMaster("m1", "default", 5, 10)
if err != nil {
t.Fatalf("CreateMaster: %v", err)
}
r := gin.New()
r.POST("/admin/masters/:id/keys", adminHandler.IssueChildKeyForMaster)
body := []byte(`{"scopes":"chat:write"}`)
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/admin/masters/%d/keys", m.ID), bytes.NewReader(body))
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("unexpected status: got=%d body=%s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["issued_by"] != "admin" {
t.Fatalf("expected issued_by=admin, got=%v", resp["issued_by"])
}
rawKey, _ := resp["key_secret"].(string)
if rawKey == "" {
t.Fatalf("expected key_secret in response")
}
// DB audit
var keys []model.Key
if err := db.Where("master_id = ?", m.ID).Find(&keys).Error; err != nil {
t.Fatalf("load keys: %v", err)
}
if len(keys) != 1 {
t.Fatalf("expected 1 key row, got %d", len(keys))
}
if keys[0].IssuedBy != "admin" {
t.Fatalf("expected IssuedBy=admin, got %q", keys[0].IssuedBy)
}
// Redis sync: auth:token:{hash} exists
hash := tokenhash.HashToken(rawKey)
if !mr.Exists("auth:token:" + hash) {
t.Fatalf("expected auth token hash key to exist in redis")
}
}