fix: 端启停改为仅由 identity 执行
This commit is contained in:
@@ -899,6 +899,15 @@
|
|||||||
- 备选方案:登录 4 KiB(与注册一致);未采用,8 KiB 对口令字段更宽裕。
|
- 备选方案:登录 4 KiB(与注册一致);未采用,8 KiB 对口令字段更宽裕。
|
||||||
- 影响:超大请求快速失败;合法批量 JSON(约 200 KiB)仍低于 1 MiB。
|
- 影响:超大请求快速失败;合法批量 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
|
## 后台网页 W
|
||||||
|
|
||||||
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
1. **W1–W3 阶段使用内存假数据,不请求真实 `/api/admin`**
|
||||||
|
|||||||
@@ -350,8 +350,13 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
hasMeta := req.Name != nil || req.Remark != nil || req.DefaultDelaySeconds != nil
|
hasMeta := req.Name != nil || req.Remark != nil || req.DefaultDelaySeconds != nil
|
||||||
var wasEnabled bool
|
var wasEnabled bool
|
||||||
var err error
|
var err error
|
||||||
|
// 注入 Identity 时启停只走 identity,避免先写 enabled 再级联失败造成半生效。
|
||||||
|
patchEnabled := req.Enabled
|
||||||
|
if h.identity != nil {
|
||||||
|
patchEnabled = nil
|
||||||
|
}
|
||||||
if hasMeta || (req.Enabled != nil && h.identity == 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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
h.audit(actorString(p), "endpoint_patch", id, "not_found", ip)
|
h.audit(actorString(p), "endpoint_patch", id, "not_found", ip)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user