mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
fix: validate form edits and enforce task result ownership
This commit is contained in:
1 parent
ab17ffda87
commit
bdefa535b0
19 files changed
+331
-20
No files matched your search
@@ -49,3 +49,12 @@
|
||||
- 配置凭据处理保留既有字段与空字符串清除语义;掩码标记只用于已保存的同路径凭据,新通道不能直接保存掩码。通道探活新增可选 `channel_id`,模型列表/审查测试支持可选凭据范围,避免同 URL 多密钥选择错误。
|
||||
- 新增 GET 配置不泄漏/不变更内存、掩码保留/替换/清空、歧义拒绝、WebShell 回显及执行凭据、Markdown 创建/更新唯一性、技能描述切换测试。
|
||||
- C2 Shell 回归只提取模板中的 Shell 辅助函数,不运行传输循环。验证无换行、有换行、尾部分号、注释、失败退出、带空格目录切换;Unix 系统通过,Windows 路径未修改。模板修复适用于重新生成的客户端。
|
||||
|
||||
## 追加:输入校验、权限入口与回传归属
|
||||
|
||||
- Excel 第 40 行:权限管理页三个新建角色/添加成员按钮补上 `rbac:write` 标记,复用现有隐藏、禁用与点击守卫。前端用权限被拒绝的真实守卫验证入口不可见且不可点击。
|
||||
- Excel 第 41 行:并发数内联编辑对空值、小数、0、9 等明确报错并保留编辑框,不发送请求;队列元数据更新在任何修改前拒绝越界值。保留旧配置加载时的归一化行为。
|
||||
- Excel 第 42 行:项目设置前端和更新 API 均拒绝空/纯空格名称;API 省略名称仍允许部分更新,拒绝时描述等字段不发生部分保存。
|
||||
- Excel 第 44 行:后端 URL 补齐使用 `IP 非空` 作为分支条件,导致已由前端解析的 IP 被再填入 Domain。改为先判别 URL 主机类型再补齐缺失 IP;IPv4、IPv6 预填及仅 URL 三条数据库保存回归通过。第 22 行截图与第 25 行为同一 WebShell 路径丢失/错误文本条目问题。
|
||||
- Excel 第 36 行:WebSocket 与 TCP Beacon 回传按连接已登记的会话和监听器校验;HTTP 回传及取任务先校验监听器;已有 UUID 不允许通过其他监听器重新登记改变归属。未授权回传不能更改任务状态、结果或产生完成事件。HTTP 同监听器下的会话认证尚待继续核对,本项不标为全部完成。
|
||||
- 验证:`form-validation.test.cjs` 4 项通过;项目空名称无部分保存、并发数非法无修改、IP URL 入库及结果来源拒绝均新增回归。全部包回归在提交前再次执行。
|
||||
@@ -24,7 +24,7 @@
|
||||
| 19 | 18 | 用户并发配额耗尽时任务静默排队,界面无提示 | 当前仓库未实现,不扩展能力 |
|
||||
| 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 |
|
||||
| 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 |
|
||||
| 22 | 20 | 不知道这个是啥,应该是样式问题 | 待核对 |
|
||||
| 22 | 20 | 不知道这个是啥,应该是样式问题 | 与第 25 行同一 WebShell 路径/假文件问题,已修复 |
|
||||
| 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 |
|
||||
| 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 |
|
||||
| 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 |
|
||||
@@ -38,15 +38,15 @@
|
||||
| 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 |
|
||||
| 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 |
|
||||
| 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 |
|
||||
| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 待核对 |
|
||||
| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 已修复持久连接跨会话及跨监听器回传;HTTP 同监听器会话隔离待核对 |
|
||||
| 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 |
|
||||
| 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 |
|
||||
| 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 |
|
||||
| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 待核对 |
|
||||
| 41 | — | 点击“并发数”当前值进行编辑。 | 待核对 |
|
||||
| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 待核对 |
|
||||
| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 已补权限入口标记,按钮可见性验证通过 |
|
||||
| 41 | — | 点击“并发数”当前值进行编辑。 | 已修复并发越界提示,前后端回归通过 |
|
||||
| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 已修复空名称误报保存成功,前后端回归通过 |
|
||||
| 43 | — | 样式问题 | 待核对 |
|
||||
| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 待核对 |
|
||||
| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 已修复 URL 与预填 IP 重复解析,IPv4/IPv6 回归通过 |
|
||||
| 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 待核对 |
|
||||
| 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 |
|
||||
| 47 | — | nmap 参数缺少前置校验 | 待核对 |
|
||||
|
||||
@@ -254,7 +254,7 @@ func (l *HTTPBeaconListener) handleTasks(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
session, err := l.manager.DB().GetC2Session(sessionID)
|
||||
if err != nil || session == nil {
|
||||
if err != nil || session == nil || session.ListenerID != l.rec.ID {
|
||||
l.disguisedReject(w)
|
||||
return
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request
|
||||
l.disguisedReject(w)
|
||||
return
|
||||
}
|
||||
if err := l.manager.IngestTaskResult(report); err != nil {
|
||||
if err := l.manager.IngestTaskResultFromListener(l.rec.ID, "", report); err != nil {
|
||||
http.Error(w, "ingest result failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (l *WebSocketListener) handleConn(ws *websocket.Conn) {
|
||||
case "result":
|
||||
var report TaskResultReport
|
||||
if err := json.Unmarshal(body, &report); err == nil {
|
||||
_ = l.manager.IngestTaskResult(report)
|
||||
_ = l.manager.IngestTaskResultFromListener(l.rec.ID, conn.sessionID, report)
|
||||
}
|
||||
case "checkin":
|
||||
// 心跳更新:beacon 周期性送上心跳
|
||||
|
||||
@@ -359,6 +359,9 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && existing.ListenerID != listenerID {
|
||||
return nil, ErrAuthFailed
|
||||
}
|
||||
now := time.Now()
|
||||
isFirstSeen := existing == nil
|
||||
var sessID string
|
||||
@@ -659,6 +662,30 @@ func (m *Manager) PopTasksForBeacon(sessionID string, limit int) ([]TaskEnvelope
|
||||
}
|
||||
|
||||
// IngestTaskResult beacon 回传任务结果的统一入口
|
||||
// IngestTaskResultFromListener verifies transport-established ownership before
|
||||
// accepting a result. HTTP transports authenticate the listener; persistent
|
||||
// connections additionally bind the result to the session established at check-in.
|
||||
func (m *Manager) IngestTaskResultFromListener(listenerID, sessionID string, report TaskResultReport) error {
|
||||
if listenerID == "" {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
task, err := m.db.GetC2Task(report.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task == nil {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
session, err := m.db.GetC2Session(task.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session == nil || session.ListenerID != listenerID || (sessionID != "" && session.ID != sessionID) {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
return m.IngestTaskResult(report)
|
||||
}
|
||||
|
||||
func (m *Manager) IngestTaskResult(report TaskResultReport) error {
|
||||
if strings.TrimSpace(report.TaskID) == "" {
|
||||
return ErrInvalidInput
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestResultTransportOwnershipRejectsForeignTask(t *testing.T) {
|
||||
m, db := terminalTestManager(t)
|
||||
now := time.Now()
|
||||
if err := db.UpsertC2Session(&database.C2Session{ID: "other", ListenerID: "listener", ImplantUUID: "other-uuid", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &database.C2Task{ID: "t_owner", SessionID: "session", TaskType: "exec", Status: "sent", CreatedAt: now}
|
||||
if err := db.CreateC2Task(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := TaskResultReport{TaskID: task.ID, Success: true, Output: "test-only-result"}
|
||||
for _, source := range [][2]string{{"listener", "other"}, {"foreign-listener", "session"}, {"foreign-listener", ""}, {"", "session"}} {
|
||||
if err := m.IngestTaskResultFromListener(source[0], source[1], report); err != ErrAuthFailed {
|
||||
t.Fatalf("source %v: %v", source, err)
|
||||
}
|
||||
saved, err := db.GetC2Task(task.ID)
|
||||
if err != nil || saved.Status != "sent" || saved.ResultText != "" {
|
||||
t.Fatalf("foreign result persisted: %#v %v", saved, err)
|
||||
}
|
||||
}
|
||||
if err := m.IngestTaskResultFromListener("listener", "session", report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _ := db.GetC2Task(task.ID)
|
||||
if saved.Status != "success" || saved.ResultText != report.Output {
|
||||
t.Fatalf("valid result failed: %#v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPResultCannotUpdateAnotherListenerTask(t *testing.T) {
|
||||
m, db := terminalTestManager(t)
|
||||
task := &database.C2Task{ID: "t_foreign", SessionID: "session", TaskType: "exec", Status: "sent", CreatedAt: time.Now()}
|
||||
if err := db.CreateC2Task(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := GenerateAESKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := &HTTPBeaconListener{rec: &database.C2Listener{ID: "http_other", EncryptionKey: key, ImplantToken: "test-only-token"}, manager: m, logger: zap.NewNop()}
|
||||
raw, _ := json.Marshal(TaskResultReport{TaskID: task.ID, Success: true, Output: "forged"})
|
||||
body, err := EncryptAESGCM(key, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(body))
|
||||
req.Header.Set("X-Implant-Token", "test-only-token")
|
||||
rr := httptest.NewRecorder()
|
||||
l.handleResult(rr, req)
|
||||
if rr.Code == http.StatusOK {
|
||||
t.Fatal("accepted foreign listener result")
|
||||
}
|
||||
saved, _ := db.GetC2Task(task.ID)
|
||||
if saved.Status != "sent" || saved.ResultText != "" {
|
||||
t.Fatalf("foreign result persisted: %#v", saved)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/tasks?session_id=session", nil)
|
||||
req.Header.Set("X-Implant-Token", "test-only-token")
|
||||
rr = httptest.NewRecorder()
|
||||
l.handleTasks(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("foreign session task request status: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInCannotMoveAnExistingSessionToAnotherListener(t *testing.T) {
|
||||
m, db := terminalTestManager(t)
|
||||
if _, err := m.IngestCheckIn("foreign-listener", ImplantCheckInRequest{ImplantUUID: "test-uuid", Hostname: "forged"}); err != ErrAuthFailed {
|
||||
t.Fatalf("expected rejection, got %v", err)
|
||||
}
|
||||
saved, err := db.GetC2Session("session")
|
||||
if err != nil || saved.ListenerID != "listener" || saved.Hostname == "forged" {
|
||||
t.Fatalf("session changed: %#v %v", saved, err)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ func tcpBeaconCheckToken(expected, got string) bool {
|
||||
// handleTCPBeaconSession 处理已消费魔数 CSB1 之后的 TCP Beacon 会话(与 HTTP Beacon 相同的 AES-GCM + JSON 语义)。
|
||||
func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Reader) {
|
||||
var writeMu sync.Mutex
|
||||
var boundSessionID string
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
@@ -144,6 +145,10 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
|
||||
l.logger.Warn("tcp beacon check_in", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if boundSessionID != "" && boundSessionID != session.ID {
|
||||
return
|
||||
}
|
||||
boundSessionID = session.ID
|
||||
queued, _ := l.manager.DB().ListC2Tasks(database.ListC2TasksFilter{
|
||||
SessionID: session.ID,
|
||||
Status: string(TaskQueued),
|
||||
@@ -163,7 +168,7 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
|
||||
return
|
||||
}
|
||||
var sessionID string
|
||||
if err := json.Unmarshal(rawSID, &sessionID); err != nil || sessionID == "" {
|
||||
if err := json.Unmarshal(rawSID, &sessionID); err != nil || sessionID == "" || sessionID != boundSessionID {
|
||||
return
|
||||
}
|
||||
sess, err := l.manager.DB().GetC2Session(sessionID)
|
||||
@@ -188,7 +193,10 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
|
||||
if err := json.Unmarshal(raw, &report); err != nil {
|
||||
return
|
||||
}
|
||||
if err := l.manager.IngestTaskResult(report); err != nil {
|
||||
if boundSessionID == "" {
|
||||
return
|
||||
}
|
||||
if err := l.manager.IngestTaskResultFromListener(l.rec.ID, boundSessionID, report); err != nil {
|
||||
return
|
||||
}
|
||||
resp = map[string]string{"ok": "1"}
|
||||
|
||||
@@ -135,8 +135,10 @@ func normalizeAsset(a *Asset) {
|
||||
if strings.Contains(a.Host, "://") {
|
||||
if parsed, err := url.Parse(a.Host); err == nil && parsed.Hostname() != "" && parsed.User == nil {
|
||||
hostname := strings.Trim(strings.ToLower(parsed.Hostname()), "[]")
|
||||
if net.ParseIP(hostname) != nil && a.IP == "" {
|
||||
a.IP = hostname
|
||||
if net.ParseIP(hostname) != nil {
|
||||
if a.IP == "" {
|
||||
a.IP = hostname
|
||||
}
|
||||
} else if a.Domain == "" {
|
||||
if ascii, err := idna.Lookup.ToASCII(hostname); err == nil {
|
||||
a.Domain = strings.ToLower(ascii)
|
||||
|
||||
@@ -447,3 +447,25 @@ func TestAssetListFlexibleFiltersAndOldestScanPagination(t *testing.T) {
|
||||
t.Fatalf("structured filters: total=%d assets=%#v err=%v", total, filtered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetIPURLWithPrefilledIPDoesNotBecomeDomain(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "ip-url.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
cases := []*Asset{
|
||||
{Host: "http://192.168.200.100:18081", IP: "192.168.200.100", Port: 18081, Protocol: "http"},
|
||||
{Host: "http://[2001:db8::1]:18081", IP: "2001:db8::1", Port: 18081, Protocol: "http"},
|
||||
{Host: "http://192.0.2.1:18081"},
|
||||
}
|
||||
for _, a := range cases {
|
||||
result, err := db.UpsertAssets([]*Asset{a}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("rejected IP URL: %#v %v", result, err)
|
||||
}
|
||||
if a.Domain != "" || a.IP == "" || a.Port != 18081 {
|
||||
t.Fatalf("wrong structured fields: %#v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -757,6 +757,9 @@ func batchQueueConcurrencyFromRow(row *database.BatchTaskQueueRow) int {
|
||||
|
||||
// UpdateQueueMetadata 更新队列标题、角色、代理模式和并发数(非 running 时可用)
|
||||
func (m *BatchTaskManager) UpdateQueueMetadata(queueID, title, role, agentMode string, concurrency *int, hitlPolicies ...string) error {
|
||||
if concurrency != nil && (*concurrency < 1 || *concurrency > MaxBatchQueueConcurrency) {
|
||||
return fmt.Errorf("并发数必须为 1–%d", MaxBatchQueueConcurrency)
|
||||
}
|
||||
if utf8.RuneCountInString(title) > MaxBatchQueueTitleLen {
|
||||
return fmt.Errorf("标题不能超过 %d 个字符", MaxBatchQueueTitleLen)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProjectUpdateRejectsBlankNameWithoutPartialSave(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "project.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
project, err := db.CreateProject(&database.Project{Name: "original", Description: "old", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := gin.New()
|
||||
r.PUT("/projects/:id", NewProjectHandler(db, zap.NewNop()).UpdateProject)
|
||||
for _, body := range []string{`{"name":"","description":"new"}`, `{"name":" "}`} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/projects/"+project.ID, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
saved, err := db.GetProject(project.ID)
|
||||
if err != nil || saved.Name != "original" || saved.Description != "old" {
|
||||
t.Fatalf("partial save: %#v %v", saved, err)
|
||||
}
|
||||
}
|
||||
// An omitted name remains a valid partial update.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/projects/"+project.ID, strings.NewReader(`{"description":"updated"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("partial update: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchConcurrencyEditRejectsOutOfRangeWithoutMutation(t *testing.T) {
|
||||
m := NewBatchTaskManager(zap.NewNop())
|
||||
q, err := m.CreateBatchQueue("original", "", "eino_single", "manual", "", "", nil, 3, []string{"hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []int{-1, 0, 9, 99} {
|
||||
if err := m.UpdateQueueMetadata(q.ID, "changed", "", "eino_single", &n); err == nil {
|
||||
t.Fatalf("accepted %d", n)
|
||||
}
|
||||
saved, _ := m.GetBatchQueue(q.ID)
|
||||
if saved.Concurrency != 3 || saved.Title != "original" {
|
||||
t.Fatalf("mutated queue: %#v", saved)
|
||||
}
|
||||
}
|
||||
for _, n := range []int{1, 8} {
|
||||
if err := m.UpdateQueueMetadata(q.ID, "original", "", "eino_single", &n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, _ := m.GetBatchQueue(q.ID)
|
||||
if saved.Concurrency != n {
|
||||
t.Fatalf("saved %d, want %d", saved.Concurrency, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,9 +198,12 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if s := strings.TrimSpace(*req.Name); s != "" {
|
||||
p.Name = s
|
||||
s := strings.TrimSpace(*req.Name)
|
||||
if s == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "项目名称不能为空"})
|
||||
return
|
||||
}
|
||||
p.Name = s
|
||||
}
|
||||
if req.Description != nil {
|
||||
p.Description = clampProjectDescription(*req.Description)
|
||||
|
||||
@@ -3619,6 +3619,7 @@
|
||||
"completedAt": "Completed at",
|
||||
"taskTotal": "Total tasks",
|
||||
"concurrency": "Concurrency",
|
||||
"concurrencyInvalid": "Concurrency must be an integer from 1 to 8",
|
||||
"concurrencyEditHint": "Click to edit. Cannot change while the queue is running.",
|
||||
"taskList": "Task list",
|
||||
"startLabel": "Start",
|
||||
|
||||
@@ -3631,6 +3631,7 @@
|
||||
"completedAt": "Завершено",
|
||||
"taskTotal": "Всего задач",
|
||||
"concurrency": "Параллельность",
|
||||
"concurrencyInvalid": "Параллелизм должен быть целым числом от 1 до 8",
|
||||
"concurrencyEditHint": "Нажмите, чтобы изменить. Нельзя менять, пока очередь выполняется.",
|
||||
"taskList": "Список задач",
|
||||
"startLabel": "Старт",
|
||||
|
||||
@@ -3607,6 +3607,7 @@
|
||||
"completedAt": "完成时间",
|
||||
"taskTotal": "任务总数",
|
||||
"concurrency": "并发数",
|
||||
"concurrencyInvalid": "并发数必须为 1–8",
|
||||
"concurrencyEditHint": "点击可修改;队列运行中不可改。",
|
||||
"taskList": "任务列表",
|
||||
"startLabel": "开始",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const taskSource = fs.readFileSync('web/static/js/tasks.js','utf8');
|
||||
function concurrencyHarness(value) {
|
||||
let calls = 0, message, focused = false;
|
||||
const input = {value, focus() { focused = true; }};
|
||||
const ctx = {_bqInlineSaving:false, batchQueuesState:{currentQueueId:'q'},
|
||||
document:{getElementById:() => input}, _t:x => x, alert:x => {message=x;},
|
||||
apiFetch:async () => {calls++;return {ok:true,json:async () => ({queue:{title:'title'}})};},
|
||||
showBatchQueueDetail() {},refreshBatchQueues() {},console};
|
||||
vm.createContext(ctx);
|
||||
const start = taskSource.indexOf('async function saveInlineConcurrency(');
|
||||
vm.runInContext(taskSource.slice(start,taskSource.indexOf('// --- 单条执行 ---',start)),ctx);
|
||||
return {ctx,calls:() => calls,message:() => message,focused:() => focused};
|
||||
}
|
||||
test('invalid concurrency leaves the editor open and makes no API calls', async () => {
|
||||
for (const value of ['0','9','1.5','2abc','']) {
|
||||
const h=concurrencyHarness(value);
|
||||
await h.ctx.saveInlineConcurrency();
|
||||
assert.equal(h.calls(),0);
|
||||
assert.equal(h.message(),'tasks.concurrencyInvalid');
|
||||
assert.equal(h.focused(),true);
|
||||
assert.equal(h.ctx._bqInlineSaving,false);
|
||||
}
|
||||
});
|
||||
test('valid concurrency updates and refreshes normally',async () => {
|
||||
for (const value of ['1','8']) {
|
||||
const h=concurrencyHarness(value);
|
||||
await h.ctx.saveInlineConcurrency();
|
||||
assert.equal(h.calls(),2);
|
||||
assert.equal(h.message(),undefined);
|
||||
}
|
||||
});
|
||||
test('blank project name stops before making a save request',async () => {
|
||||
const source=fs.readFileSync('web/static/js/projects.js','utf8');
|
||||
let calls=0,message;
|
||||
const ctx={currentProjectId:'p',requireProjectWrite:() => true,
|
||||
document:{getElementById:() => ({value:' '})},tp:x => x,alert:x => {message=x;},apiFetch:() => {calls++;}};
|
||||
vm.createContext(ctx);
|
||||
const start=source.indexOf('async function saveProjectSettings(');
|
||||
vm.runInContext(source.slice(start,source.indexOf('function findProjectById(',start)),ctx);
|
||||
await ctx.saveProjectSettings();
|
||||
assert.equal(calls,0);
|
||||
assert.equal(message,'projects.enterProjectName');
|
||||
});
|
||||
test('RBAC creation buttons use the existing permission visibility guard',() => {
|
||||
const html=fs.readFileSync('web/templates/index.html','utf8');
|
||||
const buttons=html.match(/<button\b[^>]*onclick="openRbac(?:Role|User)Modal\(\)"[^>]*>/g);
|
||||
assert.ok(buttons.length>=3);
|
||||
for (const b of buttons) assert.match(b,/data-require-permission="rbac:write"/);
|
||||
const auth=fs.readFileSync('web/static/js/auth.js','utf8');
|
||||
const ctx={permissionAllowedForElement:() => false};
|
||||
vm.createContext(ctx);
|
||||
const start=auth.indexOf('function applyPermissionElement(');
|
||||
vm.runInContext(auth.slice(start,auth.indexOf('let permissionClickGuardInstalled',start)),ctx);
|
||||
const button={disabled:false,hidden:false,getAttribute:key => key==='data-require-permission'?'rbac:write':null,classList:{toggle() {}},setAttribute() {}};
|
||||
ctx.applyPermissionElement(button);
|
||||
assert.equal(button.hidden,true);
|
||||
assert.equal(button.disabled,true);
|
||||
});
|
||||
@@ -2099,6 +2099,8 @@ function insertProjectScopeExample() {
|
||||
|
||||
async function saveProjectSettings() {
|
||||
if (!currentProjectId || !requireProjectWrite()) return;
|
||||
const name = document.getElementById('project-edit-name').value.trim();
|
||||
if (!name) return alert(tp('projects.enterProjectName'));
|
||||
const scopeRaw = document.getElementById('project-edit-scope').value.trim();
|
||||
if (scopeRaw) {
|
||||
try {
|
||||
@@ -2109,7 +2111,7 @@ async function saveProjectSettings() {
|
||||
}
|
||||
}
|
||||
const body = {
|
||||
name: document.getElementById('project-edit-name').value.trim(),
|
||||
name,
|
||||
description: clampProjectDescription(document.getElementById('project-edit-description').value),
|
||||
scope_json: scopeRaw,
|
||||
status: document.getElementById('project-edit-status')?.value || 'active',
|
||||
|
||||
@@ -2733,7 +2733,13 @@ async function saveInlineConcurrency() {
|
||||
const queueId = batchQueuesState.currentQueueId;
|
||||
if (!queueId) { _bqInlineSaving = false; return; }
|
||||
const inp = document.getElementById('bq-edit-concurrency');
|
||||
const concurrency = normalizeBatchQueueConcurrencyInput(inp ? inp.value : 1);
|
||||
const concurrency = inp && inp.value.trim() !== '' ? Number(inp.value) : NaN;
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 8) {
|
||||
_bqInlineSaving = false;
|
||||
alert(_t('tasks.concurrencyInvalid'));
|
||||
if (inp) inp.focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
const detail = await detailResp.json();
|
||||
|
||||
@@ -5185,8 +5185,8 @@
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary" type="button" onclick="refreshPlatformRbac(this)" data-i18n="common.refresh">刷新</button>
|
||||
<button class="btn-secondary" type="button" onclick="openRbacRoleModal()" data-i18n="rbac.createRole">新建角色</button>
|
||||
<button class="btn-primary" type="button" onclick="openRbacUserModal()" data-i18n="rbac.createUser">添加成员</button>
|
||||
<button class="btn-secondary" type="button" onclick="openRbacRoleModal()" data-require-permission="rbac:write" data-i18n="rbac.createRole">新建角色</button>
|
||||
<button class="btn-primary" type="button" onclick="openRbacUserModal()" data-require-permission="rbac:write" data-i18n="rbac.createUser">添加成员</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
@@ -5411,7 +5411,7 @@
|
||||
<label for="rbac-role-search" data-i18n="rbac.filterRoles">筛选角色</label>
|
||||
<input id="rbac-role-search" class="form-input" type="search" placeholder="搜索角色或权限" data-i18n="rbac.searchRoles" data-i18n-attr="placeholder" oninput="setRbacRoleSearch(this.value)">
|
||||
</div>
|
||||
<button class="btn-primary btn-small" type="button" onclick="openRbacRoleModal()" data-i18n="rbac.createRole">新建角色</button>
|
||||
<button class="btn-primary btn-small" type="button" onclick="openRbacRoleModal()" data-require-permission="rbac:write" data-i18n="rbac.createRole">新建角色</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rbac-role-catalog" class="rbac-role-catalog"></div>
|
||||
|
||||
Reference in new issue
Block a user