feat(oracle): reuse user and privilege admin pages

This commit is contained in:
efeire
2026-10-14 12:14:45 +08:00
committed by GitHub
parent 1e039bfae2
commit 1aee1e8cb8
25 changed files with 3099 additions and 156 deletions
@@ -28,6 +28,11 @@ const props = defineProps<{
connectionId: string;
/** 紧凑布局:用于右侧权限编辑面板,隐藏标题区、压缩列表高度并让表范围控件换行显示 */
compact?: boolean;
/** Oracle exposes owner/object selection; it has no all-schema grant scope. */
objectScope?: {
loadObjects: (owner: string) => Promise<AuthorizationTableSelection[]>;
privileges: readonly string[];
};
}>();
const emit = defineEmits<{
@@ -45,7 +50,7 @@ const tablesLoading = ref<Record<string, boolean>>({});
const tableErrors = ref<Record<string, string>>({});
const tableSearch = ref<Record<string, string>>({});
const supportsTableGrants = computed(() => !!props.provider?.supportsTableGrantsOnCreate);
const supportsTableGrants = computed(() => !!props.objectScope || !!props.provider?.supportsTableGrantsOnCreate);
const filteredDatabases = computed(() => {
const query = search.value.trim().toLowerCase();
return query ? props.databases.filter((database) => databaseLabel(database).toLowerCase().includes(query)) : props.databases;
@@ -114,11 +119,12 @@ function toggleDatabase(database: AuthorizationDatabaseOption) {
commitSelections(currentSelections().filter((selection) => authorizationSelectionKey(selection) !== authorizationOptionKey(database)));
return;
}
commitSelections([...currentSelections(), { database: database.database, ...(database.catalog ? { catalog: database.catalog } : {}), preset: "readOnly", privileges: ["SELECT"] }]);
commitSelections([...currentSelections(), { database: database.database, ...(database.catalog ? { catalog: database.catalog } : {}), preset: "readOnly", privileges: ["SELECT"], ...(props.objectScope ? { tables: [] } : {}) }]);
}
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
function privilegesForSelection(selection?: DatabaseAuthorizationSelection): string[] {
if (props.objectScope) return [...props.objectScope.privileges];
const userProvider = props.provider;
if (!userProvider) return [];
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
@@ -153,7 +159,9 @@ async function loadTables(database: AuthorizationDatabaseOption) {
tableErrors.value = { ...tableErrors.value, [key]: "" };
try {
let result: AuthorizationTableSelection[];
if (props.provider?.authorizationModel === "postgres") {
if (props.objectScope) {
result = await props.objectScope.loadObjects(database.database);
} else if (props.provider?.authorizationModel === "postgres") {
const schemas = (await api.listSchemas(props.connectionId, database.database)).filter(isPostgresGrantSchema);
const schemaTables = await Promise.all(
schemas.map(async (schema) => {
@@ -253,11 +261,14 @@ function databaseLabel(option: AuthorizationDatabaseOption): string {
}
function authorizationTableKey(table: AuthorizationTableSelection): string {
return JSON.stringify([table.schema ?? "", table.name]);
// Oracle allows a PACKAGE/PROCEDURE to share a name with a TABLE in one
// schema; dropping objectType here would make the table unselectable.
return JSON.stringify([table.schema ?? "", table.name, table.objectType ?? ""]);
}
function tableLabel(table: AuthorizationTableSelection): string {
return table.schema ? `${table.schema}.${table.name}` : table.name;
const name = table.schema ? `${table.schema}.${table.name}` : table.name;
return props.objectScope && table.objectType ? `${name} · ${table.objectType}` : name;
}
function uniqueTableOptions(options: AuthorizationTableSelection[]): AuthorizationTableSelection[] {
@@ -288,15 +299,15 @@ function isPostgresGrantSchema(schema: string): boolean {
</div>
<div class="flex h-8 shrink-0 items-center gap-2 rounded-md border px-2">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input v-model="search" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t('userAdmin.searchDatabase')" />
<input v-model="search" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t(objectScope ? 'oracleSecurity.searchOwner' : 'userAdmin.searchDatabase')" />
</div>
<!-- 紧凑模式下面板高度有限,数据库列表占满剩余高度并自行滚动,避免多个库挤在一起 -->
<div class="overflow-auto rounded-md border" :class="compact ? 'min-h-0 flex-1' : 'max-h-64'">
<div v-if="databasesLoading" class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingDatabases") }}
{{ t(objectScope ? "common.loading" : "userAdmin.loadingDatabases") }}
</div>
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t(objectScope ? "oracleSecurity.empty" : "userAdmin.emptyDatabases") }}</div>
<div v-for="database in visibleDatabases" :key="authorizationOptionKey(database)" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
<div class="flex items-center gap-2">
<input :checked="selectedDatabaseSet.has(authorizationOptionKey(database))" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
@@ -315,7 +326,7 @@ function isPostgresGrantSchema(schema: string): boolean {
</Select>
</div>
<div v-if="supportsTableGrants && database.database !== GLOBAL_SCOPE && selectedDatabaseSet.has(authorizationOptionKey(database))" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
<div class="flex items-center gap-2">
<div v-if="!objectScope" class="flex items-center gap-2">
<span v-if="!compact" class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="!usesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateTableScope(database, false)">
@@ -331,7 +342,7 @@ function isPostgresGrantSchema(schema: string): boolean {
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border bg-background px-2 text-left hover:bg-accent" :class="compact ? 'h-9 text-xs' : 'h-7 text-[11px]'">
<Table2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">
{{ selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
{{ objectScope ? `${t("oracleSecurity.chooseObjects")} (${selectionFor(database)?.tables?.length ?? 0})` : selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
</span>
<Loader2 v-if="tablesLoading[authorizationOptionKey(database)]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -341,23 +352,23 @@ function isPostgresGrantSchema(schema: string): boolean {
<div class="flex items-center gap-2 border-b p-2">
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="tableSearch[authorizationOptionKey(database)]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
<input v-model="tableSearch[authorizationOptionKey(database)]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t(objectScope ? 'oracleSecurity.searchObjects' : 'userAdmin.searchTable')" />
</div>
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[authorizationOptionKey(database)]?.length > 0)" @click="toggleAllTables(database)">
<button v-if="!objectScope" type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[authorizationOptionKey(database)]?.length > 0)" @click="toggleAllTables(database)">
{{ t(allTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
</button>
</div>
<div class="max-h-56 overflow-auto p-1">
<div v-if="tablesLoading[authorizationOptionKey(database)]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingTables") }}
{{ t(objectScope ? "common.loading" : "userAdmin.loadingTables") }}
</div>
<div v-else-if="tableErrors[authorizationOptionKey(database)]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[authorizationOptionKey(database)] }) }}</p>
<p class="break-words">{{ objectScope ? tableErrors[authorizationOptionKey(database)] : t("userAdmin.loadTablesFailed", { message: tableErrors[authorizationOptionKey(database)] }) }}</p>
<button type="button" class="mt-2 text-primary" @click="loadTables(database)">{{ t("userAdmin.retry") }}</button>
</div>
<div v-else-if="filteredTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
{{ t("userAdmin.emptyTables") }}
{{ t(objectScope ? "oracleSecurity.empty" : "userAdmin.emptyTables") }}
</div>
<button v-for="table in filteredTables(database)" :key="authorizationTableKey(table)" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="isTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
@@ -369,7 +380,7 @@ function isPostgresGrantSchema(schema: string): boolean {
</PopoverContent>
</Popover>
</div>
<p v-if="usesSelectedTables(database) && !selectionFor(database)?.tables?.length" class="mt-1 text-[10px] text-destructive" :class="compact ? '' : 'pl-5'">
<p v-if="!objectScope && usesSelectedTables(database) && !selectionFor(database)?.tables?.length" class="mt-1 text-[10px] text-destructive" :class="compact ? '' : 'pl-5'">
{{ t("userAdmin.tableSelectionRequired") }}
</p>
<div v-if="selectionFor(database)?.preset === 'custom'" class="mt-2 grid gap-1.5" :class="compact ? 'grid-cols-2' : 'grid-cols-3 pl-5'">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { oracleDirectGrantsSql } from "@/lib/database/oracleUserAdmin";
import { oracleGrantSources, oracleObjectGrantSources, oracleSecurityObjectMatches, type OracleSecuritySnapshot } from "@/lib/database/oracleSecurity";
const props = defineProps<{ snapshot: OracleSecuritySnapshot; principal: string }>();
const { t } = useI18n();
const { highlight } = useSqlHighlighter();
const directSql = computed(() => oracleDirectGrantsSql(props.snapshot, props.principal).join("\n"));
const owner = ref("");
const objectName = ref("");
const sources = computed(() => (props.principal ? oracleGrantSources(props.snapshot, props.principal) : { grants: oracleObjectGrantSources(props.snapshot), bounded: false }));
const grants = computed(() => sources.value.grants.filter((row) => (!owner.value && !objectName.value) || oracleSecurityObjectMatches(row.grant, owner.value, objectName.value)));
const memberships = computed(() => props.snapshot.roleGrants.rows.filter((row) => !props.principal || row.grantee === props.principal || row.role === props.principal));
const sections = computed(() => Object.entries(props.snapshot));
const incomplete = computed(() => sources.value.bounded || Object.values(props.snapshot).some((read) => !["ok", "empty"].includes(read.state) || read.visibility === "limited"));
const selectedUser = computed(() => props.snapshot.users.rows.find((row) => row.name === props.principal));
const selectedRole = computed(() => props.snapshot.roles.rows.find((row) => row.name === props.principal));
const stateLabel = (state: string) => t(`oracleSecurity.${{ ok: "loaded", empty: "empty", denied: "denied", unavailable: "unavailable", error: "error", unsupported: "unsupported" }[state] ?? state}`);
</script>
<template>
<div class="flex h-full flex-col gap-3 text-xs" data-oracle-security>
<p class="text-muted-foreground">{{ t("oracleSecurity.directSqlNotice") }}</p>
<p v-if="incomplete" role="status" class="text-muted-foreground">{{ t("oracleSecurity.incomplete") }}</p>
<p v-if="sources.bounded" role="alert" class="text-destructive">{{ t("oracleSecurity.bounded") }}</p>
<pre v-if="principal" class="min-h-48 flex-1 whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono text-xs leading-5 text-foreground" data-direct-grants v-html="highlight(directSql || t(incomplete ? 'oracleSecurity.incomplete' : 'oracleSecurity.noGrants'))" />
<details :open="!principal">
<summary class="cursor-pointer font-medium">{{ t("oracleSecurity.source") }}</summary>
<p class="my-3 text-muted-foreground">{{ t("oracleSecurity.sourceNotice") }}</p>
<div class="flex flex-wrap gap-2">
<Input v-model="owner" class="h-8 w-40 text-xs" :aria-label="t('oracleSecurity.owner')" :placeholder="t('oracleSecurity.owner')" />
<Input v-model="objectName" class="h-8 w-48 text-xs" :aria-label="t('oracleSecurity.objectName')" :placeholder="t('oracleSecurity.objectName')" />
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="border-b bg-muted/20 text-muted-foreground">
<tr>
<th class="p-2 font-medium">{{ t("oracleSecurity.privilege") }}</th>
<th class="p-2 font-medium">{{ t("oracleSecurity.object") }}</th>
<th class="p-2 font-medium">{{ t("oracleSecurity.source") }}</th>
<th class="p-2 font-medium">{{ t("oracleSecurity.grantOption") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in grants" :key="index" class="border-b">
<td class="p-2">
{{ row.grant.privilege }}<span class="mt-1 block text-muted-foreground">{{ t(`oracleSecurity.${row.kind}`) }}</span>
</td>
<td class="p-2 break-all">{{ "owner" in row.grant ? `${row.grant.owner}.${row.grant.objectName}` : "—" }}{{ "columnName" in row.grant ? `.${row.grant.columnName}` : "" }}</td>
<td class="p-2">
{{ t(`oracleSecurity.${row.source}`) }} · {{ row.grant.grantee }}<span v-if="row.rolePath.length" class="mt-1 block text-muted-foreground">{{ row.rolePath.join(" → ") }}</span
><span v-if="'grantor' in row.grant" class="mt-1 block text-muted-foreground">grantor: {{ row.grant.grantor || t("oracleSecurity.unknownGrantor") }}</span>
</td>
<td class="p-2">{{ t(("adminOption" in row.grant ? row.grant.adminOption : row.grant.grantable) ? "oracleSecurity.yes" : "oracleSecurity.no") }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!incomplete && !grants.length" class="text-muted-foreground">{{ t("oracleSecurity.noGrants") }}</p>
<details class="border-b pb-3">
<summary class="cursor-pointer font-medium">{{ t("oracleSecurity.memberships") }} · {{ memberships.length }}</summary>
<div class="mt-3 overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr>
<th class="p-2">{{ t("oracleSecurity.grantee") }}</th>
<th>{{ t("oracleSecurity.role") }}</th>
<th>ADMIN OPTION</th>
<th>DEFAULT ROLE</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in memberships" :key="index" class="border-b">
<td class="p-2">{{ row.grantee }}</td>
<td>{{ row.role }}</td>
<td>{{ t(row.adminOption ? "oracleSecurity.yes" : "oracleSecurity.no") }}</td>
<td>{{ row.defaultRole === undefined ? t("oracleSecurity.unknown") : t(row.defaultRole ? "oracleSecurity.yes" : "oracleSecurity.no") }}</td>
</tr>
</tbody>
</table>
</div>
</details>
<details>
<summary class="cursor-pointer font-medium">{{ t("oracleSecurity.readDetails") }}</summary>
<p class="mt-3 leading-5 text-muted-foreground">{{ t("oracleSecurity.notice") }}</p>
<p v-if="selectedUser?.profile" class="mt-3">PROFILE: {{ selectedUser.profile }}</p>
<p v-if="selectedRole" class="mt-3">PASSWORD_REQUIRED: {{ selectedRole.authentication || t("oracleSecurity.unknown") }}</p>
<div v-for="[name, result] in sections" :key="name" class="mt-3 border-t pt-3" :data-security-state="result.state">
<p>{{ t(`oracleSecurity.${name}`) }} · {{ stateLabel(result.state) }} · {{ result.rows.length }} · {{ result.visibility === "complete" ? t("oracleSecurity.full") : t("oracleSecurity.limited") }}{{ result.truncated ? t("oracleSecurity.truncated") : "" }}</p>
<p v-if="result.message" class="mt-2 whitespace-pre-wrap text-destructive">{{ result.message }}</p>
<pre class="mt-2 overflow-auto whitespace-pre-wrap text-muted-foreground">{{ result.source }}</pre>
</div>
</details>
</details>
</div>
</template>
+62
View File
@@ -64,6 +64,68 @@ const consul = {
};
export default {
oracleSecurity: {
directSqlNotice: "Visible direct grants only, not effective session permissions or a complete replay script. Inheritance and grantors are in the source details.",
searchOwner: "Search owner/schema",
chooseObjects: "Choose objects",
searchObjects: "Search objects",
invalidObjectSelection: "Select specific objects and privileges applicable to every selected object.",
sourceNotice: "Grant sources do not represent effective session permissions.",
managementHint: "Management statements may commit implicitly. Revoke targets direct grants to the selected principal; other sources may still apply.",
writeRejected: "The database rejected the operation. Check privileges, objects and inputs before preparing it again.",
writeUnknown: "The outcome is unknown. Read back the state or reconnect to verify before retrying.",
stopFollowing: "Stop later statements",
stopBeforeDispatch: "Stop before sending",
stoppingAwaitingResult: "Later statements will stop. A sent statement is still awaiting its response.",
stoppedFollowing: "Later statements stopped",
notSentAfterStop: "Not sent because execution was stopped.",
readDetails: "Read details",
loaded: "Loaded",
empty: "Empty in visible scope",
denied: "Permission denied",
unavailable: "View absent or inaccessible",
error: "Read failed",
unsupported: "Unsupported",
title: "Users, roles and grants",
notice:
"Shows grants visible to the current account. Role inheritance does not imply that a role is enabled in this session. Limited visibility cannot prove the absence of other grants. One discovered inheritance path is shown per role from each of the selected principal and PUBLIC; other paths may exist.",
full: "Full dictionary scope",
limited: "Limited visibility",
truncated: ", row limit reached",
filter: "Filter principal names",
principal: "Principal",
all: "All visible object grants",
owner: "Exact object owner",
objectName: "Exact object name",
accountUnavailable: "Account status unavailable",
role: "Role",
unknown: "Unavailable",
bounded: "Role traversal reached its limit; the following provenance is incomplete.",
kind: "Kind",
privilege: "Privilege",
object: "Object",
source: "Source",
grantOption: "Grant option",
unknownGrantor: "Unknown",
incomplete: "Grant sources are incomplete; other grants may exist.",
noGrants: "No grants in the current visible result and filter scope.",
memberships: "Role memberships",
grantee: "Grantee",
direct: "Direct",
public: "PUBLIC",
system: "System",
column: "Column",
yes: "Yes",
no: "No",
currentUser: "Current user",
users: "Users",
roles: "Roles",
roleGrants: "Role grants",
systemGrants: "System grants",
objectGrants: "Object grants",
columnGrants: "Column grants",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+62
View File
@@ -66,6 +66,68 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "Solo concesiones directas visibles, no permisos efectivos de sesión ni un script completo para reproducirlas. La herencia y los otorgantes están en los detalles de origen.",
searchOwner: "Buscar owner/schema",
chooseObjects: "Elegir objetos",
searchObjects: "Buscar objetos",
invalidObjectSelection: "Seleccione objetos específicos y permisos aplicables a todos los objetos seleccionados.",
sourceNotice: "Las fuentes de permisos no representan los permisos efectivos de la sesión.",
managementHint: "Las sentencias de administración pueden confirmar cambios implícitamente. La revocación solo afecta a concesiones directas al sujeto seleccionado; otros orígenes pueden seguir vigentes.",
writeRejected: "La base de datos rechazó la operación. Revise permisos, objetos y entradas antes de prepararla de nuevo.",
writeUnknown: "El resultado es desconocido. Consulte el estado o vuelva a conectarse para verificarlo antes de reintentar.",
stopFollowing: "Detener las sentencias posteriores",
stopBeforeDispatch: "Detener antes de enviar",
stoppingAwaitingResult: "Se detendrán las sentencias posteriores. Una sentencia enviada aún espera respuesta.",
stoppedFollowing: "Sentencias posteriores detenidas",
notSentAfterStop: "No se envió porque se detuvo la ejecución.",
readDetails: "Detalles de lectura",
loaded: "Cargado",
empty: "Ámbito visible vacío",
denied: "Permiso denegado",
unavailable: "Vista inexistente o inaccesible",
error: "Error de lectura",
unsupported: "No compatible",
title: "Usuarios, roles y permisos",
notice:
"Muestra los permisos visibles para la cuenta actual. La herencia de roles no implica que el rol esté habilitado en esta sesión. La visibilidad limitada no demuestra la ausencia de otros permisos. Para cada rol se muestra una ruta de herencia descubierta desde el sujeto seleccionado y otra desde PUBLIC; pueden existir otras rutas.",
full: "Ámbito completo del diccionario",
limited: "Visibilidad limitada",
truncated: ", límite de filas alcanzado",
filter: "Filtrar nombres de sujetos",
principal: "Sujeto",
all: "Todos los permisos de objetos visibles",
owner: "Propietario exacto del objeto",
objectName: "Nombre exacto del objeto",
accountUnavailable: "Estado de cuenta no disponible",
role: "Rol",
unknown: "No disponible",
bounded: "Se alcanzó el límite de recorrido de roles; el origen mostrado está incompleto.",
kind: "Tipo",
privilege: "Permiso",
object: "Objeto",
source: "Origen",
grantOption: "Puede conceder",
unknownGrantor: "Desconocido",
incomplete: "Los orígenes están incompletos; pueden existir otros permisos.",
noGrants: "No hay permisos en el resultado visible y el filtro actuales.",
memberships: "Relaciones de roles",
grantee: "Beneficiario",
direct: "Directo",
public: "PUBLIC",
system: "Sistema",
column: "Columna",
yes: "Sí",
no: "No",
currentUser: "Usuario actual",
users: "Usuarios",
roles: "Roles",
roleGrants: "Roles concedidos",
systemGrants: "Permisos de sistema",
objectGrants: "Permisos de objetos",
columnGrants: "Permisos de columnas",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+62
View File
@@ -64,6 +64,68 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "Solo concessioni dirette visibili, non privilegi effettivi della sessione né uno script completo da rieseguire. Eredità e concedenti sono nei dettagli delle origini.",
searchOwner: "Cerca owner/schema",
chooseObjects: "Scegli oggetti",
searchObjects: "Cerca oggetti",
invalidObjectSelection: "Seleziona oggetti specifici e privilegi applicabili a tutti gli oggetti selezionati.",
sourceNotice: "Le origini dei privilegi non rappresentano i privilegi effettivi della sessione.",
managementHint: "Le istruzioni di gestione possono eseguire commit impliciti. La revoca riguarda solo le concessioni dirette al soggetto selezionato; altre origini possono restare valide.",
writeRejected: "Il database ha rifiutato l’operazione. Verificare privilegi, oggetti e dati prima di prepararla di nuovo.",
writeUnknown: "Esito sconosciuto. Rileggere lo stato o riconnettersi per verificarlo prima di riprovare.",
stopFollowing: "Interrompi le istruzioni successive",
stopBeforeDispatch: "Interrompi prima dell’invio",
stoppingAwaitingResult: "Le istruzioni successive verranno interrotte. Un’istruzione inviata è ancora in attesa di risposta.",
stoppedFollowing: "Istruzioni successive interrotte",
notSentAfterStop: "Non inviata perché l’esecuzione è stata interrotta.",
readDetails: "Dettagli della lettura",
loaded: "Caricato",
empty: "Ambito visibile vuoto",
denied: "Permesso negato",
unavailable: "Vista assente o inaccessibile",
error: "Lettura non riuscita",
unsupported: "Non supportato",
title: "Utenti, ruoli e privilegi",
notice:
"Mostra i privilegi visibili all’account corrente. L’ereditarietà non implica che il ruolo sia abilitato nella sessione. La visibilità limitata non dimostra l’assenza di altri privilegi. Per ogni ruolo viene mostrato un percorso di ereditarietà individuato dal soggetto selezionato e uno da PUBLIC; possono esistere altri percorsi.",
full: "Ambito completo del dizionario",
limited: "Visibilità limitata",
truncated: ", limite di righe raggiunto",
filter: "Filtra nomi dei soggetti",
principal: "Soggetto",
all: "Tutti i privilegi visibili sugli oggetti",
owner: "Proprietario esatto dell’oggetto",
objectName: "Nome esatto dell’oggetto",
accountUnavailable: "Stato account non disponibile",
role: "Ruolo",
unknown: "Non disponibile",
bounded: "Limite di attraversamento dei ruoli raggiunto; le origini sono incomplete.",
kind: "Tipo",
privilege: "Privilegio",
object: "Oggetto",
source: "Origine",
grantOption: "Può concedere",
unknownGrantor: "Sconosciuto",
incomplete: "Le origini sono incomplete; possono esistere altri privilegi.",
noGrants: "Nessun privilegio nei risultati visibili e nel filtro attuali.",
memberships: "Relazioni dei ruoli",
grantee: "Beneficiario",
direct: "Diretto",
public: "PUBLIC",
system: "Sistema",
column: "Colonna",
yes: "Sì",
no: "No",
currentUser: "Utente corrente",
users: "Utenti",
roles: "Ruoli",
roleGrants: "Ruoli concessi",
systemGrants: "Privilegi di sistema",
objectGrants: "Privilegi sugli oggetti",
columnGrants: "Privilegi sulle colonne",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+62
View File
@@ -65,6 +65,68 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "参照できる直接付与のみを表示します。セッションの有効な権限や完全な再実行スクリプトではありません。継承と付与者は付与元の詳細を参照してください。",
searchOwner: "owner/schema を検索",
chooseObjects: "オブジェクトを選択",
searchObjects: "オブジェクトを検索",
invalidObjectSelection: "具体的なオブジェクトと、選択したすべてのオブジェクトに適用できる権限を選択してください。",
sourceNotice: "付与元は現在のセッションで有効な権限を示すものではありません。",
managementHint: "管理文は暗黙的にコミットする場合があります。取消対象は選択した主体への直接付与のみで、他の付与元は引き続き有効な場合があります。",
writeRejected: "データベースが操作を拒否しました。権限、オブジェクト、入力を確認してから再度プレビューしてください。",
writeUnknown: "実行結果は不明です。再試行する前に状態を読み直すか再接続して確認してください。",
stopFollowing: "後続の文を停止",
stopBeforeDispatch: "送信前に停止",
stoppingAwaitingResult: "後続の文を停止します。送信済みの文は応答待ちです。",
stoppedFollowing: "後続の文を停止しました",
notSentAfterStop: "実行が停止されたため送信されませんでした。",
readDetails: "読み取りの詳細",
loaded: "読み込み済み",
empty: "表示可能な範囲は空です",
denied: "権限がありません",
unavailable: "ビューが存在しないか参照できません",
error: "読み込み失敗",
unsupported: "未対応",
title: "ユーザー、ロールと権限",
notice:
"現在のアカウントで参照可能な付与関係を表示します。継承元のロールが現在のセッションで有効とは限りません。表示範囲が制限されている場合、他の権限が存在しないとは判断できません。 選択した主体と PUBLIC を起点に、各ロールへの継承経路をそれぞれ1つだけ表示します。他の経路が存在する可能性があります。",
full: "辞書の全範囲",
limited: "表示範囲に制限あり",
truncated: "、行数上限に到達",
filter: "主体名で絞り込み",
principal: "主体",
all: "参照可能なすべてのオブジェクト権限",
owner: "オブジェクト所有者(完全一致)",
objectName: "オブジェクト名(完全一致)",
accountUnavailable: "アカウント状態は参照不可",
role: "ロール",
unknown: "参照不可",
bounded: "ロール探索の上限に達したため、付与元は不完全です。",
kind: "種類",
privilege: "権限",
object: "オブジェクト",
source: "付与元",
grantOption: "再付与可能",
unknownGrantor: "不明",
incomplete: "付与元の読み込みは不完全です。他の権限が存在する可能性があります。",
noGrants: "現在の表示結果と絞り込み範囲に権限レコードはありません。",
memberships: "ロールの関係",
grantee: "付与先",
direct: "直接",
public: "PUBLIC",
system: "システム",
column: "列",
yes: "はい",
no: "いいえ",
currentUser: "現在のユーザー",
users: "ユーザー",
roles: "ロール",
roleGrants: "ロール付与",
systemGrants: "システム権限",
objectGrants: "オブジェクト権限",
columnGrants: "列権限",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+61
View File
@@ -64,6 +64,67 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "조회 가능한 직접 부여만 표시하며 세션의 유효 권한이나 완전한 재실행 스크립트가 아닙니다. 상속과 부여자는 출처 상세 정보에 표시됩니다.",
searchOwner: "owner/schema 검색",
chooseObjects: "객체 선택",
searchObjects: "객체 검색",
invalidObjectSelection: "구체적인 객체와 선택한 모든 객체에 적용 가능한 권한을 선택하세요.",
sourceNotice: "권한 부여 출처는 현재 세션의 유효 권한을 의미하지 않습니다.",
managementHint: "관리 구문은 암시적으로 커밋될 수 있습니다. 회수는 선택한 주체의 직접 부여만 대상으로 하며 다른 출처는 계속 유효할 수 있습니다.",
writeRejected: "데이터베이스가 작업을 거부했습니다. 권한, 객체 및 입력을 확인한 후 다시 미리 보세요.",
writeUnknown: "실행 결과를 알 수 없습니다. 재시도하기 전에 상태를 다시 조회하거나 재연결하여 확인하세요.",
stopFollowing: "후속 문 중지",
stopBeforeDispatch: "전송 전에 중지",
stoppingAwaitingResult: "후속 문을 중지합니다. 이미 전송된 문은 응답을 기다리고 있습니다.",
stoppedFollowing: "후속 문 중지됨",
notSentAfterStop: "실행이 중지되어 전송되지 않았습니다.",
readDetails: "조회 상세",
loaded: "읽음",
empty: "표시 범위가 비어 있음",
denied: "권한 거부",
unavailable: "뷰가 없거나 접근할 수 없음",
error: "읽기 실패",
unsupported: "지원하지 않음",
title: "사용자, 역할 및 권한",
notice: "현재 계정에 보이는 권한 관계를 표시합니다. 역할 상속이 현재 세션에서 역할이 활성화됨을 의미하지는 않습니다. 제한된 조회 범위로 다른 권한의 부재를 확인할 수는 없습니다. 선택한 주체와 PUBLIC 각각에서 각 역할로 이어지는 발견된 상속 경로 하나만 표시합니다. 다른 경로가 존재할 수 있습니다.",
full: "전체 사전 범위",
limited: "조회 범위 제한",
truncated: ", 행 수 제한에 도달",
filter: "주체 이름 필터",
principal: "주체",
all: "표시 가능한 모든 객체 권한",
owner: "정확한 객체 소유자",
objectName: "정확한 객체 이름",
accountUnavailable: "계정 상태 확인 불가",
role: "역할",
unknown: "확인 불가",
bounded: "역할 탐색 한도에 도달하여 출처가 불완전합니다.",
kind: "유형",
privilege: "권한",
object: "객체",
source: "출처",
grantOption: "재부여 가능",
unknownGrantor: "알 수 없음",
incomplete: "권한 출처가 불완전합니다. 다른 권한이 존재할 수 있습니다.",
noGrants: "현재 표시 결과와 필터 범위에 권한 레코드가 없습니다.",
memberships: "역할 관계",
grantee: "권한 수신자",
direct: "직접",
public: "PUBLIC",
system: "시스템",
column: "열",
yes: "예",
no: "아니요",
currentUser: "현재 사용자",
users: "사용자",
roles: "역할",
roleGrants: "역할 부여",
systemGrants: "시스템 권한",
objectGrants: "객체 권한",
columnGrants: "열 권한",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+62
View File
@@ -65,6 +65,68 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "Apenas concessões diretas visíveis, não permissões efetivas da sessão nem um script completo para reprodução. Herança e concedentes estão nos detalhes de origem.",
searchOwner: "Pesquisar owner/schema",
chooseObjects: "Escolher objetos",
searchObjects: "Pesquisar objetos",
invalidObjectSelection: "Selecione objetos específicos e privilégios aplicáveis a todos os objetos selecionados.",
sourceNotice: "As origens das concessões não representam as permissões efetivas da sessão.",
managementHint: "As instruções de gerenciamento podem confirmar alterações implicitamente. A revogação afeta somente concessões diretas ao principal selecionado; outras origens podem continuar válidas.",
writeRejected: "O banco de dados rejeitou a operação. Verifique privilégios, objetos e entradas antes de prepará-la novamente.",
writeUnknown: "O resultado é desconhecido. Consulte o estado ou reconecte-se para verificar antes de tentar novamente.",
stopFollowing: "Parar as instruções seguintes",
stopBeforeDispatch: "Parar antes de enviar",
stoppingAwaitingResult: "As instruções seguintes serão interrompidas. Uma instrução enviada ainda aguarda resposta.",
stoppedFollowing: "Instruções seguintes interrompidas",
notSentAfterStop: "Não foi enviada porque a execução foi interrompida.",
readDetails: "Detalhes da leitura",
loaded: "Carregado",
empty: "Escopo visível vazio",
denied: "Permissão negada",
unavailable: "Visualização ausente ou inacessível",
error: "Falha na leitura",
unsupported: "Não suportado",
title: "Usuários, funções e privilégios",
notice:
"Mostra os privilégios visíveis para a conta atual. A herança de funções não implica que a função esteja habilitada nesta sessão. A visibilidade limitada não comprova a ausência de outros privilégios. Para cada função, é mostrado um caminho de herança encontrado a partir do principal selecionado e outro a partir de PUBLIC; outros caminhos podem existir.",
full: "Escopo completo do dicionário",
limited: "Visibilidade limitada",
truncated: ", limite de linhas atingido",
filter: "Filtrar nomes de entidades",
principal: "Entidade",
all: "Todos os privilégios visíveis de objetos",
owner: "Proprietário exato do objeto",
objectName: "Nome exato do objeto",
accountUnavailable: "Estado da conta indisponível",
role: "Função",
unknown: "Indisponível",
bounded: "Limite de percurso de funções atingido; as origens estão incompletas.",
kind: "Tipo",
privilege: "Privilégio",
object: "Objeto",
source: "Origem",
grantOption: "Pode conceder",
unknownGrantor: "Desconhecido",
incomplete: "As origens estão incompletas; outros privilégios podem existir.",
noGrants: "Nenhum privilégio no resultado visível e no filtro atuais.",
memberships: "Relações de funções",
grantee: "Beneficiário",
direct: "Direto",
public: "PUBLIC",
system: "Sistema",
column: "Coluna",
yes: "Sim",
no: "Não",
currentUser: "Usuário atual",
users: "Usuários",
roles: "Funções",
roleGrants: "Funções concedidas",
systemGrants: "Privilégios de sistema",
objectGrants: "Privilégios de objetos",
columnGrants: "Privilégios de colunas",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+62
View File
@@ -7,6 +7,68 @@ import { meilisearchManagementRu } from "./meilisearchManagement";
// Russian locale. Entries omitted here fall back to English at runtime.
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "Только видимые прямые назначения, а не действующие права сеанса или полный скрипт для повторного выполнения. Наследование и выдавшие права указаны в подробностях источников.",
searchOwner: "Поиск owner/schema",
chooseObjects: "Выбрать объекты",
searchObjects: "Поиск объектов",
invalidObjectSelection: "Выберите конкретные объекты и права, применимые ко всем выбранным объектам.",
sourceNotice: "Источники прав не отражают действующие права текущего сеанса.",
managementHint: "Операторы управления могут выполнять неявный commit. Отзыв касается только прямых назначений выбранному субъекту; права из других источников могут сохраняться.",
writeRejected: "База данных отклонила операцию. Проверьте права, объекты и ввод перед повторной подготовкой.",
writeUnknown: "Результат неизвестен. Прочитайте состояние или переподключитесь для проверки перед повторной попыткой.",
stopFollowing: "Остановить последующие команды",
stopBeforeDispatch: "Остановить до отправки",
stoppingAwaitingResult: "Последующие команды будут остановлены. Отправленная команда всё ещё ожидает ответа.",
stoppedFollowing: "Последующие команды остановлены",
notSentAfterStop: "Не отправлено, так как выполнение было остановлено.",
readDetails: "Подробности чтения",
loaded: "Загружено",
empty: "Видимая область пуста",
denied: "Доступ запрещён",
unavailable: "Представление отсутствует или недоступно",
error: "Ошибка чтения",
unsupported: "Не поддерживается",
title: "Пользователи, роли и права",
notice:
"Показаны права, видимые текущей учётной записи. Наследование роли не означает, что она включена в текущем сеансе. Ограниченная видимость не доказывает отсутствие других прав. Для каждой роли показан один найденный путь наследования от выбранного субъекта и один от PUBLIC; могут существовать другие пути.",
full: "Полный охват словаря",
limited: "Ограниченная видимость",
truncated: ", достигнут предел строк",
filter: "Фильтр имён субъектов",
principal: "Субъект",
all: "Все видимые права на объекты",
owner: "Точный владелец объекта",
objectName: "Точное имя объекта",
accountUnavailable: "Состояние учётной записи недоступно",
role: "Роль",
unknown: "Недоступно",
bounded: "Достигнут предел обхода ролей; сведения об источниках неполны.",
kind: "Тип",
privilege: "Право",
object: "Объект",
source: "Источник",
grantOption: "Можно передавать",
unknownGrantor: "Неизвестно",
incomplete: "Сведения об источниках неполны; могут существовать другие права.",
noGrants: "В текущих видимых результатах и области фильтра нет записей о правах.",
memberships: "Связи ролей",
grantee: "Получатель",
direct: "Прямое",
public: "PUBLIC",
system: "Система",
column: "Столбец",
yes: "Да",
no: "Нет",
currentUser: "Текущий пользователь",
users: "Пользователи",
roles: "Роли",
roleGrants: "Предоставленные роли",
systemGrants: "Системные права",
objectGrants: "Права на объекты",
columnGrants: "Права на столбцы",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+61
View File
@@ -10,6 +10,67 @@ import { mongodbDatabaseDumpZhCN as mongoDump } from "./mongodbDatabaseDump";
import { damengDumpZhCN as damengDump } from "./damengDump";
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "仅显示可见的直接授权,不代表会话有效权限,也不是完整重放脚本。继承及授予者见来源详情。",
searchOwner: "搜索 owner/schema",
chooseObjects: "选择对象",
searchObjects: "搜索对象",
invalidObjectSelection: "请选择具体对象,以及适用于每个所选对象的权限。",
sourceNotice: "授予来源不代表当前会话的有效权限。",
managementHint: "管理语句可能隐式提交。撤销仅针对选中主体的直接授权,其他来源可能仍有效。",
writeRejected: "数据库拒绝了此操作。请核对权限、对象和输入后重新预览。",
writeUnknown: "执行结果不明。请读回状态或重新连接核实,不要直接重试。",
stopFollowing: "停止后续语句",
stopBeforeDispatch: "发送前停止",
stoppingAwaitingResult: "后续语句将停止;已发送的语句仍在等待响应。",
stoppedFollowing: "已停止后续语句",
notSentAfterStop: "执行已停止,此语句未发送。",
readDetails: "读取详情",
loaded: "已读取",
empty: "当前范围为空",
denied: "无权读取",
unavailable: "视图不存在或不可见",
error: "读取失败",
unsupported: "不支持",
title: "用户、角色与授权",
notice: "显示当前账号可见的授予关系。继承来源不代表角色已在当前会话启用;受限范围无法证明其他授权不存在。 从所选主体和 PUBLIC 出发,对每个角色分别仅展示一条已发现的继承路径;可能存在其他路径。",
full: "完整字典范围",
limited: "可见范围受限",
truncated: ",已达读取上限",
filter: "筛选主体名称",
principal: "主体",
all: "全部可见对象授权",
owner: "对象 owner(精确)",
objectName: "对象名称(精确)",
accountUnavailable: "账号状态不可见",
role: "角色",
unknown: "不可见",
bounded: "角色关系超过读取边界,以下来源不完整。",
kind: "授权类型",
privilege: "权限",
object: "对象",
source: "来源",
grantOption: "可转授",
unknownGrantor: "未知",
incomplete: "授权来源读取不完整,无法判断是否存在其他授权。",
noGrants: "当前可见结果与筛选范围内没有授权记录。",
memberships: "角色关系",
grantee: "主体",
direct: "直接授权",
public: "PUBLIC 授权",
system: "系统权限",
column: "列权限",
yes: "是",
no: "否",
currentUser: "当前用户",
users: "用户",
roles: "角色",
roleGrants: "角色授予",
systemGrants: "系统授权",
objectGrants: "对象授权",
columnGrants: "列授权",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
+61
View File
@@ -66,6 +66,67 @@ const consul = {
};
export default withEnglishFallback({
oracleSecurity: {
directSqlNotice: "僅顯示可見的直接授權,不代表工作階段有效權限,也不是完整重播指令碼。繼承及授予者請見來源詳情。",
searchOwner: "搜尋 owner/schema",
chooseObjects: "選擇物件",
searchObjects: "搜尋物件",
invalidObjectSelection: "請選擇具體物件,以及適用於每個所選物件的權限。",
sourceNotice: "授予來源不代表目前工作階段的有效權限。",
managementHint: "管理語句可能隱含提交。撤銷僅針對所選主體的直接授權,其他來源可能仍有效。",
writeRejected: "資料庫拒絕此操作。請核對權限、物件和輸入後重新預覽。",
writeUnknown: "執行結果不明。請讀回狀態或重新連線核實,不要直接重試。",
stopFollowing: "停止後續陳述式",
stopBeforeDispatch: "送出前停止",
stoppingAwaitingResult: "後續陳述式將停止;已送出的陳述式仍在等待回應。",
stoppedFollowing: "已停止後續陳述式",
notSentAfterStop: "執行已停止,此陳述式未送出。",
readDetails: "讀取詳情",
loaded: "已讀取",
empty: "目前範圍為空",
denied: "無權讀取",
unavailable: "檢視不存在或無法存取",
error: "讀取失敗",
unsupported: "不支援",
title: "使用者、角色與授權",
notice: "顯示目前帳號可見的授予關係。繼承來源不代表角色已在目前工作階段啟用;受限範圍無法證明其他授權不存在。 從所選主體和 PUBLIC 出發,對每個角色分別僅顯示一條已發現的繼承路徑;可能存在其他路徑。",
full: "完整字典範圍",
limited: "可見範圍受限",
truncated: ",已達讀取上限",
filter: "篩選主體名稱",
principal: "主體",
all: "全部可見物件授權",
owner: "物件擁有者(精確)",
objectName: "物件名稱(精確)",
accountUnavailable: "帳號狀態不可見",
role: "角色",
unknown: "不可見",
bounded: "角色關係超過讀取邊界,以下來源不完整。",
kind: "授權類型",
privilege: "權限",
object: "物件",
source: "來源",
grantOption: "可轉授",
unknownGrantor: "未知",
incomplete: "授權來源讀取不完整,無法判斷是否存在其他授權。",
noGrants: "目前可見結果與篩選範圍內沒有授權記錄。",
memberships: "角色關係",
grantee: "主體",
direct: "直接授權",
public: "PUBLIC 授權",
system: "系統權限",
column: "欄位權限",
yes: "是",
no: "否",
currentUser: "目前使用者",
users: "使用者",
roles: "角色",
roleGrants: "角色授予",
systemGrants: "系統授權",
objectGrants: "物件授權",
columnGrants: "欄位授權",
},
oceanbaseCloneDialog,
sidebarMenu,
modelGeneration: {
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
import { createApp, defineComponent, h, nextTick, ref, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { connectionConfigFingerprint } from "@/lib/connection/connectionDatabaseInfo";
import type { ConnectionConfig } from "@/types/database";
const mocks = vi.hoisted(() => ({
@@ -14,6 +15,14 @@ const mocks = vi.hoisted(() => ({
trackUiState: vi.fn(),
executeQuery: vi.fn(),
executeMulti: vi.fn(),
connectDb: vi.fn(),
disconnectDb: vi.fn(),
saveConnections: vi.fn(),
sessionCredentialStatus: vi.fn(),
forgetSessionCredential: vi.fn(),
loadSchemaCache: vi.fn(),
checkConnectionHealth: vi.fn(),
connectionIdentifierQuote: vi.fn(),
productionGuard: vi.fn(),
toast: vi.fn(),
listDatabases: vi.fn(),
@@ -51,7 +60,17 @@ function modelDialog(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => (attrs.open ? h("div", { ...attrs, "data-test-dialog": "true" }, slots.default?.()) : null);
return () =>
attrs.open
? h("div", { ...attrs, "data-test-dialog": "true" }, [
...(slots.default?.() ?? []),
h("button", {
type: "button",
"data-test-request-close": "true",
onClick: () => (attrs["onUpdate:open"] as ((open: boolean) => void) | undefined)?.(false),
}),
])
: null;
},
});
}
@@ -71,7 +90,8 @@ function passwordInput(): Component {
});
}
vi.mock("vue-i18n", () => ({
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) => (values?.message ? `${key}: ${values.message}` : key),
}),
@@ -125,6 +145,14 @@ vi.mock("@/stores/connectionStore", () => ({
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/lib/backend/api", () => ({
connectDb: mocks.connectDb,
disconnectDb: mocks.disconnectDb,
saveConnections: mocks.saveConnections,
sessionCredentialStatus: mocks.sessionCredentialStatus,
forgetSessionCredential: mocks.forgetSessionCredential,
loadSchemaCache: mocks.loadSchemaCache,
checkConnectionHealth: mocks.checkConnectionHealth,
connectionIdentifierQuote: mocks.connectionIdentifierQuote,
executeQuery: mocks.executeQuery,
executeMulti: mocks.executeMulti,
listDatabases: mocks.listDatabases,
@@ -133,6 +161,9 @@ vi.mock("@/lib/backend/api", () => ({
listTables: mocks.listTables,
listSchemas: mocks.listSchemas,
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({ rollbackConnectionTransactions: vi.fn(), closeConnectionTabs: vi.fn(), releaseConnectionTabs: vi.fn(), invalidateConnectionMetadata: vi.fn(), tabs: [], flushPendingPersist: vi.fn().mockResolvedValue(undefined) }),
}));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionSqlGuard: async (options: { execute: () => Promise<unknown> }) => {
mocks.productionGuard(options);
@@ -158,6 +189,16 @@ const connection: ConnectionConfig = {
password: "",
};
const oracleConnection: ConnectionConfig = {
...connection,
id: "same-oracle-connection",
db_type: "oracle",
driver_profile: "oracle",
host: "old-host",
port: 1521,
database: "OLD_SERVICE",
};
const nativeMysqlConnection: ConnectionConfig = {
...connection,
id: "native-mysql",
@@ -189,16 +230,351 @@ const starrocksConnection: ConnectionConfig = {
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
let updateConnection: ((connection: ConnectionConfig) => void) | undefined;
function mountWithReactiveConnection(initialConnection: ConnectionConfig) {
const Harness = defineComponent({
setup(_, { expose }) {
const current = ref(initialConnection);
expose({ update: (connection: ConnectionConfig) => (current.value = connection) });
return () => h(DatabaseUserAdmin, { connection: current.value });
},
});
root = document.createElement("div");
document.body.append(root);
app = createApp(Harness);
app.mount(root);
updateConnection = (app._instance?.exposed as { update: (connection: ConnectionConfig) => void }).update;
}
afterEach(() => {
app?.unmount();
root?.remove();
app = undefined;
root = undefined;
updateConnection = undefined;
mocks.tabUiState = {};
mocks.guardCancelled = false;
mocks.guardWait = undefined;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("DatabaseUserAdmin connection identity", () => {
it("does not execute a password preview after the same connection id is edited", async () => {
const config = { ...postgresConnection, id: "same-user-admin-connection", db_type: "vastbase", username: "fixture_self", password: "saved-old" } as ConnectionConfig;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.getConfig.mockReturnValue({ ...config, password: "", save_password: false });
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [["fixture_self", "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
mountWithReactiveConnection(config);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
for (const [key, value] of [
["newPassword", "fixture-new"],
["oldPassword", "fixture-old"],
]) {
const input = root!.querySelector<HTMLInputElement>(`input[placeholder="userAdmin.${key}"]`)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
await nextTick();
findButton("userAdmin.previewSql")!.click();
await nextTick();
updateConnection!({ ...config, host: "new-host" });
await nextTick();
expect(findButton("userAdmin.applySql")).toBeUndefined();
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("does not retire a newly edited connection after a late self-password acknowledgement", async () => {
const config = { ...postgresConnection, id: "same-user-admin-connection", db_type: "vastbase", username: "fixture_self", password: "saved-old" } as ConnectionConfig;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.retirePasswordAfterChange.mockResolvedValue({ status: "skipped" });
mocks.getConfig.mockReturnValue({ ...config, password: "", save_password: false });
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [["fixture_self", "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
mountWithReactiveConnection(config);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
for (const [key, value] of [
["newPassword", "fixture-new"],
["oldPassword", "fixture-old"],
]) {
const input = root!.querySelector<HTMLInputElement>(`input[placeholder="userAdmin.${key}"]`)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
await nextTick();
findButton("userAdmin.previewSql")!.click();
await nextTick();
let finish!: (results: unknown[]) => void;
mocks.executeMulti.mockImplementationOnce(() => new Promise((resolve) => (finish = resolve)));
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
updateConnection!({ ...config, host: "new-host" });
await nextTick();
finish([{ columns: [], rows: [] }]);
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
expect(mocks.retirePasswordAfterChange).not.toHaveBeenCalled();
});
it.each(["success", "unknown", "refused"] as const)("retains a late %s acknowledgement after cancel and stops the next Oracle grant", async (acknowledgement) => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listSchemas.mockResolvedValue(["OWNER_A", "OWNER_B"]);
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => {
if (sql.includes("SELECT USER AS USERNAME")) return { columns: ["USERNAME"], rows: [["DBA"]] };
if (sql.includes("DBA_USERS"))
return {
columns: ["USERNAME", "ACCOUNT_STATUS"],
rows: [
["TARGET_USER", "OPEN"],
["OTHER_USER", "OPEN"],
],
};
if (sql.includes("DBA_ROLES")) return { columns: ["ROLE"], rows: [] };
if (sql.includes("DBA_ROLE_PRIVS")) return { columns: ["GRANTEE", "GRANTED_ROLE", "ADMIN_OPTION", "DEFAULT_ROLE"], rows: [] };
if (sql.includes("DBA_SYS_PRIVS")) return { columns: ["GRANTEE", "PRIVILEGE", "ADMIN_OPTION"], rows: [] };
if (sql.includes("DBA_TAB_PRIVS")) return { columns: ["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], rows: [] };
if (sql.includes("DBA_COL_PRIVS")) return { columns: ["GRANTEE", "OWNER", "TABLE_NAME", "COLUMN_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], rows: [] };
if (sql.includes("FROM ALL_OBJECTS")) {
const owner = sql.match(/OWNER = '([^']+)'/)?.[1];
return { columns: ["OBJECT_NAME", "OBJECT_TYPE"], rows: [[`TABLE_${owner}`, "TABLE"]] };
}
return { columns: [], rows: [] };
});
mountWithReactiveConnection(oracleConnection);
await vi.waitFor(() => expect(root!.textContent).toContain("TARGET_USER"));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("TARGET_USER"))!
.click();
await nextTick();
for (const owner of ["OWNER_A", "OWNER_B"]) {
findButton(owner)!.click();
await nextTick();
const chooseObjects = Array.from(root!.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.includes("oracleSecurity.chooseObjects"));
expect(chooseObjects).toBeDefined();
chooseObjects!.click();
await vi.waitFor(() => expect(root!.textContent).toContain(`TABLE_${owner}`));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes(`TABLE_${owner}`))!
.click();
await nextTick();
}
findButton("userAdmin.grant")!.click();
await nextTick();
expect(findButton("userAdmin.applySql")).toBeDefined();
let finishFirst!: (results: unknown[]) => void;
let failFirst!: (error: Error) => void;
mocks.executeMulti.mockImplementationOnce(
() =>
new Promise((resolve, reject) => {
finishFirst = resolve;
failFirst = reject;
}),
);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
const cancel = Array.from(root!.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "oracleSecurity.stopFollowing");
expect(cancel).toBeDefined();
cancel!.click();
await nextTick();
expect(root!.textContent).toContain("oracleSecurity.stoppingAwaitingResult");
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("OTHER_USER"))!
.click();
await nextTick();
if (acknowledgement === "success") finishFirst([{ columns: [], rows: [] }]);
else if (acknowledgement === "refused") finishFirst([{ columns: [], rows: [["ORA-01031: insufficient privileges"]], execution_error: true }]);
else failFirst(new Error("connection lost after dispatch"));
const expectedReceipt = acknowledgement === "success" ? "userAdmin.resultPartial" : acknowledgement === "refused" ? "oracleSecurity.writeRejected" : "oracleSecurity.writeUnknown";
await vi.waitFor(() => expect(root!.textContent).toContain(expectedReceipt));
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
expect(root!.textContent).toContain(expectedReceipt);
expect(root!.textContent).toContain("OWNER_A.TABLE_OWNER_A");
expect(root!.textContent).toContain("OWNER_B.TABLE_OWNER_B");
expect(root!.textContent).toContain("OceanBase · oracle · TARGET_USER");
expect(root!.textContent).toContain("oracleSecurity.notSentAfterStop");
});
it("discards an Oracle security read and snapshot when the same connection id is edited", async () => {
let currentUser = "OLD_ADMIN";
let releaseOldRead!: (result: { columns: string[]; rows: unknown[][] }) => void;
let firstCurrentUserRead = true;
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listSchemas.mockResolvedValue([]);
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => {
if (sql.includes("SELECT USER AS USERNAME")) {
if (firstCurrentUserRead) {
firstCurrentUserRead = false;
return new Promise((resolve) => (releaseOldRead = resolve));
}
return { columns: ["USERNAME"], rows: [[currentUser]] };
}
if (sql.includes("DBA_USERS")) return { columns: ["USERNAME", "ACCOUNT_STATUS"], rows: [[currentUser, "OPEN"]] };
if (sql.includes("DBA_ROLES")) return { columns: ["ROLE"], rows: [] };
if (sql.includes("DBA_ROLE_PRIVS") || sql.includes("DBA_SYS_PRIVS") || sql.includes("DBA_TAB_PRIVS") || sql.includes("DBA_COL_PRIVS")) {
const columns = sql.includes("ROLE_PRIVS")
? ["GRANTEE", "GRANTED_ROLE", "ADMIN_OPTION", "DEFAULT_ROLE"]
: sql.includes("SYS_PRIVS")
? ["GRANTEE", "PRIVILEGE", "ADMIN_OPTION"]
: sql.includes("COL_PRIVS")
? ["GRANTEE", "OWNER", "TABLE_NAME", "COLUMN_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"]
: ["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"];
return { columns, rows: [] };
}
return { columns: [], rows: [] };
});
mountWithReactiveConnection(oracleConnection);
await vi.waitFor(() => expect(releaseOldRead).toBeDefined());
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(7));
currentUser = "NEW_ADMIN";
updateConnection!({ ...oracleConnection, host: "new-host", database: "NEW_SERVICE" });
await nextTick();
expect(mocks.executeQuery).toHaveBeenCalledTimes(14);
await vi.waitFor(() => expect(root!.textContent).toContain("NEW_ADMIN"));
releaseOldRead({ columns: ["USERNAME"], rows: [["OLD_ADMIN"]] });
await nextTick();
await nextTick();
expect(root!.textContent).not.toContain("OLD_ADMIN");
});
it("skips a confirmed Oracle plan when the selected subject changes before dispatch", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.listSchemas.mockResolvedValue(["OWNER_A"]);
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => {
if (sql.includes("SELECT USER AS USERNAME")) return { columns: ["USERNAME"], rows: [["DBA"]] };
if (sql.includes("DBA_USERS"))
return {
columns: ["USERNAME", "ACCOUNT_STATUS"],
rows: [
["TARGET_USER", "OPEN"],
["OTHER_USER", "OPEN"],
],
};
if (sql.includes("DBA_ROLES")) return { columns: ["ROLE"], rows: [] };
if (sql.includes("DBA_ROLE_PRIVS") || sql.includes("DBA_SYS_PRIVS") || sql.includes("DBA_TAB_PRIVS") || sql.includes("DBA_COL_PRIVS")) return { columns: ["GRANTEE", "PRIVILEGE"], rows: [] };
if (sql.includes("FROM ALL_OBJECTS")) return { columns: ["OBJECT_NAME", "OBJECT_TYPE"], rows: [["TABLE_OWNER_A", "TABLE"]] };
return { columns: [], rows: [] };
});
mountWithReactiveConnection(oracleConnection);
await vi.waitFor(() => expect(root!.textContent).toContain("OTHER_USER"));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("TARGET_USER"))!
.click();
await nextTick();
findButton("OWNER_A")!.click();
await nextTick();
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("oracleSecurity.chooseObjects"))!
.click();
await vi.waitFor(() => expect(root!.textContent).toContain("TABLE_OWNER_A"));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("TABLE_OWNER_A"))!
.click();
await nextTick();
findButton("userAdmin.grant")!.click();
await nextTick();
let confirm!: () => void;
mocks.guardWait = new Promise<void>((resolve) => {
confirm = resolve;
});
findButton("userAdmin.applySql")!.click();
await nextTick();
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("OTHER_USER"))!
.click();
await nextTick();
confirm();
await vi.waitFor(() => expect(root!.textContent).toContain("oracleSecurity.notSentAfterStop"));
expect(mocks.executeMulti).not.toHaveBeenCalled();
expect(root!.textContent).toContain("OceanBase · oracle · TARGET_USER");
expect(root!.textContent).toContain("userAdmin.resultFailed");
});
it.each(["transport rejection", "malformed acknowledgement"] as const)("retains an unknown receipt for a single Oracle statement after %s", async (failure) => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => {
if (sql.includes("SELECT USER AS USERNAME")) return { columns: ["USERNAME"], rows: [["DBA"]] };
if (sql.includes("DBA_USERS")) return { columns: ["USERNAME", "ACCOUNT_STATUS"], rows: [["TARGET_USER", "OPEN"]] };
if (sql.includes("DBA_ROLES")) return { columns: ["ROLE"], rows: [] };
if (sql.includes("DBA_ROLE_PRIVS") || sql.includes("DBA_SYS_PRIVS") || sql.includes("DBA_TAB_PRIVS") || sql.includes("DBA_COL_PRIVS")) return { columns: ["GRANTEE", "PRIVILEGE"], rows: [] };
return { columns: [], rows: [] };
});
mountWithReactiveConnection(oracleConnection);
await vi.waitFor(() => expect(root!.textContent).toContain("TARGET_USER"));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("TARGET_USER"))!
.click();
await nextTick();
findButton("userAdmin.lock")!.click();
await nextTick();
expect(findButton("userAdmin.applySql")).toBeDefined();
if (failure === "transport rejection") mocks.executeMulti.mockRejectedValueOnce(new Error("connection lost after dispatch"));
else mocks.executeMulti.mockResolvedValueOnce([{}] as any);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(root!.textContent).toContain("oracleSecurity.writeUnknown"));
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
expect(findButton("userAdmin.applySql")).toBeUndefined();
expect(findButton("userAdmin.closeResult")).toBeDefined();
expect(root!.textContent).toContain("OceanBase · oracle · TARGET_USER");
});
it("keeps a single Oracle statement pending and records its late acknowledgement after a close request", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation(async (_id, _database, sql: string) => {
if (sql.includes("SELECT USER AS USERNAME")) return { columns: ["USERNAME"], rows: [["DBA"]] };
if (sql.includes("DBA_USERS")) return { columns: ["USERNAME", "ACCOUNT_STATUS"], rows: [["TARGET_USER", "OPEN"]] };
if (sql.includes("DBA_ROLES") || sql.includes("DBA_ROLE_PRIVS") || sql.includes("DBA_SYS_PRIVS") || sql.includes("DBA_TAB_PRIVS") || sql.includes("DBA_COL_PRIVS")) return { columns: ["GRANTEE", "PRIVILEGE"], rows: [] };
return { columns: [], rows: [] };
});
mountWithReactiveConnection(oracleConnection);
await vi.waitFor(() => expect(root!.textContent).toContain("TARGET_USER"));
Array.from(root!.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.includes("TARGET_USER"))!
.click();
await nextTick();
findButton("userAdmin.lock")!.click();
await nextTick();
expect(findButton("userAdmin.applySql")).toBeDefined();
let acknowledge!: (results: unknown[]) => void;
mocks.executeMulti.mockImplementationOnce(
() =>
new Promise((resolve) => {
acknowledge = resolve;
}),
);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(1));
const waiting = Array.from(root!.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === "common.pending");
expect(waiting).toBeDefined();
expect(waiting!.disabled).toBe(true);
root!.querySelector<HTMLButtonElement>('[data-test-request-close="true"]')!.click();
await nextTick();
expect(root!.querySelector('[data-test-dialog="true"]')).not.toBeNull();
acknowledge([{ columns: [], rows: [] }]);
await vi.waitFor(() => expect(root!.textContent).toContain("userAdmin.resultSuccess"));
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
expect(root!.textContent).toContain("OceanBase · oracle · TARGET_USER");
findButton("userAdmin.closeResult")!.click();
await nextTick();
expect(root!.querySelector('[data-test-dialog="true"]')).toBeNull();
});
});
describe("DatabaseUserAdmin Vastbase passwords", () => {
@@ -206,14 +582,15 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
async function mountPassword(user = "fixture_self", dbConfig = config) {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.retirePasswordAfterChange.mockResolvedValue(undefined);
mocks.retirePasswordAfterChange.mockImplementation(async () => {
const retired = { ...dbConfig, password: "", save_password: false };
updateConnection?.(retired);
return { status: "retired", fingerprint: connectionConfigFingerprint(retired) };
});
mocks.connect.mockResolvedValue(undefined);
mocks.getConfig.mockReturnValue({ ...dbConfig, password: "", save_password: false });
mocks.executeQuery.mockImplementation(async (_id, _db, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [[user, "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: dbConfig });
app.mount(root);
mountWithReactiveConnection(dbConfig);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
@@ -240,7 +617,7 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
expect(root!.textContent).toContain(`ALTER ROLE "fixture_self" IDENTIFIED BY 'fixture-new' REPLACE 'fixture-old';`);
mocks.executeMulti.mockResolvedValueOnce([]);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith("vastbase"));
await vi.waitFor(() => expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith("vastbase", expect.any(String)));
await nextTick();
expect(mocks.executeMulti.mock.invocationCallOrder[0]).toBeLessThan(mocks.retirePasswordAfterChange.mock.invocationCallOrder[0]);
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
@@ -254,6 +631,108 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
expect(JSON.stringify(trackedState)).not.toContain("fixture-old");
});
it("keeps the reconnect notice when the real store retires the changed password", async () => {
const { createPinia, setActivePinia } = await import("pinia");
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) });
setActivePinia(createPinia());
const { useConnectionStore: useActualConnectionStore } = await vi.importActual<typeof import("@/stores/connectionStore")>("@/stores/connectionStore");
const store = useActualConnectionStore();
const storeConfig = { ...config, save_password: true };
store.connections = [{ ...storeConfig }];
store.connectedIds.add(storeConfig.id);
mocks.disconnectDb.mockResolvedValue(undefined);
mocks.saveConnections.mockResolvedValue(undefined);
mocks.sessionCredentialStatus.mockResolvedValue(true);
mocks.forgetSessionCredential.mockResolvedValue(undefined);
mocks.loadSchemaCache.mockResolvedValue(null);
mocks.checkConnectionHealth.mockResolvedValue(true);
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation(async (_id, _db, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [["fixture_self", "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
mocks.retirePasswordAfterChange.mockImplementation((id, fingerprint) => store.retirePasswordAfterChange(id, fingerprint));
const Harness = defineComponent({ setup: () => () => h(DatabaseUserAdmin, { connection: store.getConfig(storeConfig.id)! }) });
root = document.createElement("div");
document.body.append(root);
app = createApp(Harness);
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
for (const [key, value] of [
["newPassword", "fixture-new"],
["oldPassword", "fixture-old"],
]) {
const input = root!.querySelector<HTMLInputElement>(`input[placeholder="userAdmin.${key}"]`)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
await nextTick();
findButton("userAdmin.previewSql")!.click();
await nextTick();
mocks.executeMulti.mockResolvedValueOnce([]);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("userAdmin.passwordChangedReconnect", 8000));
expect(store.getConfig(storeConfig.id)).toMatchObject({ password: "", save_password: false });
expect(root!.textContent).toContain("userAdmin.passwordChangedReconnect");
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
expect(mocks.forgetSessionCredential).toHaveBeenCalledWith(storeConfig.id);
});
it("does not show the reconnect notice when the connection is edited during real store retirement", async () => {
const { createPinia, setActivePinia } = await import("pinia");
const storage = new Map<string, string>();
vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) });
setActivePinia(createPinia());
const { useConnectionStore: useActualConnectionStore } = await vi.importActual<typeof import("@/stores/connectionStore")>("@/stores/connectionStore");
const store = useActualConnectionStore();
const storeConfig = { ...config, save_password: true };
store.connections = [{ ...storeConfig }];
store.connectedIds.add(storeConfig.id);
mocks.disconnectDb.mockResolvedValue(undefined);
mocks.saveConnections.mockResolvedValue(undefined);
mocks.forgetSessionCredential.mockResolvedValue(undefined);
mocks.loadSchemaCache.mockResolvedValue(null);
mocks.checkConnectionHealth.mockResolvedValue(true);
let finishStatus!: (value: boolean) => void;
mocks.sessionCredentialStatus.mockImplementationOnce(() => new Promise((resolve) => (finishStatus = resolve)));
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation(async (_id, _db, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [["fixture_self", "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
mocks.retirePasswordAfterChange.mockImplementation((id, fingerprint) => store.retirePasswordAfterChange(id, fingerprint));
const Harness = defineComponent({ setup: () => () => h(DatabaseUserAdmin, { connection: store.getConfig(storeConfig.id)! }) });
root = document.createElement("div");
document.body.append(root);
app = createApp(Harness);
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
for (const [key, value] of [
["newPassword", "fixture-new"],
["oldPassword", "fixture-old"],
]) {
const input = root!.querySelector<HTMLInputElement>(`input[placeholder="userAdmin.${key}"]`)!;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
await nextTick();
findButton("userAdmin.previewSql")!.click();
await nextTick();
mocks.executeMulti.mockResolvedValueOnce([]);
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(finishStatus).toBeDefined());
store.connections = [{ ...store.getConfig(storeConfig.id)!, host: "edited-host" }];
await nextTick();
finishStatus(true);
await vi.waitFor(() => expect(mocks.retirePasswordAfterChange).toHaveBeenCalled());
expect(store.getConfig(storeConfig.id)).toMatchObject({ host: "edited-host", password: "", save_password: false });
expect(mocks.toast).not.toHaveBeenCalledWith("userAdmin.passwordChangedReconnect", 8000);
expect(root!.textContent).not.toContain("userAdmin.passwordChangedReconnect");
});
it("refreshes after an authorized other-user reset without retiring current credentials", async () => {
await mountPassword("fixture_other");
await preview("");
@@ -320,6 +799,29 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
expect(mocks.retirePasswordAfterChange).not.toHaveBeenCalled();
});
it("does not dispatch a preview when same-id connection settings change during confirmation", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.getConfig.mockReturnValue({ ...config, password: "", save_password: false });
mocks.executeQuery.mockImplementation(async (_id, _db, sql: string) => (!sql.startsWith("WITH target") ? { columns: ["user", "host", "plugin"], rows: [["fixture_self", "LOGIN", ""]] } : { columns: ["grant"], rows: [] }));
mountWithReactiveConnection(config);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.changePassword")!.click();
await nextTick();
await preview();
let confirm!: () => void;
mocks.guardWait = new Promise<void>((resolve) => {
confirm = resolve;
});
findButton("userAdmin.applySql")!.click();
await nextTick();
updateConnection!({ ...config, host: "changed-during-confirmation" });
await nextTick();
confirm();
await nextTick();
await nextTick();
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("retains the mutation identity when its dialog closes during execution", async () => {
await mountPassword();
await preview();
@@ -336,7 +838,7 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
cancels.at(-1)!.click();
await nextTick();
finish([]);
await vi.waitFor(() => expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith(config.id));
await vi.waitFor(() => expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith(config.id, expect.any(String)));
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
});
@@ -344,7 +846,10 @@ describe("DatabaseUserAdmin Vastbase passwords", () => {
await mountPassword();
await preview();
mocks.executeMulti.mockResolvedValueOnce([]);
mocks.retirePasswordAfterChange.mockRejectedValueOnce(new Error("cleanup failed"));
mocks.retirePasswordAfterChange.mockImplementationOnce(async () => {
updateConnection!({ ...config, password: "", save_password: false });
throw new Error("cleanup failed");
});
findButton("userAdmin.applySql")!.click();
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("userAdmin.passwordChangedCleanupFailed", 10000));
expect(mocks.executeQuery).toHaveBeenCalledTimes(2);
@@ -0,0 +1,643 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { connectionConfigFingerprint } from "@/lib/connection/connectionDatabaseInfo";
import type { ConnectionConfig } from "@/types/database";
import en from "@/i18n/locales/en";
const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn(),
listSchemas: vi.fn().mockResolvedValue(["APP_OWNER"]),
listDatabases: vi.fn().mockResolvedValue([]),
executeMulti: vi.fn().mockResolvedValue([{ columns: [], rows: [], execution_error: false }]),
retirePasswordAfterChange: vi.fn().mockResolvedValue(undefined),
requestConfirmation: vi.fn().mockResolvedValue(true),
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string, values?: Record<string, unknown>) => (key.split(".").reduce((value: any, part) => value?.[part], en) ?? key).replace(/\{(\w+)\}/g, (_match: string, name: string) => String(values?.[name] ?? name)) }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: mocks.requestConfirmation }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/lib/tabs/tabUiState", () => ({ useTabUiState: () => ({ initialState: {}, track: vi.fn() }) }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected, retirePasswordAfterChange: mocks.retirePasswordAfterChange }) }));
vi.mock("@/lib/backend/api", () => ({ executeQuery: mocks.executeQuery, executeMulti: mocks.executeMulti, listSchemas: mocks.listSchemas, listDatabases: mocks.listDatabases }));
vi.mock("@/components/ui/button", () => ({
Button: defineComponent({
setup:
(_, { attrs, slots }) =>
() =>
h("button", attrs, slots.default?.()),
}),
}));
vi.mock("@/components/ui/input", () => ({
Input: defineComponent({
props: ["modelValue"],
emits: ["update:modelValue"],
setup:
(props, { attrs, emit }) =>
() =>
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
}),
}));
import DatabaseUserAdmin from "@/components/admin/DatabaseUserAdmin.vue";
const cleanup: (() => void)[] = [];
const mountedPages = new WeakMap<HTMLElement, { refresh: () => Promise<void> }>();
afterEach(() => {
cleanup.splice(0).forEach((dispose) => dispose());
vi.clearAllMocks();
});
async function settle() {
for (let i = 0; i < 12; i++) {
await Promise.resolve();
await nextTick();
}
}
function mount(db_type: "oracle" | "oceanbase-oracle" = "oracle", extra: Partial<ConnectionConfig> = {}) {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(DatabaseUserAdmin, { connection: { id: "oracle", name: "Oracle test", db_type, ...extra } as ConnectionConfig });
app.mount(host);
mountedPages.set(host, {
refresh: async () => {
(host.querySelector("button") as HTMLButtonElement).click();
await settle();
},
});
cleanup.push(() => {
app.unmount();
host.remove();
});
return host;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
describe("Oracle security page", () => {
it("loads the production reader, exposes limited visibility and filters object grants", async () => {
mocks.executeQuery.mockImplementation(async (_connection: string, _database: string, sql: string) => {
if (sql.includes("DBA_USERS")) throw new Error("ORA-01031");
if (sql.includes("ALL_USERS") || sql.includes("FROM DUAL")) return { columns: ["USERNAME"], rows: [["Reader"]] };
if (sql.includes("DBA_TAB_PRIVS"))
return {
columns: ["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"],
rows: [
["Reader", "Owner", "T", "Owner", "SELECT", "NO"],
["Reader", "Other", "T", "Other", "UPDATE", "NO"],
["Reader", "Owner", "T", null, "DELETE", "NO"],
],
};
return { columns: [], rows: [] };
});
const host = mount();
await settle();
expect(host.textContent).toContain("Limited visibility");
expect(host.textContent).toContain("Account status unavailable");
expect(host.textContent).toContain("Other.T");
expect(host.textContent).toContain("grantor: Unknown");
const search = host.querySelector("input") as HTMLInputElement;
search.value = "No matching account";
search.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
expect(host.querySelector("main")?.textContent).toContain("Reader");
expect(host.querySelector("select")).toBeNull();
expect(host.textContent).toContain("One discovered inheritance path is shown per role");
const owner = host.querySelector('input[placeholder="Exact object owner"]') as HTMLInputElement;
owner.value = "Owner";
owner.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
expect(host.textContent).toContain("Owner.T");
expect(host.textContent).not.toContain("Other.T");
expect(mocks.executeQuery.mock.calls.every((call) => /^SELECT\s/.test(call[2]))).toBe(true);
expect(mocks.executeQuery.mock.calls.every((call) => call[5]?.maxRows === 10000)).toBe(true);
});
it("does not label failed dictionary reads as empty grants", async () => {
mocks.executeQuery.mockRejectedValue(new Error("connection lost"));
const host = mount();
await settle();
expect(host.querySelectorAll('[data-security-state="error"]').length).toBe(7);
expect(host.textContent).toContain("Read failed");
expect(host.textContent).not.toContain("Empty in visible scope");
expect(host.textContent).not.toContain("No grants in the current visible result");
});
it("does not start dictionary reads after the page closes while connecting", async () => {
let connected!: () => void;
mocks.ensureConnected.mockReturnValueOnce(
new Promise<void>((resolve) => {
connected = resolve;
}),
);
mount();
cleanup.splice(0).forEach((dispose) => dispose());
connected();
await settle();
expect(mocks.executeQuery).not.toHaveBeenCalled();
});
it("uses the OceanBase fallback dialect when opening a restricted connection", async () => {
mocks.executeQuery.mockImplementation(async (_connection: string, _database: string, sql: string) => {
if (/FROM DBA_/.test(sql)) throw new Error("ORA-00942");
if (sql.includes("FROM USER_ROLE_PRIVS UNION") && !sql.startsWith("SELECT GRANTEE,")) throw new Error("ORA-00904");
if (sql.includes("FROM ALL_COL_PRIVS") && !sql.startsWith("SELECT GRANTEE, OWNER,")) throw new Error("ORA-00904");
return { columns: [], rows: [] };
});
const host = mount("oceanbase-oracle");
await settle();
expect(host.textContent).not.toContain("ORA-00904");
expect(host.querySelectorAll('[data-security-state="error"]').length).toBe(0);
expect(host.textContent).toContain("Limited visibility");
});
it("invalidates an old connection wait before issuing any queries", async () => {
const old = deferred<void>();
mocks.ensureConnected.mockImplementation((id: string) => (id === "old" ? old.promise : Promise.resolve()));
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
const connection = ref({ id: "old", db_type: "oracle" } as ConnectionConfig);
const host = document.createElement("div");
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => app.unmount());
connection.value = { id: "new", db_type: "oceanbase-oracle" } as ConnectionConfig;
await settle();
old.resolve();
await settle();
expect(mocks.executeQuery.mock.calls).toHaveLength(7);
expect(mocks.executeQuery.mock.calls.every(([id]) => id === "new")).toBe(true);
mocks.ensureConnected.mockResolvedValue(undefined);
});
it("keeps the latest refresh and ignores old failures and fallback reads", async () => {
const old = deferred<{ columns: string[]; rows: string[][] }>();
mocks.executeQuery.mockReturnValue(old.promise);
const host = mount();
await settle();
mocks.executeQuery.mockImplementation(async (_id: string, _db: string, sql: string) => ({ columns: ["USERNAME"], rows: sql.includes("FROM DUAL") || sql.includes("DBA_USERS") ? [["LATEST"]] : [] }));
// Refresh while an earlier load is pending.
await mountedPages.get(host)!.refresh();
await settle();
const calls = mocks.executeQuery.mock.calls.length;
old.reject(new Error("ORA-01031"));
await settle();
expect(mocks.executeQuery).toHaveBeenCalledTimes(calls);
expect(host.textContent).toContain("LATEST");
expect(host.textContent).not.toContain("ORA-01031");
});
it("preserves partial grants while indicating failed role visibility", async () => {
mocks.executeQuery.mockImplementation(async (_id: string, _db: string, sql: string) => {
if (sql.includes("DBA_ROLE_PRIVS")) throw new Error("connection lost");
if (sql.includes("FROM DUAL") || sql.includes("DBA_USERS")) return { columns: ["USERNAME"], rows: [["Reader"]] };
if (sql.includes("DBA_TAB_PRIVS")) return { columns: ["GRANTEE", "OWNER", "TABLE_NAME", "PRIVILEGE"], rows: [["Reader", "Owner", "T", "SELECT"]] };
return { columns: [], rows: [] };
});
const host = mount();
await settle();
expect(host.textContent).toContain("Owner.T");
expect(host.textContent).toContain("Grant sources are incomplete");
expect(host.textContent).not.toContain("No grants in the current visible result");
});
it("shows a confirmed empty visible result after successful reads", async () => {
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] });
const host = mount();
await settle();
expect(host.textContent).toContain("No grants in the current visible result");
});
it("uses the shared list, hides account actions for roles, and collapses technical detail", async () => {
mocks.executeQuery.mockImplementation(async (_id: string, _db: string, sql: string) => {
if (sql.includes("FROM DUAL")) return { columns: ["USERNAME"], rows: [["Reader"]] };
if (sql.includes("DBA_USERS") || sql.includes("ALL_USERS")) throw new Error("ORA-01031");
if (sql.includes("DBA_ROLES")) return { columns: ["ROLE"], rows: [["R1"]] };
if (sql.includes("DBA_TAB_PRIVS")) return { columns: ["GRANTEE", "OWNER", "TABLE_NAME", "PRIVILEGE"], rows: [["R1", "Owner", "RoleTable", "SELECT"]] };
return { columns: [], rows: [] };
});
const host = mount();
await settle();
expect(host.querySelector("aside")?.textContent).toContain("Reader");
expect(host.querySelector("aside")?.textContent).toContain("PUBLIC");
const role = [...host.querySelectorAll<HTMLButtonElement>("aside button")].find((button) => button.textContent?.includes("R1"))!;
role.click();
await settle();
expect(host.querySelector("main")?.textContent).toContain("Owner.RoleTable");
expect([...host.querySelectorAll("details")].every((details) => !details.open)).toBe(true);
expect(host.querySelector('[role="status"]')?.closest("details")).toBeNull();
expect(host.querySelector('[role="status"]')?.textContent).toContain("incomplete");
expect(host.querySelector("[data-oracle-security] > p")?.textContent).toContain(en.oracleSecurity.directSqlNotice);
expect(host.querySelector("[data-direct-grants]")?.closest("details")).toBeNull();
const buttons = [...host.querySelectorAll("button")].map((button) => button.textContent);
for (const text of [en.userAdmin.newUser, en.userAdmin.changePassword, en.userAdmin.dropUser, en.userAdmin.lock, en.userAdmin.unlock]) expect(buttons).not.toContain(text);
expect(buttons).toContain(en.userAdmin.grant);
expect(mocks.executeQuery.mock.calls.every((call) => /^SELECT\s/.test(call[2]))).toBe(true);
const details = [...host.querySelectorAll("details")].find((item) => item.querySelector("summary")?.textContent === "Read details")!;
details.open = true;
expect(details.textContent).toContain("ORA-01031");
});
it.each(["mysql", "postgres"] as const)("discards a late %s user-list failure after switching to Oracle", async (db_type) => {
const old = deferred<{ columns: string[]; rows: string[][] }>();
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation((id: string, _db: string, sql: string) => (id === "old" ? old.promise : Promise.resolve({ columns: ["USERNAME"], rows: sql.includes("FROM DUAL") ? [["Reader"]] : [] })));
const connection = ref({ id: "old", db_type } as ConnectionConfig);
const host = document.createElement("div");
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => app.unmount());
await settle();
connection.value = { id: "new", db_type: "oracle" } as ConnectionConfig;
await settle();
old.reject(new Error("old dictionary failure"));
await settle();
expect(host.querySelector("main")?.textContent).toContain("Reader");
expect(host.textContent).not.toContain("old dictionary failure");
expect(mocks.executeQuery.mock.calls.filter(([id]) => id === "old")).toHaveLength(1);
expect(mocks.executeQuery.mock.calls.filter(([id]) => id === "new")).toHaveLength(7);
});
it("discards Oracle results and fallback reads after switching to MySQL", async () => {
const old = deferred<{ columns: string[]; rows: string[][] }>();
mocks.executeQuery.mockImplementation((id: string, _db: string, sql: string) => (id === "old" ? old.promise : Promise.resolve(sql.includes("mysql.user") ? { columns: ["user", "host"], rows: [["app", "%"]] } : { columns: ["grants"], rows: [["GRANT SELECT ON *.* TO 'app'@'%'"]] })));
const connection = ref({ id: "old", db_type: "oracle" } as ConnectionConfig);
const host = document.createElement("div");
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => app.unmount());
await settle();
connection.value = { id: "new", db_type: "mysql" } as ConnectionConfig;
await settle();
old.reject(new Error("ORA-01031"));
await settle();
expect(host.querySelector("main")?.textContent).toContain("app@%");
expect(host.querySelector("[data-oracle-security]")).toBeNull();
expect(host.textContent).not.toContain("ORA-01031");
expect(mocks.executeQuery.mock.calls.filter(([id]) => id === "old")).toHaveLength(7);
expect(mocks.executeQuery.mock.calls.filter(([id]) => id === "new")).toHaveLength(2);
});
it("finishes a pending MySQL grant read when refresh returns the same principal", async () => {
const pending = deferred<{ columns: string[]; rows: string[][] }>();
mocks.executeQuery.mockImplementation((_id: string, _db: string, sql: string) => (sql.startsWith("SHOW GRANTS") ? pending.promise : Promise.resolve({ columns: ["user", "host"], rows: [["app", "%"]] })));
const host = document.createElement("div");
const app = createApp(DatabaseUserAdmin, { connection: { id: "mysql", db_type: "mysql" } as ConnectionConfig });
app.mount(host);
cleanup.push(() => app.unmount());
await settle();
(host.querySelector("button") as HTMLButtonElement).click();
await settle();
pending.resolve({ columns: ["grants"], rows: [["GRANT SELECT ON *.* TO 'app'@'%'"]] });
await settle();
expect(host.textContent).not.toContain(en.userAdmin.loadingGrants);
expect(host.querySelector("main")?.textContent).toContain("GRANT SELECT");
});
it("closes a MySQL create dialog and discards late grants when switching to Oracle", async () => {
const old = deferred<{ columns: string[]; rows: string[][] }>();
mocks.executeQuery.mockImplementation((id: string, _db: string, sql: string) => {
if (id === "old" && sql.startsWith("SHOW GRANTS")) return old.promise;
if (id === "old") return Promise.resolve({ columns: ["user", "host"], rows: [["app", "%"]] });
return Promise.resolve({ columns: ["USERNAME"], rows: sql.includes("FROM DUAL") ? [["Reader"]] : [] });
});
const connection = ref({ id: "old", db_type: "mysql" } as ConnectionConfig);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => {
app.unmount();
host.remove();
});
await settle();
[...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes(en.userAdmin.newUser))!.click();
await settle();
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
connection.value = { id: "new", db_type: "oracle" } as ConnectionConfig;
await settle();
old.reject(new Error("late grants failed"));
await settle();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(host.textContent).not.toContain("late grants failed");
expect(host.querySelector("main")?.textContent).toContain("Reader");
expect(host.querySelector("[data-oracle-security]")).not.toBeNull();
});
});
describe("Oracle management through the shared page", () => {
function setup() {
mocks.executeQuery.mockImplementation(async (_id: string, _db: string, sql: string) =>
sql.includes("ALL_OBJECTS")
? {
columns: ["OBJECT_NAME", "OBJECT_TYPE"],
rows: [
["Mixed.Table", "TABLE"],
["Second", "TABLE"],
["Third", "TABLE"],
],
}
: { columns: ["USERNAME"], rows: sql.includes("FROM DUAL") || sql.includes("DBA_USERS") ? [["Reader"]] : [] },
);
}
async function click(text: string) {
const button = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === text);
expect(button, text).toBeDefined();
button!.click();
await settle();
}
async function fill(placeholder: string, value: string) {
const input = [...document.querySelectorAll<HTMLInputElement>("input")].find((input) => input.placeholder === placeholder)!;
expect(input, placeholder).toBeDefined();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
}
it("previews lock and unlock, then executes only after confirmation", async () => {
setup();
mount();
await settle();
expect(document.body.textContent).not.toContain(en.userAdmin.changeHost);
expect(document.querySelector(`input[placeholder="${en.userAdmin.searchUser}"]`)).toBeNull();
expect(document.querySelector(`input[placeholder="${en.oracleSecurity.filter}"]`)).not.toBeNull();
await click(en.userAdmin.lock);
expect(document.body.textContent).toContain('ALTER USER "Reader" ACCOUNT LOCK;');
expect(mocks.executeMulti).not.toHaveBeenCalled();
await click(en.userAdmin.applySql);
expect(mocks.executeMulti.mock.calls[0][2]).toBe('ALTER USER "Reader" ACCOUNT LOCK;');
await click(en.userAdmin.unlock);
await click(en.userAdmin.applySql);
expect(mocks.executeMulti.mock.calls[1][2]).toBe('ALTER USER "Reader" ACCOUNT UNLOCK;');
});
it("keeps the SQL preview layout usable before and after execution", async () => {
setup();
mount();
await settle();
await click(en.userAdmin.lock);
const dialog = document.querySelector<HTMLElement>('[data-slot="dialog-content"]')!;
expect(dialog.classList.contains("max-h-[calc(var(--dbx-viewport-height)-2rem)]")).toBe(true);
expect(dialog.classList.contains("grid-rows-[auto_minmax(0,1fr)_auto]")).toBe(true);
const sql = dialog.querySelector("pre")!;
const middle = sql.parentElement!;
expect(middle.classList.contains("grid-rows-[minmax(0,1fr)]")).toBe(true);
expect(middle.classList.contains("h-[50vh]")).toBe(true);
expect(middle.classList.contains("max-h-full")).toBe(true);
await click(en.userAdmin.applySql);
expect(middle.classList.contains("h-[70vh]")).toBe(true);
expect(middle.classList.contains("grid-rows-[minmax(0,1fr)_minmax(0,1fr)]")).toBe(true);
const resultPane = middle.children[1] as HTMLElement;
for (const pane of [sql, resultPane]) {
expect(pane.classList.contains("min-h-0")).toBe(true);
expect(pane.classList.contains("min-w-0")).toBe(true);
expect(pane.classList.contains("overscroll-contain")).toBe(true);
}
expect(sql.classList.contains("overflow-auto")).toBe(true);
expect(resultPane.classList.contains("overflow-y-auto")).toBe(true);
expect(resultPane.textContent).toContain(en.userAdmin.resultSuccess);
await click(en.userAdmin.closeResult);
expect(document.querySelector('[data-slot="dialog-content"]')).toBeNull();
});
it("supports the old password for inferred Oracle JDBC through preview and dispatch", async () => {
setup();
const connection = ref({ id: "oracle-jdbc", name: "Oracle JDBC", db_type: "jdbc", driver_profile: "oracle", username: "Reader", password: "saved-secret", save_password: true } as ConnectionConfig);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => {
app.unmount();
host.remove();
});
mocks.retirePasswordAfterChange.mockImplementation(async () => {
const retired = { ...connection.value, password: "", save_password: false };
connection.value = retired;
return { status: "retired", fingerprint: connectionConfigFingerprint(retired) };
});
await settle();
await click(en.userAdmin.changePassword);
await fill(en.userAdmin.oldPassword, "old-secret");
await fill(en.userAdmin.newPassword, "new-secret");
await click(en.userAdmin.previewSql);
expect(document.body.textContent).toContain('IDENTIFIED BY "***" REPLACE "***"');
expect(document.body.textContent).not.toContain("old-secret");
expect(document.body.textContent).not.toContain("new-secret");
await click(en.userAdmin.applySql);
expect(mocks.executeMulti.mock.calls[0][2]).toBe('ALTER USER "Reader" IDENTIFIED BY "new-secret" REPLACE "old-secret";');
expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith("oracle-jdbc", expect.any(String));
expect(document.body.textContent).toContain(en.userAdmin.passwordChangedReconnect);
});
it.each([
{ db_type: "oracle" as const, supported: true },
{ db_type: "oceanbase-oracle" as const, supported: false },
])("keeps native and OceanBase old-password behavior for $db_type", async ({ db_type, supported }) => {
setup();
mount(db_type);
await settle();
await click(en.userAdmin.changePassword);
expect(document.querySelector(`input[placeholder="${en.userAdmin.oldPassword}"]`) !== null).toBe(supported);
});
it("masks a self-password preview and production confirmation, then retires the connection", async () => {
setup();
const connection = ref({ id: "oracle", name: "Oracle test", db_type: "oracle", is_production: true, username: "reader" } as ConnectionConfig);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({ setup: () => () => h(DatabaseUserAdmin, { connection: connection.value }) });
app.mount(host);
cleanup.push(() => {
app.unmount();
host.remove();
});
mocks.retirePasswordAfterChange.mockImplementation(async () => {
const retired = { ...connection.value, password: "", save_password: false };
connection.value = retired;
return { status: "retired", fingerprint: connectionConfigFingerprint(retired) };
});
await settle();
await settle();
await click(en.userAdmin.changePassword);
await fill(en.userAdmin.newPassword, "test';--pass");
await click(en.userAdmin.previewSql);
expect(document.body.textContent).not.toContain("test';--pass");
expect(document.body.textContent).toContain('IDENTIFIED BY "***"');
await click(en.userAdmin.applySql);
expect(mocks.requestConfirmation.mock.calls[0][0].sql).toContain('IDENTIFIED BY "***"');
expect(mocks.executeMulti.mock.calls[0][2]).toBe('ALTER USER "Reader" IDENTIFIED BY "test\';--pass";');
expect(mocks.retirePasswordAfterChange).toHaveBeenCalledWith("oracle", expect.any(String));
expect(document.body.textContent).toContain(en.userAdmin.passwordChangedReconnect);
});
it("invalidates a pending production confirmation when the principal changes", async () => {
setup();
const confirmation = deferred<boolean>();
mocks.requestConfirmation.mockReturnValueOnce(confirmation.promise);
mount("oracle", { is_production: true });
await settle();
await click(en.userAdmin.lock);
await click(en.userAdmin.applySql);
await click(en.oracleSecurity.stopBeforeDispatch);
await click("PUBLIC");
confirmation.resolve(true);
await settle();
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("uses the read-only guard and never sends a management statement on a locked connection", async () => {
setup();
mount("oracle", { read_only: true });
await settle();
await click(en.userAdmin.lock);
await click(en.userAdmin.applySql);
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("grants and revokes only the selected principal's specified object privileges", async () => {
setup();
mount();
await settle();
expect(document.querySelector('input[placeholder="APP_OWNER"]')).toBeNull();
expect(document.body.textContent).not.toContain(en.userAdmin.globalScope);
await click("APP_OWNER");
expect(document.body.textContent).not.toContain(en.userAdmin.allTables);
await click(`${en.oracleSecurity.chooseObjects} (0)`);
await click("Mixed.Table · TABLE");
await click(en.userAdmin.grant);
expect(mocks.executeMulti).not.toHaveBeenCalled();
await click(en.userAdmin.applySql);
expect(mocks.executeMulti.mock.calls[0][2]).toBe('GRANT SELECT ON "APP_OWNER"."Mixed.Table" TO "Reader";');
expect(mocks.executeQuery.mock.calls.filter((call) => call[2].includes("FROM DUAL")).length).toBe(2);
await click(en.common.close);
await click(en.userAdmin.revoke);
await click(en.userAdmin.applySql);
expect(mocks.executeMulti.mock.calls[1][2]).toBe('REVOKE SELECT ON "APP_OWNER"."Mixed.Table" FROM "Reader";');
});
it.each([
{ results: [] },
{
results: [
{ columns: [], rows: [] },
{ columns: [], rows: [] },
],
},
])("keeps invalid plan acknowledgements unknown and stops subsequent objects: %j", async ({ results }) => {
setup();
mount();
await settle();
await click("APP_OWNER");
await click(`${en.oracleSecurity.chooseObjects} (0)`);
await click("Mixed.Table · TABLE");
await click("Second · TABLE");
mocks.executeMulti.mockResolvedValueOnce(results);
await click(en.userAdmin.grant);
await click(en.userAdmin.applySql);
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain(en.oracleSecurity.writeUnknown);
});
it("preserves a successful first step and stops after a second object is rejected", async () => {
setup();
mount();
await settle();
await click("APP_OWNER");
await click(`${en.oracleSecurity.chooseObjects} (0)`);
for (const name of ["Mixed.Table", "Second", "Third"]) await click(`${name} · TABLE`);
mocks.executeMulti.mockResolvedValueOnce([{ columns: [], rows: [] }]).mockResolvedValueOnce([{ execution_error: true, columns: ["error"], rows: [["ORA-01031 rejected"]] }]);
await click(en.userAdmin.grant);
await click(en.userAdmin.applySql);
expect(mocks.executeMulti).toHaveBeenCalledTimes(2);
expect(document.body.textContent).toContain(en.oracleSecurity.writeRejected);
expect(mocks.toast.mock.calls.at(-1)?.[0]).toContain(en.userAdmin.applyPartial);
});
it("cancels an object plan while production confirmation is pending", async () => {
setup();
const confirmation = deferred<boolean>();
mocks.requestConfirmation.mockReturnValueOnce(confirmation.promise);
mount("oracle", { is_production: true });
await settle();
await click("APP_OWNER");
await click(`${en.oracleSecurity.chooseObjects} (0)`);
await click("Mixed.Table · TABLE");
await click(en.userAdmin.grant);
await click(en.userAdmin.applySql);
await click(en.oracleSecurity.stopBeforeDispatch);
await click("PUBLIC");
confirmation.resolve(true);
await settle();
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("blocks truncated object lists without presenting them as an empty successful read", async () => {
setup();
const query = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation((id, db, sql) => (sql.includes("ALL_OBJECTS") ? Promise.resolve({ columns: ["OBJECT_NAME", "OBJECT_TYPE"], rows: [["T", "TABLE"]], truncated: true }) : query(id, db, sql)));
mount();
await settle();
await click("APP_OWNER");
await click(`${en.oracleSecurity.chooseObjects} (0)`);
expect(document.body.textContent).toContain(en.oracleSecurity.truncated);
expect([...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === en.userAdmin.grant)?.disabled).toBe(true);
expect(mocks.executeMulti).not.toHaveBeenCalled();
});
it("lists both objects when a PACKAGE shares its name with a TABLE", async () => {
setup();
const query = mocks.executeQuery.getMockImplementation()!;
mocks.executeQuery.mockImplementation((id, db, sql) =>
sql.includes("ALL_OBJECTS")
? Promise.resolve({
columns: ["OBJECT_NAME", "OBJECT_TYPE"],
rows: [
["Shared", "PACKAGE"],
["Shared", "TABLE"],
],
})
: query(id, db, sql),
);
mount();
await settle();
await click("APP_OWNER");
await click(`${en.oracleSecurity.chooseObjects} (0)`);
expect(document.body.textContent).toContain("Shared · PACKAGE");
expect(document.body.textContent).toContain("Shared · TABLE");
});
it.each(["connection reset", "statement timed out", "ORA-03113: end-of-file on communication channel"])("keeps a returned execution error outcome unknown: %s", async (message) => {
setup();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["error"], rows: [[message]], execution_error: true }]);
mount();
await settle();
await click(en.userAdmin.changePassword);
await fill(en.userAdmin.newPassword, "fixture-secret");
await click(en.userAdmin.previewSql);
await click(en.userAdmin.applySql);
expect(document.body.textContent).toContain(en.oracleSecurity.writeUnknown);
expect(document.body.textContent).not.toContain(en.userAdmin.applySql);
expect(mocks.retirePasswordAfterChange).not.toHaveBeenCalled();
expect(mocks.executeMulti).toHaveBeenCalledTimes(1);
});
it("does not treat a missing execution acknowledgement as a successful password change", async () => {
setup();
mocks.executeMulti.mockResolvedValueOnce([]);
mount();
await settle();
await click(en.userAdmin.changePassword);
await fill(en.userAdmin.newPassword, "fixture-secret");
await click(en.userAdmin.previewSql);
await click(en.userAdmin.applySql);
expect(document.body.textContent).toContain(en.oracleSecurity.writeUnknown);
expect(document.body.textContent).not.toContain(en.userAdmin.applySql);
expect(mocks.retirePasswordAfterChange).not.toHaveBeenCalled();
});
it("distinguishes a rejected statement without displaying an echoed password", async () => {
setup();
mocks.executeMulti.mockResolvedValueOnce([{ columns: ["error"], rows: [['ORA-01031 near IDENTIFIED BY "test-partial']], execution_error: true }]);
mount();
await settle();
await click(en.userAdmin.changePassword);
await fill(en.userAdmin.newPassword, "test-partial-secret");
await click(en.userAdmin.previewSql);
await click(en.userAdmin.applySql);
expect(document.body.textContent).toContain(en.oracleSecurity.writeRejected);
expect(document.body.textContent).not.toContain("test-partial");
expect(mocks.retirePasswordAfterChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,284 @@
import { describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, QueryResult } from "@/types/database";
import { loadOracleSecurity, oracleGrantSources, oracleObjectGrantSources, oracleSecurityObjectMatches, oracleWriteRejected, parseOracleErrorCode, supportsOracleSecurity, ORACLE_SECURITY_ROW_LIMIT } from "@/lib/database/oracleSecurity";
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
const result = (columns: string[], rows: unknown[][] = []) => ({ columns, rows, affected_rows: 0, execution_time_ms: 0 }) as QueryResult;
const blankQuery = async (sql: string) => (sql.includes("FROM DUAL") ? result(["USERNAME"], [["Reader"]]) : result([]));
describe("Oracle write rejection classification", () => {
it("classifies rejection by error code, independent of any locale text", () => {
expect(parseOracleErrorCode("ORA-01031: insufficient privileges")).toEqual({ prefix: "ORA", code: "01031" });
expect(parseOracleErrorCode("Error: OBE-00942: table or view does not exist")).toEqual({ prefix: "OBE", code: "00942" });
expect(parseOracleErrorCode("connection reset")).toBeNull();
expect(oracleWriteRejected("ORA-01031: insufficient privileges", true)).toBe(true);
expect(oracleWriteRejected("ORA-03113: end-of-file on communication channel", true)).toBe(false);
// Transport errors can also arrive as execution errors; only refusal codes establish rejection.
expect(oracleWriteRejected("ORA-01031: insufficient privileges", false)).toBe(false);
expect(oracleWriteRejected("connection reset", true)).toBe(false);
});
});
describe("Oracle security dictionary reads", () => {
it("uses only SELECTs and exposes exact quoted identities and grant options", async () => {
const query = vi.fn(async (sql: string) => {
if (sql.includes("FROM DBA_USERS"))
return result(
["USERNAME", "ACCOUNT_STATUS"],
[
["Reader", "OPEN"],
["READER", "LOCKED"],
],
);
if (sql.includes("FROM DBA_TAB_PRIVS")) return result(["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], [["Reader", "Owner", "Mixed.Name", "Owner", "SELECT", "YES"]]);
return blankQuery(sql);
});
const snapshot = await loadOracleSecurity(query);
expect(snapshot.users.rows.map((user) => user.name)).toEqual(["Reader", "READER"]);
expect(snapshot.objectGrants.rows[0]).toMatchObject({ owner: "Owner", objectName: "Mixed.Name", grantable: true });
expect(query.mock.calls.every(([sql]) => /^SELECT\s/.test(sql))).toBe(true);
expect(query.mock.calls.filter(([sql]) => /FROM DBA_(TAB|COL)_PRIVS/.test(sql)).every(([sql]) => !/ORDER BY/i.test(sql))).toBe(true);
expect(oracleSecurityObjectMatches(snapshot.objectGrants.rows[0], "OWNER", "Mixed.Name")).toBe(false);
});
it("marks fallback visibility as limited without concealing the denied primary read", async () => {
const snapshot = await loadOracleSecurity(async (sql) => {
if (sql.includes("FROM DBA_USERS")) throw new Error("ORA-01031: insufficient privileges");
if (sql.includes("FROM ALL_USERS")) return result(["USERNAME"], [["Reader"]]);
return blankQuery(sql);
});
expect(snapshot.users).toMatchObject({ state: "ok", visibility: "limited", rows: [{ name: "Reader" }] });
expect(snapshot.users.message).toContain("ORA-01031");
expect(snapshot.users.rows[0].accountStatus).toBeUndefined();
});
it("falls back when OceanBase names an inaccessible dictionary view in OBE-00942", async () => {
const query = vi.fn(async (sql: string) => {
if (sql.includes("FROM DBA_USERS")) throw new Error("OBE-00942: table or view 'SYS.DBA_USERS' does not exist");
if (sql.includes("FROM ALL_USERS")) return result(["USERNAME"], [["Reader"]]);
return blankQuery(sql);
});
const snapshot = await loadOracleSecurity(query, { db_type: "oceanbase-oracle" } as ConnectionConfig);
expect(snapshot.users).toMatchObject({ state: "ok", visibility: "limited", rows: [{ name: "Reader" }] });
expect(snapshot.users.message).toContain("OBE-00942");
expect(query.mock.calls.some(([sql]) => sql.includes("FROM ALL_USERS"))).toBe(true);
});
it.each(["table or view does not exist", "table or view 'SYS.DBA_USERS' does not exist"])("falls back when a dictionary view is unavailable without an error code: %s", async (message) => {
const query = vi.fn(async (sql: string) => {
if (sql.includes("FROM DBA_USERS")) throw new Error(message);
if (sql.includes("FROM ALL_USERS")) return result(["USERNAME"], [["Reader"]]);
return blankQuery(sql);
});
const snapshot = await loadOracleSecurity(query, { db_type: "oceanbase-oracle" } as ConnectionConfig);
expect(snapshot.users).toMatchObject({ state: "ok", visibility: "limited", rows: [{ name: "Reader" }] });
expect(snapshot.users.message).toContain(message);
expect(query.mock.calls.some(([sql]) => sql.includes("FROM ALL_USERS"))).toBe(true);
});
it("falls back on OBE-01031 and preserves the insufficient-privilege reason", async () => {
const snapshot = await loadOracleSecurity(
async (sql) => {
if (sql.includes("FROM DBA_USERS")) throw new Error("OBE-01031: insufficient privileges");
if (sql.includes("FROM ALL_USERS")) return result(["USERNAME"], [["Reader"]]);
return blankQuery(sql);
},
{ db_type: "oceanbase-oracle" } as ConnectionConfig,
);
expect(snapshot.users).toMatchObject({ state: "ok", visibility: "limited", rows: [{ name: "Reader" }] });
expect(snapshot.users.message).toContain("OBE-01031");
});
it.each(["OBE-00944: table or view 'SYS.DBA_USERS' does not exist", "OBE-009420: table or view 'SYS.DBA_USERS' does not exist", "XOBE-00942: table or view 'SYS.DBA_USERS' does not exist"])("does not use the dictionary fallback for unknown or approximate OBE codes: %s", async (message) => {
const query = vi.fn(async (sql: string) => {
if (sql.includes("FROM DBA_USERS")) throw new Error(message);
if (sql.includes("FROM ALL_USERS")) return result(["USERNAME"], [["Reader"]]);
return blankQuery(sql);
});
const snapshot = await loadOracleSecurity(query, { db_type: "oceanbase-oracle" } as ConnectionConfig);
expect(snapshot.users.state).toBe("error");
expect(query.mock.calls.some(([sql]) => sql.includes("FROM ALL_USERS"))).toBe(false);
});
it("distinguishes empty, denied, inaccessible and unexpected errors", async () => {
const query = vi.fn(async (sql: string) => {
if (sql.includes("FROM DBA_USERS") || sql.includes("FROM ALL_USERS")) throw new Error("ORA-01031");
if (sql.includes("FROM DBA_ROLES") || sql.includes("FROM USER_ROLE_PRIVS")) throw new Error("ORA-00942");
if (sql.includes("FROM DBA_SYS_PRIVS")) throw new Error("connection lost");
return blankQuery(sql);
});
const snapshot = await loadOracleSecurity(query);
expect(snapshot.users.state).toBe("denied");
expect(snapshot.roles.state).toBe("unavailable");
expect(snapshot.systemGrants.state).toBe("error");
expect(snapshot.objectGrants.state).toBe("empty");
expect(query.mock.calls.some(([sql]) => sql.includes("FROM USER_SYS_PRIVS"))).toBe(false);
});
it.each(["oracle", "oceanbase-oracle"] as const)("reads restricted role and column dictionaries with %s column names", async (db_type) => {
const oceanbase = db_type === "oceanbase-oracle";
const snapshot = await loadOracleSecurity(
async (sql) => {
if (/FROM DBA_/.test(sql)) throw new Error("ORA-00942");
if (sql.includes("FROM USER_ROLE_PRIVS UNION")) {
const expected = oceanbase ? "SELECT GRANTEE," : "SELECT USERNAME AS GRANTEE,";
if (!sql.startsWith(expected)) throw new Error("ORA-00904: invalid role grantee");
return result(
["GRANTEE", "GRANTED_ROLE", "ADMIN_OPTION", "DEFAULT_ROLE"],
[
["Reader", "R1", "NO", "YES"],
["R1", "R2", "NO", null],
],
);
}
if (sql.includes("FROM ALL_COL_PRIVS")) {
const expected = oceanbase ? "SELECT GRANTEE, OWNER," : "SELECT GRANTEE, TABLE_SCHEMA AS OWNER,";
if (!sql.startsWith(expected)) throw new Error("ORA-00904: invalid column owner");
return result(["GRANTEE", "OWNER", "TABLE_NAME", "COLUMN_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], [["R2", "Owner", "Quoted.Table", "Mixed.Col", "Owner", "UPDATE", "NO"]]);
}
if (sql.includes("FROM ALL_TAB_PRIVS")) {
expect(sql).toContain("TABLE_SCHEMA AS OWNER");
return result(["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], [["Reader", "Owner", "Quoted.Table", "Owner", "SELECT", "YES"]]);
}
return blankQuery(sql);
},
{ db_type } as ConnectionConfig,
);
expect(snapshot.roleGrants).toMatchObject({ state: "ok", visibility: "limited" });
expect(snapshot.columnGrants).toMatchObject({ state: "ok", visibility: "limited" });
expect(snapshot.columnGrants.message).toContain("ORA-00942");
expect(oracleGrantSources(snapshot, "Reader").grants).toEqual(
expect.arrayContaining([
expect.objectContaining({ source: "role", rolePath: ["R1", "R2"], grant: expect.objectContaining({ owner: "Owner", objectName: "Quoted.Table", columnName: "Mixed.Col", privilege: "UPDATE" }) }),
expect.objectContaining({ source: "direct", grant: expect.objectContaining({ privilege: "SELECT", grantable: true }) }),
]),
);
});
it("does not treat a capped dictionary response as complete", async () => {
const snapshot = await loadOracleSecurity(async (sql) =>
sql.includes("FROM DBA_USERS")
? result(
["USERNAME"],
Array.from({ length: ORACLE_SECURITY_ROW_LIMIT }, (_, i) => [`U${i}`]),
)
: blankQuery(sql),
);
expect(snapshot.users).toMatchObject({ visibility: "limited", truncated: true });
});
it.each(["oracle", "oceanbase-oracle"] as const)("includes visible role/public object and column grants without inventing grantors for %s", async (db_type) => {
const snapshot = await loadOracleSecurity(
async (sql) => {
if (/FROM DBA_/.test(sql)) throw new Error("ORA-00942");
if (sql.includes("FROM USER_ROLE_PRIVS UNION"))
return result(
["GRANTEE", "GRANTED_ROLE", "ADMIN_OPTION"],
[
["Reader", "R1", "NO"],
["R1", "R2", "NO"],
],
);
if (sql.includes("FROM ALL_TAB_PRIVS")) {
expect(sql).toContain("R.COLUMN_NAME IS NULL AND NOT EXISTS");
expect(sql).toContain("A.TABLE_SCHEMA = R.OWNER");
const rows = [["Reader", "Owner", "T", "Owner", "SELECT", "YES"]];
if (sql.includes("FROM ROLE_TAB_PRIVS")) rows.push(["R2", "Owner", "T", "", "SELECT", "NO"], ["PUBLIC", "Owner", "T", "", "SELECT", "NO"]);
return result(["GRANTEE", "OWNER", "TABLE_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], rows);
}
if (sql.includes("FROM ALL_COL_PRIVS")) {
expect(sql).toContain("R.COLUMN_NAME IS NOT NULL AND NOT EXISTS");
expect(sql).toContain(`A.${db_type === "oceanbase-oracle" ? "OWNER" : "TABLE_SCHEMA"} = R.OWNER`);
return result(["GRANTEE", "OWNER", "TABLE_NAME", "COLUMN_NAME", "GRANTOR", "PRIVILEGE", "GRANTABLE"], sql.includes("FROM ROLE_TAB_PRIVS") ? [["R2", "Owner", "T", "Col", null, "UPDATE", "NO"]] : []);
}
return blankQuery(sql);
},
{ db_type } as ConnectionConfig,
);
const sources = oracleGrantSources(snapshot, "Reader").grants;
expect(sources.map((row) => [row.kind, row.source])).toEqual([
["object", "direct"],
["object", "role"],
["column", "role"],
["object", "public"],
]);
expect(sources[1]).toMatchObject({ rolePath: ["R1", "R2"], grant: { grantor: "" } });
expect(sources[2].grant).toMatchObject({ columnName: "Col", grantor: "" });
expect(snapshot.objectGrants).toMatchObject({ visibility: "limited", message: "Error: ORA-00942" });
});
it.each(["truncated", "has_more"] as const)("keeps short dictionary responses limited when the backend reports %s", async (flag) => {
const snapshot = await loadOracleSecurity(async (sql) => (sql.includes("FROM DBA_USERS") ? { ...result(["USERNAME"], [["Reader"]]), [flag]: true } : blankQuery(sql)));
expect(snapshot.users).toMatchObject({ state: "ok", visibility: "limited", truncated: true });
});
it("preserves direct, role and public paths and terminates role cycles", async () => {
const snapshot = await loadOracleSecurity(blankQuery);
snapshot.roleGrants.rows = [
{ grantee: "Reader", role: "R1", adminOption: false },
{ grantee: "R1", role: "R2", adminOption: false },
{ grantee: "R2", role: "R1", adminOption: false },
{ grantee: "PUBLIC", role: "R2", adminOption: false },
];
snapshot.systemGrants.rows = ["Reader", "R2", "PUBLIC"].map((grantee) => ({ grantee, privilege: "CREATE SESSION", adminOption: false }));
const sources = oracleGrantSources(snapshot, "Reader");
expect(sources.bounded).toBe(false);
expect(sources.grants.map((row) => [row.grant.grantee, row.source])).toEqual([
["Reader", "direct"],
["R2", "role"],
["R2", "public"],
["PUBLIC", "public"],
]);
expect(sources.grants[1].rolePath).toEqual(["R1", "R2"]);
expect(sources.grants[2].rolePath).toEqual(["PUBLIC", "R2"]);
});
it("lets the object view inspect grants for every visible principal", async () => {
const snapshot = await loadOracleSecurity(blankQuery);
snapshot.objectGrants.rows = ["A", "B"].map((grantee) => ({ grantee, owner: "Owner", objectName: "T", grantor: "Owner", privilege: "SELECT", grantable: false }));
expect(oracleObjectGrantSources(snapshot).map((row) => row.grant.grantee)).toEqual(["A", "B"]);
});
it("keeps one discovered path per role when inheritance paths converge", async () => {
const snapshot = await loadOracleSecurity(blankQuery);
snapshot.roleGrants.rows = [
{ grantee: "Reader", role: "R1", adminOption: false },
{ grantee: "Reader", role: "R2", adminOption: false },
{ grantee: "R1", role: "R3", adminOption: false },
{ grantee: "R2", role: "R3", adminOption: false },
{ grantee: "PUBLIC", role: "R3", adminOption: false },
];
snapshot.systemGrants.rows = [{ grantee: "R3", privilege: "CREATE SESSION", adminOption: false }];
expect(oracleGrantSources(snapshot, "Reader").grants).toEqual([expect.objectContaining({ source: "role", rolePath: ["R1", "R3"] }), expect.objectContaining({ source: "public", rolePath: ["PUBLIC", "R3"] })]);
});
it("bounds a large role graph without inventing inaccessible paths", async () => {
const snapshot = await loadOracleSecurity(blankQuery);
snapshot.roleGrants.rows = Array.from({ length: 1001 }, (_, i) => ({ grantee: i ? `R${i - 1}` : "Reader", role: `R${i}`, adminOption: false }));
snapshot.systemGrants.rows = ["R998", "R999", "R1000"].map((grantee) => ({ grantee, privilege: "CREATE SESSION", adminOption: false }));
const sources = oracleGrantSources(snapshot, "Reader");
expect(sources.bounded).toBe(true);
expect(sources.grants.map((row) => row.grant.grantee)).toEqual(["R998"]);
expect(sources.grants[0].rolePath).toHaveLength(999);
});
it("handles execution-error results as failures rather than empty dictionaries", async () => {
const snapshot = await loadOracleSecurity(async () => ({ ...result([]), execution_error: true, error: { code: "ORA-01031", detail: "ORA-01031: insufficient privileges" } }) as QueryResult);
expect(snapshot.objectGrants).toMatchObject({ state: "denied", visibility: "limited", rows: [] });
});
it("supports identified Oracle JDBC without changing other user-admin providers", () => {
expect(connectionSupportsDatabaseUserAdmin({ db_type: "jdbc", connection_string: "jdbc:oracle:thin:@//localhost:1521/XE" } as ConnectionConfig)).toBe(true);
for (const db_type of ["mysql", "postgres"] as const) expect(connectionSupportsDatabaseUserAdmin({ db_type } as ConnectionConfig)).toBe(true);
expect(connectionSupportsDatabaseUserAdmin({ db_type: "sqlite" } as ConnectionConfig)).toBe(false);
});
it("enables the connected user-admin entry only for the Oracle family", () => {
for (const db_type of ["oracle", "oceanbase-oracle"] as const) {
const config = { id: db_type, db_type } as ConnectionConfig;
expect(supportsOracleSecurity(config)).toBe(true);
expect(connectionSupportsDatabaseUserAdmin(config)).toBe(true);
}
expect(supportsOracleSecurity({ db_type: "mysql" } as ConnectionConfig)).toBe(false);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildOracleObjectAuthorizationPlan, createOracleUserAdminActions, oracleDirectGrantsSql, oraclePrivilegeSql, oraclePasswordSql } from "@/lib/database/oracleUserAdmin";
import { loadOracleSecurity } from "@/lib/database/oracleSecurity";
describe("Oracle user administration", () => {
it("uses exact selected metadata identities and rejects implicit or incompatible object scopes", async () => {
const snapshot = await loadOracleSecurity(async (sql) => ({ columns: ["USERNAME"], rows: sql.includes("DBA_USERS") ? [["Reader"]] : [] }));
const actions = createOracleUserAdminActions(snapshot, "Reader", false)!;
const selection = { database: " Mixed.Owner ", preset: "custom" as const, privileges: ["SELECT"], tables: [{ name: "Mixed.Table", objectType: "TABLE" }] };
expect(buildOracleObjectAuthorizationPlan(actions, "Reader", [selection], false).steps[0].sql).toBe('GRANT SELECT ON " Mixed.Owner "."Mixed.Table" TO "Reader";');
for (const tables of [undefined, [], [{ name: "P", objectType: "PROCEDURE" }]]) expect(() => buildOracleObjectAuthorizationPlan(actions, "Reader", [{ ...selection, tables }], false)).toThrow();
});
it("displays only visible direct grants without turning inherited or PUBLIC grants into user grants", async () => {
const snapshot = await loadOracleSecurity(async () => ({ columns: [], rows: [] }));
snapshot.roleGrants.rows = [{ grantee: "Reader", role: "R1", adminOption: false }];
snapshot.systemGrants.rows = [{ grantee: "R1", privilege: "CREATE TABLE", adminOption: false }];
snapshot.objectGrants.rows = ["Reader", "R1", "PUBLIC"].map((grantee) => ({ grantee, owner: " Mixed.Owner ", objectName: "T", grantor: "OWNER", privilege: "SELECT", grantable: false }));
expect(oracleDirectGrantsSql(snapshot, "Reader")).toEqual(['GRANT "R1" TO "Reader";', 'GRANT SELECT ON " Mixed.Owner "."T" TO "Reader";']);
});
it("quotes credentials without allowing a second SQL statement", () => {
expect(oraclePasswordSql("MixedUser", "test';--/*pass", "old-pass")).toBe('ALTER USER "MixedUser" IDENTIFIED BY "test\';--/*pass" REPLACE "old-pass";');
for (const password of ["", 'bad"value', "bad\nvalue", "bad\0value"]) expect(() => oraclePasswordSql("APP", password)).toThrow();
});
it("generates precise object and system actions, preserving explicit quoted case", () => {
const input = { user: { user: "Reader", host: "" }, privileges: ["SELECT"], database: "app", table: '"Mixed.Table"', scope: "table" as const, grantOption: true };
expect(oraclePrivilegeSql("user", input)).toBe('GRANT SELECT ON "APP"."Mixed.Table" TO "Reader" WITH GRANT OPTION;');
expect(oraclePrivilegeSql("user", input, true)).toBe('REVOKE SELECT ON "APP"."Mixed.Table" FROM "Reader";');
expect(oraclePrivilegeSql("user", { ...input, scope: "system", privileges: ["CREATE SESSION"] })).toBe('GRANT CREATE SESSION TO "Reader" WITH ADMIN OPTION;');
expect(oraclePrivilegeSql("user", { ...input, scope: "role", role: "report_reader" })).toBe('GRANT "REPORT_READER" TO "Reader" WITH ADMIN OPTION;');
});
it("rejects invalid options and tokens while allowing plain role and PUBLIC grants", () => {
const input = { user: { user: "PUBLIC", host: "" }, privileges: ["SELECT"], database: "APP", table: "T", scope: "table" as const };
expect(oraclePrivilegeSql("public", input)).toBe('GRANT SELECT ON "APP"."T" TO PUBLIC;');
expect(oraclePrivilegeSql("role", { ...input, user: { user: "R1", host: "" } })).toBe('GRANT SELECT ON "APP"."T" TO "R1";');
for (const kind of ["public", "role"] as const) expect(() => oraclePrivilegeSql(kind, { ...input, grantOption: true })).toThrow();
for (const value of ["*", "APP; DROP USER X", ""]) expect(() => oraclePrivilegeSql("user", { ...input, database: value })).toThrow();
for (const privileges of [[], ["ALL"], ["SELECT; DROP USER X"]]) expect(() => oraclePrivilegeSql("user", { ...input, privileges })).toThrow();
});
it("offers account actions only for known users and requires current identity for password changes", async () => {
const snapshot = await loadOracleSecurity(async (sql) => ({ columns: sql.includes("DBA_ROLES") ? ["ROLE"] : ["USERNAME"], rows: sql.includes("DBA_USERS") ? [["Reader"]] : sql.includes("DBA_ROLES") ? [["R1"]] : [] }));
expect(createOracleUserAdminActions(snapshot, "Reader", false)?.alterLoginSql?.({ user: "Reader", host: "" }, false)).toBe('ALTER USER "Reader" ACCOUNT LOCK;');
expect(createOracleUserAdminActions(snapshot, "Reader", false)?.alterPasswordSql).toBeUndefined();
for (const name of ["R1", "PUBLIC", ""]) expect(createOracleUserAdminActions(snapshot, name, false)?.alterLoginSql).toBeUndefined();
snapshot.currentUser.rows = ["Reader"];
expect(createOracleUserAdminActions(snapshot, "Reader", false)?.alterPasswordSql).toBeTypeOf("function");
});
});
@@ -19,6 +19,7 @@ export interface DatabaseAuthorizationSelection {
export interface AuthorizationTableSelection {
name: string;
schema?: string;
objectType?: string;
}
export interface AuthorizationDatabaseOption {
@@ -57,6 +58,7 @@ export type AuthorizationStepStatus = "success" | "failed" | "skipped";
export interface AuthorizationStepResult {
step: AuthorizationPlanStep;
status: AuthorizationStepStatus;
outcome?: "unknown";
message?: string;
}
@@ -423,7 +425,7 @@ export async function executeAuthorizationPlan(plan: AuthorizationPlan, execute:
export function authorizationPlanStatus(results: AuthorizationStepResult[]): "success" | "partial" | "failed" {
const successes = results.filter((result) => result.status === "success").length;
const failures = results.filter((result) => result.status === "failed").length;
const failures = results.filter((result) => result.status !== "success").length;
if (failures === 0) return "success";
return successes > 0 ? "partial" : "failed";
}
@@ -1,9 +1,10 @@
import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import { effectiveDatabaseTypeForConnection, gaussdbConnectionMode } from "@/lib/database/jdbcDialect";
import { supportsOracleSecurity } from "@/lib/database/oracleSecurity";
export type UserAdminDialect = "mysql" | "postgres";
export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role";
export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role" | "system";
export type AuthorizationModel = "mysql" | "postgres" | "starrocks";
export interface DatabaseUserIdentity {
@@ -1000,5 +1001,5 @@ export function resolveDatabaseUserAdminProviderForConnection(connection: Connec
}
export function connectionSupportsDatabaseUserAdmin(connection: ConnectionConfig | undefined): boolean {
return resolveDatabaseUserAdminProviderForConnection(connection) !== null;
return supportsOracleSecurity(connection) || resolveDatabaseUserAdminProviderForConnection(connection) !== null;
}
@@ -0,0 +1,209 @@
import type { ConnectionConfig, QueryResult } from "@/types/database";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
export type SecurityReadState = "ok" | "empty" | "denied" | "unavailable" | "error" | "unsupported";
export interface SecurityRead<T> {
state: SecurityReadState;
visibility: "complete" | "limited";
source: string;
rows: T[];
message?: string;
truncated: boolean;
}
export interface OracleSecurityUser {
name: string;
accountStatus?: string;
profile?: string;
defaultTablespace?: string;
temporaryTablespace?: string;
}
export interface OracleSecurityRole {
name: string;
authentication?: string;
}
export interface OracleRoleGrant {
grantee: string;
role: string;
adminOption: boolean;
defaultRole?: boolean;
}
export interface OracleSystemGrant {
grantee: string;
privilege: string;
adminOption: boolean;
}
export interface OracleObjectGrant {
grantee: string;
owner: string;
objectName: string;
grantor: string;
privilege: string;
grantable: boolean;
}
export interface OracleColumnGrant extends OracleObjectGrant {
columnName: string;
}
export interface OracleSecuritySnapshot {
currentUser: SecurityRead<string>;
users: SecurityRead<OracleSecurityUser>;
roles: SecurityRead<OracleSecurityRole>;
roleGrants: SecurityRead<OracleRoleGrant>;
systemGrants: SecurityRead<OracleSystemGrant>;
objectGrants: SecurityRead<OracleObjectGrant>;
columnGrants: SecurityRead<OracleColumnGrant>;
}
export type OracleSecurityQuery = (sql: string) => Promise<QueryResult>;
export const ORACLE_SECURITY_ROW_LIMIT = 10000;
export function supportsOracleSecurity(connection: ConnectionConfig | undefined): boolean {
const type = effectiveDatabaseTypeForConnection(connection);
return type === "oracle" || type === "oceanbase-oracle";
}
type DictionaryRow = Record<string, string>;
function dictionaryRows(result: QueryResult): DictionaryRow[] {
if (result.execution_error) throw new Error(result.error?.detail ?? result.error?.code ?? "Dictionary query failed");
return result.rows.map((row) => Object.fromEntries(result.columns.map((column, i) => [column.toUpperCase(), String(row[i] ?? "")])));
}
export interface OracleErrorCodeMatch {
prefix: "ORA" | "OBE";
code: string;
}
/** Single parser for the ORA/OBE error format shared by read and write classification. */
export function parseOracleErrorCode(message: string): OracleErrorCodeMatch | null {
const match = message.match(/\b(ORA|OBE)-(\d{4,5})\b/);
return match ? { prefix: match[1] as "ORA" | "OBE", code: match[2] } : null;
}
// Only explicit refusal codes establish rejection; transport failures stay "unknown".
const oracleWriteRejectedCodes = new Set(["01031", "00942", "00904", "00990", "01917", "01919", "01926", "01720", "28003", "28007", "28008"]);
export function oracleWriteRejected(message: string, executionError: boolean): boolean {
if (!executionError) return false;
const parsed = parseOracleErrorCode(message);
return parsed !== null && oracleWriteRejectedCodes.has(parsed.code);
}
function readState(error: unknown): SecurityReadState {
const message = String(error);
const code = parseOracleErrorCode(message)?.code;
if (code !== undefined || /(?:ORA|OBE)-\d+/.test(message)) {
return code === "01031" ? "denied" : code === "00942" ? "unavailable" : "error";
}
if (/insufficient privileges/i.test(message)) return "denied";
// These errors do not distinguish an absent view from a hidden view.
if (/table or view(?:\s+.+?)?\s+does not exist/i.test(message)) return "unavailable";
return "error";
}
const yes = (value: string | undefined) => value === "YES";
const user = (row: DictionaryRow): OracleSecurityUser => ({ name: row.USERNAME, accountStatus: row.ACCOUNT_STATUS || undefined, profile: row.PROFILE || undefined, defaultTablespace: row.DEFAULT_TABLESPACE || undefined, temporaryTablespace: row.TEMPORARY_TABLESPACE || undefined });
const role = (row: DictionaryRow): OracleSecurityRole => ({ name: row.ROLE, authentication: row.PASSWORD_REQUIRED || undefined });
const roleGrant = (row: DictionaryRow): OracleRoleGrant => ({ grantee: row.GRANTEE, role: row.GRANTED_ROLE, adminOption: yes(row.ADMIN_OPTION), defaultRole: row.DEFAULT_ROLE ? yes(row.DEFAULT_ROLE) : undefined });
const systemGrant = (row: DictionaryRow): OracleSystemGrant => ({ grantee: row.GRANTEE, privilege: row.PRIVILEGE, adminOption: yes(row.ADMIN_OPTION) });
const objectGrant = (row: DictionaryRow): OracleObjectGrant => ({ grantee: row.GRANTEE, owner: row.OWNER, objectName: row.TABLE_NAME, grantor: row.GRANTOR, privilege: row.PRIVILEGE, grantable: yes(row.GRANTABLE) });
async function readDictionary<T>(query: OracleSecurityQuery, primary: string, fallback: string | undefined, parse: (row: DictionaryRow) => T): Promise<SecurityRead<T>> {
let message: string | undefined;
for (const [index, sql] of [primary, fallback].entries()) {
if (!sql) break;
try {
const result = await query(sql);
const raw = dictionaryRows(result);
const truncated = result.truncated === true || result.has_more === true || raw.length >= ORACLE_SECURITY_ROW_LIMIT;
return { state: raw.length ? "ok" : "empty", visibility: index || truncated ? "limited" : "complete", source: sql, rows: raw.map(parse), message, truncated };
} catch (error) {
const state = readState(error);
message = message ? `${message}; ${String(error)}` : String(error);
if (index || !fallback || (state !== "denied" && state !== "unavailable")) {
return { state, visibility: "limited", source: sql, rows: [], message, truncated: false };
}
}
}
return { state: "error", visibility: "limited", source: primary, rows: [], message, truncated: false };
}
/** Only fixed SELECTs: loading or filtering this snapshot never grants permissions. */
export async function loadOracleSecurity(query: OracleSecurityQuery, connection?: ConnectionConfig): Promise<OracleSecuritySnapshot> {
const oceanbase = effectiveDatabaseTypeForConnection(connection) === "oceanbase-oracle";
const roleGrantee = oceanbase ? "GRANTEE" : "USERNAME AS GRANTEE";
const columnOwner = oceanbase ? "OWNER" : "TABLE_SCHEMA AS OWNER";
const columnOwnerName = oceanbase ? "OWNER" : "TABLE_SCHEMA";
const [currentUser, users, roles, roleGrants, systemGrants, objectGrants, columnGrants] = await Promise.all([
readDictionary(query, "SELECT USER AS USERNAME FROM DUAL", undefined, (row) => row.USERNAME),
readDictionary(query, "SELECT USERNAME, ACCOUNT_STATUS, PROFILE, DEFAULT_TABLESPACE, TEMPORARY_TABLESPACE FROM DBA_USERS ORDER BY USERNAME", "SELECT USERNAME FROM ALL_USERS ORDER BY USERNAME", user),
readDictionary(query, "SELECT ROLE, PASSWORD_REQUIRED FROM DBA_ROLES ORDER BY ROLE", "SELECT DISTINCT GRANTED_ROLE AS ROLE FROM USER_ROLE_PRIVS ORDER BY ROLE", role),
readDictionary(
query,
"SELECT GRANTEE, GRANTED_ROLE, ADMIN_OPTION, DEFAULT_ROLE FROM DBA_ROLE_PRIVS ORDER BY GRANTEE, GRANTED_ROLE",
`SELECT ${roleGrantee}, GRANTED_ROLE, ADMIN_OPTION, DEFAULT_ROLE FROM USER_ROLE_PRIVS UNION SELECT ROLE AS GRANTEE, GRANTED_ROLE, ADMIN_OPTION, NULL AS DEFAULT_ROLE FROM ROLE_ROLE_PRIVS`,
roleGrant,
),
readDictionary(query, "SELECT GRANTEE, PRIVILEGE, ADMIN_OPTION FROM DBA_SYS_PRIVS ORDER BY GRANTEE, PRIVILEGE", "SELECT USERNAME AS GRANTEE, PRIVILEGE, ADMIN_OPTION FROM USER_SYS_PRIVS UNION SELECT ROLE AS GRANTEE, PRIVILEGE, ADMIN_OPTION FROM ROLE_SYS_PRIVS", systemGrant),
readDictionary(
query,
"SELECT GRANTEE, OWNER, TABLE_NAME, GRANTOR, PRIVILEGE, GRANTABLE FROM DBA_TAB_PRIVS",
"SELECT GRANTEE, TABLE_SCHEMA AS OWNER, TABLE_NAME, GRANTOR, PRIVILEGE, GRANTABLE FROM ALL_TAB_PRIVS UNION SELECT R.ROLE AS GRANTEE, R.OWNER, R.TABLE_NAME, NULL AS GRANTOR, R.PRIVILEGE, R.GRANTABLE FROM ROLE_TAB_PRIVS R WHERE R.COLUMN_NAME IS NULL AND NOT EXISTS (SELECT 1 FROM ALL_TAB_PRIVS A WHERE A.GRANTEE = R.ROLE AND A.TABLE_SCHEMA = R.OWNER AND A.TABLE_NAME = R.TABLE_NAME AND A.PRIVILEGE = R.PRIVILEGE AND A.GRANTABLE = R.GRANTABLE)",
objectGrant,
),
readDictionary(
query,
"SELECT GRANTEE, OWNER, TABLE_NAME, COLUMN_NAME, GRANTOR, PRIVILEGE, GRANTABLE FROM DBA_COL_PRIVS",
`SELECT GRANTEE, ${columnOwner}, TABLE_NAME, COLUMN_NAME, GRANTOR, PRIVILEGE, GRANTABLE FROM ALL_COL_PRIVS UNION SELECT R.ROLE AS GRANTEE, R.OWNER, R.TABLE_NAME, R.COLUMN_NAME, NULL AS GRANTOR, R.PRIVILEGE, R.GRANTABLE FROM ROLE_TAB_PRIVS R WHERE R.COLUMN_NAME IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ALL_COL_PRIVS A WHERE A.GRANTEE = R.ROLE AND A.${columnOwnerName} = R.OWNER AND A.TABLE_NAME = R.TABLE_NAME AND A.COLUMN_NAME = R.COLUMN_NAME AND A.PRIVILEGE = R.PRIVILEGE AND A.GRANTABLE = R.GRANTABLE)`,
(row): OracleColumnGrant => ({ ...objectGrant(row), columnName: row.COLUMN_NAME }),
),
]);
return { currentUser, users, roles, roleGrants, systemGrants, objectGrants, columnGrants };
}
export interface OracleGrantSource {
kind: "system" | "object" | "column";
grant: OracleSystemGrant | OracleObjectGrant | OracleColumnGrant;
source: "direct" | "role" | "public";
rolePath: string[];
}
/** Grant provenance, not a claim that inherited roles are enabled in this session. */
export function oracleGrantSources(snapshot: OracleSecuritySnapshot, principal: string): { grants: OracleGrantSource[]; bounded: boolean } {
const roots = principal === "PUBLIC" ? [principal] : [principal, "PUBLIC"];
const grants: OracleGrantSource[] = [];
let bounded = false;
for (const root of roots) {
const paths = new Map<string, string[]>([[root, root === "PUBLIC" ? ["PUBLIC"] : []]]);
const queue = [...paths.keys()];
for (let index = 0; index < queue.length; index++) {
const parent = queue[index];
for (const edge of snapshot.roleGrants.rows) {
if (edge.grantee !== parent || paths.has(edge.role)) continue;
if (paths.size >= 1000) {
bounded = true;
continue;
}
paths.set(edge.role, [...paths.get(parent)!, edge.role]);
queue.push(edge.role);
}
}
for (const [kind, rows] of [
["system", snapshot.systemGrants.rows],
["object", snapshot.objectGrants.rows],
["column", snapshot.columnGrants.rows],
] as const) {
for (const grant of rows) {
const path = paths.get(grant.grantee);
if (!path) continue;
grants.push({ kind, grant, source: root === "PUBLIC" ? "public" : grant.grantee === principal ? "direct" : "role", rolePath: path });
}
}
}
return { grants, bounded };
}
export function oracleObjectGrantSources(snapshot: OracleSecuritySnapshot): OracleGrantSource[] {
return [
...snapshot.objectGrants.rows.map((grant): OracleGrantSource => ({ kind: "object", grant, source: grant.grantee === "PUBLIC" ? "public" : "direct", rolePath: [] })),
...snapshot.columnGrants.rows.map((grant): OracleGrantSource => ({ kind: "column", grant, source: grant.grantee === "PUBLIC" ? "public" : "direct", rolePath: [] })),
];
}
export function oracleSecurityObjectMatches(grant: OracleGrantSource["grant"], owner: string, objectName: string): boolean {
return "owner" in grant && (!owner || grant.owner === owner) && (!objectName || grant.objectName === objectName);
}
@@ -0,0 +1,125 @@
import type { DatabaseUserAdminProvider, PrivilegeChangeInput, PrivilegeScope } from "./databaseUserAdmin";
import type { OracleSecuritySnapshot } from "./oracleSecurity";
import type { AuthorizationPlan, AuthorizationPreset, DatabaseAuthorizationSelection } from "./databaseAuthorizationPlan";
type PrincipalKind = "user" | "role" | "public";
type Actions = Pick<DatabaseUserAdminProvider, "alterPasswordSql" | "supportsOldPassword" | "alterLoginSql" | "grantPrivilegesSql" | "revokePrivilegesSql">;
const SYSTEM_PRIVILEGES = ["CREATE SESSION", "CREATE TABLE", "CREATE VIEW", "CREATE SEQUENCE", "CREATE PROCEDURE", "CREATE TRIGGER", "CREATE SYNONYM"];
const OBJECT_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "REFERENCES", "ALTER", "INDEX", "EXECUTE"];
export const ORACLE_OBJECT_PRIVILEGES: Record<string, readonly string[]> = {
TABLE: ["SELECT", "INSERT", "UPDATE", "DELETE", "REFERENCES", "ALTER", "INDEX"],
VIEW: ["SELECT", "INSERT", "UPDATE", "DELETE", "REFERENCES"],
"MATERIALIZED VIEW": ["SELECT"],
SEQUENCE: ["SELECT", "ALTER"],
PROCEDURE: ["EXECUTE"],
FUNCTION: ["EXECUTE"],
PACKAGE: ["EXECUTE"],
TYPE: ["EXECUTE"],
};
export function oracleGrantObjectsSql(owner: string): string {
const types = Object.keys(ORACLE_OBJECT_PRIVILEGES)
.map((type) => `'${type}'`)
.join(", ");
return `SELECT DISTINCT OBJECT_NAME, OBJECT_TYPE FROM ALL_OBJECTS WHERE OWNER = '${owner.replaceAll("'", "''")}' AND OBJECT_TYPE IN (${types}) ORDER BY OBJECT_NAME, OBJECT_TYPE`;
}
export function oraclePresetPrivileges(preset: AuthorizationPreset, custom: string[] = []): string[] {
return preset === "custom" ? [...new Set(custom)] : preset === "readOnly" ? ["SELECT"] : preset === "ddl" ? ["ALTER", "INDEX"] : ["SELECT", "INSERT", "UPDATE", "DELETE"];
}
export function buildOracleObjectAuthorizationPlan(actions: Actions, name: string, selections: DatabaseAuthorizationSelection[], grantOption: boolean, revoke = false): AuthorizationPlan {
const buildSql = revoke ? actions.revokePrivilegesSql : actions.grantPrivilegesSql;
if (!buildSql || !selections.length) throw new Error("Select an object and its privileges");
const plan: AuthorizationPlan = { steps: [] };
for (const selection of selections) {
if (!selection.tables?.length) throw new Error("Select specific objects");
const privileges = oraclePresetPrivileges(selection.preset, selection.privileges);
for (const table of selection.tables) {
const allowed = ORACLE_OBJECT_PRIVILEGES[table.objectType ?? ""] ?? [];
if (!privileges.length || privileges.some((privilege) => !allowed.includes(privilege))) throw new Error(`Unsupported privileges for ${selection.database}.${table.name} (${table.objectType ?? "unknown"})`);
const index = plan.steps.length;
plan.steps.push({
id: `oracle-object-${index}`,
label: `${selection.database}.${table.name}`,
database: "",
sql: buildSql({ user: { user: name, host: "" }, database: identifier(selection.database, true), table: identifier(table.name, true), scope: "table", privileges, grantOption }),
operation: revoke ? "revokePrivileges" : "grantCurrentObjects",
subject: name,
targetSchema: selection.database,
targetTable: table.name,
dependsOn: index ? [`oracle-object-${index - 1}`] : [],
});
}
}
return plan;
}
/** Visible direct grants only. This is a display, not a replay or revoke plan. */
export function oracleDirectGrantsSql(snapshot: OracleSecuritySnapshot, principal: string): string[] {
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
const target = (name: string) => (name === "PUBLIC" ? "PUBLIC" : quote(name));
const direct = (row: { grantee: string }) => !!principal && row.grantee === principal;
return [
...snapshot.roleGrants.rows.filter(direct).map((row) => `GRANT ${quote(row.role)} TO ${target(row.grantee)}${row.adminOption ? " WITH ADMIN OPTION" : ""};`),
...snapshot.systemGrants.rows.filter(direct).map((row) => `GRANT ${row.privilege} TO ${target(row.grantee)}${row.adminOption ? " WITH ADMIN OPTION" : ""};`),
...[...snapshot.objectGrants.rows, ...snapshot.columnGrants.rows]
.filter(direct)
.map((row) => `GRANT ${row.privilege}${"columnName" in row ? ` (${quote(String(row.columnName))})` : ""} ON ${quote(row.owner)}.${quote(row.objectName)} TO ${target(row.grantee)}${row.grantable ? " WITH GRANT OPTION" : ""};`),
];
}
function identifier(value: string, fromMetadata = false): string {
const input = fromMetadata ? value : value.trim();
const name = fromMetadata ? input : /^"[^"\0\r\n]+"$/.test(input) ? input.slice(1, -1) : /^[A-Za-z][A-Za-z0-9_$#]*$/.test(input) ? input.toUpperCase() : "";
if (!name || /["\0\r\n]/.test(name) || name === "*") throw new Error("Invalid Oracle identifier; use an exact name, with double quotes for case-sensitive names");
return `"${name}"`;
}
function passwordToken(value: string): string {
if (!value || /["\0\r\n]/.test(value)) throw new Error("A password without double quotes, NUL or line breaks is required");
return `"${value}"`;
}
export function oraclePasswordSql(user: string, password: string, oldPassword?: string): string {
return `ALTER USER ${identifier(user, true)} IDENTIFIED BY ${passwordToken(password)}${oldPassword ? ` REPLACE ${passwordToken(oldPassword)}` : ""};`;
}
export function oraclePrivilegesForScope(scope: PrivilegeScope): readonly string[] {
return scope === "system" ? SYSTEM_PRIVILEGES : scope === "table" ? OBJECT_PRIVILEGES : [];
}
export function oraclePrivilegeSql(kind: PrincipalKind, input: PrivilegeChangeInput, revoke = false): string {
const scope = input.scope ?? "table";
const principal = kind === "public" ? "PUBLIC" : identifier(input.user.user, true);
if (!revoke && input.grantOption && (kind === "public" || (kind === "role" && scope === "table"))) throw new Error("This principal cannot receive the selected grant option");
let privileges: string;
if (scope === "role") {
privileges = identifier(input.role ?? "");
if (privileges === principal) throw new Error("A role cannot be granted to itself");
} else {
const allowed = oraclePrivilegesForScope(scope);
if (!input.privileges.length || input.privileges.some((privilege) => !allowed.includes(privilege))) throw new Error("Select supported Oracle privileges");
privileges = [...new Set(input.privileges)].join(", ");
}
const object = scope === "table" ? ` ON ${identifier(input.database)}.${identifier(input.table ?? "")}` : "";
const option = !revoke && input.grantOption ? (scope === "table" ? " WITH GRANT OPTION" : " WITH ADMIN OPTION") : "";
return `${revoke ? "REVOKE" : "GRANT"} ${privileges}${object} ${revoke ? "FROM" : "TO"} ${principal}${option};`;
}
export function createOracleUserAdminActions(snapshot: OracleSecuritySnapshot | undefined, name: string | undefined, supportsOldPassword: boolean): Actions | undefined {
if (!snapshot || !name) return;
const kind: PrincipalKind | undefined = name === "PUBLIC" ? "public" : snapshot.users.rows.some((user) => user.name === name) || snapshot.currentUser.rows.includes(name) ? "user" : snapshot.roles.rows.some((role) => role.name === name) ? "role" : undefined;
if (!kind) return;
return {
supportsOldPassword,
...(kind === "user"
? {
alterLoginSql: (_user, enabled) => `ALTER USER ${identifier(name, true)} ACCOUNT ${enabled ? "UNLOCK" : "LOCK"};`,
...(snapshot.currentUser.rows.length ? { alterPasswordSql: (_user, password, oldPassword) => oraclePasswordSql(name, password, supportsOldPassword ? oldPassword : undefined) } : {}),
}
: {}),
grantPrivilegesSql: (input) => oraclePrivilegeSql(kind, { ...input, user: { user: name, host: "" } }),
revokePrivilegesSql: (input) => oraclePrivilegeSql(kind, { ...input, user: { user: name, host: "" } }, true),
};
}
@@ -855,6 +855,7 @@ describe("connectionStore metadata loading", () => {
expect(connectionNode.children?.map((node) => [node.type, node.label, node.database, node.schema])).toEqual([
["schema", "DBX_TEST", "DBX_TEST", "DBX_TEST"],
["oracle-db-links", "tree.databaseLinks", "", undefined],
["user-admin", "tree.userAdmin", "", undefined],
]);
const linkRoot = connectionNode.children!.find((node) => node.type === "oracle-db-links")!;
@@ -907,7 +908,10 @@ describe("connectionStore metadata loading", () => {
await store.loadDatabases(connection.id, { force: true });
expect(connectionNode.children?.map((node) => [node.type, node.label])).toEqual([["schema", "DBX_TEST"]]);
expect(connectionNode.children?.map((node) => [node.type, node.label])).toEqual([
["schema", "DBX_TEST"],
["user-admin", "tree.userAdmin"],
]);
});
it.each([
@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { connectionConfigFingerprint } from "@/lib/connection/connectionDatabaseInfo";
const api = vi.hoisted(() => ({
connectDb: vi.fn(),
@@ -8,6 +9,7 @@ const api = vi.hoisted(() => ({
saveConnections: vi.fn(),
sessionCredentialStatus: vi.fn(),
forgetSessionCredential: vi.fn(),
loadSchemaCache: vi.fn(),
checkConnectionHealth: vi.fn(),
connectionIdentifierQuote: vi.fn(),
}));
@@ -18,7 +20,6 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.mock("@/lib/backend/api", () => ({
...api,
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
connectionDatabaseInfo: vi.fn().mockResolvedValue(undefined),
listInstalledAgents: vi.fn().mockResolvedValue([]),
@@ -68,6 +69,7 @@ describe("connectionStore password retirement", () => {
for (const mock of Object.values(api)) mock.mockReset().mockResolvedValue(undefined);
api.connectDb.mockResolvedValue(config.id);
api.sessionCredentialStatus.mockResolvedValue(true);
api.loadSchemaCache.mockResolvedValue(null);
retainedTabRefresh.mockReset();
requestPassword.mockReset().mockResolvedValue({ password: "fixture-new", rememberPassword: false });
});
@@ -104,7 +106,7 @@ describe("connectionStore password retirement", () => {
});
await vi.waitFor(() => expect(retainedTabRefresh).toHaveBeenCalled());
finishDisconnect();
await retirement;
await expect(retirement).resolves.toEqual({ status: "retired", fingerprint: connectionConfigFingerprint({ ...config, password: "", save_password: false }) });
expect(retainedTabError).toBeInstanceOf(Error);
expect(api.connectDb).not.toHaveBeenCalled();
expect(api.checkConnectionHealth).not.toHaveBeenCalled();
@@ -269,4 +271,59 @@ describe("connectionStore password retirement", () => {
expect(api.forgetSessionCredential).not.toHaveBeenCalled();
expect(store.connectedIds.has(config.id)).toBe(false);
});
it("skips retirement when the acknowledgement belongs to an edited same-id connection", async () => {
const store = await storeWithConnection();
const expectedFingerprint = connectionConfigFingerprint(config);
store.connections[0] = { ...config, host: "edited-host" };
await expect(store.retirePasswordAfterChange(config.id, expectedFingerprint)).resolves.toEqual({ status: "skipped" });
expect(store.getConfig(config.id)).toMatchObject({ host: "edited-host", password: config.password, save_password: true });
expect(api.disconnectDb).not.toHaveBeenCalled();
expect(api.saveConnections).not.toHaveBeenCalled();
expect(api.sessionCredentialStatus).not.toHaveBeenCalled();
expect(api.forgetSessionCredential).not.toHaveBeenCalled();
});
it("does not forget credentials or write retirement state after config changes during cleanup", async () => {
const store = await storeWithConnection();
const expectedFingerprint = connectionConfigFingerprint(config);
const credentialStatus = deferred<boolean>();
const statusEntered = deferred<void>();
api.sessionCredentialStatus.mockImplementationOnce(() => {
statusEntered.resolve();
return credentialStatus.promise;
});
const retirement = store.retirePasswordAfterChange(config.id, expectedFingerprint);
await statusEntered.promise;
store.connections[0] = { ...store.getConfig(config.id)!, host: "edited-host" };
credentialStatus.resolve(true);
await expect(retirement).resolves.toEqual({ status: "skipped" });
expect(api.forgetSessionCredential).not.toHaveBeenCalled();
expect(store.getConfig(config.id)).toMatchObject({ host: "edited-host", password: "", save_password: false });
expect(store.connectionErrors[config.id]).toBeUndefined();
});
it("does not apply old disconnect tab handling after config changes during metadata cleanup", async () => {
const store = await storeWithConnection();
const expectedFingerprint = connectionConfigFingerprint(config);
const metadataRead = deferred<null>();
const metadataEntered = deferred<void>();
api.loadSchemaCache.mockImplementationOnce(() => {
metadataEntered.resolve();
return metadataRead.promise;
});
const retirement = store.retirePasswordAfterChange(config.id, expectedFingerprint);
await metadataEntered.promise;
store.connections[0] = { ...store.getConfig(config.id)!, host: "edited-host" };
metadataRead.resolve(null);
await expect(retirement).resolves.toEqual({ status: "skipped" });
expect(retainedTabRefresh).not.toHaveBeenCalled();
expect(store.getConfig(config.id)).toMatchObject({ host: "edited-host", password: "", save_password: false });
});
});
+44 -8
View File
@@ -1170,11 +1170,16 @@ export const useConnectionStore = defineStore("connection", () => {
return bounded;
}
function startDisconnectRequest(connectionId: string, metadataDrain?: Promise<void>): Promise<void> {
function startDisconnectRequest(connectionId: string, metadataDrain?: Promise<void>, expectedConfigFingerprint?: string): Promise<void> {
const clientAttempt = activeLocalConnectionAttempts.get(connectionId) ?? successfulLocalConnectionAttempts.get(connectionId);
const disconnectIfCurrent = () => {
const config = getConfig(connectionId);
if (expectedConfigFingerprint && (!config || connectionConfigFingerprint(config) !== expectedConfigFingerprint)) return;
return api.disconnectDb(connectionId, clientAttempt);
};
let request: Promise<void>;
try {
request = metadataDrain ? metadataDrain.then(() => api.disconnectDb(connectionId, clientAttempt)) : api.disconnectDb(connectionId, clientAttempt);
request = metadataDrain ? metadataDrain.then(disconnectIfCurrent) : Promise.resolve(disconnectIfCurrent());
} catch (error) {
request = Promise.reject(error);
}
@@ -4865,15 +4870,23 @@ export const useConnectionStore = defineStore("connection", () => {
* 「关闭连接」。系统侧释放(插件页签关闭后回收连接、改密码后的退役、删除连接)必须
* 传 false,否则后续用户重新打开那个页签时会因为标记而连不上(见 `userClosedConnectionIds`)。
*/
async function disconnect(connectionId: string, options: { skipTabHandling?: boolean; markClosedByUser?: boolean } = {}) {
async function disconnect(connectionId: string, options: { skipTabHandling?: boolean; markClosedByUser?: boolean; expectedConfigFingerprint?: string } = {}) {
const isExpectedConfigCurrent = () => {
const config = getConfig(connectionId);
return !options.expectedConfigFingerprint || (!!config && connectionConfigFingerprint(config) === options.expectedConfigFingerprint);
};
if (!isExpectedConfigCurrent()) return;
const metadataDrain = cancelSchemaDiffTasksForConnection(connectionId, new Error(i18n.global.t("schemaDiff.connectionDisconnected")));
const stateRevision = bumpConnectionStateRevision(connectionId);
// 先写标记再清理:清理过程中存活下来的页签(未保存草稿、keep-tabs 模式)一旦被
// 重新挂载,也不该在这一刻把连接连回来。
if (options.markClosedByUser !== false) markConnectionClosedByUser(connectionId);
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
if (hasSqlServerActivityTraceForConnection(connectionId)) await disposeSqlServerActivityTracesForConnection(connectionId);
const disconnectRequest = startDisconnectRequest(connectionId, metadataDrain);
if (hasSqlServerActivityTraceForConnection(connectionId)) {
await disposeSqlServerActivityTracesForConnection(connectionId);
if (!isExpectedConfigCurrent()) return;
}
const disconnectRequest = startDisconnectRequest(connectionId, metadataDrain, options.expectedConfigFingerprint);
cancelLocalConnectionAttempt(connectionId);
connectedIds.value.delete(connectionId);
@@ -4903,7 +4916,9 @@ export const useConnectionStore = defineStore("connection", () => {
invalidateCompletionCache(connectionId);
invalidateObjectBrowserRowsCache({ connectionId });
await invalidateSidebarTableSearchIndexesForConnection(connectionId);
if (!isExpectedConfigCurrent()) return;
const { useQueryStore } = await import("@/stores/queryStore");
if (!isExpectedConfigCurrent()) return;
const queryStore = useQueryStore();
// 断开连接是明确的元数据新鲜度边界:数据标签页保留展示/编辑状态,但
// 其 tableMeta 不得再被视为 warm cache(issue #6623——reconnect 后重开
@@ -4923,10 +4938,12 @@ export const useConnectionStore = defineStore("connection", () => {
break;
}
}
if (!isExpectedConfigCurrent()) return;
// Tab handling is synchronous in memory, but persistence is debounced. Flush
// the scoped tab state before disconnect returns so a quick reconnect/restart
// cannot restore tabs that the selected policy already removed.
await queryStore.flushPendingPersist().catch(() => undefined);
if (!isExpectedConfigCurrent()) return;
await disconnectRequest;
if (isCurrentConnectionStateRevision(connectionId, stateRevision)) {
clearConnectionError(connectionId);
@@ -4946,21 +4963,40 @@ export const useConnectionStore = defineStore("connection", () => {
await api.forgetSessionCredential(connectionId);
}
async function retirePasswordAfterChange(connectionId: string) {
async function retirePasswordAfterChange(connectionId: string, expectedConfigFingerprint?: string): Promise<{ status: "retired"; fingerprint: string } | { status: "skipped" }> {
const originalConfig = getConfig(connectionId);
if (expectedConfigFingerprint && (!originalConfig || connectionConfigFingerprint(originalConfig) !== expectedConfigFingerprint)) return { status: "skipped" };
passwordChangeReconnectRequired.set(connectionId, "retiring");
connections.value = connections.value.map((config) => (config.id === connectionId ? { ...config, password: "", save_password: false } : config));
const retiredConfigFingerprint = connectionConfigFingerprint(getConfig(connectionId)!);
const isRetiredConfigCurrent = () => {
const current = getConfig(connectionId);
return !expectedConfigFingerprint || (!!current && connectionConfigFingerprint(current) === retiredConfigFingerprint);
};
const stopRetirementForChangedConfig = () => {
if (passwordChangeReconnectRequired.get(connectionId) === "retiring") passwordChangeReconnectRequired.delete(connectionId);
return { status: "skipped" as const };
};
// 改密码后的退役是系统侧断开:用户随后会显式重连,写"用户已关闭"标记只会让
// 页签界面在重连期间多停一档空态。
const results = await Promise.allSettled([disconnect(connectionId, { markClosedByUser: false }), persistConnections()]);
const results = await Promise.allSettled([disconnect(connectionId, { markClosedByUser: false, expectedConfigFingerprint: retiredConfigFingerprint }), persistConnections()]);
if (!isRetiredConfigCurrent()) return stopRetirementForChangedConfig();
try {
if (await api.sessionCredentialStatus(connectionId)) await api.forgetSessionCredential(connectionId);
const hasCredential = await api.sessionCredentialStatus(connectionId);
if (!isRetiredConfigCurrent()) return stopRetirementForChangedConfig();
if (hasCredential) {
await api.forgetSessionCredential(connectionId);
if (!isRetiredConfigCurrent()) return stopRetirementForChangedConfig();
}
if (results.some((result) => result.status === "rejected")) throw new Error();
passwordChangeReconnectRequired.set(connectionId, "ready");
setConnectionError(connectionId, i18n.global.t("userAdmin.passwordChangedReconnect"));
} catch {
if (!isRetiredConfigCurrent()) return stopRetirementForChangedConfig();
setConnectionError(connectionId, i18n.global.t("userAdmin.passwordChangedCleanupFailed"));
throw new Error(i18n.global.t("userAdmin.passwordChangedCleanupFailed"));
}
return { status: "retired", fingerprint: retiredConfigFingerprint };
}
async function closeDatabaseConnection(connectionId: string, database: string) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+1 -4
View File
@@ -9,10 +9,7 @@ import { AlertTriangle, Check, Loader2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const owners = [
{ name: "user authorization", path: "admin/DatabaseUserAdmin.vue", marker: '<Dialog v-model:open="sqlDialogOpen">', previewHeight: "50vh" },
{ name: "database authorization", path: "sidebar/SidebarTreeItemDialogs.vue", marker: '<Dialog :open="showCreateDatabasePreviewDialog"', previewHeight: "48vh" },
];
const owners = [{ name: "database authorization", path: "sidebar/SidebarTreeItemDialogs.vue", marker: '<Dialog :open="showCreateDatabasePreviewDialog"', previewHeight: "48vh" }];
const mountedApps: Vue.App[] = [];
afterEach(() => {