233 lines
9.3 KiB
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, ¤t)
|
|
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, ¤t)
|
|
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())
|
|
}
|
|
}
|