1 parent
2ecbac4a6f
commit
c892c8d1b0
13 files changed
+279
-6
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Check, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
|
||||
import { AlertTriangle, Check, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -51,6 +51,7 @@ const loadError = ref("");
|
||||
const grantError = ref("");
|
||||
|
||||
const createDialogOpen = ref(false);
|
||||
const hostDialogOpen = ref(false);
|
||||
const passwordDialogOpen = ref(false);
|
||||
const sqlDialogOpen = ref(false);
|
||||
const pendingSql = ref("");
|
||||
@@ -62,6 +63,7 @@ const pendingAfterApply = ref<(() => Promise<void>) | undefined>();
|
||||
const createUser = ref("app_user");
|
||||
const createHost = ref("%");
|
||||
const createPassword = ref("");
|
||||
const newHost = ref("");
|
||||
const newPassword = ref("");
|
||||
const privilegeDatabase = ref(props.connection.database || "*");
|
||||
const privilegeTable = ref("*");
|
||||
@@ -81,6 +83,7 @@ const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(pr
|
||||
const supported = computed(() => provider.value !== null);
|
||||
const isPostgres = computed(() => provider.value?.dialect === "postgres");
|
||||
const canCreateUser = computed(() => !!provider.value?.createUserSql);
|
||||
const canRenameUser = computed(() => !!provider.value?.renameUserSql);
|
||||
const canAlterPassword = computed(() => !!provider.value?.alterPasswordSql);
|
||||
const canAlterLogin = computed(() => !!provider.value?.alterLoginSql);
|
||||
const canDropUser = computed(() => !!provider.value?.dropUserSql);
|
||||
@@ -114,6 +117,10 @@ const filteredCreateDatabases = computed(() => {
|
||||
const selectedCreateDatabaseSet = computed(() => new Set(createDatabaseAuthorizations.value.map((selection) => selection.database)));
|
||||
const createDatabaseAuthorizationsValid = computed(() => createDatabaseAuthorizations.value.every((selection) => selection.preset !== "custom" || (selection.privileges?.length ?? 0) > 0));
|
||||
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
|
||||
const canPreviewHostChange = computed(() => {
|
||||
const host = newHost.value.trim();
|
||||
return !!host && host !== selectedUser.value?.host;
|
||||
});
|
||||
|
||||
function syncPrivilegeSelectionFromGrants() {
|
||||
const selectionFromGrants = provider.value?.privilegeSelectionFromGrants;
|
||||
@@ -205,6 +212,13 @@ function selectUser(user: DatabaseUserIdentity) {
|
||||
selectedUserKey.value = userKey(user);
|
||||
}
|
||||
|
||||
function openHostDialog() {
|
||||
const user = selectedUser.value;
|
||||
if (!user || !provider.value?.renameUserSql) return;
|
||||
newHost.value = user.host;
|
||||
hostDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function togglePrivilege(privilege: string) {
|
||||
const set = new Set(selectedPrivileges.value);
|
||||
if (set.has(privilege)) set.delete(privilege);
|
||||
@@ -396,6 +410,21 @@ function previewPasswordChange() {
|
||||
});
|
||||
}
|
||||
|
||||
function previewHostChange() {
|
||||
const user = selectedUser.value;
|
||||
const renameUserSql = provider.value?.renameUserSql;
|
||||
const host = newHost.value.trim();
|
||||
if (!user || !renameUserSql || !host || host === user.host) return;
|
||||
previewSql(renameUserSql(user, host), {
|
||||
danger: true,
|
||||
afterApply: async () => {
|
||||
hostDialogOpen.value = false;
|
||||
newHost.value = "";
|
||||
selectedUserKey.value = userKey({ ...user, host });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function previewDropUser() {
|
||||
const user = selectedUser.value;
|
||||
const userProvider = provider.value;
|
||||
@@ -586,6 +615,10 @@ onMounted(loadUsers);
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<Button v-if="canRenameUser" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openHostDialog">
|
||||
<Globe2 class="h-3.5 w-3.5" />
|
||||
{{ t("userAdmin.changeHost") }}
|
||||
</Button>
|
||||
<Button v-if="canAlterPassword" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="passwordDialogOpen = true">
|
||||
<KeyRound class="h-3.5 w-3.5" />
|
||||
{{ t("userAdmin.changePassword") }}
|
||||
@@ -807,6 +840,19 @@ onMounted(loadUsers);
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="hostDialogOpen">
|
||||
<DialogContent class="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("userAdmin.changeHost") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input v-model="newHost" :placeholder="t('userAdmin.newHost')" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="hostDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="!canPreviewHostChange" @click="previewHostChange">{{ t("userAdmin.previewSql") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="sqlDialogOpen">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/i18n/locales/en";
|
||||
import es from "@/i18n/locales/es";
|
||||
import itLocale from "@/i18n/locales/it";
|
||||
import ja from "@/i18n/locales/ja";
|
||||
import ko from "@/i18n/locales/ko";
|
||||
import ptBR from "@/i18n/locales/pt-BR";
|
||||
import zhCN from "@/i18n/locales/zh-CN";
|
||||
import zhTW from "@/i18n/locales/zh-TW";
|
||||
|
||||
describe("user administration Host translations", () => {
|
||||
it("provides the Host-change labels in English", () => {
|
||||
expect(en.userAdmin.changeHost).toEqual(expect.any(String));
|
||||
expect(en.userAdmin.newHost).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("provides localized Host-change labels in every non-English locale", () => {
|
||||
const locales = { es, it: itLocale, ja, ko, "pt-BR": ptBR, "zh-CN": zhCN, "zh-TW": zhTW };
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
expect(messages.userAdmin.changeHost, locale).not.toBe(en.userAdmin.changeHost);
|
||||
expect(messages.userAdmin.newHost, locale).not.toBe(en.userAdmin.newHost);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3066,6 +3066,8 @@ export default {
|
||||
username: "Username",
|
||||
host: "Host",
|
||||
addHost: "Add Host",
|
||||
changeHost: "Change Host",
|
||||
newHost: "New Host",
|
||||
changePassword: "Change Password",
|
||||
newPassword: "New password",
|
||||
lock: "Lock",
|
||||
|
||||
@@ -2910,6 +2910,8 @@ export default withEnglishFallback({
|
||||
revoke: "Revocar",
|
||||
username: "Nombre de usuario",
|
||||
host: "Host",
|
||||
changeHost: "Cambiar host",
|
||||
newHost: "Nuevo host",
|
||||
changePassword: "Cambiar contraseña",
|
||||
newPassword: "Nueva contraseña",
|
||||
lock: "Bloquear",
|
||||
|
||||
@@ -2908,6 +2908,8 @@ export default withEnglishFallback({
|
||||
revoke: "Revoca",
|
||||
username: "Nome utente",
|
||||
host: "Host",
|
||||
changeHost: "Modifica host",
|
||||
newHost: "Nuovo host",
|
||||
changePassword: "Cambia Password",
|
||||
newPassword: "Nuova password",
|
||||
lock: "Blocca",
|
||||
|
||||
@@ -2971,6 +2971,8 @@ export default withEnglishFallback({
|
||||
revoke: "取り消し",
|
||||
username: "ユーザー名",
|
||||
host: "ホスト",
|
||||
changeHost: "ホストを変更",
|
||||
newHost: "新しいホスト",
|
||||
changePassword: "パスワードを変更",
|
||||
newPassword: "新しいパスワード",
|
||||
lock: "ロック",
|
||||
|
||||
@@ -2912,6 +2912,8 @@ export default withEnglishFallback({
|
||||
revoke: "권한 회수",
|
||||
username: "사용자 이름",
|
||||
host: "호스트",
|
||||
changeHost: "호스트 변경",
|
||||
newHost: "새 호스트",
|
||||
changePassword: "비밀번호 변경",
|
||||
newPassword: "새 비밀번호",
|
||||
lock: "잠금",
|
||||
|
||||
@@ -2910,6 +2910,8 @@ export default withEnglishFallback({
|
||||
revoke: "Revogar",
|
||||
username: "Nome de usuário",
|
||||
host: "Host",
|
||||
changeHost: "Alterar host",
|
||||
newHost: "Novo host",
|
||||
changePassword: "Alterar Senha",
|
||||
newPassword: "Nova senha",
|
||||
lock: "Bloquear",
|
||||
|
||||
@@ -3055,6 +3055,8 @@ export default withEnglishFallback({
|
||||
revoke: "撤权",
|
||||
username: "用户名",
|
||||
host: "Host",
|
||||
changeHost: "修改主机",
|
||||
newHost: "新主机",
|
||||
changePassword: "修改密码",
|
||||
newPassword: "新密码",
|
||||
lock: "锁定",
|
||||
|
||||
@@ -5837,6 +5837,8 @@ export default withEnglishFallback({
|
||||
revoke: "撤銷",
|
||||
username: "使用者名稱",
|
||||
host: "主機",
|
||||
changeHost: "變更主機",
|
||||
newHost: "新主機",
|
||||
changePassword: "變更密碼",
|
||||
newPassword: "新密碼",
|
||||
lock: "鎖定",
|
||||
|
||||
+149
-5
@@ -7,6 +7,9 @@ import type { ConnectionConfig } from "@/types/database";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureConnected: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeMulti: vi.fn(),
|
||||
productionGuard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
@@ -18,12 +21,40 @@ function passthrough(tag: string): Component {
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
function modelInput(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
return () =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: attrs.modelValue as string,
|
||||
onInput: (event: Event) => (attrs["onUpdate:modelValue"] as ((value: string) => void) | undefined)?.((event.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function modelDialog(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => (attrs.open ? h("div", attrs, slots.default?.()) : null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values?.message ? `${key}: ${values.message}` : key),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => {
|
||||
const Icon = passthrough("span");
|
||||
return {
|
||||
AlertTriangle: Icon,
|
||||
Check: Icon,
|
||||
Globe2: Icon,
|
||||
KeyRound: Icon,
|
||||
Lock: Icon,
|
||||
Loader2: Icon,
|
||||
@@ -39,13 +70,13 @@ vi.mock("@lucide/vue", () => {
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: passthrough("div"),
|
||||
Dialog: modelDialog(),
|
||||
DialogContent: passthrough("div"),
|
||||
DialogFooter: passthrough("div"),
|
||||
DialogHeader: passthrough("div"),
|
||||
DialogTitle: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: modelInput() }));
|
||||
vi.mock("@/components/ui/PasswordInput.vue", () => ({ default: passthrough("input") }));
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: passthrough("div"),
|
||||
@@ -57,14 +88,20 @@ vi.mock("@/components/ui/select", () => ({
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }),
|
||||
}));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
executeQuery: mocks.executeQuery,
|
||||
executeMulti: vi.fn(),
|
||||
executeMulti: mocks.executeMulti,
|
||||
listDatabases: vi.fn(),
|
||||
listSchemas: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({
|
||||
executeWithProductionSqlGuard: (options: { execute: () => Promise<unknown> }) => {
|
||||
mocks.productionGuard(options);
|
||||
return options.execute();
|
||||
},
|
||||
}));
|
||||
|
||||
import DatabaseUserAdmin from "@/components/admin/DatabaseUserAdmin.vue";
|
||||
|
||||
@@ -79,6 +116,15 @@ const connection: ConnectionConfig = {
|
||||
password: "",
|
||||
};
|
||||
|
||||
const nativeMysqlConnection: ConnectionConfig = {
|
||||
...connection,
|
||||
id: "native-mysql",
|
||||
name: "Native MySQL",
|
||||
db_type: "mysql",
|
||||
driver_profile: "mysql",
|
||||
port: 3306,
|
||||
};
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
|
||||
@@ -142,3 +188,101 @@ describe("DatabaseUserAdmin MySQL grant loading", () => {
|
||||
expect(root.textContent).not.toContain("Access denied");
|
||||
});
|
||||
});
|
||||
|
||||
function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(root?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
|
||||
}
|
||||
|
||||
async function mountNativeMysqlUserAdmin() {
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'old-host'"]] });
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("DatabaseUserAdmin MySQL account Host changes", () => {
|
||||
it("shows the action only for native MySQL and validates the replacement Host", async () => {
|
||||
await mountNativeMysqlUserAdmin();
|
||||
|
||||
findButton("userAdmin.changeHost")?.click();
|
||||
await nextTick();
|
||||
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
|
||||
const previewButton = findButton("userAdmin.previewSql");
|
||||
|
||||
expect(hostInput?.value).toBe("old-host");
|
||||
expect(previewButton?.disabled).toBe(true);
|
||||
hostInput!.value = " ";
|
||||
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(previewButton?.disabled).toBe(true);
|
||||
hostInput!.value = " old-host ";
|
||||
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(previewButton?.disabled).toBe(true);
|
||||
|
||||
const nonNativeRoot = document.createElement("div");
|
||||
document.body.append(nonNativeRoot);
|
||||
app?.unmount();
|
||||
mocks.executeQuery.mockReset();
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "mysql_native_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [[]] });
|
||||
app = createApp(DatabaseUserAdmin, { connection });
|
||||
app.mount(nonNativeRoot);
|
||||
root?.remove();
|
||||
root = nonNativeRoot;
|
||||
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
|
||||
expect(findButton("userAdmin.changeHost")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("previews the exact RENAME USER SQL and reloads the renamed identity after success", async () => {
|
||||
await mountNativeMysqlUserAdmin();
|
||||
findButton("userAdmin.changeHost")?.click();
|
||||
await nextTick();
|
||||
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
|
||||
hostInput!.value = "new-host";
|
||||
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
findButton("userAdmin.previewSql")?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(root?.textContent).toContain("RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';");
|
||||
expect(findButton("userAdmin.applySql")?.getAttribute("variant")).toBe("destructive");
|
||||
|
||||
mocks.executeMulti.mockResolvedValueOnce([]);
|
||||
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "new-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'new-host'"]] });
|
||||
findButton("userAdmin.applySql")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(mocks.executeQuery.mock.calls.some((call) => call[2] === "SHOW GRANTS FOR 'same-user'@'new-host';")).toBe(true));
|
||||
expect(mocks.executeMulti.mock.calls[0][2]).toBe("RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';");
|
||||
expect(mocks.productionGuard).toHaveBeenCalledWith(expect.objectContaining({ connection: nativeMysqlConnection, sql: "RENAME USER 'same-user'@'old-host' TO 'same-user'@'new-host';" }));
|
||||
expect(mocks.executeQuery.mock.calls.slice(2).map((call) => call[2])).toEqual([
|
||||
"SELECT User AS user, Host AS host, plugin AS plugin FROM mysql.user ORDER BY User, Host;",
|
||||
"SHOW GRANTS FOR 'same-user'@'new-host';",
|
||||
]);
|
||||
expect(root?.textContent).toContain("same-user@new-host");
|
||||
expect(root?.querySelector('input[placeholder="userAdmin.newHost"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the old identity selected and surfaces execution errors", async () => {
|
||||
await mountNativeMysqlUserAdmin();
|
||||
findButton("userAdmin.changeHost")?.click();
|
||||
await nextTick();
|
||||
const hostInput = root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]');
|
||||
hostInput!.value = "existing-host";
|
||||
hostInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
findButton("userAdmin.previewSql")?.click();
|
||||
await nextTick();
|
||||
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["error"], rows: [["Operation RENAME USER failed"]], execution_error: true }]);
|
||||
findButton("userAdmin.applySql")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("userAdmin.applyFailed: Operation RENAME USER failed", 5000));
|
||||
expect(root?.textContent).toContain("same-user@old-host");
|
||||
expect(root?.querySelector<HTMLInputElement>('input[placeholder="userAdmin.newHost"]')?.value).toBe("existing-host");
|
||||
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
kingbaseShowGrantsSql,
|
||||
kingbaseUserAdminProvider,
|
||||
mysqlPrivilegeSelectionFromGrants,
|
||||
nativeMysqlUserAdminProvider,
|
||||
postgresUserAdminProvider,
|
||||
resolveDatabaseUserAdminProviderForConnection,
|
||||
} from "@/lib/database/databaseUserAdmin";
|
||||
@@ -80,6 +81,35 @@ describe("MySQL grant privilege selection", () => {
|
||||
});
|
||||
|
||||
describe("database user admin providers", () => {
|
||||
it("opts only native MySQL connections into account Host changes", () => {
|
||||
const legacyNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql"));
|
||||
const blankLegacyNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql", " "));
|
||||
const native = resolveDatabaseUserAdminProviderForConnection(connection("mysql", "mysql"));
|
||||
const normalizedNative = resolveDatabaseUserAdminProviderForConnection(connection("mysql", " MySQL "));
|
||||
|
||||
expect(legacyNative).toBe(nativeMysqlUserAdminProvider);
|
||||
expect(blankLegacyNative).toBe(nativeMysqlUserAdminProvider);
|
||||
expect(native).toBe(nativeMysqlUserAdminProvider);
|
||||
expect(normalizedNative).toBe(nativeMysqlUserAdminProvider);
|
||||
expect(native?.renameUserSql?.({ user: "app'user", host: "old\\host" }, "new'\\host")).toBe(
|
||||
"RENAME USER 'app''user'@'old\\\\host' TO 'app''user'@'new''\\\\host';",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose account Host changes to MySQL-compatible providers", () => {
|
||||
const jdbcMysql = { ...connection("jdbc", "mysql"), connection_string: "jdbc:mysql://localhost:3306/app" };
|
||||
|
||||
expect(getDatabaseUserAdminProvider("mysql")?.renameUserSql).toBeUndefined();
|
||||
expect(resolveDatabaseUserAdminProviderForConnection(connection("goldendb", "goldendb"))?.renameUserSql).toBeUndefined();
|
||||
expect(resolveDatabaseUserAdminProviderForConnection(jdbcMysql)?.renameUserSql).toBeUndefined();
|
||||
for (const profile of ["mariadb", "oceanbase", "tidb", "tdsql", "polardb", "greatsql", "goldendb", "doris", "selectdb", "starrocks"]) {
|
||||
expect(resolveDatabaseUserAdminProviderForConnection(connection("mysql", profile))?.renameUserSql, profile).toBeUndefined();
|
||||
}
|
||||
for (const dbType of ["gbase", "doris", "starrocks", "postgres"] as const) {
|
||||
expect(resolveDatabaseUserAdminProviderForConnection(connection(dbType, dbType))?.renameUserSql, dbType).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("selects the dedicated provider for GaussDB M-mode connections", () => {
|
||||
const provider = resolveDatabaseUserAdminProviderForConnection(connection("gaussdb", "GAUSSDB-M"));
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface DatabaseUserAdminProvider {
|
||||
showGrantsSql(user: DatabaseUserIdentity): string;
|
||||
parseGrants?(result: QueryResult): string[];
|
||||
createUserSql?(input: CreatePrincipalInput): string;
|
||||
renameUserSql?(user: DatabaseUserIdentity, newHost: string): string;
|
||||
alterPasswordSql?(user: DatabaseUserIdentity, password: string): string;
|
||||
alterLoginSql?(user: DatabaseUserIdentity, enabled: boolean): string;
|
||||
dropUserSql?(user: DatabaseUserIdentity): string;
|
||||
@@ -163,6 +164,10 @@ export function mysqlCreateUserSql(input: CreatePrincipalInput): string {
|
||||
return `CREATE USER ${mysqlUserAccount(input)} IDENTIFIED BY ${quoteMySqlString(input.password)};`;
|
||||
}
|
||||
|
||||
export function mysqlRenameUserHostSql(user: DatabaseUserIdentity, newHost: string): string {
|
||||
return `RENAME USER ${mysqlUserAccount(user)} TO ${mysqlUserAccount({ ...user, host: newHost })};`;
|
||||
}
|
||||
|
||||
export function mysqlAlterUserPasswordSql(user: DatabaseUserIdentity, password: string): string {
|
||||
return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`;
|
||||
}
|
||||
@@ -702,6 +707,11 @@ export const mysqlUserAdminProvider: DatabaseUserAdminProvider = {
|
||||
privilegeSelectionFromGrants: mysqlPrivilegeSelectionFromGrants,
|
||||
};
|
||||
|
||||
export const nativeMysqlUserAdminProvider: DatabaseUserAdminProvider = {
|
||||
...mysqlUserAdminProvider,
|
||||
renameUserSql: mysqlRenameUserHostSql,
|
||||
};
|
||||
|
||||
export const postgresUserAdminProvider: DatabaseUserAdminProvider = {
|
||||
dialect: "postgres",
|
||||
defaultScope: "database",
|
||||
@@ -787,6 +797,9 @@ const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserA
|
||||
|
||||
export function getDatabaseUserAdminProvider(dbType: DatabaseType | undefined, connection?: ConnectionConfig): DatabaseUserAdminProvider | null {
|
||||
if (!dbType) return null;
|
||||
if (dbType === "mysql" && connection?.db_type === "mysql" && (!connection.driver_profile?.trim() || connection.driver_profile.trim().toLowerCase() === "mysql")) {
|
||||
return nativeMysqlUserAdminProvider;
|
||||
}
|
||||
// GaussDB M mode (MySQL-compatible) uses backtick quoting and treats "user"
|
||||
// as a reserved keyword, so a dedicated provider with backtick-quoted aliases
|
||||
// is required.
|
||||
|
||||
Reference in new issue
Block a user