mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 18:39:48 +08:00
🐛 fix(jvm): 绑定前端变更执行到预览上下文
将 JVM 资源变更执行绑定到最近一次成功预览和连接配置指纹,并遮蔽敏感快照、payload 示例和 AI 上下文中的敏感值。
This commit is contained in:
41
frontend/package-lock.json
generated
41
frontend/package-lock.json
generated
@@ -33,8 +33,10 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
@@ -2037,6 +2039,16 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "18.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz",
|
||||
"integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -5645,6 +5657,20 @@
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-shallow-renderer": {
|
||||
"version": "16.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
|
||||
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
@@ -5665,6 +5691,21 @@
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz",
|
||||
"integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^18.2.0",
|
||||
"react-shallow-renderer": "^16.15.0",
|
||||
"scheduler": "^0.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal file
563
frontend/src/components/JVMResourceBrowser.interaction.test.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMResourceBrowser from "./JVMResourceBrowser";
|
||||
import type { JVMValueSnapshot } from "../types";
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-writable",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
user: "jmx-user",
|
||||
port: 9010,
|
||||
type: "jvm",
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
readOnly: false,
|
||||
jmx: {
|
||||
password: "initial-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
addTab: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
theme: "light",
|
||||
}));
|
||||
|
||||
const backendApp = vi.hoisted(() => ({
|
||||
JVMGetValue: vi.fn(),
|
||||
JVMPreviewChange: vi.fn(),
|
||||
JVMApplyChange: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@monaco-editor/react", () => ({
|
||||
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
|
||||
}));
|
||||
|
||||
vi.mock("@ant-design/icons", () => ({
|
||||
FileSearchOutlined: () => <span />,
|
||||
ReloadOutlined: () => <span />,
|
||||
RobotOutlined: () => <span />,
|
||||
}));
|
||||
|
||||
vi.mock("antd", () => {
|
||||
const Text = ({ children }: any) => <span>{children}</span>;
|
||||
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-button-type={type}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ children, title }: any) => (
|
||||
<section>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
|
||||
Descriptions.Item = ({ children, label }: any) => (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{children}</dd>
|
||||
</div>
|
||||
);
|
||||
const Input: any = ({ value, onChange, placeholder }: any) => (
|
||||
<input value={value} onChange={onChange} placeholder={placeholder} />
|
||||
);
|
||||
Input.TextArea = ({ value, onChange }: any) => (
|
||||
<textarea value={value} onChange={onChange} />
|
||||
);
|
||||
|
||||
return {
|
||||
Alert: ({ message }: any) => <div role="alert">{message}</div>,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty: ({ description }: any) => <div>{description}</div>,
|
||||
Input,
|
||||
Skeleton: () => <div>loading</div>,
|
||||
Space: ({ children }: any) => <div>{children}</div>,
|
||||
Tag: ({ children }: any) => <span>{children}</span>,
|
||||
Typography: { Text },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../store", () => {
|
||||
const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
|
||||
useStore.getState = () => storeState;
|
||||
return { useStore };
|
||||
});
|
||||
|
||||
vi.mock("./jvm/JVMModeBadge", () => ({
|
||||
default: ({ mode }: { mode: string }) => <span>{mode}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
|
||||
getJVMWorkspaceCardStyle: () => ({}),
|
||||
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
{description}
|
||||
{badges}
|
||||
{actions}
|
||||
</header>
|
||||
),
|
||||
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
|
||||
}));
|
||||
|
||||
vi.mock("./jvm/JVMChangePreviewModal", () => ({
|
||||
default: ({ open, onConfirm }: any) =>
|
||||
open ? <button type="button" onClick={onConfirm}>确认执行</button> : null,
|
||||
}));
|
||||
|
||||
const writableTab = {
|
||||
id: "tab-jvm-resource",
|
||||
type: "jvm-resource",
|
||||
title: "[orders-jvm] JVM 资源",
|
||||
connectionId: "conn-jvm-writable",
|
||||
providerMode: "jmx",
|
||||
resourcePath: "jmx:/attribute/app/Mode",
|
||||
resourceKind: "attribute",
|
||||
} as any;
|
||||
|
||||
const textContent = (node: any): string =>
|
||||
(node.children || [])
|
||||
.map((item: any) => (typeof item === "string" ? item : textContent(item)))
|
||||
.join("");
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, text: string) =>
|
||||
renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
|
||||
|
||||
const waitForEffects = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe("JVMResourceBrowser interactions", () => {
|
||||
beforeEach(() => {
|
||||
storeState.connections = [
|
||||
{
|
||||
id: "conn-jvm-writable",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "127.0.0.1",
|
||||
user: "jmx-user",
|
||||
port: 9010,
|
||||
type: "jvm",
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
readOnly: false,
|
||||
jmx: {
|
||||
password: "initial-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot: JVMValueSnapshot = {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "cold",
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "warm" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
|
||||
backendApp.JVMPreviewChange.mockResolvedValue({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "设置 Mode",
|
||||
riskLevel: "high",
|
||||
before: snapshot,
|
||||
after: { ...snapshot, value: "warm", version: "v2" },
|
||||
});
|
||||
backendApp.JVMApplyChange.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
status: "applied",
|
||||
updatedValue: { ...snapshot, value: "warm", version: "v2" },
|
||||
},
|
||||
});
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
go: {
|
||||
app: {
|
||||
App: backendApp,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
backendApp.JVMGetValue.mockReset();
|
||||
backendApp.JVMPreviewChange.mockReset();
|
||||
backendApp.JVMApplyChange.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("applies the latest successful preview request even when the draft is edited afterward", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
});
|
||||
|
||||
const payloadEditor = () => renderer!.root.findByType("textarea");
|
||||
await act(async () => {
|
||||
payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
|
||||
expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
|
||||
backendApp.JVMPreviewChange.mock.calls[0][0],
|
||||
);
|
||||
expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
|
||||
action: "set",
|
||||
confirmationToken: "token-from-preview",
|
||||
payload: { value: "previewed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale snapshot resource id override the current resource preview", async () => {
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "cold",
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "warm" },
|
||||
},
|
||||
],
|
||||
} as JVMValueSnapshot,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
|
||||
resourceId: "jmx:/attribute/app/OtherMode",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale preview responses after the resource context changes", async () => {
|
||||
let resolvePreview: (value: any) => void = () => {};
|
||||
backendApp.JVMPreviewChange.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
resolvePreview({
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "stale-token",
|
||||
summary: "旧预览",
|
||||
riskLevel: "high",
|
||||
before: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "cold",
|
||||
},
|
||||
after: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "warm",
|
||||
},
|
||||
});
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(findButton(renderer!, "确认执行")).toBeUndefined();
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after the resource context changes", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/OtherMode",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
findButton(renderer!, "确认执行").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after the connection config changes", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
...storeState.connections[0],
|
||||
config: {
|
||||
...storeState.connections[0].config,
|
||||
jvm: {
|
||||
...storeState.connections[0].config.jvm,
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
|
||||
const confirmButton = findButton(renderer!, "确认执行");
|
||||
if (confirmButton) {
|
||||
await act(async () => {
|
||||
confirmButton.props.onClick();
|
||||
});
|
||||
}
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects confirming a preview after JVM credentials change", async () => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const reasonInput = renderer!.root
|
||||
.findAllByType("input")
|
||||
.find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
|
||||
await act(async () => {
|
||||
reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
|
||||
renderer!.root.findByType("textarea").props.onChange({
|
||||
target: { value: '{"value":"previewed"}' },
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, "预览变更").props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
storeState.connections = [
|
||||
{
|
||||
...storeState.connections[0],
|
||||
config: {
|
||||
...storeState.connections[0].config,
|
||||
jvm: {
|
||||
...storeState.connections[0].config.jvm,
|
||||
jmx: {
|
||||
...storeState.connections[0].config.jvm.jmx,
|
||||
password: "rotated-jmx-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(<JVMResourceBrowser tab={writableTab} />);
|
||||
});
|
||||
|
||||
const confirmButton = findButton(renderer!, "确认执行");
|
||||
if (confirmButton) {
|
||||
await act(async () => {
|
||||
confirmButton.props.onClick();
|
||||
});
|
||||
}
|
||||
await waitForEffects();
|
||||
|
||||
expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not seed sensitive payload examples into the draft editor", async () => {
|
||||
backendApp.JVMGetValue.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
resourceId: "jmx:/attribute/app/Password",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
version: "v1",
|
||||
value: "secret-token",
|
||||
sensitive: true,
|
||||
supportedActions: [
|
||||
{
|
||||
action: "set",
|
||||
label: "设置属性",
|
||||
payloadExample: { value: "secret-token" },
|
||||
},
|
||||
],
|
||||
} as JVMValueSnapshot,
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<JVMResourceBrowser
|
||||
tab={{
|
||||
...writableTab,
|
||||
resourcePath: "jmx:/attribute/app/Password",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -38,9 +38,14 @@ import {
|
||||
type JVMAIChangePlan,
|
||||
} from "../utils/jvmAiPlan";
|
||||
import {
|
||||
buildJVMActionPayloadTemplate,
|
||||
buildJVMPreviewApplyRequest,
|
||||
estimateJVMResourceEditorHeight,
|
||||
formatJVMActionDisplayText,
|
||||
formatJVMActionSummary,
|
||||
formatJVMMetadataForDisplay,
|
||||
formatJVMValueForDisplay,
|
||||
JVM_DEFAULT_PAYLOAD_TEMPLATE,
|
||||
resolveJVMActionDisplay,
|
||||
resolveJVMValueEditorLanguage,
|
||||
} from "../utils/jvmResourcePresentation";
|
||||
@@ -56,7 +61,7 @@ import {
|
||||
const { Text } = Typography;
|
||||
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||
const { TextArea } = Input;
|
||||
const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
|
||||
const DEFAULT_PAYLOAD_TEXT = JVM_DEFAULT_PAYLOAD_TEMPLATE;
|
||||
|
||||
type JVMResourceBrowserProps = {
|
||||
tab: TabData;
|
||||
@@ -76,6 +81,66 @@ const buildJVMRuntimeConfig = (
|
||||
});
|
||||
};
|
||||
|
||||
const buildJVMPreviewConfigRevision = (value: unknown): string => {
|
||||
let text = "";
|
||||
try {
|
||||
text = JSON.stringify(value ?? null);
|
||||
} catch {
|
||||
return "unserializable";
|
||||
}
|
||||
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
hash ^= text.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
};
|
||||
|
||||
const buildJVMPreviewRuntimeFingerprint = (
|
||||
connection: SavedConnection | undefined,
|
||||
providerMode: string,
|
||||
): string => {
|
||||
const config = connection?.config;
|
||||
const jvm = config?.jvm || {};
|
||||
return JSON.stringify({
|
||||
configRevision: buildJVMPreviewConfigRevision(config),
|
||||
type: config?.type || "",
|
||||
host: config?.host || "",
|
||||
port: config?.port || 0,
|
||||
user: config?.user || "",
|
||||
providerMode,
|
||||
environment: jvm.environment || "",
|
||||
readOnly: jvm.readOnly !== false,
|
||||
allowedModes: jvm.allowedModes || [],
|
||||
preferredMode: jvm.preferredMode || "",
|
||||
jmx: {
|
||||
enabled: jvm.jmx?.enabled || false,
|
||||
host: jvm.jmx?.host || "",
|
||||
port: jvm.jmx?.port || 0,
|
||||
username: jvm.jmx?.username || "",
|
||||
domainAllowlist: jvm.jmx?.domainAllowlist || [],
|
||||
},
|
||||
endpoint: {
|
||||
enabled: jvm.endpoint?.enabled || false,
|
||||
baseUrl: jvm.endpoint?.baseUrl || "",
|
||||
timeoutSeconds: jvm.endpoint?.timeoutSeconds || 0,
|
||||
},
|
||||
agent: {
|
||||
enabled: jvm.agent?.enabled || false,
|
||||
baseUrl: jvm.agent?.baseUrl || "",
|
||||
timeoutSeconds: jvm.agent?.timeoutSeconds || 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const buildJVMPreviewContextKey = (
|
||||
connectionId: string,
|
||||
mode: string,
|
||||
path: string,
|
||||
runtimeFingerprint: string,
|
||||
): string => `${connectionId}::${mode}::${path}::${runtimeFingerprint}`;
|
||||
|
||||
const snapshotBlockStyle = (background: string): React.CSSProperties => ({
|
||||
margin: 0,
|
||||
borderRadius: 8,
|
||||
@@ -83,17 +148,6 @@ const snapshotBlockStyle = (background: string): React.CSSProperties => ({
|
||||
overflow: "auto",
|
||||
});
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
|
||||
try {
|
||||
return JSON.stringify(draft.payload ?? {}, null, 2);
|
||||
@@ -102,19 +156,6 @@ const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildActionPayloadTemplate = (
|
||||
definition?: JVMActionDefinition | null,
|
||||
): string => {
|
||||
if (definition?.payloadExample) {
|
||||
try {
|
||||
return JSON.stringify(definition.payloadExample, null, 2);
|
||||
} catch {
|
||||
return DEFAULT_PAYLOAD_TEXT;
|
||||
}
|
||||
}
|
||||
return DEFAULT_PAYLOAD_TEXT;
|
||||
};
|
||||
|
||||
const resolveDefaultAction = (
|
||||
actions: JVMActionDefinition[] | undefined,
|
||||
providerMode: "jmx" | "endpoint" | "agent",
|
||||
@@ -164,6 +205,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
"jmx") as "jmx" | "endpoint" | "agent";
|
||||
const resourcePath = String(tab.resourcePath || "").trim();
|
||||
const readOnly = connection?.config.jvm?.readOnly !== false;
|
||||
const runtimeFingerprint = useMemo(
|
||||
() => buildJVMPreviewRuntimeFingerprint(connection, providerMode),
|
||||
[connection, providerMode],
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -181,24 +226,50 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(
|
||||
null,
|
||||
);
|
||||
const [previewRequest, setPreviewRequest] = useState<JVMChangeRequest | null>(
|
||||
null,
|
||||
);
|
||||
const [previewRuntimeConfig, setPreviewRuntimeConfig] = useState<any | null>(
|
||||
null,
|
||||
);
|
||||
const [previewContextKey, setPreviewContextKey] = useState("");
|
||||
const [applyLoading, setApplyLoading] = useState(false);
|
||||
const previewSequenceRef = useRef(0);
|
||||
const currentPreviewContextKey = buildJVMPreviewContextKey(
|
||||
tab.connectionId,
|
||||
providerMode,
|
||||
resourcePath,
|
||||
runtimeFingerprint,
|
||||
);
|
||||
const previewContextKeyRef = useRef(currentPreviewContextKey);
|
||||
previewContextKeyRef.current = currentPreviewContextKey;
|
||||
|
||||
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
|
||||
const clearPreviewState = () => {
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
setPreviewRequest(null);
|
||||
setPreviewRuntimeConfig(null);
|
||||
setPreviewContextKey("");
|
||||
};
|
||||
|
||||
const displayValue = useMemo(() => formatJVMValueForDisplay(snapshot), [snapshot]);
|
||||
const displayLanguage = useMemo(
|
||||
() =>
|
||||
resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
|
||||
[snapshot?.format, snapshot?.value],
|
||||
snapshot?.sensitive
|
||||
? "plaintext"
|
||||
: resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
|
||||
[snapshot?.format, snapshot?.sensitive, snapshot?.value],
|
||||
);
|
||||
const metadataText = useMemo(
|
||||
() =>
|
||||
snapshot?.metadata && Object.keys(snapshot.metadata).length > 0
|
||||
? JSON.stringify(snapshot.metadata, null, 2)
|
||||
: "",
|
||||
[snapshot?.metadata],
|
||||
() => formatJVMMetadataForDisplay(snapshot),
|
||||
[snapshot],
|
||||
);
|
||||
const metadataLanguage = useMemo(
|
||||
() => resolveJVMValueEditorLanguage("json", snapshot?.metadata),
|
||||
[snapshot?.metadata],
|
||||
() =>
|
||||
snapshot?.sensitive
|
||||
? "plaintext"
|
||||
: resolveJVMValueEditorLanguage("json", snapshot?.metadata),
|
||||
[snapshot?.metadata, snapshot?.sensitive],
|
||||
);
|
||||
const supportedActions = useMemo(() => {
|
||||
if (!Array.isArray(snapshot?.supportedActions)) {
|
||||
@@ -218,6 +289,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
);
|
||||
|
||||
const loadSnapshot = async () => {
|
||||
const loadContextKey = currentPreviewContextKey;
|
||||
if (!connection) {
|
||||
setLoading(false);
|
||||
setSnapshot(null);
|
||||
@@ -247,6 +319,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
buildJVMRuntimeConfig(connection, providerMode),
|
||||
resourcePath,
|
||||
);
|
||||
if (loadContextKey !== previewContextKeyRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result?.success) {
|
||||
setSnapshot(null);
|
||||
setError(String(result?.message || "读取 JVM 资源失败"));
|
||||
@@ -263,9 +338,10 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
|
||||
useEffect(() => {
|
||||
void loadSnapshot();
|
||||
}, [connection, providerMode, resourcePath, tab.connectionId]);
|
||||
}, [connection, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot(null);
|
||||
setAction("");
|
||||
setReason("");
|
||||
setPayloadText(DEFAULT_PAYLOAD_TEXT);
|
||||
@@ -273,9 +349,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setDraftResourceId("");
|
||||
setDraftError("");
|
||||
setApplyMessage("");
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
}, [providerMode, resourcePath, tab.connectionId]);
|
||||
previewSequenceRef.current += 1;
|
||||
clearPreviewState();
|
||||
}, [currentPreviewContextKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (action.trim()) {
|
||||
@@ -290,7 +366,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
String(payloadText || "").trim() === "" ||
|
||||
payloadText === DEFAULT_PAYLOAD_TEXT
|
||||
) {
|
||||
setPayloadText(buildActionPayloadTemplate(nextDefinition));
|
||||
setPayloadText(buildJVMActionPayloadTemplate(nextDefinition, snapshot?.sensitive));
|
||||
}
|
||||
}, [action, payloadText, providerMode, supportedActions]);
|
||||
|
||||
@@ -328,8 +404,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
|
||||
);
|
||||
setApplyMessage("");
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
clearPreviewState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,8 +413,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
|
||||
);
|
||||
setApplyMessage("");
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
clearPreviewState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,8 +423,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
|
||||
setApplyMessage("");
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
clearPreviewState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,8 +436,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setApplyMessage(
|
||||
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
|
||||
);
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
clearPreviewState();
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
@@ -393,7 +465,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
currentPayload === "{}" ||
|
||||
payloadText === DEFAULT_PAYLOAD_TEXT
|
||||
) {
|
||||
setPayloadText(buildActionPayloadTemplate(definition));
|
||||
setPayloadText(buildJVMActionPayloadTemplate(definition, snapshot?.sensitive));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -414,9 +486,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
payload = parsed as Record<string, any>;
|
||||
}
|
||||
|
||||
const resourceId = String(
|
||||
draftResourceId || snapshot?.resourceId || resourcePath,
|
||||
).trim();
|
||||
const resourceId = String(draftResourceId || resourcePath).trim();
|
||||
if (!resourceId) {
|
||||
throw new Error("资源 ID 为空,无法生成变更草稿");
|
||||
}
|
||||
@@ -497,34 +567,45 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const previewSequence = ++previewSequenceRef.current;
|
||||
const previewContextKey = currentPreviewContextKey;
|
||||
const runtimeConfig = buildJVMRuntimeConfig(connection, providerMode);
|
||||
|
||||
setPreviewLoading(true);
|
||||
setDraftError("");
|
||||
setApplyMessage("");
|
||||
try {
|
||||
const result = await backendApp.JVMPreviewChange(
|
||||
buildJVMRuntimeConfig(connection, providerMode),
|
||||
runtimeConfig,
|
||||
draftPlan,
|
||||
);
|
||||
if (
|
||||
previewSequence !== previewSequenceRef.current ||
|
||||
previewContextKey !== previewContextKeyRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.success === false) {
|
||||
setPreviewResult(null);
|
||||
setPreviewOpen(false);
|
||||
clearPreviewState();
|
||||
setDraftError(String(result?.message || "预览 JVM 变更失败"));
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = normalizePreviewResult(result);
|
||||
if (!preview) {
|
||||
setPreviewResult(null);
|
||||
setPreviewOpen(false);
|
||||
clearPreviewState();
|
||||
setDraftError("预览结果格式不正确");
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewResult(preview);
|
||||
setPreviewRequest(draftPlan);
|
||||
setPreviewRuntimeConfig(runtimeConfig);
|
||||
setPreviewContextKey(previewContextKey);
|
||||
setPreviewOpen(true);
|
||||
} catch (err: any) {
|
||||
setPreviewResult(null);
|
||||
setPreviewOpen(false);
|
||||
clearPreviewState();
|
||||
setDraftError(err?.message || "预览 JVM 变更失败");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
@@ -532,6 +613,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
await Promise.resolve();
|
||||
|
||||
if (!connection) {
|
||||
setDraftError("连接不存在或已被删除");
|
||||
return;
|
||||
@@ -543,11 +626,21 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let draftPlan: JVMChangeRequest;
|
||||
if (!previewResult || !previewRequest || !previewRuntimeConfig) {
|
||||
setDraftError("请先预览变更,再确认执行");
|
||||
return;
|
||||
}
|
||||
if (previewContextKey !== previewContextKeyRef.current) {
|
||||
clearPreviewState();
|
||||
setDraftError("资源上下文已变化,请重新预览后再执行");
|
||||
return;
|
||||
}
|
||||
|
||||
let applyRequest: JVMChangeRequest;
|
||||
try {
|
||||
draftPlan = buildDraftPlan();
|
||||
applyRequest = buildJVMPreviewApplyRequest(previewRequest, previewResult);
|
||||
} catch (err: any) {
|
||||
setDraftError(err?.message || "变更草稿不合法");
|
||||
setDraftError(err?.message || "确认令牌缺失,请重新预览后再执行");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -556,8 +649,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setApplyMessage("");
|
||||
try {
|
||||
const result = await backendApp.JVMApplyChange(
|
||||
buildJVMRuntimeConfig(connection, providerMode),
|
||||
draftPlan,
|
||||
previewRuntimeConfig,
|
||||
applyRequest,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
setDraftError(String(result?.message || "执行 JVM 变更失败"));
|
||||
@@ -569,8 +662,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
setSnapshot(applyResult.updatedValue);
|
||||
}
|
||||
|
||||
setPreviewOpen(false);
|
||||
setPreviewResult(null);
|
||||
clearPreviewState();
|
||||
setApplyMessage(
|
||||
applyResult?.message || result?.message || "JVM 变更已执行",
|
||||
);
|
||||
@@ -897,8 +989,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>Payload(JSON)</Text>
|
||||
<Text type="secondary">
|
||||
需要输入 JSON 对象,预览和执行都会直接使用这份 payload。
|
||||
{selectedActionDefinition?.payloadExample
|
||||
预览会使用当前草稿;确认执行会使用最近一次成功预览的
|
||||
request,修改草稿后请重新预览。
|
||||
{selectedActionDefinition?.payloadExample && !snapshot?.sensitive
|
||||
? " 已按当前动作填充推荐模板。"
|
||||
: ""}
|
||||
</Text>
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { useMemo } from "react";
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMChangePreview } from "../../types";
|
||||
import { formatJVMRiskLevelText } from "../../utils/jvmResourcePresentation";
|
||||
import {
|
||||
formatJVMRiskLevelText,
|
||||
formatJVMValueForDisplay,
|
||||
} from "../../utils/jvmResourcePresentation";
|
||||
|
||||
const { Text } = Typography;
|
||||
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||
@@ -21,17 +24,6 @@ const riskColorMap: Record<string, string> = {
|
||||
high: "red",
|
||||
};
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const previewBlockStyle: React.CSSProperties = {
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
@@ -135,7 +127,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<pre style={previewBlockStyle}>
|
||||
{formatValue(preview.before?.value)}
|
||||
{formatJVMValueForDisplay(preview.before)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +152,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<pre style={previewBlockStyle}>
|
||||
{formatValue(preview.after?.value)}
|
||||
{formatJVMValueForDisplay(preview.after)}
|
||||
</pre>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
@@ -237,6 +237,7 @@ export interface JVMValueSnapshot {
|
||||
export interface JVMChangePreview {
|
||||
allowed: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
confirmationToken?: string;
|
||||
summary: string;
|
||||
riskLevel: "low" | "medium" | "high";
|
||||
blockingReason?: string;
|
||||
@@ -251,6 +252,7 @@ export interface JVMChangeRequest {
|
||||
reason: string;
|
||||
source?: "manual" | "ai-plan";
|
||||
expectedVersion?: string;
|
||||
confirmationToken?: string;
|
||||
payload?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildJVMChangeDraftFromAIPlan, extractJVMChangePlan, resolveJVMAIPlanResourceId, resolveJVMAIPlanTargetTabId } from './jvmAiPlan';
|
||||
import {
|
||||
buildJVMChangeDraftFromAIPlan,
|
||||
buildJVMAIPlanPrompt,
|
||||
extractJVMChangePlan,
|
||||
resolveJVMAIPlanResourceId,
|
||||
resolveJVMAIPlanTargetTabId,
|
||||
} from './jvmAiPlan';
|
||||
|
||||
describe('extractJVMChangePlan', () => {
|
||||
it('parses fenced json plan with namespace and key selector', () => {
|
||||
@@ -102,6 +108,34 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildJVMAIPlanPrompt', () => {
|
||||
it('masks sensitive snapshot values before injecting the AI prompt', () => {
|
||||
const prompt = buildJVMAIPlanPrompt({
|
||||
connectionName: 'orders-jvm',
|
||||
host: '127.0.0.1',
|
||||
providerMode: 'jmx',
|
||||
resourcePath: 'jmx:/attribute/app/Password',
|
||||
readOnly: false,
|
||||
snapshot: {
|
||||
resourceId: 'jmx:/attribute/app/Password',
|
||||
kind: 'attribute',
|
||||
format: 'string',
|
||||
value: 'secret-token',
|
||||
sensitive: true,
|
||||
supportedActions: [
|
||||
{
|
||||
action: 'set',
|
||||
payloadExample: { value: 'secret-token' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompt).toContain('********');
|
||||
expect(prompt).not.toContain('secret-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJVMAIPlanTargetTabId', () => {
|
||||
it('prefers the original tab when message context still matches', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
|
||||
import { JVM_SENSITIVE_VALUE_MASK } from './jvmResourcePresentation';
|
||||
|
||||
export type JVMAIChangePlan = {
|
||||
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
|
||||
@@ -106,6 +107,9 @@ const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
|
||||
if (!snapshot) {
|
||||
return '当前资源快照尚未加载成功。';
|
||||
}
|
||||
if (snapshot.sensitive) {
|
||||
return JVM_SENSITIVE_VALUE_MASK;
|
||||
}
|
||||
if (typeof snapshot.value === 'string') {
|
||||
return snapshot.value;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildJVMActionPayloadTemplate,
|
||||
buildJVMPreviewApplyRequest,
|
||||
estimateJVMResourceEditorHeight,
|
||||
formatJVMAuditResultLabel,
|
||||
formatJVMActionSummary,
|
||||
formatJVMMetadataForDisplay,
|
||||
formatJVMRiskLevelText,
|
||||
formatJVMValueForDisplay,
|
||||
resolveJVMAuditResultColor,
|
||||
resolveJVMActionDisplay,
|
||||
resolveJVMValueEditorLanguage,
|
||||
@@ -68,6 +72,121 @@ describe("jvmResourcePresentation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("masks sensitive JVM snapshot values for display", () => {
|
||||
expect(
|
||||
formatJVMValueForDisplay({
|
||||
resourceId: "jmx:/attribute/app/Password",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "secret-token",
|
||||
sensitive: true,
|
||||
}),
|
||||
).toBe("********");
|
||||
expect(
|
||||
formatJVMValueForDisplay({
|
||||
resourceId: "jmx:/attribute/app/State",
|
||||
kind: "attribute",
|
||||
format: "json",
|
||||
value: { state: "READY" },
|
||||
}),
|
||||
).toBe(JSON.stringify({ state: "READY" }, null, 2));
|
||||
});
|
||||
|
||||
it("masks sensitive JVM snapshot metadata for display", () => {
|
||||
expect(
|
||||
formatJVMMetadataForDisplay({
|
||||
metadata: { token: "secret-token" },
|
||||
sensitive: true,
|
||||
}),
|
||||
).toBe("********");
|
||||
expect(
|
||||
formatJVMMetadataForDisplay({
|
||||
metadata: { owner: "orders" },
|
||||
}),
|
||||
).toBe(JSON.stringify({ owner: "orders" }, null, 2));
|
||||
});
|
||||
|
||||
it("masks sensitive action payload examples", () => {
|
||||
expect(
|
||||
buildJVMActionPayloadTemplate(
|
||||
{
|
||||
action: "set",
|
||||
payloadExample: { value: "secret-token" },
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toBe("{\n \n}");
|
||||
});
|
||||
|
||||
it("builds apply requests from the previewed request and confirmation token", () => {
|
||||
const previewedRequest = {
|
||||
providerMode: "jmx" as const,
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
action: "set",
|
||||
reason: "修复运行模式",
|
||||
source: "manual" as const,
|
||||
expectedVersion: "v1",
|
||||
payload: { value: "warm" },
|
||||
};
|
||||
|
||||
expect(
|
||||
buildJVMPreviewApplyRequest(previewedRequest, {
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
confirmationToken: "token-from-preview",
|
||||
summary: "设置 Mode",
|
||||
riskLevel: "high",
|
||||
before: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "cold",
|
||||
},
|
||||
after: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "warm",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
...previewedRequest,
|
||||
confirmationToken: "token-from-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects confirmed apply requests when preview token is missing", () => {
|
||||
expect(() =>
|
||||
buildJVMPreviewApplyRequest(
|
||||
{
|
||||
providerMode: "jmx",
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
action: "set",
|
||||
reason: "修复运行模式",
|
||||
payload: { value: "warm" },
|
||||
},
|
||||
{
|
||||
allowed: true,
|
||||
requiresConfirmation: true,
|
||||
summary: "设置 Mode",
|
||||
riskLevel: "high",
|
||||
before: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "cold",
|
||||
},
|
||||
after: {
|
||||
resourceId: "jmx:/attribute/app/Mode",
|
||||
kind: "attribute",
|
||||
format: "string",
|
||||
value: "warm",
|
||||
},
|
||||
},
|
||||
),
|
||||
).toThrow("确认令牌缺失");
|
||||
});
|
||||
|
||||
it("caps editor height for very long payloads while keeping short content compact", () => {
|
||||
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { JVMActionDefinition } from "../types";
|
||||
import type {
|
||||
JVMActionDefinition,
|
||||
JVMChangePreview,
|
||||
JVMChangeRequest,
|
||||
JVMValueSnapshot,
|
||||
} from "../types";
|
||||
|
||||
type JVMActionDisplay = {
|
||||
action: string;
|
||||
@@ -191,6 +196,69 @@ export const formatJVMAuditResultLabel = (value?: string | null): string => {
|
||||
return normalizeText(value);
|
||||
};
|
||||
|
||||
export const JVM_SENSITIVE_VALUE_MASK = "********";
|
||||
export const JVM_DEFAULT_PAYLOAD_TEMPLATE = "{\n \n}";
|
||||
|
||||
const formatRawJVMValue = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatJVMValueForDisplay = (
|
||||
snapshot?: JVMValueSnapshot | null,
|
||||
): string => {
|
||||
if (snapshot?.sensitive) {
|
||||
return JVM_SENSITIVE_VALUE_MASK;
|
||||
}
|
||||
return formatRawJVMValue(snapshot?.value);
|
||||
};
|
||||
|
||||
export const formatJVMMetadataForDisplay = (
|
||||
snapshot?: Pick<JVMValueSnapshot, "metadata" | "sensitive"> | null,
|
||||
): string => {
|
||||
if (!snapshot?.metadata || Object.keys(snapshot.metadata).length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (snapshot.sensitive) {
|
||||
return JVM_SENSITIVE_VALUE_MASK;
|
||||
}
|
||||
return formatRawJVMValue(snapshot.metadata);
|
||||
};
|
||||
|
||||
export const buildJVMActionPayloadTemplate = (
|
||||
definition?: JVMActionDefinition | null,
|
||||
sensitive = false,
|
||||
): string => {
|
||||
if (sensitive || !definition?.payloadExample) {
|
||||
return JVM_DEFAULT_PAYLOAD_TEMPLATE;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(definition.payloadExample, null, 2);
|
||||
} catch {
|
||||
return JVM_DEFAULT_PAYLOAD_TEMPLATE;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildJVMPreviewApplyRequest = (
|
||||
previewRequest: JVMChangeRequest,
|
||||
preview: JVMChangePreview,
|
||||
): JVMChangeRequest => {
|
||||
const confirmationToken = String(preview.confirmationToken || "").trim();
|
||||
if (preview.requiresConfirmation && !confirmationToken) {
|
||||
throw new Error("确认令牌缺失,请重新预览后再执行");
|
||||
}
|
||||
return {
|
||||
...previewRequest,
|
||||
confirmationToken: confirmationToken || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveJVMValueEditorLanguage = (
|
||||
format: string,
|
||||
value: unknown,
|
||||
|
||||
Reference in New Issue
Block a user