Files
configcenter/internal/api/httpapi/server_test.go

233 lines
9.3 KiB
Go

package httpapi_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/longpeng/configcenter/internal/api/httpapi"
"github.com/longpeng/configcenter/internal/auth"
"github.com/longpeng/configcenter/internal/domain"
"github.com/longpeng/configcenter/internal/metrics"
"github.com/longpeng/configcenter/internal/outbox"
memoryruntime "github.com/longpeng/configcenter/internal/runtime/memory"
memory_store "github.com/longpeng/configcenter/internal/store/memory"
"github.com/longpeng/configcenter/internal/watch"
)
func TestCRUDPublishAndRuntimeRead(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repository := memory_store.New(false)
runtimeStore := memoryruntime.New()
defer runtimeStore.Close() //nolint:errcheck
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
hub := watch.New(ctx, runtimeStore)
authorizer, err := auth.New(repository, false, "", 8*time.Hour)
if err != nil {
t.Fatal(err)
}
collector := metrics.New()
worker := outbox.New(repository, runtimeStore, time.Millisecond, 10, 3, collector, logger)
go worker.Run(ctx)
handler := httpapi.New(repository, runtimeStore, hub, authorizer, collector, nil, logger).Handler()
var app domain.Application
post(t, handler, "/v1/applications", map[string]any{"code": "orders", "name": "Orders"}, http.StatusCreated, &app)
var env domain.Environment
post(t, handler, "/v1/environments", map[string]any{"code": "PROD", "name": "Production", "order": 1}, http.StatusCreated, &env)
var namespace domain.Namespace
post(t, handler, "/v1/namespaces", map[string]any{"appId": app.ID, "name": "application", "type": "properties"}, http.StatusCreated, &namespace)
var config domain.ConfigItem
post(t, handler, "/v1/config-items", map[string]any{
"appId": app.ID, "nsId": namespace.ID, "envId": env.ID, "key": "server.port", "value": "8080",
}, http.StatusCreated, &config)
var release domain.Release
post(t, handler, "/v1/publish", map[string]any{
"appId": app.ID, "nsId": namespace.ID, "envId": env.ID, "comment": "first release",
}, http.StatusAccepted, &release)
if release.Status != "pending" || release.Version != 1 {
t.Fatalf("unexpected accepted release: %#v", release)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
get(t, handler, fmt.Sprintf("/v1/releases/%d", release.ID), http.StatusOK, &release)
if release.Status == "applied" {
break
}
time.Sleep(5 * time.Millisecond)
}
if release.Status != "applied" || release.EtcdRevision == nil {
t.Fatalf("outbox was not applied: %#v", release)
}
var current domain.RuntimeConfig
get(t, handler, "/v1/config?env=PROD&app=orders&namespace=application", http.StatusOK, &current)
if current.Items["server.port"] != "8080" || current.ReleaseVersion != 1 {
t.Fatalf("unexpected runtime config: %#v", current)
}
var grayRule domain.GrayRule
post(t, handler, "/v1/gray-rules", map[string]any{
"appId": app.ID, "nsId": namespace.ID, "envId": env.ID,
"ruleType": "instance", "ruleValue": map[string]any{"instances": []string{"orders-7"}},
"overrides": map[string]string{"server.port": "9080", "feature.gray": "on"}, "enabled": true, "priority": 10,
}, http.StatusCreated, &grayRule)
get(t, handler, "/v1/config?env=PROD&app=orders&namespace=application&instance=orders-7", http.StatusOK, &current)
if current.Items["server.port"] != "9080" || current.Items["feature.gray"] != "on" || len(current.GrayRuleIDs) != 1 || current.GrayRuleIDs[0] != grayRule.ID {
t.Fatalf("gray overrides were not applied: %#v", current)
}
}
func TestAuthenticationRBACAndMetrics(t *testing.T) {
ctx := context.Background()
repository := memory_store.New(false)
runtimeStore := memoryruntime.New()
defer runtimeStore.Close() //nolint:errcheck
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
authorizer, err := auth.New(repository, true, "0123456789abcdef0123456789abcdef", 90*time.Minute)
if err != nil {
t.Fatal(err)
}
if err := authorizer.Bootstrap(ctx, "root", "a-strong-password", "Root Admin"); err != nil {
t.Fatal(err)
}
collector := metrics.New()
handler := httpapi.New(repository, runtimeStore, watch.New(ctx, runtimeStore), authorizer, collector, nil, logger).Handler()
request := httptest.NewRequest(http.MethodGet, "/v1/applications", nil)
doStatus(t, handler, request, http.StatusUnauthorized)
var adminLogin struct {
Token string `json:"token"`
ExpiresIn int64 `json:"expiresIn"`
}
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "root", "password": "a-strong-password"}, http.StatusOK, &adminLogin)
if adminLogin.Token == "" || adminLogin.ExpiresIn != 5400 {
t.Fatalf("unexpected login response: %#v", adminLogin)
}
var application domain.Application
requestJSON(t, handler, http.MethodPost, "/v1/applications", adminLogin.Token, map[string]any{"code": "secure-app", "name": "Secure App"}, http.StatusCreated, &application)
var viewer domain.User
requestJSON(t, handler, http.MethodPost, "/v1/users", adminLogin.Token, map[string]any{
"username": "reader", "displayName": "Read Only", "password": "reader-password-123",
}, http.StatusCreated, &viewer)
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/roles/%d", viewer.ID, application.ID), adminLogin.Token, map[string]any{"role": auth.RoleViewer}, http.StatusOK, &domain.UserAppRole{})
var audits []domain.AuditLog
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?limit=20", adminLogin.Token, nil, http.StatusOK, &audits)
foundActor := false
for _, audit := range audits {
if audit.Actor == "root" && audit.TargetType == "app" && audit.Action == "create" {
foundActor = true
break
}
}
if !foundActor {
t.Fatalf("JWT actor was not propagated to audit log: %#v", audits)
}
var viewerLogin struct {
Token string `json:"token"`
}
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "reader", "password": "reader-password-123"}, http.StatusOK, &viewerLogin)
var applications []domain.Application
requestJSON(t, handler, http.MethodGet, "/v1/applications", viewerLogin.Token, nil, http.StatusOK, &applications)
if len(applications) != 1 || applications[0].ID != application.ID {
t.Fatalf("viewer application filtering failed: %#v", applications)
}
requestJSON(t, handler, http.MethodPost, "/v1/namespaces", viewerLogin.Token, map[string]any{"appId": application.ID, "name": "application", "type": "properties"}, http.StatusForbidden, nil)
doStatus(t, handler, httptest.NewRequest(http.MethodGet, "/health/live", nil), http.StatusOK)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `configcenter_http_requests_total{method="GET",route="GET /health/live",status="200"}`) || !strings.Contains(recorder.Body.String(), "configcenter_outbox_pending 0") {
t.Fatalf("unexpected metrics response: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func post(t *testing.T, handler http.Handler, path string, input any, wantStatus int, output any) {
t.Helper()
payload, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-User", "tester")
do(t, handler, request, wantStatus, output)
}
func get(t *testing.T, handler http.Handler, path string, wantStatus int, output any) {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
do(t, handler, request, wantStatus, output)
}
func do(t *testing.T, handler http.Handler, request *http.Request, wantStatus int, output any) {
t.Helper()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
response := recorder.Result()
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != wantStatus {
t.Fatalf("%s %s: got status %d, want %d; body=%s", request.Method, request.URL, response.StatusCode, wantStatus, body)
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode response envelope: %v; body=%s", err, body)
}
if err := json.Unmarshal(envelope.Data, output); err != nil {
t.Fatalf("decode response data: %v; body=%s", err, body)
}
}
func requestJSON(t *testing.T, handler http.Handler, method, path, token string, input any, wantStatus int, output any) {
t.Helper()
var body io.Reader
if input != nil {
payload, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
body = bytes.NewReader(payload)
}
request := httptest.NewRequest(method, path, body)
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
if output == nil {
doStatus(t, handler, request, wantStatus)
return
}
do(t, handler, request, wantStatus, output)
}
func doStatus(t *testing.T, handler http.Handler, request *http.Request, wantStatus int) {
t.Helper()
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != wantStatus {
t.Fatalf("%s %s: got status %d, want %d; body=%s", request.Method, request.URL, recorder.Code, wantStatus, recorder.Body.String())
}
}