2026-08-26 06:41:07 +08:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"data-terminal-cloud/internal/audit"
|
|
|
|
|
"data-terminal-cloud/internal/concurrency"
|
|
|
|
|
"data-terminal-cloud/internal/identity"
|
|
|
|
|
"data-terminal-cloud/internal/opsconfig"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestOpsGateHandlersResourceReferencesAndDeviceGroups(t *testing.T) {
|
|
|
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
|
|
|
if databaseURL == "" {
|
|
|
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
|
|
|
}
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
defer pool.Close()
|
|
|
|
|
service, err := opsconfig.NewService(pool, audit.NewRepository())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-09-12 08:57:29 +08:00
|
|
|
admin := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
2026-08-26 06:41:07 +08:00
|
|
|
tenantID := "10000000-0000-0000-0000-000000000010"
|
|
|
|
|
deviceID := "10000000-0000-0000-0000-000000000511"
|
|
|
|
|
resourceKey := "http-ops-gates-resource-" + uuid.NewString()
|
|
|
|
|
groupKey := "http-ops-gates-group-" + uuid.NewString()
|
|
|
|
|
deniedGroupKey := "http-ops-gates-denied-" + uuid.NewString()
|
|
|
|
|
referenceName := "vault://certificates/http-gate-" + uuid.NewString()
|
|
|
|
|
groupName := "HTTP灰度设备组-" + uuid.NewString()
|
|
|
|
|
requestPrefix := "http-ops-gates-"
|
|
|
|
|
defer func() {
|
|
|
|
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE $1`, requestPrefix+"%")
|
|
|
|
|
_, _ = pool.Exec(ctx, `DELETE FROM idempotency_record WHERE idempotency_key=ANY($1::text[])`, []string{resourceKey, groupKey, deniedGroupKey})
|
|
|
|
|
_, _ = pool.Exec(ctx, `DELETE FROM ops_device_group_member WHERE group_id IN (SELECT id FROM ops_device_group WHERE name=$1)`, groupName)
|
|
|
|
|
_, _ = pool.Exec(ctx, `DELETE FROM ops_device_group WHERE name=$1`, groupName)
|
|
|
|
|
_, _ = pool.Exec(ctx, `DELETE FROM ops_resource_reference WHERE reference_name=$1`, referenceName)
|
|
|
|
|
}()
|
|
|
|
|
setHeaders := func(request *http.Request, key string) {
|
|
|
|
|
request.Header.Set("X-Tenant-Id", tenantID)
|
|
|
|
|
if key != "" {
|
|
|
|
|
request.Header.Set("Idempotency-Key", key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
withRequestID := func(request *http.Request, id string) *http.Request {
|
|
|
|
|
return request.WithContext(context.WithValue(request.Context(), requestIDKey, id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
invalidTenantRequest := authenticatedRequest(http.MethodGet, "/api/v1/ops/resource-references", "", admin)
|
|
|
|
|
invalidTenantRecorder := httptest.NewRecorder()
|
|
|
|
|
ListOpsResourceReferencesHandler(service).ServeHTTP(invalidTenantRecorder, invalidTenantRequest)
|
|
|
|
|
if invalidTenantRecorder.Code != http.StatusBadRequest || !strings.Contains(invalidTenantRecorder.Body.String(), "VALIDATION_FAILED") {
|
|
|
|
|
t.Fatalf("invalid tenant status=%d body=%s", invalidTenantRecorder.Code, invalidTenantRecorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
verifiedAt := time.Now().UTC().Add(-time.Hour)
|
|
|
|
|
expiresAt := time.Now().UTC().Add(30 * 24 * time.Hour)
|
|
|
|
|
resourceBody, _ := json.Marshal(map[string]any{
|
|
|
|
|
"resource_type": "CERTIFICATE", "reference_name": referenceName, "display_name": "HTTP集成受控证书",
|
|
|
|
|
"verified_at": verifiedAt, "expires_at": expiresAt,
|
|
|
|
|
})
|
|
|
|
|
request := withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/resource-references", string(resourceBody), admin), requestPrefix+"resource-create")
|
|
|
|
|
setHeaders(request, resourceKey)
|
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
|
CreateOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" {
|
|
|
|
|
t.Fatalf("create resource status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var resourceResponse struct {
|
|
|
|
|
Data opsconfig.ResourceReference `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &resourceResponse); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
resource := resourceResponse.Data
|
|
|
|
|
if resource.Status != "ACTIVE" || resource.RowVersion != 1 || resource.ReferenceName != referenceName {
|
|
|
|
|
t.Fatalf("resource=%+v", resource)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/resource-references", string(resourceBody), admin), requestPrefix+"resource-replay")
|
|
|
|
|
setHeaders(request, resourceKey)
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
CreateOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" || !strings.Contains(recorder.Body.String(), resource.ID) {
|
|
|
|
|
t.Fatalf("resource replay status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/resource-references", "", admin)
|
|
|
|
|
setHeaders(request, "")
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
ListOpsResourceReferencesHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), referenceName) {
|
|
|
|
|
t.Fatalf("list resources status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/resource-references/"+resource.ID+"/revoke", `{"reason":"轮换后撤销旧证书"}`, admin), requestPrefix+"resource-revoke-missing-etag")
|
|
|
|
|
request.SetPathValue("id", resource.ID)
|
|
|
|
|
setHeaders(request, "")
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusPreconditionRequired {
|
|
|
|
|
t.Fatalf("missing resource ETag status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
request.Header.Set("If-Match", concurrency.FormatETag(1))
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) || !strings.Contains(recorder.Body.String(), `"status":"REVOKED"`) {
|
|
|
|
|
t.Fatalf("revoke resource status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPatch, "/api/v1/ops/resource-references/"+resource.ID+"/revoke", `{"reason":"重复撤销应拒绝"}`, admin), requestPrefix+"resource-revoke-repeat")
|
|
|
|
|
request.SetPathValue("id", resource.ID)
|
|
|
|
|
setHeaders(request, "")
|
|
|
|
|
request.Header.Set("If-Match", concurrency.FormatETag(2))
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
RevokeOpsResourceReferenceHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "CONFIG_STATE_CONFLICT") {
|
|
|
|
|
t.Fatalf("repeat revoke status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
groupBody, _ := json.Marshal(map[string]any{"name": groupName, "selector": map[string]any{"purpose": "http-integration"}, "device_ids": []string{deviceID}})
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(groupBody), admin), requestPrefix+"group-create")
|
|
|
|
|
setHeaders(request, groupKey)
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), deviceID) {
|
|
|
|
|
t.Fatalf("create group status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(groupBody), admin), requestPrefix+"group-replay")
|
|
|
|
|
setHeaders(request, groupKey)
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusOK || recorder.Header().Get("Idempotency-Replayed") != "true" {
|
|
|
|
|
t.Fatalf("group replay status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
request = authenticatedRequest(http.MethodGet, "/api/v1/ops/device-groups", "", admin)
|
|
|
|
|
setHeaders(request, "")
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
ListOpsDeviceGroupsHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), groupName) {
|
|
|
|
|
t.Fatalf("list groups status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deniedBody, _ := json.Marshal(map[string]any{"name": groupName + "-denied", "selector": map[string]any{}, "device_ids": []string{uuid.NewString()}})
|
|
|
|
|
request = withRequestID(authenticatedRequest(http.MethodPost, "/api/v1/ops/device-groups", string(deniedBody), admin), requestPrefix+"group-denied")
|
|
|
|
|
setHeaders(request, deniedGroupKey)
|
|
|
|
|
recorder = httptest.NewRecorder()
|
|
|
|
|
CreateOpsDeviceGroupHandler(service).ServeHTTP(recorder, request)
|
|
|
|
|
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "OPS_ACCESS_DENIED") {
|
|
|
|
|
t.Fatalf("unknown device group status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|