feat: expose admin audit pages
This commit is contained in:
parent
e220d5368c
commit
ec3ceb2c9a
@ -111,7 +111,7 @@ flowchart LR
|
|||||||
|
|
||||||
- **已完成**:严格配置、Provider 获取与协调、Redis 活动池、Distribution 原子
|
- **已完成**:严格配置、Provider 获取与协调、Redis 活动池、Distribution 原子
|
||||||
提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL
|
提取与限流、Controller 的 Admin/Distribution/Metrics 监听,以及 PostgreSQL
|
||||||
管理状态;WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
|
管理状态与有界审计查询;WorkerControlPlane 的 Register、Snapshot ACK、Runtime 心跳接收和
|
||||||
Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试;
|
Redis 会话栅栏,以及 Gateway Outcome 上报的有界队列、序列确认与重试;
|
||||||
Controller 的 Redis 共享 BASIC/EGRESS/TARGET 检查任务、按上游的有界轮转调度、HTTP/HTTPS/SOCKS5
|
Controller 的 Redis 共享 BASIC/EGRESS/TARGET 检查任务、按上游的有界轮转调度、HTTP/HTTPS/SOCKS5
|
||||||
Checker 探测和
|
Checker 探测和
|
||||||
|
|||||||
@ -7,6 +7,7 @@ servers:
|
|||||||
- url: http://127.0.0.1:8082
|
- url: http://127.0.0.1:8082
|
||||||
tags:
|
tags:
|
||||||
- name: Status
|
- name: Status
|
||||||
|
- name: Audit
|
||||||
- name: Upstreams
|
- name: Upstreams
|
||||||
- name: Routing
|
- name: Routing
|
||||||
- name: Configuration
|
- name: Configuration
|
||||||
@ -36,6 +37,42 @@ paths:
|
|||||||
'405': {$ref: '#/components/responses/MethodNotAllowed'}
|
'405': {$ref: '#/components/responses/MethodNotAllowed'}
|
||||||
'500': {$ref: '#/components/responses/InternalServerError'}
|
'500': {$ref: '#/components/responses/InternalServerError'}
|
||||||
'503': {$ref: '#/components/responses/ServiceUnavailable'}
|
'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:
|
/api/v1/upstreams/{name}/enable:
|
||||||
post:
|
post:
|
||||||
tags: [Upstreams]
|
tags: [Upstreams]
|
||||||
@ -152,6 +189,31 @@ components:
|
|||||||
description: 服务端最终使用的请求标识。
|
description: 服务端最终使用的请求标识。
|
||||||
schema: {type: string, maxLength: 128}
|
schema: {type: string, maxLength: 128}
|
||||||
schemas:
|
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:
|
Status:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
type document struct {
|
type document struct {
|
||||||
OpenAPI string `yaml:"openapi"`
|
OpenAPI string `yaml:"openapi"`
|
||||||
Paths map[string]map[string]any `yaml:"paths"`
|
Paths map[string]map[string]any `yaml:"paths"`
|
||||||
|
Components map[string]any `yaml:"components"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDistributionContract(t *testing.T) {
|
func TestDistributionContract(t *testing.T) {
|
||||||
@ -37,6 +38,7 @@ func TestAdminContract(t *testing.T) {
|
|||||||
spec := readDocument(t, "admin.yaml")
|
spec := readDocument(t, "admin.yaml")
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
"/api/v1/status",
|
"/api/v1/status",
|
||||||
|
"/api/v1/audit",
|
||||||
"/api/v1/upstreams/{name}/enable",
|
"/api/v1/upstreams/{name}/enable",
|
||||||
"/api/v1/upstreams/{name}/disable",
|
"/api/v1/upstreams/{name}/disable",
|
||||||
"/api/v1/routing/{name}/switch",
|
"/api/v1/routing/{name}/switch",
|
||||||
@ -48,6 +50,67 @@ func TestAdminContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
requireResponses(t, spec.Paths["/api/v1/routing/{name}/switch"]["post"],
|
requireResponses(t, spec.Paths["/api/v1/routing/{name}/switch"]["post"],
|
||||||
"200", "400", "401", "403", "404", "405", "409", "413", "415", "422", "500", "503")
|
"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) {
|
func requireResponses(t *testing.T, operation any, codes ...string) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
|
|||||||
## 端点
|
## 端点
|
||||||
|
|
||||||
- `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。
|
- `GET /api/v1/status`:返回配置/快照版本、Upstream 聚合计数和 Worker 状态。
|
||||||
|
- `GET /api/v1/audit`:按审计记录 ID 升序读取权威管理面的变更记录。
|
||||||
- `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。
|
- `POST /api/v1/upstreams/{name}/enable`:启用 Upstream。
|
||||||
- `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。
|
- `POST /api/v1/upstreams/{name}/disable`:停止新 Fetch/分配并自然 Drain。
|
||||||
- `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。
|
- `POST /api/v1/routing/{name}/switch`:用 expectedCurrent 做 CAS 手工切换。
|
||||||
@ -17,6 +18,19 @@ Admin API 使用独立监听器与权限,契约位于 `api/openapi/admin.yaml`
|
|||||||
配置重载校验失败返回 422,旧配置继续运行。Status 只返回低基数聚合信息,
|
配置重载校验失败返回 422,旧配置继续运行。Status 只返回低基数聚合信息,
|
||||||
不得返回 Proxy 地址、凭据、Client 标识或完整 Provider URL。
|
不得返回 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` 控制面接口,不直接操作数据库、路由游标或配置
|
`admin.Handler` 只依赖 `Service` 控制面接口,不直接操作数据库、路由游标或配置
|
||||||
|
|||||||
@ -16,7 +16,8 @@
|
|||||||
|
|
||||||
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
||||||
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
||||||
- Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。
|
- Admin OpenAPI:状态、按 ID 游标分页的权威审计查询、Upstream 启停、Routing
|
||||||
|
切换和配置重载。
|
||||||
- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和
|
- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和
|
||||||
security scheme;完整标准工具验证仍待补齐。
|
security scheme;完整标准工具验证仍待补齐。
|
||||||
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
||||||
@ -44,8 +45,9 @@
|
|||||||
- `PROVIDER-*`:Provider HTTP Client、严格响应上限、模板解析安全边界、凭据
|
- `PROVIDER-*`:Provider HTTP Client、严格响应上限、模板解析安全边界、凭据
|
||||||
引用 Store 与 Reconciler Adapter 已实现。
|
引用 Store 与 Reconciler Adapter 已实现。
|
||||||
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
|
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
|
||||||
Handler 已实现;共享认证、CIDR、可信代理、Client ID 与本地准入保护链已接入,
|
Handler 已实现;Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录,
|
||||||
Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
|
不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID 与本地准入
|
||||||
|
保护链已接入,Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
|
||||||
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
|
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
|
||||||
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理,Memory/Redis
|
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理,Memory/Redis
|
||||||
运行同一公用契约。
|
运行同一公用契约。
|
||||||
@ -123,7 +125,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
|||||||
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
|
7. BASIC Checker 调度与 HTTP/HTTPS/SOCKS5 探测器、全局与 TARGET Profile 的 Memory/Redis
|
||||||
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
||||||
生产任务调度与 REMOVE 编排仍待实现。
|
生产任务调度与 REMOVE 编排仍待实现。
|
||||||
8. Admin/Distribution 细粒度授权和审计查询;Distribution 分布式限流已完成。
|
8. Admin/Distribution 细粒度授权;Distribution 分布式限流和 Admin 审计查询已完成。
|
||||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||||
10. 将 reject/wait/direct 接入 Distribution 运行链。
|
10. 将 reject/wait/direct 接入 Distribution 运行链。
|
||||||
## 4. 容量结论
|
## 4. 容量结论
|
||||||
|
|||||||
@ -5,18 +5,23 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"proxy-pool/internal/domain/adminstate"
|
||||||
"proxy-pool/internal/platform/httpapi"
|
"proxy-pool/internal/platform/httpapi"
|
||||||
"proxy-pool/internal/platform/httpsecurity"
|
"proxy-pool/internal/platform/httpsecurity"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
statusPath = "/api/v1/status"
|
statusPath = "/api/v1/status"
|
||||||
|
auditPath = "/api/v1/audit"
|
||||||
reloadPath = "/api/v1/config/reload"
|
reloadPath = "/api/v1/config/reload"
|
||||||
upstreamPrefix = "/api/v1/upstreams/"
|
upstreamPrefix = "/api/v1/upstreams/"
|
||||||
routingPrefix = "/api/v1/routing/"
|
routingPrefix = "/api/v1/routing/"
|
||||||
maxResourceNameBytes = 128
|
maxResourceNameBytes = 128
|
||||||
|
defaultAuditPageSize = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -29,6 +34,7 @@ var (
|
|||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
Status(context.Context) (Status, error)
|
Status(context.Context) (Status, error)
|
||||||
|
ReadAudit(context.Context, AuditQuery) (AuditPage, error)
|
||||||
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
|
SetUpstreamEnabled(context.Context, SetUpstreamCommand) (MutationResult, error)
|
||||||
SwitchRouting(context.Context, SwitchCommand) (MutationResult, error)
|
SwitchRouting(context.Context, SwitchCommand) (MutationResult, error)
|
||||||
ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
|
ReloadConfiguration(context.Context, ReloadCommand) (MutationResult, error)
|
||||||
@ -78,6 +84,36 @@ type MutationResult struct {
|
|||||||
Message string `json:"message,omitempty"`
|
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 {
|
type SetUpstreamCommand struct {
|
||||||
RequestID string
|
RequestID string
|
||||||
ActorID string
|
ActorID string
|
||||||
@ -136,6 +172,12 @@ func (handler *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Requ
|
|||||||
}
|
}
|
||||||
handler.getStatus(writer, request, requestID)
|
handler.getStatus(writer, request, requestID)
|
||||||
return
|
return
|
||||||
|
case auditPath:
|
||||||
|
if !requireMethod(writer, request, http.MethodGet, requestID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.readAudit(writer, request, requestID)
|
||||||
|
return
|
||||||
case reloadPath:
|
case reloadPath:
|
||||||
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
if !requireMethod(writer, request, http.MethodPost, requestID) {
|
||||||
return
|
return
|
||||||
@ -178,6 +220,57 @@ func (handler *Handler) getStatus(writer http.ResponseWriter, request *http.Requ
|
|||||||
_ = httpapi.WriteJSON(writer, http.StatusOK, status)
|
_ = 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(
|
func (handler *Handler) setUpstreamEnabled(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"proxy-pool/internal/platform/httpapi"
|
"proxy-pool/internal/platform/httpapi"
|
||||||
"proxy-pool/internal/platform/httpsecurity"
|
"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) {
|
func TestNewHandlerRejectsMissingDependenciesAndInvalidLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) {
|
if _, err := NewHandler(nil, allowAuthorizer{}, Options{MaxBodyBytes: 1024}); !errors.Is(err, ErrInvalidHandler) {
|
||||||
@ -232,6 +307,7 @@ func TestHandlerRejectsInvalidTransportRequests(t *testing.T) {
|
|||||||
wantStatus int
|
wantStatus int
|
||||||
}{
|
}{
|
||||||
{name: "method", method: http.MethodPut, path: "/api/v1/config/reload", wantStatus: http.StatusMethodNotAllowed},
|
{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: "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: "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},
|
{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 {
|
type stubService struct {
|
||||||
status Status
|
status Status
|
||||||
|
audit AuditPage
|
||||||
mutation MutationResult
|
mutation MutationResult
|
||||||
err error
|
err error
|
||||||
|
lastAudit AuditQuery
|
||||||
lastUpstream SetUpstreamCommand
|
lastUpstream SetUpstreamCommand
|
||||||
lastSwitch SwitchCommand
|
lastSwitch SwitchCommand
|
||||||
lastReload ReloadCommand
|
lastReload ReloadCommand
|
||||||
statusCalls int
|
statusCalls int
|
||||||
|
auditCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *stubService) Status(context.Context) (Status, error) {
|
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
|
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) {
|
func (service *stubService) SetUpstreamEnabled(_ context.Context, command SetUpstreamCommand) (MutationResult, error) {
|
||||||
service.lastUpstream = command
|
service.lastUpstream = command
|
||||||
return service.mutation, service.err
|
return service.mutation, service.err
|
||||||
|
|||||||
@ -16,6 +16,7 @@ var ErrInvalidApplicationService = errors.New("invalid admin application service
|
|||||||
type StateRepository interface {
|
type StateRepository interface {
|
||||||
adminstate.Mutator
|
adminstate.Mutator
|
||||||
adminstate.SnapshotReader
|
adminstate.SnapshotReader
|
||||||
|
adminstate.AuditReader
|
||||||
}
|
}
|
||||||
|
|
||||||
type OperationalStatusReader interface {
|
type OperationalStatusReader interface {
|
||||||
@ -150,6 +151,36 @@ func (service *ApplicationService) SwitchRouting(ctx context.Context, command Sw
|
|||||||
return mutationResult(result), mapAdminStateError(err)
|
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) {
|
func (service *ApplicationService) Status(ctx context.Context) (Status, error) {
|
||||||
snapshot, err := service.state.Snapshot(ctx)
|
snapshot, err := service.state.Snapshot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -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) {
|
func TestApplicationServiceBuildsStatusFromAuthoritativeAndOperationalSnapshots(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
state := &recordingAdminState{snapshot: adminstate.Snapshot{
|
state := &recordingAdminState{snapshot: adminstate.Snapshot{
|
||||||
@ -645,6 +681,8 @@ type recordingAdminState struct {
|
|||||||
mutation adminstate.MutationResult
|
mutation adminstate.MutationResult
|
||||||
err error
|
err error
|
||||||
snapshot adminstate.Snapshot
|
snapshot adminstate.Snapshot
|
||||||
|
audits []adminstate.AuditRecord
|
||||||
|
lastAuditQuery adminstate.AuditQuery
|
||||||
lastUpstream adminstate.SetUpstreamCommand
|
lastUpstream adminstate.SetUpstreamCommand
|
||||||
lastSwitch adminstate.SwitchRoutingCommand
|
lastSwitch adminstate.SwitchRoutingCommand
|
||||||
lastDisable adminstate.DisableRoutingCommand
|
lastDisable adminstate.DisableRoutingCommand
|
||||||
@ -683,6 +721,10 @@ func (*orderedCommitState) Snapshot(context.Context) (adminstate.Snapshot, error
|
|||||||
return adminstate.Snapshot{}, nil
|
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) {
|
func (state *recordingAdminState) SetUpstreamEnabled(_ context.Context, command adminstate.SetUpstreamCommand) (adminstate.MutationResult, error) {
|
||||||
state.lastUpstream = command
|
state.lastUpstream = command
|
||||||
return state.mutation, state.err
|
return state.mutation, state.err
|
||||||
@ -710,6 +752,11 @@ func (state *recordingAdminState) Snapshot(context.Context) (adminstate.Snapshot
|
|||||||
return state.snapshot, state.err
|
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 {
|
type staticOperationalStatusReader struct {
|
||||||
status OperationalStatus
|
status OperationalStatus
|
||||||
err error
|
err error
|
||||||
|
|||||||
@ -57,12 +57,16 @@ func TestRuntimeServesDistributionAndAdminOnIndependentListeners(t *testing.T) {
|
|||||||
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized)
|
assertStatus(t, http.MethodGet, adminURL+"/api/v1/status", nil, http.StatusUnauthorized)
|
||||||
adminHeaders := http.Header{"Authorization": []string{"Bearer admin-token"}}
|
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/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, adminURL+"/health/live", adminHeaders, http.StatusNotFound)
|
||||||
assertStatus(t, http.MethodGet, metricsURL+"/readyz", nil, http.StatusOK)
|
assertStatus(t, http.MethodGet, metricsURL+"/readyz", nil, http.StatusOK)
|
||||||
assertStatus(t, http.MethodGet, metricsURL+"/api/v1/status", nil, http.StatusNotFound)
|
assertStatus(t, http.MethodGet, metricsURL+"/api/v1/status", nil, http.StatusNotFound)
|
||||||
if adminService.statusCalls.Load() != 1 {
|
if adminService.statusCalls.Load() != 1 {
|
||||||
t.Fatalf("admin status calls = %d, want 1", adminService.statusCalls.Load())
|
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()
|
cancel()
|
||||||
select {
|
select {
|
||||||
@ -229,7 +233,9 @@ func (stubReadiness) Ready(context.Context) error { return nil }
|
|||||||
|
|
||||||
type stubAdminService struct {
|
type stubAdminService struct {
|
||||||
status admin.Status
|
status admin.Status
|
||||||
|
audit admin.AuditPage
|
||||||
statusCalls atomic.Int64
|
statusCalls atomic.Int64
|
||||||
|
auditCalls atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *stubAdminService) Status(context.Context) (admin.Status, error) {
|
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
|
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) {
|
func (*stubAdminService) SetUpstreamEnabled(context.Context, admin.SetUpstreamCommand) (admin.MutationResult, error) {
|
||||||
return admin.MutationResult{}, nil
|
return admin.MutationResult{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user