feat: sync lijiaoqiao implementation and staging validation artifacts
This commit is contained in:
437
platform-token-runtime/internal/httpapi/token_api.go
Normal file
437
platform-token-runtime/internal/httpapi/token_api.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/model"
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenBasePath = "/api/v1/platform/tokens"
|
||||
)
|
||||
|
||||
type Runtime interface {
|
||||
IssueAndAudit(ctx context.Context, input service.IssueTokenInput, auditor service.AuditEmitter) (service.TokenRecord, error)
|
||||
Refresh(ctx context.Context, tokenID string, ttl time.Duration) (service.TokenRecord, error)
|
||||
RevokeAndAudit(ctx context.Context, tokenID, reason, requestID, subjectID string, auditor service.AuditEmitter) (service.TokenRecord, error)
|
||||
Introspect(ctx context.Context, accessToken string) (service.TokenRecord, error)
|
||||
Lookup(ctx context.Context, tokenID string) (service.TokenRecord, error)
|
||||
}
|
||||
|
||||
type TokenAPI struct {
|
||||
runtime Runtime
|
||||
auditor service.AuditEmitter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTokenAPI(runtime Runtime, auditor service.AuditEmitter, now func() time.Time) *TokenAPI {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &TokenAPI{runtime: runtime, auditor: auditor, now: now}
|
||||
}
|
||||
|
||||
func (a *TokenAPI) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc(tokenBasePath+"/issue", a.handleIssue)
|
||||
mux.HandleFunc(tokenBasePath+"/introspect", a.handleIntrospect)
|
||||
mux.HandleFunc(tokenBasePath+"/audit-events", a.handleAuditEvents)
|
||||
mux.HandleFunc(tokenBasePath+"/", a.handleTokenAction)
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleTokenAction(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, tokenBasePath+"/") {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
return
|
||||
}
|
||||
tail := strings.TrimPrefix(r.URL.Path, tokenBasePath+"/")
|
||||
parts := strings.Split(tail, "/")
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
return
|
||||
}
|
||||
tokenID := strings.TrimSpace(parts[0])
|
||||
action := strings.TrimSpace(parts[1])
|
||||
|
||||
switch action {
|
||||
case "refresh":
|
||||
a.handleRefresh(w, r, tokenID)
|
||||
case "revoke":
|
||||
a.handleRevoke(w, r, tokenID)
|
||||
default:
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
|
||||
}
|
||||
}
|
||||
|
||||
type issueRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
Role string `json:"role"`
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
Scope []string `json:"scope"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
TTLSeconds int64 `json:"ttl_seconds"`
|
||||
}
|
||||
|
||||
type revokeRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type introspectRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleIssue(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req issueRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if err := validateIssueRequest(req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := a.runtime.IssueAndAudit(r.Context(), service.IssueTokenInput{
|
||||
SubjectID: req.SubjectID,
|
||||
Role: req.Role,
|
||||
Scope: req.Scope,
|
||||
TTL: time.Duration(req.TTLSeconds) * time.Second,
|
||||
RequestID: requestID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
}, a.auditor)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "idempotency key payload mismatch") {
|
||||
writeError(w, http.StatusConflict, "IDEMPOTENCY_CONFLICT", "idempotency key payload mismatch")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnprocessableEntity, "ISSUE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"access_token": record.AccessToken,
|
||||
"issued_at": record.IssuedAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"status": record.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleRefresh(w http.ResponseWriter, r *http.Request, tokenID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req refreshRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if req.TTLSeconds < 60 {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "ttl_seconds must be >= 60")
|
||||
return
|
||||
}
|
||||
|
||||
before, err := a.runtime.Lookup(r.Context(), tokenID)
|
||||
if err != nil {
|
||||
before = service.TokenRecord{}
|
||||
}
|
||||
|
||||
record, err := a.runtime.Refresh(r.Context(), tokenID, time.Duration(req.TTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
status, code := mapRuntimeError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenRefreshSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: tokenBasePath + "/" + tokenID + "/refresh",
|
||||
ResultCode: "OK",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"previous_expires_at": before.ExpiresAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"status": record.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleRevoke(w http.ResponseWriter, r *http.Request, tokenID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
if requestID == "" || idempotencyKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id or Idempotency-Key")
|
||||
return
|
||||
}
|
||||
|
||||
var req revokeRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "reason is required")
|
||||
return
|
||||
}
|
||||
|
||||
introspected, err := a.runtime.Lookup(r.Context(), tokenID)
|
||||
subjectID := ""
|
||||
if err == nil {
|
||||
subjectID = introspected.SubjectID
|
||||
}
|
||||
|
||||
record, err := a.runtime.RevokeAndAudit(r.Context(), tokenID, req.Reason, requestID, subjectID, a.auditor)
|
||||
if err != nil {
|
||||
status, code := mapRuntimeError(err)
|
||||
writeError(w, status, code, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"status": record.Status,
|
||||
"revoked_at": a.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleIntrospect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
|
||||
return
|
||||
}
|
||||
|
||||
var req introspectRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "token is required")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := a.runtime.Introspect(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenIntrospectFail,
|
||||
RequestID: requestID,
|
||||
Route: tokenBasePath + "/introspect",
|
||||
ResultCode: "INVALID_TOKEN",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
writeError(w, http.StatusUnprocessableEntity, "TOKEN_INVALID", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if a.auditor != nil {
|
||||
_ = a.auditor.Emit(r.Context(), service.AuditEvent{
|
||||
EventName: service.EventTokenIntrospectSuccess,
|
||||
RequestID: requestID,
|
||||
TokenID: record.TokenID,
|
||||
SubjectID: record.SubjectID,
|
||||
Route: tokenBasePath + "/introspect",
|
||||
ResultCode: "OK",
|
||||
CreatedAt: a.now(),
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"token_id": record.TokenID,
|
||||
"subject_id": record.SubjectID,
|
||||
"role": record.Role,
|
||||
"status": record.Status,
|
||||
"scope": record.Scope,
|
||||
"issued_at": record.IssuedAt,
|
||||
"expires_at": record.ExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *TokenAPI) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(r.Header.Get("X-Request-Id"))
|
||||
if requestID == "" {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "missing X-Request-Id")
|
||||
return
|
||||
}
|
||||
|
||||
querier, ok := a.auditor.(service.AuditEventQuerier)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotImplemented, "AUDIT_QUERY_NOT_READY", "audit query capability is not available")
|
||||
return
|
||||
}
|
||||
|
||||
limit := parseLimit(r.URL.Query().Get("limit"))
|
||||
filter := service.AuditEventFilter{
|
||||
RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
|
||||
TokenID: strings.TrimSpace(r.URL.Query().Get("token_id")),
|
||||
SubjectID: strings.TrimSpace(r.URL.Query().Get("subject_id")),
|
||||
EventName: strings.TrimSpace(r.URL.Query().Get("event_name")),
|
||||
ResultCode: strings.TrimSpace(r.URL.Query().Get("result_code")),
|
||||
Limit: limit,
|
||||
}
|
||||
events, err := querier.QueryEvents(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "AUDIT_QUERY_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(events))
|
||||
for _, ev := range events {
|
||||
items = append(items, map[string]any{
|
||||
"event_id": ev.EventID,
|
||||
"event_name": ev.EventName,
|
||||
"request_id": ev.RequestID,
|
||||
"token_id": ev.TokenID,
|
||||
"subject_id": ev.SubjectID,
|
||||
"route": ev.Route,
|
||||
"result_code": ev.ResultCode,
|
||||
"client_ip": ev.ClientIP,
|
||||
"created_at": ev.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request_id": requestID,
|
||||
"data": map[string]any{
|
||||
"total": len(items),
|
||||
"items": items,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func validateIssueRequest(req issueRequest) error {
|
||||
if strings.TrimSpace(req.SubjectID) == "" {
|
||||
return errors.New("subject_id is required")
|
||||
}
|
||||
if req.TTLSeconds < 60 {
|
||||
return errors.New("ttl_seconds must be >= 60")
|
||||
}
|
||||
if len(req.Scope) == 0 {
|
||||
return errors.New("scope is required")
|
||||
}
|
||||
switch req.Role {
|
||||
case model.RoleOwner, model.RoleViewer, model.RoleAdmin:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported role: %s", req.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func mapRuntimeError(err error) (int, string) {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "not found"):
|
||||
return http.StatusNotFound, "TOKEN_NOT_FOUND"
|
||||
case strings.Contains(msg, "not active"):
|
||||
return http.StatusConflict, "TOKEN_NOT_ACTIVE"
|
||||
case strings.Contains(msg, "idempotency key payload mismatch"):
|
||||
return http.StatusConflict, "IDEMPOTENCY_CONFLICT"
|
||||
default:
|
||||
return http.StatusUnprocessableEntity, "BUSINESS_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, out any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
var env errorEnvelope
|
||||
env.Error.Code = code
|
||||
env.Error.Message = message
|
||||
writeJSON(w, status, env)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func parseLimit(raw string) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 100
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n <= 0 {
|
||||
return 100
|
||||
}
|
||||
if n > 500 {
|
||||
return 500
|
||||
}
|
||||
return n
|
||||
}
|
||||
269
platform-token-runtime/internal/httpapi/token_api_test.go
Normal file
269
platform-token-runtime/internal/httpapi/token_api_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/platform-token-runtime/internal/auth/service"
|
||||
)
|
||||
|
||||
func TestTokenAPIIssueAndIntrospect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
api := NewTokenAPI(runtime, auditor, func() time.Time {
|
||||
return time.Date(2026, 3, 30, 15, 50, 0, 0, time.UTC)
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:*"},
|
||||
}
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, issueBody))
|
||||
issueReq.Header.Set("X-Request-Id", "req-api-001")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-api-001")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("unexpected issue status: got=%d want=%d body=%s", issueRec.Code, http.StatusCreated, issueRec.Body.String())
|
||||
}
|
||||
issueResp := decodeMap(t, issueRec.Body.Bytes())
|
||||
data := issueResp["data"].(map[string]any)
|
||||
accessToken := data["access_token"].(string)
|
||||
if accessToken == "" {
|
||||
t.Fatalf("access_token should not be empty")
|
||||
}
|
||||
|
||||
introspectBody := map[string]any{"token": accessToken}
|
||||
introReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/introspect", mustJSON(t, introspectBody))
|
||||
introReq.Header.Set("X-Request-Id", "req-api-002")
|
||||
introRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(introRec, introReq)
|
||||
|
||||
if introRec.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected introspect status: got=%d want=%d body=%s", introRec.Code, http.StatusOK, introRec.Body.String())
|
||||
}
|
||||
introResp := decodeMap(t, introRec.Body.Bytes())
|
||||
introData := introResp["data"].(map[string]any)
|
||||
if introData["role"].(string) != "owner" {
|
||||
t.Fatalf("unexpected role: got=%s want=owner", introData["role"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIIssueIdempotencyConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
firstBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:*"},
|
||||
}
|
||||
secondBody := map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 600,
|
||||
"scope": []string{"supply:read"},
|
||||
}
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, firstBody))
|
||||
firstReq.Header.Set("X-Request-Id", "req-api-003-1")
|
||||
firstReq.Header.Set("Idempotency-Key", "idem-api-003")
|
||||
firstRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(firstRec, firstReq)
|
||||
if firstRec.Code != http.StatusCreated {
|
||||
t.Fatalf("first issue should succeed: code=%d body=%s", firstRec.Code, firstRec.Body.String())
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, secondBody))
|
||||
secondReq.Header.Set("X-Request-Id", "req-api-003-2")
|
||||
secondReq.Header.Set("Idempotency-Key", "idem-api-003")
|
||||
secondRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(secondRec, secondReq)
|
||||
if secondRec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected idempotency conflict: code=%d body=%s", secondRec.Code, secondRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIRefreshAndRevoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 3, 30, 16, 0, 0, 0, time.UTC)
|
||||
runtime := service.NewInMemoryTokenRuntime(func() time.Time { return now })
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), func() time.Time { return now })
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2008",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 120,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
issueReq.Header.Set("X-Request-Id", "req-api-004-1")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-api-004")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
|
||||
}
|
||||
issued := decodeMap(t, issueRec.Body.Bytes())
|
||||
issuedData := issued["data"].(map[string]any)
|
||||
tokenID := issuedData["token_id"].(string)
|
||||
|
||||
now = now.Add(10 * time.Second)
|
||||
refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/refresh", mustJSON(t, map[string]any{"ttl_seconds": 300}))
|
||||
refreshReq.Header.Set("X-Request-Id", "req-api-004-2")
|
||||
refreshReq.Header.Set("Idempotency-Key", "idem-api-004-r")
|
||||
refreshRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(refreshRec, refreshReq)
|
||||
if refreshRec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh failed: code=%d body=%s", refreshRec.Code, refreshRec.Body.String())
|
||||
}
|
||||
refreshResp := decodeMap(t, refreshRec.Body.Bytes())
|
||||
refreshData := refreshResp["data"].(map[string]any)
|
||||
if refreshData["previous_expires_at"] == nil {
|
||||
t.Fatalf("previous_expires_at must not be nil")
|
||||
}
|
||||
|
||||
revokeReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/"+tokenID+"/revoke", mustJSON(t, map[string]any{"reason": "operator_request"}))
|
||||
revokeReq.Header.Set("X-Request-Id", "req-api-004-3")
|
||||
revokeReq.Header.Set("Idempotency-Key", "idem-api-004-v")
|
||||
revokeRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(revokeRec, revokeReq)
|
||||
if revokeRec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke failed: code=%d body=%s", revokeRec.Code, revokeRec.Body.String())
|
||||
}
|
||||
revokeResp := decodeMap(t, revokeRec.Body.Bytes())
|
||||
revokeData := revokeResp["data"].(map[string]any)
|
||||
if revokeData["status"].(string) != "revoked" {
|
||||
t.Fatalf("unexpected status after revoke: got=%s", revokeData["status"].(string))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIMissingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, service.NewMemoryAuditEmitter(), time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2001",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 120,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing headers must be rejected: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIAuditEventsQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
auditor := service.NewMemoryAuditEmitter()
|
||||
api := NewTokenAPI(runtime, auditor, time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
issueReq := httptest.NewRequest(http.MethodPost, "/api/v1/platform/tokens/issue", mustJSON(t, map[string]any{
|
||||
"subject_id": "2010",
|
||||
"role": "owner",
|
||||
"ttl_seconds": 300,
|
||||
"scope": []string{"supply:*"},
|
||||
}))
|
||||
issueReq.Header.Set("X-Request-Id", "req-audit-query-1")
|
||||
issueReq.Header.Set("Idempotency-Key", "idem-audit-query-1")
|
||||
issueRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(issueRec, issueReq)
|
||||
if issueRec.Code != http.StatusCreated {
|
||||
t.Fatalf("issue failed: code=%d body=%s", issueRec.Code, issueRec.Body.String())
|
||||
}
|
||||
issueResp := decodeMap(t, issueRec.Body.Bytes())
|
||||
tokenID := issueResp["data"].(map[string]any)["token_id"].(string)
|
||||
|
||||
queryReq := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?token_id="+tokenID+"&limit=5", nil)
|
||||
queryReq.Header.Set("X-Request-Id", "req-audit-query-2")
|
||||
queryRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(queryRec, queryReq)
|
||||
if queryRec.Code != http.StatusOK {
|
||||
t.Fatalf("audit query failed: code=%d body=%s", queryRec.Code, queryRec.Body.String())
|
||||
}
|
||||
resp := decodeMap(t, queryRec.Body.Bytes())
|
||||
data := resp["data"].(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
if len(items) == 0 {
|
||||
t.Fatalf("audit query should return at least one event")
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
if first["token_id"].(string) != tokenID {
|
||||
t.Fatalf("unexpected token_id in first item: got=%s want=%s", first["token_id"].(string), tokenID)
|
||||
}
|
||||
if strings.Contains(queryRec.Body.String(), "access_token") {
|
||||
t.Fatalf("audit query response must not contain access_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAPIAuditEventsNotReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := service.NewInMemoryTokenRuntime(nil)
|
||||
api := NewTokenAPI(runtime, noopAuditEmitter{}, time.Now)
|
||||
mux := http.NewServeMux()
|
||||
api.Register(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/platform/tokens/audit-events?limit=3", nil)
|
||||
req.Header.Set("X-Request-Id", "req-audit-query-3")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected not implemented: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, payload any) *bytes.Reader {
|
||||
t.Helper()
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal json failed: %v", err)
|
||||
}
|
||||
return bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode json failed: %v, raw=%s", err, string(raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type noopAuditEmitter struct{}
|
||||
|
||||
func (noopAuditEmitter) Emit(context.Context, service.AuditEvent) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user