diff --git a/backend/internal/httpapi/password_reset_account.go b/backend/internal/httpapi/password_reset_account.go index 53127b1..125bb36 100644 --- a/backend/internal/httpapi/password_reset_account.go +++ b/backend/internal/httpapi/password_reset_account.go @@ -5,7 +5,6 @@ import ( "github.com/gin-gonic/gin" - "git.pcux.cn/xuans/Scert/backend/internal/admin" "git.pcux.cn/xuans/Scert/backend/internal/auth" ) @@ -21,7 +20,6 @@ type passwordResetResolution struct { Title string `json:"title"` Description string `json:"description"` Action string `json:"action"` - ResetPath string `json:"reset_path,omitempty"` } // resolvePasswordResetAccount 根据账户标识返回对应的密码处理内容。 @@ -52,8 +50,7 @@ func (s *Server) resolvePasswordResetAccount(c *gin.Context) { AccountLabel: "超级管理员", Title: "超级管理员密码重置", Description: "已识别为超级管理员账户。超级管理员不能通过普通重置页面修改密码,请使用 Scert-admin 开启的一次性重置入口。", - Action: "use_super_admin_entry", - ResetPath: admin.ResetPath, + Action: "local_admin_command", }) return } diff --git a/backend/internal/httpapi/password_reset_local_test.go b/backend/internal/httpapi/password_reset_local_test.go new file mode 100644 index 0000000..d5d274b --- /dev/null +++ b/backend/internal/httpapi/password_reset_local_test.go @@ -0,0 +1,46 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPasswordResetResolutionOnlyOffersLocalAdminCommand(t *testing.T) { + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "owner" + cfg.Admin.Email = "owner@example.test" + server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}}) + for _, identifier := range []string{"owner", "owner@example.test"} { + t.Run(identifier, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"identifier": identifier}) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/password-reset/resolve", strings.NewReader(string(body))) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("查询失败:%d %s", response.Code, response.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["account_type"] != "super_admin" || payload["action"] != "local_admin_command" { + t.Fatalf("超级管理员没有返回本机命令指引:%v", payload) + } + for _, field := range []string{"reset_path", "reset_url", "token", "nonce"} { + if _, exists := payload[field]; exists { + t.Fatalf("公开查询泄露了重置入口或会话字段:%s", field) + } + } + if len(response.Result().Cookies()) != 0 { + t.Fatal("公开查询不应创建重置会话") + } + }) + } +} diff --git a/frontend/package.json b/frontend/package.json index 23ff220..f79713f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "test": "node --test src/App.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js", + "test": "node --test src/App.test.js src/views/PasswordReset/PasswordReset.test.js src/views/SetupWizard/SetupWizard.test.js src/views/SetupWizard/SetupWizardBootstrap.test.js src/views/SetupWizard/DatabaseSetup.test.js src/views/SetupWizard/SetupWizardLayout.test.js src/views/SetupWizard/BootstrapPayload.test.js src/views/Dashboard/Dashboard.test.js src/views/UserManagement/UserManagement.test.js src/views/components/HelpTip/HelpTip.test.js", "dev": "vite", "build": "vite build", "preview": "vite preview" diff --git a/frontend/src/views/PasswordReset/PasswordReset.test.js b/frontend/src/views/PasswordReset/PasswordReset.test.js new file mode 100644 index 0000000..8cf1069 --- /dev/null +++ b/frontend/src/views/PasswordReset/PasswordReset.test.js @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { baseParse } from "@vue/compiler-dom"; +import { parse } from "@vue/compiler-sfc"; +import { usePasswordReset } from "./PasswordReset.js"; +import { mountComposable } from "../../test-support/mount.js"; + +function template(path) { + const source = readFileSync(new URL(path, import.meta.url), "utf8"); + return baseParse(parse(source).descriptor.template.content); +} + +function elements(node, predicate, matches = []) { + if (predicate(node)) matches.push(node); + for (const child of node.children || []) elements(child, predicate, matches); + return matches; +} + +test("超级管理员查询保留本机处理标识,不生成网页重置链接", async (t) => { + const payload = { + account_type: "super_admin", + action: "local_admin_command", + title: "超级管理员密码重置", + description: "请在部署主机操作。", + }; + const { state } = await mountComposable(t, usePasswordReset, async (url, options) => { + assert.equal(url, "/api/v1/password-reset/resolve"); + assert.equal(options.credentials, "same-origin"); + assert.deepEqual(JSON.parse(options.body), { identifier: "owner" }); + return Response.json(payload); + }); + state.identifier.value = " owner "; + await state.submit(); + assert.equal(state.resolution.value.action, "local_admin_command"); + assert.equal("reset_path" in state.resolution.value, false); + assert.equal(state.error.value, ""); + assert.equal(state.submitting.value, false); + state.reset(); + assert.equal(state.resolution.value, null); +}); + +test("本机命令指引仅对接口明确标记的处理方式显示", () => { + const root = template("./PasswordReset.vue"); + const guides = elements(root, (node) => node.tag === "LocalResetGuide"); + assert.equal(guides.length, 1); + assert.equal(guides[0].props.find((prop) => prop.name === "if").exp.content, + "resolution.action === 'local_admin_command'"); + const message = elements(root, (node) => node.tag === "p" + && node.props.some((prop) => prop.name === "if" + && prop.exp?.content === "message && !resolution")); + assert.equal(message.length, 1); +}); + +test("本机指引只显示固定命令,解释放入悬浮提示", () => { + const root = template("../components/LocalResetGuide/LocalResetGuide.vue"); + assert.equal(elements(root, (node) => ["a", "input", "form"].includes(node.tag)).length, 0); + assert.equal(elements(root, (node) => node.tag === "HelpTip").length, 1); + const commands = elements(root, (node) => node.tag === "code") + .map((node) => node.children.filter((child) => child.type === 2) + .map((child) => child.content).join("")); + assert.deepEqual(commands, ["systemctl start Scert-admin", ".\\scert-admin.exe"]); +}); \ No newline at end of file diff --git a/frontend/src/views/PasswordReset/PasswordReset.vue b/frontend/src/views/PasswordReset/PasswordReset.vue index 7294954..5c045f2 100644 --- a/frontend/src/views/PasswordReset/PasswordReset.vue +++ b/frontend/src/views/PasswordReset/PasswordReset.vue @@ -1,5 +1,6 @@ + + + + + 本机管理命令 + + 请由部署管理员在 Scert 所在主机执行命令。Linux 使用 root 终端; + Windows 使用提升权限的控制台,在安装目录运行。 + 验证码和新密码仅在本机终端输入,本页面不会开启重置会话。 + + + + + Linux + systemctl start Scert-admin + + + Windows + .\scert-admin.exe + + + + + + \ No newline at end of file
systemctl start Scert-admin
.\scert-admin.exe