feat: expose admin audit pages

This commit is contained in:
youfak 2026-08-02 15:29:15 +08:00
parent e220d5368c
commit ec3ceb2c9a
10 changed files with 423 additions and 15 deletions

View File

@ -111,7 +111,7 @@ flowchart LR
- **已完成**严格配置、Provider 获取与协调、Redis 活动池、Distribution 原子
提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL
管理状态WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
管理状态与有界审计查询WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试;
Controller 的 Redis 共享 BASIC/EGRESS/TARGET 检查任务、按上游的有界轮转调度、HTTP/HTTPS/SOCKS5
Checker 探测和

View File

@ -7,6 +7,7 @@ servers:
- url: http://127.0.0.1:8082
tags:
- name: Status
- name: Audit
- name: Upstreams
- name: Routing
- name: Configuration
@ -36,6 +37,42 @@ paths:
'405': {$ref: '#/components/responses/MethodNotAllowed'}
'500': {$ref: '#/components/responses/InternalServerError'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/v1/audit:
get:
tags: [Audit]
operationId: listAuditRecords
summary: 按 ID 游标读取管理面审计记录
description: |
仅返回权威管理面的变更审计记录,按 `id` 升序排列。`afterId` 是排他游标:
后续页面只包含 `id` 大于该值的记录。未传 `limit` 时返回 100 条,单页最多
1000 条。
审计接口不返回 Proxy、Upstream URL、凭据或活动池内容。
parameters:
- name: afterId
in: query
required: false
description: 排他游标;仅返回 ID 大于此值的记录。
schema: {type: integer, minimum: 0, default: 0}
- name: limit
in: query
required: false
description: 单页记录数;默认 100最大 1000。
schema: {type: integer, minimum: 1, maximum: 1000, default: 100}
responses:
'200':
description: 管理面审计记录页
headers:
X-Request-ID: {$ref: '#/components/headers/RequestID'}
content:
application/json:
schema: {$ref: '#/components/schemas/AuditPage'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'400': {$ref: '#/components/responses/BadRequest'}
'405': {$ref: '#/components/responses/MethodNotAllowed'}
'500': {$ref: '#/components/responses/InternalServerError'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/v1/upstreams/{name}/enable:
post:
tags: [Upstreams]
@ -152,6 +189,31 @@ components:
description: 服务端最终使用的请求标识。
schema: {type: string, maxLength: 128}
schemas:
AuditPage:
type: object
additionalProperties: false
required: [records]
properties:
records:
type: array
description: 按 ID 升序的审计记录;没有更多记录时为空数组。
items: {$ref: '#/components/schemas/AuditRecord'}
AuditRecord:
type: object
additionalProperties: false
required: [id, requestId, actorId, action, resourceType, resourceName, changed, version, occurredAt]
properties:
id: {type: integer, minimum: 0}
requestId: {type: string}
actorId: {type: string}
sourceIp: {type: string}
action: {type: string}
resourceType: {type: string}
resourceName: {type: string}
changed: {type: boolean}
version: {type: integer, minimum: 0}
reason: {type: string}
occurredAt: {type: string, format: date-time}
Status:
type: object
additionalProperties: false

View File

@ -10,6 +10,7 @@ import (
type document struct {
OpenAPI string `yaml:"openapi"`
Paths map[string]map[string]any `yaml:"paths"`
Components map[string]any `yaml:"components"`
}
func TestDistributionContract(t *testing.T) {
@ -37,6 +38,7 @@ func TestAdminContract(t *testing.T) {
spec := readDocument(t, "admin.yaml")
for _, path := range []string{
"/api/v1/status",
"/api/v1/audit",
"/api/v1/upstreams/{name}/enable",
"/api/v1/upstreams/{name}/disable",
"/api/v1/routing/{name}/switch",
@ -48,6 +50,67 @@ func TestAdminContract(t *testing.T) {
}
requireResponses(t, spec.Paths["/api/v1/routing/{name}/switch"]["post"],
"200", "400", "401", "403", "404", "405", "409", "413", "415", "422", "500", "503")
requireResponses(t, spec.Paths["/api/v1/audit"]["get"],
"200", "400", "401", "403", "405", "500", "503")
requireAuditPaginationContract(t, spec.Paths["/api/v1/audit"]["get"])
requireAuditSchemas(t, spec)
}
func requireAuditPaginationContract(t *testing.T, operation any) {
t.Helper()
operationMap, ok := operation.(map[string]any)
if !ok {
t.Fatalf("audit operation has type %T, want map", operation)
}
parameters, ok := operationMap["parameters"].([]any)
if !ok {
t.Fatalf("audit parameters has type %T, want array", operationMap["parameters"])
}
afterID := parameterSchema(t, parameters, "afterId")
if afterID["type"] != "integer" || afterID["minimum"] != 0 || afterID["default"] != 0 {
t.Errorf("afterId schema = %#v, want integer with minimum/default 0", afterID)
}
limit := parameterSchema(t, parameters, "limit")
if limit["type"] != "integer" || limit["minimum"] != 1 || limit["maximum"] != 1000 || limit["default"] != 100 {
t.Errorf("limit schema = %#v, want integer [1, 1000] with default 100", limit)
}
}
func parameterSchema(t *testing.T, parameters []any, name string) map[string]any {
t.Helper()
for _, raw := range parameters {
parameter, ok := raw.(map[string]any)
if !ok || parameter["name"] != name || parameter["in"] != "query" {
continue
}
schema, ok := parameter["schema"].(map[string]any)
if !ok {
t.Fatalf("%s parameter schema has type %T, want map", name, parameter["schema"])
}
return schema
}
t.Fatalf("query parameter %q is missing", name)
return nil
}
func requireAuditSchemas(t *testing.T, spec document) {
t.Helper()
schemas, ok := spec.Components["schemas"].(map[string]any)
if !ok {
t.Fatal("components.schemas is missing")
}
for _, name := range []string{"AuditPage", "AuditRecord"} {
if _, ok := schemas[name].(map[string]any); !ok {
t.Errorf("schema %s is missing", name)
}
}
record, _ := schemas["AuditRecord"].(map[string]any)
properties, _ := record["properties"].(map[string]any)
for _, forbidden := range []string{"proxy", "upstreamUrl", "credential", "activePool"} {
if _, exists := properties[forbidden]; exists {
t.Errorf("AuditRecord must not expose %q", forbidden)
}
}
}
func requireResponses(t *testing.T, operation any, codes ...string) {

View File

@ -6,6 +6,7 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
## 端点
- `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。
- `GET /api/v1/audit`:按审计记录 ID 升序读取权威管理面的变更记录。
- `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。
- `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。
- `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。
@ -17,6 +18,19 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
配置重载校验失败返回 422旧配置继续运行。Status 只返回低基数聚合信息,
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。
## 审计分页与数据范围
`GET /api/v1/audit` 使用 `afterId` 排他游标分页:响应只包含 `id` 大于
`afterId` 的记录,并按 `id` 升序排列;未传时 `afterId``0`。未传 `limit`
时服务端使用 `100`
`limit` 必须为 `1..1000`,其中 `1000` 等于 `adminstate.MaxPageSize`。客户端应将
本页最后一条记录的 `id` 作为下一次请求的 `afterId`;空 `records` 表示当前游标后
没有记录。
该接口仅暴露管理面变更记录及操作者标识、可信来源 IP、操作类型、资源标识、变更
状态、控制面版本、原因和发生时间。它不返回 Proxy、任何 Upstream/Provider URL、
凭据或 Secret、Provider 响应载荷,也不读取或暴露 Redis 活动池及其提取状态。
## 运行时实现边界
`admin.Handler` 只依赖 `Service` 控制面接口,不直接操作数据库、路由游标或配置

View File

@ -16,7 +16,8 @@
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。
- Admin OpenAPI状态、按 ID 游标分页的权威审计查询、Upstream 启停、Routing
切换和配置重载。
- 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和
security scheme完整标准工具验证仍待补齐。
- ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
@ -44,8 +45,9 @@
- `PROVIDER-*`Provider HTTP Client、严格响应上限、模板解析安全边界、凭据
引用 Store 与 Reconciler Adapter 已实现。
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
Handler 已实现共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入,
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
Handler 已实现Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录,
不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID 与本地准入
保护链已接入Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis
运行同一公用契约。
@ -123,7 +125,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成EGRESS、TARGET
生产任务调度与 REMOVE 编排仍待实现。
8. Admin/Distribution 细粒度授权和审计查询Distribution 分布式限流已完成。
8. Admin/Distribution 细粒度授权Distribution 分布式限流和 Admin 审计查询已完成。
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
10. 将 reject/wait/direct 接入 Distribution 运行链。
## 4. 容量结论

View File

@ -5,18 +5,23 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"proxy-pool/internal/domain/adminstate"
"proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity"
)
const (
statusPath = "/api/v1/status"
auditPath = "/api/v1/audit"
reloadPath = "/api/v1/config/reload"
upstreamPrefix = "/api/v1/upstreams/"
routingPrefix = "/api/v1/routing/"
maxResourceNameBytes = 128
defaultAuditPageSize = 100
)
var (
@ -29,6 +34,7 @@ var (
type Service interface {
Status(context.Context) (Status, error)
ReadAudit(context.Context, AuditQuery) (AuditPage, error)
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
SwitchRouting(context.Context, SwitchCommand) (MutationResult, error)
ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
@ -78,6 +84,36 @@ type MutationResult struct {
Message string `json:"message,omitempty"`
}
type AuditQuery struct {
AfterID uint64
Limit int
}
func (query AuditQuery) Validate() error {
if query.Limit <= 0 || query.Limit > adminstate.MaxPageSize {
return ErrInvalidConfiguration
}
return nil
}
type AuditRecord struct {
ID uint64 `json:"id"`
RequestID string `json:"requestId"`
ActorID string `json:"actorId"`
SourceIP string `json:"sourceIp,omitempty"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceName string `json:"resourceName"`
Changed bool `json:"changed"`
Version uint64 `json:"version"`
Reason string `json:"reason,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
}
type AuditPage struct {
Records []AuditRecord `json:"records"`
}
type SetUpstreamCommand struct {
RequestID string
ActorID string
@ -136,6 +172,12 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
}
handler.getStatus(writer, request, requestID)
return
case auditPath:
if !requireMethod(writer, request, http.MethodGet, requestID) {
return
}
handler.readAudit(writer, request, requestID)
return
case reloadPath:
if !requireMethod(writer, request, http.MethodPost, requestID) {
return
@ -178,6 +220,57 @@ func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Requ
_ = httpapi.WriteJSON(writer, http.StatusOK, status)
}
func (handler *Handler) readAudit(writer http.ResponseWriter, request *http.Request, requestID string) {
query, err := parseAuditQuery(request)
if err != nil {
writeTransportProblem(writer, http.StatusBadRequest, "INVALID_AUDIT_QUERY", "Invalid audit query", "audit pagination fields violate the API contract", requestID)
return
}
page, err := handler.service.ReadAudit(request.Context(), query)
if err != nil {
writeServiceProblem(writer, err, requestID)
return
}
if page.Records == nil {
page.Records = []AuditRecord{}
}
writer.Header().Set(httpapi.HeaderRequestID, requestID)
_ = httpapi.WriteJSON(writer, http.StatusOK, page)
}
func parseAuditQuery(request *http.Request) (AuditQuery, error) {
if request == nil || request.URL == nil {
return AuditQuery{}, ErrInvalidConfiguration
}
values := request.URL.Query()
query := AuditQuery{Limit: defaultAuditPageSize}
for name, value := range values {
if len(value) != 1 {
return AuditQuery{}, ErrInvalidConfiguration
}
switch name {
case "afterId":
parsed, err := strconv.ParseUint(value[0], 10, 64)
if err != nil {
return AuditQuery{}, ErrInvalidConfiguration
}
query.AfterID = parsed
case "limit":
parsed, err := strconv.ParseUint(value[0], 10, 32)
if err != nil || parsed == 0 || parsed > adminstate.MaxPageSize {
return AuditQuery{}, ErrInvalidConfiguration
}
query.Limit = int(parsed)
default:
return AuditQuery{}, ErrInvalidConfiguration
}
}
if err := query.Validate(); err != nil {
return AuditQuery{}, err
}
return query, nil
}
func (handler *Handler) setUpstreamEnabled(
writer http.ResponseWriter,
request *http.Request,

View File

@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity"
@ -38,6 +39,80 @@ func TestHandlerReturnsStatusWithoutSensitiveDetails(t *testing.T) {
}
}
func TestHandlerReadsBoundedAuditPage(t *testing.T) {
t.Parallel()
occurredAt := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
service := &stubService{audit: AuditPage{Records: []AuditRecord{{
ID: 8, RequestID: "req-8", ActorID: "admin:alice", Action: "switch_routing",
ResourceType: "routing", ResourceName: "checkout", Changed: true, Version: 12,
Reason: "capacity", OccurredAt: occurredAt,
}}}}
handler := mustHandler(t, service)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/audit?afterId=7&limit=2", nil)
request.Header.Set(httpapi.HeaderRequestID, "req-audit-page")
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if service.auditCalls != 1 || service.lastAudit != (AuditQuery{AfterID: 7, Limit: 2}) {
t.Fatalf("ReadAudit() calls=%d query=%+v", service.auditCalls, service.lastAudit)
}
if requestID := recorder.Header().Get(httpapi.HeaderRequestID); requestID != "req-audit-page" {
t.Fatalf("response request ID = %q, want req-audit-page", requestID)
}
var response AuditPage
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.Records) != 1 || response.Records[0].ID != 8 || !response.Records[0].OccurredAt.Equal(occurredAt) {
t.Fatalf("unexpected audit response: %+v", response)
}
}
func TestHandlerUsesDefaultAuditLimitAndRejectsInvalidAuditQueries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
wantStatus int
wantQuery AuditQuery
}{
{name: "default", path: "/api/v1/audit", wantStatus: http.StatusOK, wantQuery: AuditQuery{Limit: defaultAuditPageSize}},
{name: "unknown field", path: "/api/v1/audit?beforeId=1", wantStatus: http.StatusBadRequest},
{name: "duplicate field", path: "/api/v1/audit?limit=1&limit=2", wantStatus: http.StatusBadRequest},
{name: "zero limit", path: "/api/v1/audit?limit=0", wantStatus: http.StatusBadRequest},
{name: "oversized limit", path: "/api/v1/audit?limit=1001", wantStatus: http.StatusBadRequest},
{name: "invalid cursor", path: "/api/v1/audit?afterId=nope", wantStatus: http.StatusBadRequest},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
service := &stubService{}
handler := mustHandler(t, service)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
if recorder.Code != test.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.wantStatus, recorder.Body.String())
}
if test.wantStatus == http.StatusOK {
if service.auditCalls != 1 || service.lastAudit != test.wantQuery {
t.Fatalf("ReadAudit() calls=%d query=%+v, want %+v", service.auditCalls, service.lastAudit, test.wantQuery)
}
return
}
if service.auditCalls != 0 || !strings.Contains(recorder.Body.String(), `"code":"INVALID_AUDIT_QUERY"`) {
t.Fatalf("invalid audit query reached service or returned wrong problem: calls=%d body=%s", service.auditCalls, recorder.Body.String())
}
})
}
}
func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) {
t.Parallel()
if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) {
@ -232,6 +307,7 @@ func TestHandlerRejectsInvalidTransportRequests(t *testing.T) {
wantStatus int
}{
{name: "method", method: http.MethodPut, path: "/api/v1/config/reload", wantStatus: http.StatusMethodNotAllowed},
{name: "audit method", method: http.MethodPost, path: "/api/v1/audit", wantStatus: http.StatusMethodNotAllowed},
{name: "unknown route", method: http.MethodGet, path: "/missing", wantStatus: http.StatusNotFound},
{name: "invalid name", method: http.MethodPost, path: "/api/v1/upstreams//enable", wantStatus: http.StatusNotFound},
{name: "unknown JSON field", method: http.MethodPost, path: "/api/v1/routing/r/switch", body: `{"expectedCurrent":"a","target":"b","extra":1}`, content: httpapi.JSONContentType, wantStatus: http.StatusBadRequest},
@ -277,12 +353,15 @@ func mustHandler(t *testing.T, service Service) *Handler {
type stubService struct {
status Status
audit AuditPage
mutation MutationResult
err error
lastAudit AuditQuery
lastUpstream SetUpstreamCommand
lastSwitch SwitchCommand
lastReload ReloadCommand
statusCalls int
auditCalls int
}
func (service *stubService) Status(context.Context) (Status, error) {
@ -290,6 +369,12 @@ func (service *stubService) Status(context.Context) (Status, error) {
return service.status, service.err
}
func (service *stubService) ReadAudit(_ context.Context, query AuditQuery) (AuditPage, error) {
service.auditCalls++
service.lastAudit = query
return service.audit, service.err
}
func (service *stubService) SetUpstreamEnabled(_ context.Context, command SetUpstreamCommand) (MutationResult, error) {
service.lastUpstream = command
return service.mutation, service.err

View File

@ -16,6 +16,7 @@ var ErrInvalidApplicationService = errors.New("invalid admin application service
type StateRepository interface {
adminstate.Mutator
adminstate.SnapshotReader
adminstate.AuditReader
}
type OperationalStatusReader interface {
@ -150,6 +151,36 @@ func (service *ApplicationService) SwitchRouting(ctx context.Context, command Sw
return mutationResult(result), mapAdminStateError(err)
}
// ReadAudit returns one bounded, stable page from the authoritative management
// audit log. It deliberately exposes only management-plane records and never
// reads Proxy activity, credentials, or Provider payloads.
func (service *ApplicationService) ReadAudit(ctx context.Context, query AuditQuery) (AuditPage, error) {
if service == nil || nilInterface(service.state) || query.Validate() != nil {
return AuditPage{}, ErrInvalidConfiguration
}
records, err := service.state.ReadAudit(ctx, adminstate.AuditQuery{AfterID: query.AfterID, Limit: query.Limit})
if err != nil {
return AuditPage{}, mapAdminStateError(err)
}
page := AuditPage{Records: make([]AuditRecord, 0, len(records))}
for _, record := range records {
page.Records = append(page.Records, AuditRecord{
ID: record.ID,
RequestID: record.RequestID,
ActorID: record.Actor.ID,
SourceIP: record.Actor.SourceIP,
Action: string(record.Action),
ResourceType: record.ResourceType,
ResourceName: record.ResourceName,
Changed: record.Changed,
Version: record.Revision,
Reason: record.Reason,
OccurredAt: record.OccurredAt.UTC(),
})
}
return page, nil
}
func (service *ApplicationService) Status(ctx context.Context) (Status, error) {
snapshot, err := service.state.Snapshot(ctx)
if err != nil {

View File

@ -206,6 +206,42 @@ func TestApplicationServiceMapsRoutingSwitchAndDomainErrors(t *testing.T) {
}
}
func TestApplicationServiceReadsAuthoritativeAuditPage(t *testing.T) {
t.Parallel()
occurredAt := time.Date(2026, 8, 2, 10, 0, 0, 0, time.FixedZone("test", 8*60*60))
state := &recordingAdminState{audits: []adminstate.AuditRecord{{
ID: 42, RequestID: "req-switch", Actor: adminstate.Actor{ID: "admin:alice", SourceIP: "192.0.2.10"},
Action: adminstate.ActionSwitchRoute, ResourceType: "routing", ResourceName: "checkout",
Changed: true, Revision: 9, Reason: "capacity", OccurredAt: occurredAt,
}}}
service := mustApplicationService(t, state, applicationTestOptions(time.Now))
page, err := service.ReadAudit(context.Background(), AuditQuery{AfterID: 41, Limit: 2})
if err != nil {
t.Fatalf("ReadAudit() error = %v", err)
}
if state.lastAuditQuery != (adminstate.AuditQuery{AfterID: 41, Limit: 2}) {
t.Fatalf("audit query = %+v", state.lastAuditQuery)
}
if len(page.Records) != 1 {
t.Fatalf("audit records = %+v", page.Records)
}
record := page.Records[0]
if record.ID != 42 || record.Action != string(adminstate.ActionSwitchRoute) || record.Version != 9 ||
record.ActorID != "admin:alice" || record.SourceIP != "192.0.2.10" || !record.OccurredAt.Equal(occurredAt.UTC()) {
t.Fatalf("mapped audit record = %+v", record)
}
state.err = adminstate.ErrUnavailable
if _, err := service.ReadAudit(context.Background(), AuditQuery{Limit: 1}); !errors.Is(err, ErrUnavailable) || !errors.Is(err, adminstate.ErrUnavailable) {
t.Fatalf("ReadAudit() unavailable error = %v", err)
}
if _, err := service.ReadAudit(context.Background(), AuditQuery{}); !errors.Is(err, ErrInvalidConfiguration) {
t.Fatalf("ReadAudit() invalid query error = %v", err)
}
}
func TestApplicationServiceBuildsStatusFromAuthoritativeAndOperationalSnapshots(t *testing.T) {
t.Parallel()
state := &recordingAdminState{snapshot: adminstate.Snapshot{
@ -645,6 +681,8 @@ type recordingAdminState struct {
mutation adminstate.MutationResult
err error
snapshot adminstate.Snapshot
audits []adminstate.AuditRecord
lastAuditQuery adminstate.AuditQuery
lastUpstream adminstate.SetUpstreamCommand
lastSwitch adminstate.SwitchRoutingCommand
lastDisable adminstate.DisableRoutingCommand
@ -683,6 +721,10 @@ func (*orderedCommitState) Snapshot(context.Context) (adminstate.Snapshot, error
return adminstate.Snapshot{}, nil
}
func (*orderedCommitState) ReadAudit(context.Context, adminstate.AuditQuery) ([]adminstate.AuditRecord, error) {
return nil, nil
}
func (state *recordingAdminState) SetUpstreamEnabled(_ context.Context, command adminstate.SetUpstreamCommand) (adminstate.MutationResult, error) {
state.lastUpstream = command
return state.mutation, state.err
@ -710,6 +752,11 @@ func (state *recordingAdminState) Snapshot(context.Context) (adminstate.Snapshot
return state.snapshot, state.err
}
func (state *recordingAdminState) ReadAudit(_ context.Context, query adminstate.AuditQuery) ([]adminstate.AuditRecord, error) {
state.lastAuditQuery = query
return append([]adminstate.AuditRecord(nil), state.audits...), state.err
}
type staticOperationalStatusReader struct {
status OperationalStatus
err error

View File

@ -57,12 +57,16 @@ func TestRuntimeServesDistributionAndAdminOnIndependentListeners(t *testing.T) {
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized)
adminHeaders := http.Header{"Authorization": []string{"Bearer admin-token"}}
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", adminHeaders, http.StatusOK)
assertStatus(t, http.MethodGet, adminURL+"/api/v1/audit", adminHeaders, http.StatusOK)
assertStatus(t, http.MethodGet, adminURL+"/health/live", adminHeaders, http.StatusNotFound)
assertStatus(t, http.MethodGet, metricsURL+"/readyz", nil, http.StatusOK)
assertStatus(t, http.MethodGet, metricsURL+"/api/v1/status", nil, http.StatusNotFound)
if adminService.statusCalls.Load() != 1 {
t.Fatalf("admin status calls = %d, want 1", adminService.statusCalls.Load())
}
if adminService.auditCalls.Load() != 1 {
t.Fatalf("admin audit calls = %d, want 1", adminService.auditCalls.Load())
}
cancel()
select {
@ -229,7 +233,9 @@ func (stubReadiness) Ready(context.Context) error { return nil }
type stubAdminService struct {
status admin.Status
audit admin.AuditPage
statusCalls atomic.Int64
auditCalls atomic.Int64
}
func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
@ -237,6 +243,11 @@ func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
return service.status, nil
}
func (service *stubAdminService) ReadAudit(context.Context, admin.AuditQuery) (admin.AuditPage, error) {
service.auditCalls.Add(1)
return service.audit, nil
}
func (*stubAdminService) SetUpstreamEnabled(context.Context, admin.SetUpstreamCommand) (admin.MutationResult, error) {
return admin.MutationResult{}, nil
}