feat(auth): keep unsaved passwords in session memory
This commit is contained in:
1 parent
bcec60ebef
commit
1ff24aaeb4
23 files changed
+1265
-65
No files matched your search
@@ -44,12 +44,15 @@ function installApiMocks(extra: Record<string, unknown> = {}) {
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
connectDb: vi.fn().mockResolvedValue("pg-1"),
|
||||
disconnectDb: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
sessionCredentialStatus: vi.fn().mockResolvedValue(false),
|
||||
forgetSessionCredential: vi.fn().mockResolvedValue(undefined),
|
||||
...extra,
|
||||
}));
|
||||
}
|
||||
@@ -157,6 +160,58 @@ describe("connectionStore save_password opt-out", () => {
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connect skips the prompt when the backend already has a session credential", async () => {
|
||||
installApiMocks({ sessionCredentialStatus: vi.fn().mockResolvedValue(true) });
|
||||
installPasswordPromptMock();
|
||||
requestPassword.mockResolvedValue({ password: "never-used", rememberPassword: false });
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
|
||||
store.connections = [connection];
|
||||
|
||||
await store.connect(connection);
|
||||
|
||||
// 本次运行期已输入过密码:不再弹窗,直接以空密码 connectDb(后端补主密码)。
|
||||
expect(requestPassword).not.toHaveBeenCalled();
|
||||
const { connectDb } = await import("@/lib/backend/api");
|
||||
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "pg-1", password: "" }), expect.any(Number));
|
||||
});
|
||||
|
||||
it("disconnectAndForgetConnectionPassword clears the backend session credential", async () => {
|
||||
const forgetSessionCredential = vi.fn().mockResolvedValue(undefined);
|
||||
installApiMocks({
|
||||
forgetSessionCredential,
|
||||
sessionCredentialStatus: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add("pg-1");
|
||||
|
||||
await store.disconnectAndForgetConnectionPassword("pg-1");
|
||||
|
||||
expect(forgetSessionCredential).toHaveBeenCalledWith("pg-1");
|
||||
expect(store.connectedIds.has("pg-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("disconnectAndForgetConnectionPassword propagates forget failures", async () => {
|
||||
const forgetSessionCredential = vi.fn().mockRejectedValue(new Error("no session credential"));
|
||||
installApiMocks({
|
||||
forgetSessionCredential,
|
||||
sessionCredentialStatus: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
installPasswordPromptMock();
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
const connection = postgresConnection({ id: "pg-1", save_password: false, password: "" });
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add("pg-1");
|
||||
|
||||
await expect(store.disconnectAndForgetConnectionPassword("pg-1")).rejects.toThrow("no session credential");
|
||||
});
|
||||
|
||||
it("prompts and retries when MySQL rejects a synced connection that sent no password", async () => {
|
||||
const connectDb = vi.fn().mockRejectedValueOnce(new Error("MySQL connection failed: Access denied for user 'root'@'192.168.100.133' (using password: NO)")).mockResolvedValueOnce("mysql-1");
|
||||
installApiMocks({ connectDb });
|
||||
|
||||
@@ -3054,6 +3054,23 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query whether this connection already has a transient password stored in the
|
||||
* backend session-credential store (a `save_password === false` connection that
|
||||
* was connected once this run). Used to skip the interactive password prompt on
|
||||
* later connects. Only returns a boolean — the password itself never leaves the
|
||||
* backend process memory.
|
||||
*/
|
||||
async function hasSessionCredential(connectionId: string): Promise<boolean> {
|
||||
try {
|
||||
return await api.sessionCredentialStatus(connectionId);
|
||||
} catch {
|
||||
// Status query failure is treated as "no credential": falling back to the
|
||||
// interactive prompt is the safe, well-known path.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for a transient password when saving is disabled, or when a server
|
||||
* confirms that a metadata-only synced connection sent no password. The
|
||||
@@ -3109,9 +3126,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
try {
|
||||
let rememberPassword = false;
|
||||
if (config.save_password === false && !config.password) {
|
||||
const prompted = await ensureConnectionPassword(config);
|
||||
config = prompted.config;
|
||||
rememberPassword = prompted.rememberPassword;
|
||||
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
|
||||
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
|
||||
if (!(await hasSessionCredential(config.id))) {
|
||||
const prompted = await ensureConnectionPassword(config);
|
||||
config = prompted.config;
|
||||
rememberPassword = prompted.rememberPassword;
|
||||
}
|
||||
}
|
||||
await beforeConnectHandler?.(config);
|
||||
if (config.db_type === "sqlserver") {
|
||||
@@ -3243,6 +3264,16 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "断开并忘记本次密码":关闭连接池(保留其它已保存连接的凭据),并清除该
|
||||
* `save_password === false` 连接本次运行期的会话密码,使下一次连接必须重新输入。
|
||||
* 若该连接本无会话凭据,后端会返回错误并在此抛出(不静默吞掉),避免误报成功。
|
||||
*/
|
||||
async function disconnectAndForgetConnectionPassword(connectionId: string) {
|
||||
await disconnect(connectionId);
|
||||
await api.forgetSessionCredential(connectionId);
|
||||
}
|
||||
|
||||
async function closeDatabaseConnection(connectionId: string, database: string) {
|
||||
if (hasSqlServerActivityTraceForConnection(connectionId, database)) await disposeSqlServerActivityTracesForConnection(connectionId, database);
|
||||
await api.closeDatabaseConnection(connectionId, database);
|
||||
@@ -3310,9 +3341,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
// in-flight dedup above keeps its exact microtask cadence; only await the
|
||||
// interactive prompt when the connection actually needs a typed password.
|
||||
if (config.save_password === false && !config.password) {
|
||||
const prompted = await ensureConnectionPassword(config);
|
||||
config = prompted.config;
|
||||
rememberPassword = prompted.rememberPassword;
|
||||
// 本次运行期已在会话凭据仓库暂存密码(save_password=false):免弹窗,
|
||||
// 直接以空密码 connectDb,后端会从会话凭据仓库补主密码。
|
||||
if (!(await hasSessionCredential(connectionId))) {
|
||||
const prompted = await ensureConnectionPassword(config);
|
||||
config = prompted.config;
|
||||
rememberPassword = prompted.rememberPassword;
|
||||
}
|
||||
}
|
||||
await beforeConnectHandler?.(config);
|
||||
if (config.db_type === "sqlserver") {
|
||||
@@ -7816,6 +7851,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connect,
|
||||
cancelConnecting,
|
||||
disconnect,
|
||||
disconnectAndForgetConnectionPassword,
|
||||
hasSessionCredential,
|
||||
closeDatabaseConnection,
|
||||
ensureConnected,
|
||||
loadConnectedConnectionRootForSidebarSearch,
|
||||
|
||||
Reference in new issue
Block a user