From ac08b6c0717b468f7a1aa1c2c728863c25dc6f5e Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 14:56:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AB=AF=E5=90=AF=E5=81=9C=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=85=E7=94=B1=20identity=20=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 9 +++ internal/admin/endpoints.go | 7 +- internal/admin/h03_test.go | 146 ++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 internal/admin/h03_test.go diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index b6a0f9e..1a77095 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -899,6 +899,15 @@ - 备选方案:登录 4 KiB(与注册一致);未采用,8 KiB 对口令字段更宽裕。 - 影响:超大请求快速失败;合法批量 JSON(约 200 KiB)仍低于 1 MiB。 +### 复审修复 H-03 + +- 日期:2026-09-30 +- 原条款:PRD F01 停用作废不可恢复;审查 #46。 +- 实际做法:注入 Identity 时 `handleEndpointPatch` 给 `patchEndpoint` 传 nil 的 enabled,启停只由 `identity.Disable`/`Enable` 执行。界面部分在 W-05。 +- 原因:先写 enabled 再级联失败会留下半生效状态。 +- 备选方案:同一事务里写 enabled 与作废;Identity 接口当前不暴露事务。 +- 影响:Identity 失败时端仍保持原启用状态。 + ## 后台网页 W 1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`** diff --git a/internal/admin/endpoints.go b/internal/admin/endpoints.go index d6ef5bc..93c433e 100644 --- a/internal/admin/endpoints.go +++ b/internal/admin/endpoints.go @@ -350,8 +350,13 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) { hasMeta := req.Name != nil || req.Remark != nil || req.DefaultDelaySeconds != nil var wasEnabled bool var err error + // 注入 Identity 时启停只走 identity,避免先写 enabled 再级联失败造成半生效。 + patchEnabled := req.Enabled + if h.identity != nil { + patchEnabled = nil + } if hasMeta || (req.Enabled != nil && h.identity == nil) { - wasEnabled, err = h.patchEndpoint(r.Context(), id, req.Name, req.Remark, req.DefaultDelaySeconds, req.Enabled) + wasEnabled, err = h.patchEndpoint(r.Context(), id, req.Name, req.Remark, req.DefaultDelaySeconds, patchEnabled) if err != nil { if errors.Is(err, sql.ErrNoRows) { h.audit(actorString(p), "endpoint_patch", id, "not_found", ip) diff --git a/internal/admin/h03_test.go b/internal/admin/h03_test.go new file mode 100644 index 0000000..d4ebd92 --- /dev/null +++ b/internal/admin/h03_test.go @@ -0,0 +1,146 @@ +package admin_test + +import ( + "context" + "errors" + "net/http" + "sync" + "testing" + + "git.asio.asia/nixevol/NixMsg/internal/admin" + "git.asio.asia/nixevol/NixMsg/internal/app/identity" + "git.asio.asia/nixevol/NixMsg/internal/auth" + "git.asio.asia/nixevol/NixMsg/internal/store" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" +) + +type failDisableIdentity struct { + identity.Stub + err error +} + +func (f *failDisableIdentity) Disable(context.Context, string) error { return f.err } +func (f *failDisableIdentity) Enable(context.Context, string) error { return nil } + +type trackIdentity struct { + identity.Stub + mu sync.Mutex + disableN int + enableN int +} + +func (t *trackIdentity) Disable(context.Context, string) error { + t.mu.Lock() + defer t.mu.Unlock() + t.disableN++ + return nil +} + +func (t *trackIdentity) Enable(context.Context, string) error { + t.mu.Lock() + defer t.mu.Unlock() + t.enableN++ + return nil +} + +func setupEndpointsWithIdentity(t *testing.T, ident identity.Service) (*store.DB, *httptest.Server, *http.Client) { + t.Helper() + dir := t.TempDir() + db, err := store.Open(filepath.Join(dir, "data"), "FULL") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + hash := auth.NewStubHashPool() + if seedErr := admin.SeedAdminPassword(context.Background(), db, hash, testPassword); seedErr != nil { + t.Fatal(seedErr) + } + h := admin.New(admin.Deps{ + DB: db, + Hash: hash, + Tokens: admin.NewRandomAPITokens(), + Locks: admin.NewMemoryLoginLocks(), + Identity: ident, + }) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + res := postJSON(t, client, srv.URL+"/api/admin/login", + `{"username":"admin","password":"`+testPassword+`"}`, nil) + env := decodeEnv(t, res) + if res.StatusCode != http.StatusOK || !env.OK { + t.Fatalf("login failed: %d %+v", res.StatusCode, env) + } + return db, srv, client +} + +func TestPatchEnabledFalseKeepsEnabledWhenIdentityFails(t *testing.T) { + ident := &failDisableIdentity{err: errors.New("disable failed")} + db, srv, client := setupEndpointsWithIdentity(t, ident) + base := srv.URL + + res := postJSON(t, client, base+"/api/admin/endpoints", + `{"id":"keep-on","name":"仍启用","login_password":"password1"}`, + csrfHeaders()) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("create: %d %+v", res.StatusCode, env) + } + + res = doReq(t, client, http.MethodPatch, base+"/api/admin/endpoints/keep-on", + `{"enabled":false}`, csrfHeaders()) + env = decodeEnv(t, res) + if res.StatusCode != http.StatusInternalServerError { + t.Fatalf("want 500 got %d %+v", res.StatusCode, env) + } + var enabled int + if err := db.Read.QueryRow(`SELECT enabled FROM endpoints WHERE id='keep-on'`).Scan(&enabled); err != nil { + t.Fatal(err) + } + if enabled != 1 { + t.Fatalf("want still enabled, got %d", enabled) + } +} + +func TestPatchNameOnlyDoesNotTouchEnabled(t *testing.T) { + ident := &trackIdentity{} + db, srv, client := setupEndpointsWithIdentity(t, ident) + base := srv.URL + + res := postJSON(t, client, base+"/api/admin/endpoints", + `{"id":"name-only","name":"旧名","login_password":"password1"}`, + csrfHeaders()) + env := decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("create: %d %+v", res.StatusCode, env) + } + + res = doReq(t, client, http.MethodPatch, base+"/api/admin/endpoints/name-only", + `{"name":"新名"}`, csrfHeaders()) + env = decodeEnv(t, res) + if res.StatusCode != 200 || !env.OK { + t.Fatalf("patch: %d %+v", res.StatusCode, env) + } + ident.mu.Lock() + d, e := ident.disableN, ident.enableN + ident.mu.Unlock() + if d != 0 || e != 0 { + t.Fatalf("identity enable/disable should not run, disable=%d enable=%d", d, e) + } + var enabled int + var name string + if err := db.Read.QueryRow(`SELECT enabled, name FROM endpoints WHERE id='name-only'`).Scan(&enabled, &name); err != nil { + t.Fatal(err) + } + if enabled != 1 || name != "新名" { + t.Fatalf("enabled=%d name=%q", enabled, name) + } +}