bf9395e022
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run
317 lines
14 KiB
Go
317 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/httpmock"
|
||
)
|
||
|
||
const (
|
||
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
|
||
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
|
||
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
|
||
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
|
||
)
|
||
|
||
// ── audit-status ──
|
||
|
||
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
|
||
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditStatusURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
// 单表无记录 → 占位对象 enabled:false(不是数组)。
|
||
var env struct {
|
||
Data struct {
|
||
Table string `json:"table"`
|
||
Enabled bool `json:"enabled"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||
}
|
||
if env.Data.Table != "orders" || env.Data.Enabled {
|
||
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
|
||
}
|
||
}
|
||
|
||
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
|
||
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditStatusURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
|
||
map[string]interface{}{"table": "users", "enabled": false},
|
||
}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
|
||
t.Fatalf("pretty table malformed:\n%s", got)
|
||
}
|
||
}
|
||
|
||
// ── audit-enable / disable ──
|
||
|
||
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
|
||
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
// 缺 --table → cobra required, exit 1
|
||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||
t.Fatalf("expected required --table error")
|
||
}
|
||
// 非法 retention → enum 校验 (validation)
|
||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsDBAuditEnable,
|
||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
|
||
var ve *errs.ValidationError
|
||
if !errors.As(err, &ve) {
|
||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||
}
|
||
if ve.Param != "--retention" {
|
||
t.Fatalf("Param = %q, want --retention", ve.Param)
|
||
}
|
||
}
|
||
|
||
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。
|
||
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||
// dry-run body {table, enabled:true, retention}
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var env struct {
|
||
API []struct {
|
||
Method string `json:"method"`
|
||
URL string `json:"url"`
|
||
Body map[string]interface{} `json:"body"`
|
||
} `json:"api"`
|
||
}
|
||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||
a := env.API[0]
|
||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||
}
|
||
|
||
// success
|
||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "POST", URL: dbAuditSetURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
|
||
t.Fatalf("pretty: %s", stdout2.String())
|
||
}
|
||
}
|
||
|
||
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。
|
||
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var env struct {
|
||
API []struct {
|
||
Body map[string]interface{} `json:"body"`
|
||
} `json:"api"`
|
||
}
|
||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||
}
|
||
|
||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "POST", URL: dbAuditSetURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
|
||
t.Fatalf("pretty: %s", stdout2.String())
|
||
}
|
||
}
|
||
|
||
// ── audit-list ──
|
||
|
||
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
|
||
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||
t.Fatalf("expected required --table error")
|
||
}
|
||
}
|
||
|
||
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
|
||
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var env struct {
|
||
API []struct {
|
||
Method string `json:"method"`
|
||
URL string `json:"url"`
|
||
Params map[string]interface{} `json:"params"`
|
||
} `json:"api"`
|
||
}
|
||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||
a := env.API[0]
|
||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||
}
|
||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||
t.Fatalf("since not normalized: %v", a.Params["since"])
|
||
}
|
||
}
|
||
|
||
// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。
|
||
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
|
||
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditListURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||
"has_more": false, "page_token": "",
|
||
"items": []interface{}{map[string]interface{}{
|
||
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
|
||
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
|
||
"before": `{"amount":100}`, "after": `{"amount":999}`,
|
||
}},
|
||
}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
// operator → 对象;before/after → 还原成对象(非字符串)。
|
||
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("missing %q:\n%s", want, got)
|
||
}
|
||
}
|
||
if strings.Contains(got, `"skipped"`) {
|
||
t.Errorf("single-table query must not emit skipped:\n%s", got)
|
||
}
|
||
if strings.Contains(got, `"before": "{`) {
|
||
t.Errorf("before should be an object, not a JSON string:\n%s", got)
|
||
}
|
||
}
|
||
|
||
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
|
||
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditListURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
|
||
}
|
||
got := stdout.String()
|
||
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
|
||
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
|
||
}
|
||
}
|
||
|
||
// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list,
|
||
// 不存在 / 未开启审计的表进 skipped。
|
||
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
|
||
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
// schema:orders/users/carts 存在,ghost 不存在。
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbTablesListURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
|
||
}}},
|
||
})
|
||
// status:orders/users 开启审计,carts 未开启。
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditStatusURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
|
||
map[string]interface{}{"table": "carts", "enabled": false},
|
||
}}},
|
||
})
|
||
// audit_list 只应被传入有效表 orders,users。
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditListURL,
|
||
OnMatch: func(req *http.Request) {
|
||
if got := req.URL.Query().Get("tables"); got != "orders,users" {
|
||
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
|
||
}
|
||
},
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
|
||
}}},
|
||
})
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("execute err=%v", err)
|
||
}
|
||
got := stdout.String()
|
||
// skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
|
||
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("missing %q:\n%s", want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。
|
||
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
|
||
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
|
||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbTablesListURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||
map[string]interface{}{"name": "orders"},
|
||
}}},
|
||
})
|
||
reg.Register(&httpmock.Stub{
|
||
Method: "GET", URL: dbAuditStatusURL,
|
||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||
})
|
||
// 不注册 audit_list:若被调用会命中未注册请求而报错。
|
||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
|
||
}
|
||
got := stdout.String()
|
||
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
|
||
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
|
||
}
|
||
}
|