diff --git a/.claude/commands/ralph.md b/.claude/commands/ralph.md new file mode 100644 index 000000000000..50d3c27b4978 --- /dev/null +++ b/.claude/commands/ralph.md @@ -0,0 +1,447 @@ +# Generate Ralph Wiggum Automation + +Generate a Ralph Wiggum-style iterative automation for large tasks. The Ralph Wiggum technique runs an AI CLI in a stateless loop where each iteration does ONE unit of work, then stops. Progress is tracked via git history and filesystem state. + +Supports two AI engines: **Claude CLI** (default) and **GitHub Copilot CLI** (`--type copilot`). + +## Task Description + +$ARGUMENTS + +## CLI Engine Parameters + +Parse these from `$ARGUMENTS` if present (flags like `--type copilot --model opus --effort high`): + +| Parameter | Values | Default | +|-----------|--------|---------| +| `--type` | `claude`, `copilot` | `claude` | +| `--model` | See model table below | `opus` | +| `--effort` | `low`, `medium`, `high`, `max` | `high` | + +### Model Name Mapping + +The two CLIs use different model name formats: + +| Alias | Claude CLI (`--model`) | Copilot CLI (`--model`) | +|-------|----------------------|------------------------| +| `opus` | `claude-opus-4-6` | `claude-opus-4.6` | +| `sonnet` | `claude-sonnet-4-6` | `claude-sonnet-4.6` | +| `haiku` | `claude-haiku-4-5` | `claude-haiku-4.5` | + +### Effort Support + +| Engine | Flag | Values | Notes | +|--------|------|--------|-------| +| Claude | `--effort` | `low`, `medium`, `high`, `max` | `max` is Opus-only | +| Copilot | `--reasoning-effort` | `low`, `medium`, `high`, `xhigh` | Map `max` → `xhigh` | + +Strip `--type`, `--model`, and `--effort` from `$ARGUMENTS` before processing the task description. + +## Modes + +This command operates in two modes based on context: + +### Mode 1: Wrap Existing Work +If there are **uncommitted changes** or a **recent commit pattern** to follow: +- Analyze the changes to infer task parameters +- Generate wrapper for "more of the same" + +### Mode 2: Full Planning +If the user provides a **project description** or asks for a full plan: +- Analyze the entire project/codebase +- Create a comprehensive plan with all work items +- Generate a TRACKER.md with all items as PENDING +- Generate the wiggum wrapper to execute the plan + +## Instructions + +### Step 1: Determine Mode + +Check the arguments and current state: +- If `$ARGUMENTS` describes a project or feature -> **Full Planning Mode** +- If `$ARGUMENTS` is a task name or empty with uncommitted changes -> **Wrap Existing Mode** + +### Step 2A: Full Planning Mode + +1. **Analyze the project scope** +2. **Create a tracker file** at `docs/trackers/features/{TASK}-TRACKER.md` +3. **Count items** to calculate max iterations: `N + 10` +4. **Generate the prompt and orchestrator** + +### Step 2B: Wrap Existing Mode + +1. **Analyze current state** via git status/diff +2. **Infer task parameters** from the changes +3. **If ambiguous**, ask the user + +### Step 3: Generate Files + +#### File 1: `scripts/{task}-prompt.md` + +The generated prompt MUST include ALL 8 quality check sections from the "Quality Checks" section below. Copy them verbatim into the prompt — these are the instructions Claude will follow headlessly, so if they're not in the prompt, they won't happen. The quality checks must appear AFTER the work instructions and BEFORE any "mark DONE" step. + +#### File 2: `scripts/{task}.ps1` + +**CRITICAL**: Set `$MaxIterations` to **item count + 5**. + +**Resolve template placeholders** from the parsed CLI parameters: +- `{TYPE}` → `claude` or `copilot` (default: `claude`) +- `{MODEL}` → resolved model name for the chosen engine (use the Model Name Mapping table above; default: `opus` alias → `claude-opus-4-6` for claude, `claude-opus-4.6` for copilot) +- `{EFFORT}` → `low`, `medium`, `high`, or `max` (default: `high`) + +Use this template for the PowerShell orchestrator - it streams tool use with file details: + +```powershell +#!/usr/bin/env pwsh +param( + [int]$MaxIterations = {ITEM_COUNT + 10}, + [ValidateSet('claude', 'copilot')] + [string]$Type = '{TYPE}', + [string]$Model = '{MODEL}', + [ValidateSet('low', 'medium', 'high', 'max')] + [string]$Effort = '{EFFORT}', + [switch]$DryRun +) + +$ErrorActionPreference = 'Stop' +$TrackerPath = "/workspace/docs/trackers/features/{TASK}-TRACKER.md" +$PromptPath = "/workspace/scripts/{task}-prompt.md" + +# Resolve engine binary +if ($Type -eq 'copilot') { + $EngineBin = "/home/vscode/.local/bin/copilot" + if (-not (Test-Path $EngineBin)) { + Write-Host "ERROR: Copilot CLI not found at $EngineBin" -ForegroundColor Red + exit 1 + } +} else { + $EngineBin = "claude" +} + +function Get-PendingCount { + $tracker = Get-Content $TrackerPath -Raw + return ([regex]::Matches($tracker, '\| PENDING[\s|]')).Count +} + +function Get-CompletedCount { + $tracker = Get-Content $TrackerPath -Raw + $done = ([regex]::Matches($tracker, '\| DONE[\s|✓]')).Count + $needsWork = ([regex]::Matches($tracker, '\| NEEDS_WORK[\s|✓]')).Count + $blocked = ([regex]::Matches($tracker, '\| BLOCKED[\s|✓]')).Count + return $done + $needsWork + $blocked +} + +function Invoke-Iteration { + param([int]$Iteration) + + Write-Host "" + Write-Host ("=" * 5) -ForegroundColor Yellow + Write-Host " Iteration $Iteration of $MaxIterations ($Type)" -ForegroundColor Yellow + Write-Host ("=" * 5) -ForegroundColor Yellow + + $prompt = Get-Content $PromptPath -Raw + + if ($Type -eq 'copilot') { + $engineArgs = @( + '-p', $prompt + '--model', $Model + '--allow-all' + ) + $copilotEffort = if ($Effort -eq 'max') { 'xhigh' } else { $Effort } + $engineArgs += @('--reasoning-effort', $copilotEffort) + + if ($DryRun) { + Write-Host " [DRY RUN] Would run: copilot $($engineArgs -join ' ')" -ForegroundColor DarkGray + return $true + } + + & $EngineBin @engineArgs 2>&1 | ForEach-Object { + $line = "$PSItem" + if ($line -and $line -notmatch '^\s*$') { + Write-Host " $line" -ForegroundColor White + } + } + } else { + $sessionId = [guid]::NewGuid().ToString() + $engineArgs = @( + '--dangerously-skip-permissions' + '--session-id', $sessionId + '--no-session-persistence' + '--model', $Model + '--effort', $Effort + '--verbose' + '--output-format', 'stream-json' + '-p', $prompt + ) + + if ($DryRun) { + Write-Host " [DRY RUN] Would run: claude $($engineArgs -join ' ')" -ForegroundColor DarkGray + return $true + } + + # Stream and parse JSON - show tool use with file details + & $EngineBin @engineArgs 2>&1 | ForEach-Object { + $line = $PSItem + try { + $obj = $line | ConvertFrom-Json -ErrorAction Stop + switch ($obj.type) { + 'assistant' { + if ($obj.message.content) { + foreach ($content in $obj.message.content) { + switch ($content.type) { + 'tool_use' { + $toolName = $content.name + $detail = "" + if ($content.input) { + switch ($toolName) { + 'Read' { $detail = Split-Path $content.input.file_path -Leaf } + 'Write' { $detail = Split-Path $content.input.file_path -Leaf } + 'Edit' { $detail = Split-Path $content.input.file_path -Leaf } + 'Glob' { $detail = $content.input.pattern -replace '.*/',''} + 'Grep' { $detail = $content.input.pattern.Substring(0, [Math]::Min(30, $content.input.pattern.Length)) } + 'Bash' { $detail = ($content.input.command -split '\n')[0].Substring(0, [Math]::Min(40, ($content.input.command -split '\n')[0].Length)) } + } + } + if ($detail) { + Write-Host " 🔧 $toolName " -ForegroundColor DarkCyan -NoNewline + Write-Host $detail -ForegroundColor DarkGray + } else { + Write-Host " 🔧 $toolName" -ForegroundColor DarkCyan + } + } + 'text' { + if ($content.text) { Write-Host $content.text -ForegroundColor White } + } + } + } + } + } + 'result' { + $duration = [math]::Round($obj.duration_ms / 1000, 1) + $cost = [math]::Round($obj.total_cost_usd, 4) + Write-Host "" + Write-Host "✓ Completed in ${duration}s (\$$cost)" -ForegroundColor Green + } + } + } catch { + if ($line -and $line -notmatch '^\s*$') { Write-Host $line -ForegroundColor DarkGray } + } + } + } + + return $LASTEXITCODE -eq 0 +} + +# Main loop +Write-Host "Ralph Wiggum: {TASK} ($Type, $Model, effort=$Effort)" -ForegroundColor Cyan +$total = {ITEM_COUNT} +$iteration = 0 +$stalledCount = 0 +$maxStalled = 3 + +while ($iteration -lt $MaxIterations) { + $iteration++ + $pending = Get-PendingCount + $completed = Get-CompletedCount + $completedBefore = $completed + $percent = [math]::Min(100, [math]::Round(($completed / $total) * 100)) + + $barFilled = [math]::Min(20, [math]::Floor($percent / 5)) + $barEmpty = [math]::Max(0, 20 - $barFilled) + Write-Host "[$(('=' * $barFilled))$(('-' * $barEmpty))] $percent% ($completed/$total)" -ForegroundColor Cyan + + if ($pending -eq 0) { + Write-Host "All items completed!" -ForegroundColor Green + break + } + + if (-not (Invoke-Iteration -Iteration $iteration)) { + Write-Host "⚠️ Iteration failed. Sleeping 5 minutes before retry..." -ForegroundColor Yellow + Start-Sleep -Seconds 300 + Write-Host "Retrying iteration $iteration..." -ForegroundColor Yellow + if (-not (Invoke-Iteration -Iteration $iteration)) { + Write-Host "✗ Iteration $iteration failed again after retry. Stopping." -ForegroundColor Red + exit 1 + } + } + + # Zero-trust: verify the iteration actually progressed + $completedAfter = Get-CompletedCount + + if ($completedAfter -le $completedBefore) { + $stalledCount++ + Write-Host "⚠️ No progress detected (stalled $stalledCount/$maxStalled)" -ForegroundColor Yellow + Write-Host " Before: $completedBefore completed, $pending pending" -ForegroundColor Yellow + Write-Host " After: $completedAfter completed" -ForegroundColor Yellow + + if ($stalledCount -ge $maxStalled) { + Write-Host "✗ Stalled $maxStalled times in a row. Claude is claiming done without finishing." -ForegroundColor Red + Write-Host " Check the tracker for items marked DONE that shouldn't be." -ForegroundColor Red + exit 1 + } + } else { + $stalledCount = 0 + Write-Host "✓ Progress: $completedBefore → $completedAfter completed" -ForegroundColor Green + } + + Start-Sleep -Seconds 2 +} + +Write-Host "Done. Completed: $(Get-CompletedCount) / $total" -ForegroundColor Green +``` + +### Step 4: Report to User + +Show: +- Mode used (Full Planning or Wrap Existing) +- Item count and max iterations calculation +- Generated file paths +- How to run: `./scripts/{task}.ps1` +- Engine override example: `./scripts/{task}.ps1 -Type copilot -Model claude-sonnet-4.6 -Effort high` + +### Verification Before Writing Code + +```bash +# 1. Find the PSU endpoint and get the EXACT URL +grep -r "New-ProtectedEndpoint.*{resource}" /workspace/src/psu/endpoints/ + +# 2. Read the SQL schema for EXACT column names +cat /workspace/src/sql/Schema/Tables/{schema}.{Table}.sql +``` + +## Quality Checks (MANDATORY in every generated prompt) + +The generated prompt in `scripts/{task}-prompt.md` MUST include ALL of the following sections verbatim. These are not optional — they are the difference between "Claude says it's done" and "it's actually done." + +### 1. NAMING CONSISTENCY (before writing any code) + +``` +BEFORE writing any code for this item, verify naming: +1. Find the PSU endpoint URL: grep -r "New-ProtectedEndpoint" for the resource +2. Read the SQL schema: cat the .sql file for exact column names +3. Verify URL is plural kebab-case matching the SQL table name +4. If JS API calls exist, verify they match the PSU URL path +DO NOT proceed if any names are mismatched — fix the mismatch first. +``` + +### 2. VERIFY (prove the code works) + +``` +AFTER writing code, verify it actually works: +1. If C# was modified: `cd /workspace/src/module && dotnet build --nologo 2>&1 | grep "error CS"` + - MUST return zero errors. If errors exist, fix them before continuing. +2. If PSU endpoints were modified: `docker restart dbpro-psu` and wait 30 seconds +3. If Hugo templates were modified: `docker restart dbpro-hugo` +4. Read back every file you modified and confirm the changes are present + - Do NOT trust your memory of what you wrote — actually Read the file +``` + +### 3. TEST (run tests, don't just claim they pass) + +``` +AFTER verifying the build: +1. If C# tests exist for this area: `cd /workspace/src/module && dotnet test --filter "FullyQualifiedName~{relevant}" --nologo` + - Report the exact Passed/Failed/Skipped counts +2. If Pester tests exist: run them and report results +3. If no tests exist for the code you wrote, state that explicitly — do not say "tests pass" when no tests were run +``` + +### 4. BUG REVIEW (find bugs before marking done) + +``` +BEFORE marking this item DONE, review your code for these common bugs: +1. **Null references**: Any property access that could be null? Add guards. +2. **Off-by-one**: Any loops, pagination, or array indexing? Verify bounds. +3. **Missing error handling at boundaries**: API inputs validated? SQL params parameterized? +4. **Stale references**: Did you reference a function/variable/column that doesn't exist? + - grep for it — don't assume it exists from memory +5. **Incomplete implementations**: Search your changes for TODO, FIXME, NotImplemented, stub, placeholder: + ```bash + git diff HEAD~1 --unified=0 | grep -i "TODO\|FIXME\|NotImplemented\|stub\|placeholder" + ``` + - MUST return zero matches. If any exist, you are NOT done. +``` + +### 5. SECURITY REVIEW (5 checks, non-negotiable) + +``` +BEFORE marking this item DONE, verify these 5 security properties: +- [ ] **Input validation**: All new parameters are typed and validated — no raw strings passed to SQL +- [ ] **Auth enforcement**: All new endpoints use New-ProtectedEndpoint (not New-PSUEndpoint) — no accidental anonymous access +- [ ] **SQL safety**: All queries use QueryBuilder/MutationBuilder parameterization — grep for string concatenation near SQL +- [ ] **No secrets**: No hardcoded credentials, connection strings, or tokens: `grep -rn "password\|secret\|apikey\|connectionstring" [your files] -i` +- [ ] **No info leaks**: Error responses use the standard envelope — no stack traces, internal paths, or raw SQL errors exposed + +If ANY check fails, fix it. Do not mark DONE with security issues. +``` + +### 6. SIMPLIFY (remove unnecessary complexity) + +``` +REVIEW your changes for unnecessary complexity: +1. Any single-use helper functions? Inline them. +2. Any dead code or commented-out blocks? Remove them. +3. Any over-engineered abstractions for one-time operations? Flatten them. +4. Any duplicated logic that should be consolidated? Merge it. +Keep changes minimal — only simplify code YOU wrote in this iteration. +``` + +### 7. DOUBLECHECK (zero-trust final verification) + +``` +FINAL VERIFICATION — do not trust your earlier checks. Re-verify from scratch: + +1. Read the tracker and find the item you just marked DONE +2. Read EVERY file you claim to have modified — confirm the changes are real +3. If it was a C# change, run `dotnet build` ONE MORE TIME right now +4. Verify the tracker status is correct: + ```bash + grep -c "| DONE |" {TRACKER_PATH} + grep -c "| PENDING |" {TRACKER_PATH} + ``` +5. Fill out this verification table (include in your output): + +| Check | Result | Evidence | +|-------|--------|----------| +| Files modified | X files | [list them] | +| Build passes | yes/no | [error count from dotnet build] | +| Tests pass | yes/no/N/A | [pass/fail counts] | +| No TODOs/stubs | yes/no | [grep result] | +| Security checks | 5/5 | [any failures?] | +| Naming consistent | yes/no | [URL + SQL verified] | + +If ANY row is "no" or incomplete, you are NOT done. Fix it first. +DO NOT mark the tracker item as DONE until this table is fully green. +``` + +### 8. COMMIT YOUR WORK (mandatory — do NOT skip) + +``` +After ALL checks pass and the tracker is updated, you MUST commit: + + git add -A + git commit -m "{type}({task-slug}): {item-name} — {summary}" + +Use conventional commit format. If no files were modified (all checks passed), +commit just the tracker update. Each item gets its own commit so progress is +never lost between iterations. Do NOT skip this step. +``` + +### 9. STRUCTURED COMPLETION REPORT (mandatory output format) + +``` +After completing an item, your output MUST end with this structure: + +ITEM: [item name from tracker] +STATUS: DONE | BLOCKED [reason] +FILES_MODIFIED: [exact file list] +FILES_READ_BACK: [yes — I re-read every modified file | no — EXPLAIN WHY] +BUILD: [PASS (0 errors) | FAIL (N errors) | N/A (no C# changes)] +TESTS: [PASS (N passed, 0 failed) | FAIL (details) | N/A (no tests)] +SECURITY: [5/5 | N/5 (list failures)] +BUGS_FOUND: [0 | N (list them and confirm fixed)] +VERIFICATION_TABLE: [included above | MISSING — NOT DONE] + +If this structure is missing from your output, the orchestrator will +treat the iteration as failed regardless of what you claim. +``` diff --git a/.claude/hooks/pre-bash-backslash.sh b/.claude/hooks/pre-bash-backslash.sh new file mode 100644 index 000000000000..d0dab786712e --- /dev/null +++ b/.claude/hooks/pre-bash-backslash.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +input=$(cat) +command=$(echo "$input" | grep -oP '"command"\s*:\s*"\K[^"]*' | head -1) + +if echo "$command" | grep -qP '[A-Za-z]:\\\\'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"Windows backslash paths are mangled by Git Bash. Use forward slashes instead (e.g. C:/github/LeLab/scratchpad/foo.ps1). PowerShell handles forward slashes fine on Windows."}} +EOF + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/pre-bash-pwsh-script.sh b/.claude/hooks/pre-bash-pwsh-script.sh new file mode 100644 index 000000000000..8afaff92a234 --- /dev/null +++ b/.claude/hooks/pre-bash-pwsh-script.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +input=$(cat) +command=$(echo "$input" | grep -oP '"command"\s*:\s*"\K[^"]*' | head -1) + +# Block powershell.exe entirely +if echo "$command" | grep -qiP '\bpowershell(\.exe)?\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"Do not use powershell.exe (Windows PowerShell 5.1). Write a .ps1 file and run: pwsh -NoProfile -File "}} +EOF + exit 0 +fi + +# Block pwsh -Command / pwsh -c +if echo "$command" | grep -qiP 'pwsh(\s+-\w+)*\s+-(c|Command)\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"Do not run inline PowerShell via pwsh -Command. Write the script to scratchpad/.ps1 first, then execute with: pwsh -NoProfile -File scratchpad/.ps1"}} +EOF + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/prevent-destructive-git.sh b/.claude/hooks/prevent-destructive-git.sh new file mode 100644 index 000000000000..b2aadfc0b524 --- /dev/null +++ b/.claude/hooks/prevent-destructive-git.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +input=$(cat) +command=$(echo "$input" | grep -oP '"command"\s*:\s*"\K[^"]*' | head -1) + +# Block git push --force and variants (-f, --force-with-lease, --force-if-includes) +if echo "$command" | grep -qiP 'git\s+push\s+.*(-f|--force)\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"Force push is blocked. Use regular git push instead. If you need to force push, ask the user to do it manually."}} +EOF + exit 0 +fi + +# Block git reset --hard +if echo "$command" | grep -qiP 'git\s+reset\s+.*--hard\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"git reset --hard is blocked because it discards uncommitted changes. Use git stash or git checkout -- instead."}} +EOF + exit 0 +fi + +# Block git clean -f (force delete untracked files) +if echo "$command" | grep -qiP 'git\s+clean\s+.*-[a-zA-Z]*f'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"git clean -f is blocked because it permanently deletes untracked files. Ask the user to run it manually if needed."}} +EOF + exit 0 +fi + +# Block git checkout with --force/-f on branches (not file restores) +if echo "$command" | grep -qiP 'git\s+checkout\s+(-f|--force)\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"git checkout --force is blocked because it discards local changes. Use git stash first, then checkout."}} +EOF + exit 0 +fi + +# Block git branch -D (force delete) +if echo "$command" | grep -qiP 'git\s+branch\s+.*-D\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"git branch -D (force delete) is blocked. Use git branch -d for safe deletion, or ask the user to force-delete manually."}} +EOF + exit 0 +fi + +# Block git rebase on shared/remote branches (rebase with upstream refs) +if echo "$command" | grep -qiP 'git\s+rebase\s+.*(origin|upstream)\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"Rebasing against remote branches is blocked to prevent history rewrites. Ask the user before rebasing."}} +EOF + exit 0 +fi + +# Block amending commits (could rewrite published history) +if echo "$command" | grep -qiP 'git\s+commit\s+.*--amend\b'; then + cat <<'EOF' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny", +"permissionDecisionReason":"git commit --amend is blocked because it rewrites commit history. Create a new commit instead, or ask the user to amend manually."}} +EOF + exit 0 +fi + +exit 0 diff --git a/.claude/hooks/redirect-glob.sh b/.claude/hooks/redirect-glob.sh new file mode 100644 index 000000000000..d10db558655b --- /dev/null +++ b/.claude/hooks/redirect-glob.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +input=$(cat) +tool=$(echo "$input" | grep -oP '"tool_name"\s*:\s*"\K[^"]*' | head -1) + +if [ "$tool" != "Glob" ]; then + exit 0 +fi + +pattern=$(echo "$input" | grep -oP '"pattern"\s*:\s*"\K[^"]*' | head -1) +path=$(echo "$input" | grep -oP '"path"\s*:\s*"\K[^"]*' | head -1) + +search_in="" +if [ -n "$path" ]; then + search_in=" \"$path\"" +fi + +cat >&2 </dev/null; git diff --cached --name-only 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null) +CHANGED_FILES=$(echo "$CHANGED_FILES" | sort -u | grep -E '\.(ps1|psm1|psd1|cs|sql|js|ts|html|go|py|sh)$') + +if [[ -z "$CHANGED_FILES" ]]; then + exit 0 +fi + +# Scan for TODO/FIXME/HACK/XXX/WORKAROUND in changed files +TODO_REPORT="" +while IFS= read -r file; do + [[ -f "$file" ]] || continue + HITS=$(grep -n -i -E '\b(TODO|FIXME|HACK|XXX|WORKAROUND)\b' "$file" 2>/dev/null) + if [[ -n "$HITS" ]]; then + TODO_REPORT+="### $file\n" + while IFS= read -r line; do + TODO_REPORT+=" $line\n" + done <<< "$HITS" + TODO_REPORT+="\n" + fi +done <<< "$CHANGED_FILES" + +if [[ -n "$TODO_REPORT" ]]; then + # Escape for JSON + ESCAPED=$(echo -e "$TODO_REPORT" | jq -Rs .) + jq -n --argjson report "$ESCAPED" '{ + decision: "block", + reason: ("⚠️ UNFINISHED WORK DETECTED — do not stop until resolved.\n\nThe following TODO/FIXME/HACK items were found in changed files.\nFor each one you MUST either:\n\n 1. Resolve it now (implement the missing code), OR\n\n 2. If you cannot finish due to context window size or complexity, write a self-contained prompt to docs/prompts/ that a fresh Claude session can run to complete the work. The prompt MUST:\n - Describe exactly what each TODO requires\n - Include all relevant file paths and line numbers\n - Use the Agent tool with specialized subagents where appropriate (e.g. psu-developer, hugo-frontend, csharp-engineer)\n - End with an instruction to commit the completed work using conventional commits\n Then tell the user: \"I wrote a completion prompt to docs/prompts/.md — run it in a new session to finish.\"\n\n 3. As a last resort only: explicitly tell the user what remains and why it cannot be done at all.\n\nDo NOT silently leave TODOs behind. Go finish them.\n\n" + $report + "\n--- End of TODO Report ---") + }' +fi + +exit 0 diff --git a/.claude/hooks/stop-verify.sh b/.claude/hooks/stop-verify.sh new file mode 100644 index 000000000000..f7a746da19e6 --- /dev/null +++ b/.claude/hooks/stop-verify.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# stop-verify.sh - Comprehensive quality gate when Claude finishes responding +# Incorporates: doublecheck, simplify, review, verify, and completeness checks. +# Injects a reminder to self-verify. Does NOT hard-block (no infinite loops). +# Guards against re-entry via stop_hook_active flag. + +INPUT=$(cat) + +# Prevent infinite loops: if stop hook is already active, exit immediately +STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false') +if [[ "$STOP_ACTIVE" == "true" ]]; then + exit 0 +fi + +jq -n '{ + hookSpecificOutput: { + hookEventName: "Stop", + additionalContext: "QUALITY GATE — If you wrote or modified code in this response, perform ALL checks below before finishing. If you only answered a question or did research, skip this.\n\n## 1. VERIFY (does it work?)\n- Check for syntax errors in changed files\n- Run tests if applicable (Pester for PS, dotnet test for C#)\n- Confirm imports, dependencies, and module loading work\n- Report what works and what does not\n\n## 2. REVIEW (is it safe and correct?)\n- Logic errors and edge cases\n- Security: credential handling, injection vulnerabilities (SQL, XSS)\n- Missing validation on user input\n- Error responses must not expose stack traces\n- Destructive operations need confirmation\n- API naming contract (plural URLs, kebab-case)\n- PSU endpoints use New-ProtectedEndpoint\n\n## 3. SIMPLIFY (is it clean?)\n- Remove unnecessary complexity\n- Consolidate duplicate logic\n- Use idiomatic patterns (PowerShell best practices for .ps1)\n- Remove dead code, unused variables, unused imports\n- Do not add features or change functionality — only simplify\n\n## 4. DOUBLECHECK (final verification)\nCreate a table:\n| Claim/Item | Verified? | Notes |\n|------------|-----------|-------|\nInclude: primary functionality, tests passing, security, naming conventions.\nBe thorough and honest about what you could not verify.\n\n## 5. COMPLETENESS\n- Were ALL requested changes made?\n- Any TODO/FIXME left behind that should be resolved?\n- Files over 400 lines that need splitting?\n- OBSERVABILITY IN ACTION: If you built or modified a page displaying fleet data, does it include action capabilities (Fix Now / Schedule / Execute buttons)? Display-only pages are not acceptable unless purely audit/history.\n\nIf anything fails, fix it before finishing." + } +}' + +exit 0 diff --git a/.claude/hooks/validate-style.ps1 b/.claude/hooks/validate-style.ps1 new file mode 100644 index 000000000000..718ff4158aeb --- /dev/null +++ b/.claude/hooks/validate-style.ps1 @@ -0,0 +1,150 @@ +#!/usr/bin/env pwsh +# PreToolUse hook: Consolidated style validation for dbatools +# Runs all style checks in a single PowerShell process for performance + +$ErrorActionPreference = "Stop" + +try { + $inputJson = $input | Out-String | ConvertFrom-Json +} catch { + exit 0 +} + +$toolInput = $inputJson.tool_input +$filePath = $toolInput.file_path +if (-not $filePath) { exit 0 } +if ($filePath -notlike "*.ps1") { exit 0 } + +$content = if ($toolInput.new_string) { $toolInput.new_string } else { $toolInput.content } +if (-not $content) { exit 0 } + +$violations = @() +$lines = $content -split "`n" + +# Track state for multi-line constructs +$inHereStringSingle = $false +$inHereStringDouble = $false +$inHashtable = $false +$hashtableLines = @() +$hashtableStart = 0 +$misalignedHashtables = @() + +# Patterns (using double quotes with escaping) +$patternComment = "^\s*#" +$patternHereStringSingleStart = "@'" +$patternHereStringDoubleStart = "@`"" +$patternHereStringSingleEnd = "^'@" +$patternHereStringDoubleEnd = "^`"@" +$patternBacktick = "``\s*$" +$patternBoolAttribute = "\[\s*(Parameter|CmdletBinding|OutputType|ValidateSet)\s*\([^]]*=\s*\`$(true|false)" +$patternStaticNew = "::new\s*\(" +$patternSingleQuote = "(? naming (e.g., `$splatConnection)." + } + + # 6. No ArrayList or Generic.List collection + if ($line -match $patternArrayList) { + $violations += "Line ${lineNum}: Output directly to pipeline, not ArrayList." + } + if ($line -match $patternGenericList) { + $violations += "Line ${lineNum}: Output directly to pipeline, not Generic.List." + } + + # 7. OTBS - no standalone opening brace (Allman style) + if ($line -match $patternStandaloneBrace -and $i -gt 0) { + $prevLine = $lines[$i - 1].TrimEnd("`r") + if ($prevLine -match $patternPrevLineEnd -or $prevLine -match $patternControlKeyword) { + $violations += "Line ${lineNum}: Use OTBS - opening brace on same line as statement." + } + } + + # 8. Track hashtables for alignment check + if ($line -match $patternHashtableStart) { + $inHashtable = $true + $hashtableLines = @() + $hashtableStart = $lineNum + # Don't add this line to hashtableLines - it's the opening, not an entry + } elseif ($inHashtable) { + if ($line -match $patternHashtableEnd) { + if ($hashtableLines.Count -ge 2) { + $equalsPositions = $hashtableLines | ForEach-Object { + $pos = $_.IndexOf("=") + if ($pos -ge 0) { $pos } + } | Where-Object { $null -ne $_ } | Select-Object -Unique + if ($equalsPositions.Count -gt 1) { + $misalignedHashtables += "Lines $hashtableStart-$lineNum" + } + } + $inHashtable = $false + } elseif ($line -match "=" -and $line.Trim() -ne "") { + $hashtableLines += $line + } + } + } + + # 9. No trailing spaces (check even in comments) + if ($line -match $patternTrailingSpace) { + $violations += "Line ${lineNum}: Trailing whitespace." + } +} + +# Add hashtable alignment violations +foreach ($ht in $misalignedHashtables) { + $violations += "${ht}: Hashtable = signs must be vertically aligned." +} + +if ($violations.Count -gt 0) { + $summary = ($violations | Select-Object -First 5) -join "`n" + $more = if ($violations.Count -gt 5) { "`n... and $($violations.Count - 5) more violations" } else { "" } + [Console]::Error.WriteLine("BLOCKED: dbatools style violations:`n$summary$more") + exit 2 +} + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000000..1016167b651e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "pwsh -NoProfile -File .claude/hooks/validate-style.ps1" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/prevent-destructive-git.sh" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 225a9b4df463..96ddf4643be8 100644 --- a/.gitignore +++ b/.gitignore @@ -57,11 +57,12 @@ allcommands.ps1 /.aitools/.aitools/.aider /.aitools .aider* -/.shadowgit.git -/.claude -publish.ps1 +/.shadowgit.git +publish.ps1 PR_BODY.md PR_DESCRIPTION.md -nul +nul +.claude/settings.local.json +/scripts diff --git a/docs/trackers/features/commit-bug-review-TRACKER.md b/docs/trackers/features/commit-bug-review-TRACKER.md new file mode 100644 index 000000000000..203805e2d464 --- /dev/null +++ b/docs/trackers/features/commit-bug-review-TRACKER.md @@ -0,0 +1,145 @@ +# Commit Bug Review Tracker + +Review each commit from `297844e964bfe5197d914e7b794bea8cffeeb066` to HEAD for bugs. +Find real bugs (logic errors, null refs, incorrect behavior) and fix them. Skip version bumps, CI fixes, and doc-only changes. + +| Hash | Subject | Status | Notes | +|------|---------|--------|-------| +| 899ea759c | Copy-DbaDbMail: Enhance handling of dedicated admin connections (#10155) | DONE | Fixed forced Get-DecryptedObject -EnableException regression. | +| add550fd2 | Copy-DbaLinkedServer: Enhance handling of dedicated admin connections (#10156) | DONE | Fixed forced Get-DecryptedObject -EnableException regression. | +| f43a61348 | Added SQL Server 2025 CU2 to dbatools-buildref-index.json (#10168) | DONE | Missed LastUpdated metadata; corrected by follow-up commit 54e65cf5c. | +| 54e65cf5c | Updated LastUpdated of dbatools-buildref-index.json followup to SQL Server 2025 CU2 (#10169) | DONE | Reviewed metadata-only LastUpdated fix; no bugs found. | +| 4ef488757 | Get-DbaDbTable: Optimize usage of ClearAndInitialize (#10157) | DONE | Fixed config bypass so ClearAndInitialize stays opt-in; added regression test. | +| f8e673145 | Export-DbaCredential: Enhance handling of dedicated admin connections (#10158) | DONE | Fixed forced Get-DecryptedObject -EnableException regression; added unit regression test. | +| ed1c8b0bb | Export-DbaLinkedServer: Enhance handling of dedicated admin connections (#10159) | DONE | Fixed forced Get-DecryptedObject -EnableException regression; added unit regression test. | +| 9e15aa3e3 | Invoke-DbaDbDecryptObject: Enhance handling of dedicated admin connections (#10160) | DONE | Fixed DAC detection so reused DAC connections are not disconnected; added regression tests. | +| a06ff44dc | [Start|Stop]-DbaDbEncryption: Fix usage of Disconnect-DbaInstance (#10161) | DONE | Fixed parallel cleanup to disconnect thread connections during WhatIf; added unit regressions. | +| d7d327f25 | Sync-DbaAvailabilityGroup: Enhance handling of dedicated admin connections (#10163) | DONE | Stopped forcing DAC at top level so password-aware Copy-Dba* commands manage it; added unit regression tests. | +| be1c99333 | Get-DbaNetworkConfiguration: Fix bug and really add SuitableCertificate property to output (#10165) | DONE | Fixed new test cleanup to remove the certificate from the target SQL host instead of defaulting to localhost. | +| 72ba70110 | New-DbaComputerCertificate: Update security defaults to industry standards (#10167) | DONE | Fixed NonExportable regression so remote installs keep the source cert exportable; added unit regression test. | +| ffb91a13e | Correct help text to correctly reflect Duration units (#10171) | DONE | Reviewed help-text-only change; no bugs found. | +| dd58a4edb | Export-DbaInstance: Enhance handling of dedicated admin connections (#10173) | DONE | Fixed leaked DAC cleanup on export failure paths; added unit regression test. | +| 276b0f6d3 | Add-DbaComputerCertificate - handle multiple flags for NonExportable keys (#10176) | DONE | Fixed combined UserProtected/NonExportable detection so remote imports are skipped; added unit regression test. | +| 1a510a12d | Part 2 of refactoring of Get-DecryptedObject (#10174) | DONE | Restored Stop-Function handling for password query failures; added unit regression test. | +| b82f01eeb | Install-DbaMaintenanceSolution: make off switches work and increase test coverage (#10172) | DONE | Default-false switches forced Compress/Verify/CheckSum off when omitted; corrected by follow-up commit 1f43cbbf0. | +| b0403f5f5 | Start-DbaMigration: Enhance handling of dedicated admin connections (#10162) | DONE | Guarded null DAC/source connections and added unit regression tests. | +| 920fe547b | v2.7.25 | DONE | version bump - skip | +| 58f64c580 | speedup dependency detection for integration tests (#10179) | DONE | Fixed debug helper name parsing, corrected dependency return bookkeeping, removed backtick recursion, and added a regression test. | +| cb7a5d77a | Comment out 2008R2SP2Express AppVeyor jobs | DONE | Reviewed CI-only AppVeyor matrix change; no bugs found. | +| 9799c1877 | Minor test fixes for Connect-DbaInstance (#10175) | DONE | Reviewed test-only cleanup-output suppression change; no bugs found. | +| 1616a6d04 | Add command Test-DbaNetworkCertificate (#10178) | DONE | Fixed ConfiguredCertificateValid so future or missing NotBefore values do not report false positives; added unit regression tests. | +| dbe2f29ca | Get-DbaDbTable: Fix for Azure SQL Database (#10182) | DONE | Fixed Azure default view to skip unsupported IndexSpaceUsed/DataSpaceUsed properties; added unit regression test. | +| eb40ff802 | Test-DbaLsnChain: Fix bug in case log backup is taken during full backup (#10185) | DONE | Introduced deserialized BigInt comparison regression; already corrected by follow-up commit 91972b420 (#10201). | +| 71d1310b4 | Backup-DbaDatabase: Respect explicit FileCount when using StorageBaseUrl (S3/Azure) (#10186) | DONE | Fixed multi-URL StorageBaseUrl striping so explicit FileCount only applies to single URLs; added OutputScriptOnly regression coverage. | +| 8f7039699 | Copy-DbaDatabase: Fix renaming for database names with special characters (#10187) | DONE | Fixed literal replacement so new database names with regex tokens stay intact; added regression test. | +| e798ca45e | Start-DbaDbEncryption: Add missing parameter and fix documentation (#10191) | DONE | Fixed parallel pre-key creation to honor ExcludeDatabase filtering; corrected help text and added a unit regression test. | +| 218a98a4a | Import-DbaCsv - Fix RFC 4180 multiline quoted field handling (#10190) | DONE | Fixed SupportsMultiline scope bug so AutoCreateTable honors explicit opt-out; added unit regression test. | +| 861b0dbce | Import-DbaCsv: Add -NoColumnOptimize switch (#10195) | DONE | Reviewed NoColumnOptimize switch; no bugs found. | +| 91972b420 | Test-DbaLsnChain: Fix bug when reading history from file (#10201) | DONE | Fixed numeric sorting for deserialized LSN values from file-backed history; added regression test. | +| 0332cfbae | Get-DbaAgentJob and Sync-DbaAvailabilityGroup: Move filter for MSX jobs (#10198) | DONE | Fixed AG job sync to request Local jobs instead of filtering by CategoryID; added unit regression test. | +| a0833cc2b | Test-DbaBackupInformation: Add member IsVerified to output if not already present (#10200) | DONE | Added missing IsVerified initialization for failed inputs and a regression test. | +| 14a287e37 | Connect-DbaInstance: Use localhost for dedicated admin connections (#10199) | DONE | Introduced localhost DAC certificate validation regression; already corrected by follow-up commit 3d6fa113f (#10263). | +| 7777c1401 | Export-DbaLogin: Add -IncludeRolePermissions switch (#10196) | DONE | Fixed role script ordering and duplicate CREATE ROLE output; added regression coverage. | +| fd7402b94 | Get-DbaUserPermission: Fix incorrect schema name shown as 'STIG' (#10210) | DONE | Reviewed SQL-only schema lookup fix; no bugs found. | +| da0941593 | Export-DbaInstance: Propagate -EnableException to sub-commands and wrap exports in try-catch (#10211) | DONE | Fixed skipped Write-Progress completion on continued export failures; added unit regression test. | +| 85e129c4d | Get-DbaReportingService: Use Credential in every call to Get-DbaCmObject (#10207) | DONE | Reviewed credential propagation fix; no bugs found. | +| f3e40229f | New-DbaLogin: add ExternalGroup support and SQL Server 2022 Entra login handling (#10225) | DONE | Fixed invalid external-provider fallback WITH clause so defaults are applied with ALTER LOGIN; added unit regression test. | +| d5d9fd3e2 | Get-DbaStartupParameter: Fix multiple issues (#10208) | DONE | Fixed silent and ambiguous WMI service lookups; added unit regression tests. | +| 2bc80a623 | Test-DbaKerberos: Remove CNAME test (#10209) | DONE | Fixed stale help/output check counts after removing the CNAME diagnostic. | +| 95e3aa220 | Update-DbaInstance: Fall back to computer name if Resolve-DbaNetworkName fails (#10212) | DONE | Propagated Authentication into early remote helpers, removed pending reboot CIM dependency, and added regression tests. | +| 480e72a46 | New-DbaDbMailProfile - Fix to allow multiple accounts per profile (#10214) | DONE | Reviewed profile reuse/add-account change; no bugs found. | +| b94e33eaa | Connect-DbaInstance: Auto-retry with Initial Catalog=master for mirrored SQL Server instances (#10215) | DONE | Fixed connection-string/registered-server retry handling and chained certificate->Initial Catalog retry; added unit regression tests. | +| dc581344d | Export-DbaScript: Handle Distributed Availability Groups gracefully (#10216) | DONE | Fixed manual DAG scripting to escape AG names, replica names, and listener URLs; added unit regression test. | +| 8dacfa20a | Get-DbaReplSubscription: Also check distribution DB for pull subscriptions (#10218) | DONE | Scoped distribution fallback to publication_id to avoid cross-publisher false positives; added unit regression test. | +| eb0aa1d3d | Invoke-DbaDbLogShipping: Add -IgnoreFileChecks parameter (#10219) | DONE | Fixed root shared-path validation so IgnoreFileChecks is honored before the generated full backup; added unit regression test. | +| f2fb47857 | Copy-DbaPolicyManagement: Add ObjectSets migration (#10220) | DONE | Filtered ObjectSets to selected policies so -Policy/-ExcludePolicy no longer copies unrelated sets; added unit regression test. | +| 902f23b98 | Get-DbaDb[StoredProcedure|Table|Udf|View]: Wrap all ClearAndInitialize in try-catch-block (#10226) | DONE | Reviewed ClearAndInitialize fallback workaround; no bugs found. | +| 23b429bfc | Backup-DbaDatabase: Prevent duplicate dbname when using CreateFolder with ReplaceInName (#10224) | DONE | Limited dbname auto-skip to directory paths so filename tokens still create the database folder; added regression test. | +| e79bef926 | Invoke-DbaDbDataMasking: Fix MaskingID column and index left behind after masking (#10223) | DONE | Fixed WhatIf unique-index side effects and made WhatIf row counts honor FilterQuery; added unit regressions. | +| 7240105e0 | Invoke-DbaDbDataMasking: Fix Deterministic masking not applied with multiple columns (#10222) | DONE | Reviewed deterministic multi-column lookup flow; no bugs found. | +| 156fc62e1 | Read-DbaXEFile: Fix database_name and other action columns being empty (#10221) | DONE | Reviewed action-key normalization fix; no bugs found. | +| d5b122bf5 | March 2026 CVEs (#10230) | DONE | Reviewed buildref/test-only update; no bugs found. | +| 2472ea8fd | Remove Invoke-SmoCheck - no longer needed (#10229) | DONE | Reviewed helper removal; no bugs found. | +| 5e0b70d66 | Set-DbaPrivilege: Use per-service SID (NT SERVICE\ServiceName) for IFI, LPIM, SecAudit (#10228) | DONE | Reviewed per-service SID change; no bugs found. | +| aa4e253cb | v2.7.26 | DONE | version bump - skip | +| 9c81f3d3d | Latest CUs for 2022 and 2025 (#10231) | DONE | Reviewed buildref metadata update; no bugs found. | +| 3e26310f2 | Add Test-DbaInstantFileInitialization command (#10236) | DONE | Fixed IsBestPractice when StartName already uses the virtual service account; added unit regression test. | +| a26e6195b | Add-DbaAgDatabase, New-DbaAvailabilityGroup - auto-copy TDE certificate to replicas (#10237) | DONE | Validated replica TDE certificates by thumbprint/private key, fail fast when SharedPath is missing, and added unit regression tests. | +| 8099963d1 | Get-DbaRegServer - Fix IncludeSelf to return pipeline-compatible object (#10238) | DONE | Fixed IncludeSelf to emit one CMS instance per requested SqlInstance; added unit regression test. | +| 6084ecbe5 | Get-DbaLastBackup - Add -ExcludeReplica switch for AlwaysOn preferred backup replica filtering (#10240) | DONE | Fixed empty filtered-set fallback that queried all backup history; added unit regression test. | +| fe26c8764 | Export-DbaInstance - Wire up IncludeDbMasterKey to export certs and master keys (#10251) | DONE | Fixed FileInfo output contract and staged remote cert/master-key exports back into the local export folder; added unit regression test. | +| 0ee03fc32 | New-DbaAgentJobStep - Fix OnFailAction ValidateSet order to match actual default (#10244) | DONE | Reviewed parameter metadata-only ValidateSet reorder; no bugs found. | +| 63c906f9d | Set-DbaDbCompression - Add SortInTempDB parameter and fix views T-SQL bug (#10248) | DONE | Fixed indexed-view output/error metadata to use the view name instead of a stale table reference; added integration regression test. | +| 4d1a9d80c | v2.7.27 | DONE | version bump - skip | +| 232395207 | Set-DbaPrivilege, Get-DbaPrivilege - Add CreateGlobalObjects privilege support (#10235) | DONE | Fixed empty privilege-entry updates so Set-DbaPrivilege still grants rights when secedit exports a blank line; added unit regression test. | +| 099624061 | Get-DbaDbRestoreHistory - Add BackupStartDate, StopAt, and LastRestorePoint columns (#10249) | DONE | Fixed LastRestorePoint so StopAt is used whenever specified; added unit regression test. | +| 4f1e56ce4 | New-DbaDbMailAccount, Set-DbaDbMailAccount - Add Port, SSL, and authentication parameters (#10257) | DONE | Added validation to reject conflicting SMTP authentication modes and incomplete credential pairs; added unit regression tests. | +| 8218d327e | Restore-DbaDatabase, Invoke-DbaAdvancedRestore - Add ErrorBrokerConversations parameter (#10253) | DONE | Fixed missing ExecuteAs script prefix and added NoRecovery/Standby validation; added regression tests. | +| 9a4e4bacb | Connect-DbaInstance - Set NonPooledConnection on ServerConnection (#10260) | DONE | Fixed AccessToken regression by skipping a redundant NonPooledConnection assignment on SqlConnection-backed contexts; added unit regression test. | +| a0ab78a66 | Get-DbaCmObject - Apply CimOperationTimeout to all CIM connections (#10252) | DONE | Fixed missing timeout initialization in Test-DbaCmConnection and added a unit regression test. | +| fbdb47053 | Import-DbaXESessionTemplate - Add event_file target when TargetFilePath specified (#10250) | DONE | Fixed comment-sensitive event_file detection, scoped filename updates to the event_file target, and added a unit regression test. | +| 3d6fa113f | Connect-DbaInstance - Trust server certificate for localhost DAC connections (#10263) | DONE | Reviewed localhost DAC trust-certificate follow-up; no bugs found. | +| 0c0629d25 | Restore-DbaDatabase - Add examples for filtering partial backup files (#10242) | DONE | Fixed the new backup-filtering examples to enumerate files only so matching directories are not piped into restore processing, and updated the new regex example to use double quotes. | +| bb56c43d3 | Read-DbaXEFile, Get-DbaXESessionTargetFile - Document Windows-only admin share requirement (#10243) | DONE | Reviewed doc-only help update; no bugs found. | +| 152fec170 | Test-DbaLastBackup - Add -Path parameter to test backups from folder paths (#10241) | DONE | Fixed -Path wildcard Database/ExcludeDatabase filtering, separated same-name backups by source, and added unit regression tests. | +| 5590bc30d | New-DbaComputerCertificate - Add DocumentEncryptionCert switch for Always Encrypted (#10264) | DONE | Rejected the default WebServer CA template for DocumentEncryptionCert unless -SelfSigned or an explicit Always Encrypted template is provided; added unit regression coverage. | +| f73e4413c | Get-DbaAgDatabase - Add -ExcludeDatabase parameter (#10269) | DONE | Reviewed commit and tests; no bugs found. | +| 118aed54e | New-DbaDbTable - Handle bracket-quoted names and two-part names (#10279) | DONE | Rejected unsupported three-part -Name values that silently ignored the database component; added a unit regression test. | +| 27f3fef01 | Save-DbaKbUpdate - Add UseWebRequest switch and BitsTransfer fallback (#10278) | DONE | Added missing -ErrorAction Stop so BITS failures reliably trigger the Invoke-TlsWebRequest fallback; added unit regression tests. | +| 7c7b8ed9c | Get-DbaWaitStatistic - Add ExcludeWaitType and IncludeWaitType parameters (#10276) | DONE | Original -IncludeIgnorable regression was corrected by follow-up b09063aa0 (#10323); this review added wait-type input validation and deterministic unit coverage. | +| 562e3ac31 | Expand-DbaDbLogFile - Add -TargetVlfCount parameter (#10272) | DONE | Fixed TargetVlfCount planning to account for smaller final growth steps; added unit regression test. | +| 3bda32716 | Test-DbaLastBackup - Add DbccOutput property with detailed DBCC messages (#10239) | DONE | Restored Start-DbccCheck string return for legacy callers and added unit regression coverage. | +| d6552592e | Update-DbaInstance - Add early validation for empty -Path parameter (#10283) | DONE | Fixed whitespace-only Path validation gap and added regression coverage. | +| c82ae190c | Get-DbaAgRingBuffer - Add command for HADR ring buffer diagnostics (#10282) | DONE | Fixed timestamp DataTable expansion and routed query failures through Stop-Function; added unit regression tests. | +| 552b77af4 | Invoke-DbaDbDataMasking - Fix StaticValue empty string fallback and FilterQuery in Actions (#10281) | DONE | Fixed action FilterQuery updates to honor the selected row set and added a unit regression test. | +| 99a4a9067 | Update-ServiceStatus - Fix WinRM error on machines without WinRM configured (#10274) | DONE | Fixed worker CIM session credential propagation and cleanup; added unit regression test. | +| 1f70b62bd | Add Remove-DbaAgentJobSchedule cmdlet (#10273) | DONE | Fixed duplicate-name detach handling and preserved failure output on detach errors; added unit regression tests. | +| 50c0bfdaf | Connect-DbaInstance - Add -AuthenticationType parameter for Entra ID support (#10271) | DONE | Fixed password-based AuthenticationType handling so explicit Entra auth uses SqlConnectionInfo credentials, added missing SqlCredential validation, and added unit regression tests. | +| 27e4da9d1 | Get-DbaDbOrphanUser - Skip SQL login orphan check for contained databases (#10270) | DONE | Guarded ContainmentType for pre-SQL 2012 servers and added unit regression tests. | +| 21a522047 | Test-DbaAgPolicyState - Add new command for Always On policy state checks (#10246) | DONE | Added missing replica sync policy, corrected Microsoft category/name mismatches, and added regression tests. | +| 1f43cbbf0 | Install-DbaMaintenanceSolution: change Compress/Verify/CheckSum to ValidateSet string params (#10247) | DONE | Scoped NUL/Verify validation to InstallJobs, added a unit regression test, and corrected the AutoScheduleJobs example. | +| 444659b0a | Get-DbaDbMailAccount, Get-DbaDbMailProfile - Add Account-Profile link details (#10280) | DONE | Reviewed account/profile link property additions; no bugs found. | +| 57fa89a0e | Invoke-DbaDbShrink - Add error message output for failed shrink operations (#10258) | DONE | Avoided Stop-Function interrupt in per-file shrink failure output while preserving EnableException; added unit regression tests. | +| df60d986f | Export-DbaUser - Add schema ownership to exported scripts (#10275) | DONE | Guarded SQL Server 2000 schema ownership scripting and added regression coverage. | +| 7b349b546 | Test-DbaPath - Handle xp_fileexist execution failures gracefully (#10288) | DONE | Restored -EnableException behavior for xp_fileexist failures and added unit regression tests. | +| 89c06e287 | Invoke-DbaCycleErrorLog - Fix example command names (#10290) | DONE | Reviewed help-text-only example-name fix; no bugs found. | +| 899cdc30c | Update-SqlPermission - Remove unnecessary SqlConnectionObject.Close() calls (#10291) | DONE | Initial materialize-SMO-enumerations fix (0e954140a) broke Copy-DbaLogin and Sync-DbaLoginPermission and was reverted; needs a more complete patch. | +| 97d03bee3 | Restore-DbaDatabase - Add -StopAtLsn parameter for LSN-based restore (#10245) | DONE | Normalized StopAtLsn input so sys.fn_dblog and lsn:-prefixed values restore correctly; added unit regression tests. | +| 1fdfddee6 | New-DbaFirewallRule - Fix binary path extraction and remove dead code (#10294) | DONE | Bounded sqlservr.exe/sqlbrowser.exe extraction so folder names do not produce invalid Program rules; added unit regression tests. | +| aaa8f9eaa | Add ReleaseDate for SQL Server releases to buildref-index / Get-DbaBuild / Test-DbaBuild / Add -MaxTimeBehind (#10277) | DONE | Added ReleaseDate twice in Test-DbaBuild output; already corrected by follow-up commit 13807a2b3 (#10328). | +| c16f7f349 | Export-DbaCredential - Add IF NOT EXISTS guard to exported SQL scripts (#10295) | DONE | Reviewed IF NOT EXISTS credential export guard and existing unit coverage; no bugs found. | +| e83ff4854 | Compare-DbaDbSchema - Add new command for schema comparison via sqlpackage (#10299) | DONE | Fixed pipeline-bound SourcePath validation, rejected dual target selectors, and removed verbose credential leakage; added unit regression tests. | +| 67ce694ee | Get-DbaNetworkEncryption - Add command to retrieve TLS certificate from SQL Server network (#10293) | DONE | Preferred SQL Browser TCP endpoints over named pipes, guarded truncated pre-login reads, and added unit regression tests. | +| ce6d7c159 | Add manual instance autocomplete list (Add/Get/Remove-DbaInstanceList) (#10300) | DONE | Fixed Remove-DbaInstanceList so removals also clear the live sqlinstance TEPP cache; added regression test. | +| 0d4acaa0a | Invoke-DbaDbShrink - Add WAIT_AT_LOW_PRIORITY support (#10307) | DONE | Fixed flaky WAIT_AT_LOW_PRIORITY integration-test timing by polling sys.dm_exec_requests and extending the blocker window. | +| 092a092bb | Copy-DbaLogin - Add -ExcludeDatabaseMapping to sync only server permissions (#10305) | DONE | Fixed Copy-DbaLogin so -ExcludeDatabaseMapping also excludes database mappings in -OutFile exports; added a unit regression test. | +| 1ab8d1fba | Get-DbaHelpIndex - Fix SQL injection and remove SQL 2005 code path (#10302) | DONE | Reviewed SQL injection fix and SQL 2005 path removal; no bugs found. | +| fb94490d9 | Install-DbaMaintenanceSolution - Fix AutoScheduleJobs schedule bugs (#10303) | DONE | Added AutoScheduleJobs validation for -InstallJobs and exactly one full schedule; added unit regression tests. | +| 45e46e6ae | Copy-DbaAgentJob - Add AD group membership check for job owner login validation (#10297) | DONE | Reviewed AD group membership owner validation; no bugs found. | +| daa4e306e | Invoke-DbaBalanceDataFiles - Add -TargetFileGroup parameter (#10296) | DONE | Validated target filegroups have at least one data file before rebuild; added regression coverage. | +| 02ad1f092 | ConvertTo-DbaTimeline - Add support for Find-DbaDbGrowthEvent input (#10304) | DONE | Escaped growth-event database names before emitting JavaScript timeline rows; added unit regression coverage. | +| 8987045a9 | Refactor Set-DbaNetworkCertificate (#10232) | DONE | Passed Credential to Restart-DbaService when -RestartService is used; added unit regression coverage. | +| 392fc9dea | Set-DbaDbCompression, Invoke-DbaBalanceDataFiles, Invoke-DbaDbPiiScan - Normalize table names via Get-ObjectNameParts (#10312) | DONE | Preserved schema-qualified table matching across all three commands and added unit regression coverage. | +| b2f217f47 | Invoke-TlsWebRequest - Auto-detect system proxy (#10310) | DONE | Preserved configured proxies without Address members, skipped explicit -Proxy overrides, and added regression coverage. | +| 1662d73a6 | New-DbaDatabase - Support Azure Blob Storage paths for data and log files (#10315) | DONE | Preserved rooted Data/Log paths for validation while still trimming file names; added unit regression tests. | +| 241a118ce | Test-DbaDbCompression, Get-DbaDbPageInfo - Normalize table names via Get-ObjectNameParts (#10313) | DONE | Preserved schema-/database-qualified table matching and escaped SQL literals; added unit regression tests. | +| 14a47a26c | Export-DbaCsv, Export-DbaDacPackage - Normalize table/schema names via Get-ObjectNameParts (#10314) | DONE | Export-DbaCsv invalid-name handling was already fixed by follow-up 74a2d1ae1; this review added Export-DbaDacPackage table-name validation and a unit regression test. +| fe639f6e6 | Remove-DbaDbTableData - Normalize table name via Get-ObjectNameParts (#10316) | DONE | Preserved database..table names, escaped ] in rebuilt identifiers, and added regression coverage. | +| 070d2ee7f | Fix AppVeyor dbatools.library cache miss by installing to AllUsers scope (#10335) | DONE | Reviewed CI-only AppVeyor scope change; no bugs found. | +| e3f6cc121 | Fix test for Invoke-DbaAdvancedUpdate (#10334) | DONE | Reviewed test-only removal of stray Write-Host from the mock assertion; no bugs found. | +| 6aafd24ed | Fix test for Test-DbaSpn by suppressing the warning on AppVeyor (#10331) | DONE | Reviewed test-only warning suppression; no bugs found. | +| 7ad9acf9f | Refactor test for Stop-Function (#10332) | DONE | Reviewed test-only Stop-Function refactor with Invoke-ManualPester; no bugs found. | +| 13807a2b3 | Test-DbaBuild: Fix bug introduced in last change (#10328) | DONE | Reviewed duplicate ReleaseDate follow-up; no bugs found. | +| 4382a8118 | Test-DbaLinkedServerConnection - Fix test failure when Named Pipes is disabled (#10326) | DONE | Reviewed test-only TCP datasrc change; no bugs found. | +| b09063aa0 | Get-DbaWaitStatistic - Fix bug from recent refactoring (#10323) | DONE | Reviewed commit; later commit 9a9236a13 fixed unsafe wait-type SQL filtering and missing normalization. | +| eddfeeeca | Find-DbaInstance - Fix TcpConnected false for default instances (#10327) | DONE | Added fallback port scanning for Browser default instances without reusing named-instance ports; added unit regression test. | +| 5f483d42c | Get-DbaPermission - Fix Azure SQL DB compatibility (#10320) | DONE | Reviewed Azure SQL DB compatibility fix; no bugs found. | +| a38bc6b35 | Get-DbaDbIdentity, Set-DbaDbIdentity, Invoke-DbaDbDbccUpdateUsage - Normalize table names (#10318) | DONE | Fixed escaped-bracket table name regression and added unit regression tests. | +| 9899bd274 | Copy-DbaDbTableData - Add -ScriptingOptionsObject parameter (#10317) | DONE | Propagated ScriptingOptionsObject to Copy-DbaDbViewData and added unit coverage. | +| 0c486b964 | Backup-DbaDbCertificate: Don't use decryption password if cert encrypted by master key (#10329) | DONE | Reviewed SMO export overload selection for master-key-encrypted certs; no bugs found. | +| db77a3476 | Find-DbaObject - Add unified command to search database objects by name (#10321) | DONE | Added database DDL trigger name search via sys.triggers and covered it with an integration regression test. | +| 6416b4e91 | Add Compare-DbaLogin command (#10319) | DONE | Skipped failed destination connections instead of reusing the previous server; added unit regression coverage. | +| bee08f8e7 | Copy-DbaSsisCatalog - Add standard MigrationObject output, integrate with Start-DbaMigration (#10311) | DONE | Skipped SSIS catalog migration when the source lacks SSISDB and added Start-DbaMigration unit coverage. | +| 21e4795ee | Get-DbaService - Add PowerBI Report Server detection (#10298) | DONE | Skipped generic SqlService lookups for reporting-only types and added unit regression coverage. | +| 828ebc3b3 | Get-DbaBackupInformation - Fix inconsistencies with Get-DbaDbBackupHistory (#10308) | PENDING | | +| 74a2d1ae1 | Import-DbaCsv, Export-DbaCsv - Normalize table/schema names via Get-ObjectNameParts (#10306) | PENDING | | diff --git a/private/functions/Get-DecryptedObject.ps1 b/private/functions/Get-DecryptedObject.ps1 index 632772736f19..4324c7e27052 100644 --- a/private/functions/Get-DecryptedObject.ps1 +++ b/private/functions/Get-DecryptedObject.ps1 @@ -133,7 +133,12 @@ function Get-DecryptedObject { } Write-Message -Level Verbose -Message "Query password information from the Db." - $results = $server.Query($sql) + try { + $results = $server.Query($sql) + } catch { + Stop-Function -Message "Can't execute password query on $sourceName." -Target $server -ErrorRecord $_ + return + } Write-Message -Level Verbose -Message "Go through each row in results" foreach ($result in $results) { diff --git a/private/functions/Get-SQLInstanceComponent.ps1 b/private/functions/Get-SQLInstanceComponent.ps1 index 039b31380364..a0c67cf794ea 100644 --- a/private/functions/Get-SQLInstanceComponent.ps1 +++ b/private/functions/Get-SQLInstanceComponent.ps1 @@ -69,7 +69,8 @@ function Get-SQLInstanceComponent { [DbaInstanceParameter[]]$ComputerName = $Env:COMPUTERNAME, [ValidateSet('SSDS', 'SSAS', 'SSRS')] [string[]]$Component = @('SSDS', 'SSAS', 'SSRS'), - [pscredential]$Credential + [pscredential]$Credential, + [string]$Authentication = "Default" ) begin { @@ -297,7 +298,17 @@ function Get-SQLInstanceComponent { process { foreach ($computer in $ComputerName) { $arguments = @{ Component = $Component } - $results = Invoke-Command2 -ComputerName $computer -ScriptBlock $regScript -Credential $Credential -ErrorAction Stop -Raw -ArgumentList $arguments -RequiredPSVersion 3.0 + $splatInvokeCommand2 = @{ + ComputerName = $computer + ScriptBlock = $regScript + Credential = $Credential + Authentication = $Authentication + ErrorAction = "Stop" + Raw = $true + ArgumentList = $arguments + RequiredPSVersion = [version]"3.0" + } + $results = Invoke-Command2 @splatInvokeCommand2 # Log is stored in the log property, pile it all into the debug log foreach ($logEntry in $results.Log) { diff --git a/private/functions/Get-SqlServerTlsCertificate.ps1 b/private/functions/Get-SqlServerTlsCertificate.ps1 index 85346be3e326..aa158a0952a1 100644 --- a/private/functions/Get-SqlServerTlsCertificate.ps1 +++ b/private/functions/Get-SqlServerTlsCertificate.ps1 @@ -47,11 +47,11 @@ Function Get-SqlServerTlsCertificate { Gets the certificate of the first instance found on sql01 using the SQL Browser service to find the TCP port or Named Pipe. .EXAMPLE - PS> Get-SqlServerTlsCertificate -ComputerName sql01 -Instance MySQLInstance + PS> Get-SqlServerTlsCertificate -ComputerName sql01 -InstanceName MySQLInstance Gets the certificate for the instance 'sql01\MySQLInstance' using the SQL Browser service to find the TCP port or Named Pipe. .EXAMPLE - PS> Get-SqlServerTlSCertificate -ComputerName sql01 -Port 65334 -ConnectionType TCP + PS> Get-SqlServerTlsCertificate -ComputerName sql01 -Port 65334 -ConnectionType TCP Gets the certificate for server sql01 using the TCP port 65334 .EXAMPLE @@ -145,7 +145,12 @@ public class TdsTlsStream : Stream { byte[] header = new byte[8]; int read = 0; while (read < 8) { - read += _innerStream.Read(header, 0, 8); + int currentRead = _innerStream.Read(header, read, 8 - read); + if (currentRead == 0) { + throw new EndOfStreamException("Unexpected EOF while reading TDS header."); + } + + read += currentRead; } int lengthBeforeHeader = (int)BitConverter.ToUInt16(new byte[] { header[3], header[2] }, 0); @@ -191,8 +196,7 @@ public class TdsTlsStream : Stream { try { $pipeName = if ($InstanceName -and $InstanceName -ne 'MSSQLSERVER') { 'MSSQL${0}\sql\query' -f $InstanceName - } - else { + } else { 'sql\query' } @@ -210,37 +214,33 @@ public class TdsTlsStream : Stream { Write-Message -Level Verbose -Message "Recieved SQL Browser response: '$rawResponse'" $response = $rawResponse -split ';' - $instanceInfo = [Ordered]@{} + $instanceInfo = [Ordered]@{ } $remoteInstance = @( for ($i = 0; $i -lt $response.Length; $i += 2) { if ($response[$i]) { $instanceInfo[$response[$i]] = $response[$i + 1] - } - elseif ($i -eq $response.Length - 1) { + } elseif ($i -eq $response.Length - 1) { break - } - else { + } else { $info = [PSCustomObject]$instanceInfo Write-Message -Level Verbose -Message "Processed SQL Browser Response:`n$($info | Out-String)" $info - $instanceInfo = [Ordered]@{} + $instanceInfo = [Ordered]@{ } $i -= 1 } } ) | Where-Object { -not $InstanceName -or $_.InstanceName -eq $InstanceName } | Select-Object -First 1 - if ($remoteInstance.np) { - $ConnectionType = 'NamedPipe' - $ComputerName = $remoteInstance.ServerName - $pipeName = $remoteInstance.np -replace "\\\\.*?\\pipe\\(.*)", '$1' - } - elseif ($remoteInstance.tcp) { - $ConnectionType = 'TCP' + if ($remoteInstance.tcp) { + $ConnectionType = "TCP" $ComputerName = $remoteInstance.ServerName $Port = $remoteInstance.tcp - } - else { + } elseif ($remoteInstance.np) { + $ConnectionType = "NamedPipe" + $ComputerName = $remoteInstance.ServerName + $pipeName = $remoteInstance.np -replace "\\\\.*?\\pipe\\(.*)", '$1' + } else { throw "Failed to receive any SQL Browser responses from $($ComputerName):1434, cannot continue" } } @@ -255,8 +255,7 @@ public class TdsTlsStream : Stream { } $targetStream = $socket.GetStream() - } - else { + } else { Write-Message -Level Verbose -Message "Connecting to Named Pipe endpoint \\$($ComputerName)\pipe\$pipeName" $targetStream = New-Object -TypeName System.IO.Pipes.NamedPipeClientStream -ArgumentList @( $ComputerName, @@ -272,8 +271,7 @@ public class TdsTlsStream : Stream { if ($StrictEncrypt) { Write-Message -Level Verbose -Message "Using TDS 8 TLS Handshake" $streamToWrap = $targetStream - } - else { + } else { Write-Message -Level Verbose -Message "Using TDS 7.x Pre-Login method for the TLS handshake" # This is a pre-calculated TDS Pre-Login payload with the ENCRYPTION @@ -292,7 +290,11 @@ public class TdsTlsStream : Stream { $headerBytes = New-Object byte[] 8 $read = 0 while ($read -ne $headerBytes.Length) { - $read += $targetStream.Read($headerBytes, $read, $headerBytes.Length - $read) + $bytesRead = $targetStream.Read($headerBytes, $read, $headerBytes.Length - $read) + if ($bytesRead -eq 0) { + throw "Unexpected EOF while reading the TDS pre-login response header from $ComputerName" + } + $read += $bytesRead } # Integer values are big endian encoded so swap them around. It also @@ -303,13 +305,17 @@ public class TdsTlsStream : Stream { $tdsPreLoginResp = New-Object byte[] $payloadLength $read = 0 while ($read -ne $tdsPreLoginResp.Length) { - $read += $targetStream.Read($tdsPreLoginResp, $read, $tdsPreLoginResp.Length - $read) + $bytesRead = $targetStream.Read($tdsPreLoginResp, $read, $tdsPreLoginResp.Length - $read) + if ($bytesRead -eq 0) { + throw "Unexpected EOF while reading the TDS pre-login response payload from $ComputerName" + } + $read += $bytesRead } # The TDS Pre-Login payload starts with a variable amount of headers - # TYPE - BYTE - # OFFSET - USHORT (offset in the payload of the value) - # LENGTH - USHORT + # TYPE - BYTE + # OFFSET - USHORT (offset in the payload of the value) + # LENGTH - USHORT # The headers are terminated with the type 0xFF. We want to extract the # value for the ENCRYPT type (1) from the payload to see if the server # supported encryption. @@ -319,8 +325,7 @@ public class TdsTlsStream : Stream { $plOptionType = $tdsPreLoginResp[$offset] if ($plOptionType -eq 0xFF) { break - } - elseif ($plOptionType -ne 1) { + } elseif ($plOptionType -ne 1) { $offset += 5 continue } @@ -352,7 +357,7 @@ public class TdsTlsStream : Stream { # This allows it to connect to a self signed or cert with different # hostname. The callback will also capture more information about the peer # Allows us to emit warnings if it was going to fail. - $certState = @{} + $certState = @{ } $sslValidationCallback = [System.Net.Security.RemoteCertificateValidationCallback] { param($Sender, $Certificate, $Chain, $SslPolicyErrors) @@ -377,14 +382,12 @@ public class TdsTlsStream : Stream { Write-Message -Level Verbose -Message "Found cert for $($cert.Subject), Expires: $($cert.NotAfter), SANs: $($cert.DnsNameList -join ", ")" $cert - } - catch { + } catch { $PSCmdlet.WriteError($_) - } - finally { + } finally { if ($udpClient) { $udpClient.Dispose() } if ($sslStream) { $sslStream.Dispose() } if ($targetStream) { $targetStream.Dispose() } if ($socket) { $socket.Dispose() } } -} +} \ No newline at end of file diff --git a/private/functions/Invoke-TlsWebRequest.ps1 b/private/functions/Invoke-TlsWebRequest.ps1 index 45d746237ab5..3cfefec1a201 100644 --- a/private/functions/Invoke-TlsWebRequest.ps1 +++ b/private/functions/Invoke-TlsWebRequest.ps1 @@ -14,8 +14,19 @@ function Invoke-TlsWebRequest { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor $_ } + $proxySpecified = $Args -contains "-Proxy" + $hasConfiguredProxy = $false + $defaultWebProxy = [System.Net.WebRequest]::DefaultWebProxy + if ($defaultWebProxy) { + if ($defaultWebProxy.PSObject.Properties.Name -contains "Address" -and $defaultWebProxy.Address) { + $hasConfiguredProxy = $true + } elseif ($defaultWebProxy.Credentials) { + $hasConfiguredProxy = $true + } + } + # Auto-detect system proxy if not already configured and not opted out - if (-not (Get-DbatoolsConfigValue -FullName "commands.invoke-tlswebrequest.disableautoproxy") -and -not [System.Net.WebRequest]::DefaultWebProxy.Address) { + if (-not (Get-DbatoolsConfigValue -FullName "commands.invoke-tlswebrequest.disableautoproxy") -and -not $proxySpecified -and -not $hasConfiguredProxy) { $systemProxy = [System.Net.WebRequest]::GetSystemWebProxy() if ($systemProxy) { [System.Net.WebRequest]::DefaultWebProxy = $systemProxy diff --git a/private/functions/Start-DbccCheck.ps1 b/private/functions/Start-DbccCheck.ps1 index 6410f71645b4..4701382d9338 100644 --- a/private/functions/Start-DbccCheck.ps1 +++ b/private/functions/Start-DbccCheck.ps1 @@ -4,7 +4,8 @@ function Start-DbccCheck { [object]$server, [string]$DbName, [switch]$table, - [int]$MaxDop + [int]$MaxDop, + [switch]$DetailedOutput ) $servername = $server.name @@ -17,18 +18,24 @@ function Start-DbccCheck { try { if ($table) { - $null = $server.databases[$DbName].CheckTables('None') + $null = $server.databases[$DbName].CheckTables("None") Write-Verbose "DBCC CheckTables finished successfully for $DbName on $servername" - return [PSCustomObject]@{ - Status = "Success" - Output = $null + if ($DetailedOutput) { + return [PSCustomObject]@{ + Status = "Success" + Output = $null + } } + return "Success" + } + + if ($MaxDop) { + $query = "DBCC CHECKDB ([$escapedDbName]) WITH MAXDOP = $MaxDop" } else { - if ($MaxDop) { - $query = "DBCC CHECKDB ([$escapedDbName]) WITH MAXDOP = $MaxDop" - } else { - $query = "DBCC CHECKDB ([$escapedDbName])" - } + $query = "DBCC CHECKDB ([$escapedDbName])" + } + + if ($DetailedOutput) { $dbccOutput = Invoke-DbaQuery -SqlInstance $server -Query $query -MessagesToOutput -EnableException Write-Verbose "DBCC CHECKDB finished successfully for $DbName on $servername" return [PSCustomObject]@{ @@ -36,6 +43,10 @@ function Start-DbccCheck { Output = $dbccOutput } } + + $null = $server.Query($query) + Write-Verbose "DBCC CHECKDB finished successfully for $DbName on $servername" + return "Success" } catch { $originalException = $_.Exception $loopNo = 0 @@ -69,10 +80,14 @@ function Start-DbccCheck { } catch { $null } - return [PSCustomObject]@{ - Status = $message.Trim() - Output = $null + + if ($DetailedOutput) { + return [PSCustomObject]@{ + Status = $message.Trim() + Output = $null + } } + return $message.Trim() } } -} +} \ No newline at end of file diff --git a/private/functions/Test-DbaLsnChain.ps1 b/private/functions/Test-DbaLsnChain.ps1 index 5f0239d0095e..08897cad396d 100644 --- a/private/functions/Test-DbaLsnChain.ps1 +++ b/private/functions/Test-DbaLsnChain.ps1 @@ -107,28 +107,32 @@ function Test-DbaLsnChain { Write-Message -Level Verbose -Message "Checking $($_.FullName) - isLogBackup $isLogBackup, isBasedOnAnchor $isBasedOnAnchor, hasGreaterLastLsn $hasGreaterLastLsn, FullDBAnchor.CheckPointLsn $($FullDBAnchor.CheckPointLsn), DatabaseBackupLsn $($_.DatabaseBackupLsn), FirstLsn $($_.FirstLsn) LastLsn $($_.LastLsn)" $isLogBackup -and ($isBasedOnAnchor -or $hasGreaterLastLsn) - } | Sort-Object -Property LastLsn, FirstLsn - - for ($i = 0; $i -lt ($TranLogBackups.Count)) { - Write-Message -Level Debug -Message "looping t logs" - if ($i -eq 0) { - if ([BigInt]$TranLogBackups[$i].FirstLsn.ToString() -gt [BigInt]$TlogAnchor.LastLsn.ToString()) { - Write-Message -Level Warning -Message "Break in LSN Chain between $($TlogAnchor.FullName) and $($TranLogBackups[($i)].FullName) " - Write-Message -Level Verbose -Message "Anchor $($TlogAnchor.LastLsn) - FirstLSN $($TranLogBackups[$i].FirstLsn)" - return $false - break - } - } else { - if ([BigInt]$TranLogBackups[($i - 1)].LastLsn.ToString() -ne [BigInt]$TranLogBackups[($i)].FirstLsn.ToString() -and ($TranLogBackups[($i)] -ne $TranLogBackups[($i - 1)])) { - Write-Message -Level Warning -Message "Break in transaction log between $($TranLogBackups[($i-1)].FullName) and $($TranLogBackups[($i)].FullName) " - return $false - break - } - } - $i++ + } | Sort-Object -Property @{ + Expression = { [BigInt]$_.LastLsn.ToString() } + }, @{ + Expression = { [BigInt]$_.FirstLsn.ToString() } + } + for ($i = 0; $i -lt ($TranLogBackups.Count)) { + Write-Message -Level Debug -Message "looping t logs" + if ($i -eq 0) { + if ([BigInt]$TranLogBackups[$i].FirstLsn.ToString() -gt [BigInt]$TlogAnchor.LastLsn.ToString()) { + Write-Message -Level Warning -Message "Break in LSN Chain between $($TlogAnchor.FullName) and $($TranLogBackups[($i)].FullName) " + Write-Message -Level Verbose -Message "Anchor $($TlogAnchor.LastLsn) - FirstLSN $($TranLogBackups[$i].FirstLsn)" + return $false + break + } + } else { + if ([BigInt]$TranLogBackups[($i - 1)].LastLsn.ToString() -ne [BigInt]$TranLogBackups[($i)].FirstLsn.ToString() -and ($TranLogBackups[($i)] -ne $TranLogBackups[($i - 1)])) { + Write-Message -Level Warning -Message "Break in transaction log between $($TranLogBackups[($i-1)].FullName) and $($TranLogBackups[($i)].FullName) " + return $false + break + } } - Write-Message -Level VeryVerbose -Message "Passed LSN Chain checks" - return $true + $i++ + } + Write-Message -Level VeryVerbose -Message "Passed LSN Chain checks" + return $true +} } \ No newline at end of file diff --git a/private/functions/Test-PendingReboot.ps1 b/private/functions/Test-PendingReboot.ps1 index a9a4701e7820..dbe558f342c5 100644 --- a/private/functions/Test-PendingReboot.ps1 +++ b/private/functions/Test-PendingReboot.ps1 @@ -18,6 +18,7 @@ function Test-PendingReboot { [ValidateNotNullOrEmpty()] [DbaInstanceParameter]$ComputerName, [pscredential]$Credential, + [string]$Authentication = "Default", [switch]$NoPendingRename ) process { @@ -29,16 +30,14 @@ function Test-PendingReboot { if (Test-Bound -ParameterName Credential) { $icmParams.Credential = $Credential } + if (Test-Bound -ParameterName Authentication) { + $icmParams.Authentication = $Authentication + } - $operatingSystem = Get-DbaCmObject -ComputerName $ComputerName.ComputerName -Credential $Credential -ClassName Win32_OperatingSystem -EnableException - - # If Vista/2008 & Above query the CBS Reg Key - If ($operatingSystem.BuildNumber -ge 6001) { - $pendingReboot = Invoke-Command2 @icmParams -ScriptBlock { Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' -Name 'RebootPending' -ErrorAction SilentlyContinue } - if ($pendingReboot) { - Write-Message -Level Verbose -Message 'Reboot pending detected in the Component Based Servicing registry key' - return $true - } + $pendingReboot = Invoke-Command2 @icmParams -ScriptBlock { Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' -Name 'RebootPending' -ErrorAction SilentlyContinue } + if ($pendingReboot) { + Write-Message -Level Verbose -Message 'Reboot pending detected in the Component Based Servicing registry key' + return $true } # Query WUAU from the registry @@ -58,4 +57,4 @@ function Test-PendingReboot { } return $false } -} +} \ No newline at end of file diff --git a/private/functions/Update-ServiceStatus.ps1 b/private/functions/Update-ServiceStatus.ps1 index ef1898f28465..7c2726d470cf 100644 --- a/private/functions/Update-ServiceStatus.ps1 +++ b/private/functions/Update-ServiceStatus.ps1 @@ -71,174 +71,181 @@ function Update-ServiceStatus { $svcControlBlock = { $group = $_.Group $computerName = $_.Name - # Create a CIM session preferring DCOM to avoid requiring WinRM on the target machine. - # CIM instances become deserialized when crossing runspace boundaries and lose their - # session context, causing Invoke-CimMethod to attempt a new WinRM connection by default. - # Using DCOM avoids this WinRM dependency for machines where WinRM is not configured. - $splatCimDcomOption = @{ - Protocol = "Dcom" + $cimSession = $null + $splatCimSession = @{ + ComputerName = $computerName + ErrorAction = "Stop" } - $cimDcomOption = New-CimSessionOption @splatCimDcomOption - $splatCimSessionDcom = @{ - ComputerName = $computerName - SessionOption = $cimDcomOption - ErrorAction = "Stop" + if ($Credential) { + $splatCimSession.Credential = $Credential } + try { - $cimSession = New-CimSession @splatCimSessionDcom - } catch { - # Fall back to default protocol (WinRM) if DCOM is unavailable + # Create a CIM session preferring DCOM to avoid requiring WinRM on the target machine. + # CIM instances become deserialized when crossing runspace boundaries and lose their + # session context, causing Invoke-CimMethod to attempt a new WinRM connection by default. + # Using DCOM avoids this WinRM dependency for machines where WinRM is not configured. + $splatCimDcomOption = @{ + Protocol = "Dcom" + } + $cimDcomOption = New-CimSessionOption @splatCimDcomOption + $splatCimSession.SessionOption = $cimDcomOption try { - $cimSession = New-CimSession -ComputerName $computerName -ErrorAction "Stop" + $cimSession = New-CimSession @splatCimSession } catch { - $cimSession = $null - } - } - $servicePriorityCollection = $group.ServicePriority | Select-Object -unique | Sort-Object -Property @{ Expression = { [int]$_ }; Descending = $action -ne 'stop' } - foreach ($priority in $servicePriorityCollection) { - $services = $group | Where-Object { $_.ServicePriority -eq $priority } - $servicesToRestart = @() - foreach ($service in $services) { - if ('dbatools.DbaSqlService' -in $service.PSObject.TypeNames) { - $cimObject = $service._CimObject - if (($cimObject.State -eq 'Running' -and $action -eq 'start') -or ($cimObject.State -eq 'Stopped' -and $action -eq 'stop')) { - $service | Add-Member -Force -NotePropertyName Status -NotePropertyValue 'Successful' -PassThru | - Add-Member -Force -NotePropertyName Message -NotePropertyValue "The service is already $actionText, no action required" -PassThru - } elseif ($cimObject.StartMode -eq 'Disabled' -and $action -in 'start', 'restart') { - $service | Add-Member -Force -NotePropertyName Status -NotePropertyValue 'Failed' -PassThru | - Add-Member -Force -NotePropertyName Message -NotePropertyValue "The service is disabled and cannot be $actionText" -PassThru - } else { - $servicesToRestart += $service - } - } else { - throw "Unknown object in pipeline - make sure to use Get-DbaService cmdlet" - } - } - #Set desired $action - if ($action -in 'start', 'restart') { - $methodName = 'StartService' - $desiredState = 'Running' - $undesiredState = 'Stopped' - } elseif ($action -eq 'stop') { - $methodName = 'StopService' - $desiredState = 'Stopped' - $undesiredState = 'Running' - } - $invokeResults = @() - foreach ($service in $servicesToRestart) { - if ($Pscmdlet.ShouldProcess("Sending $action request to service $($service.ServiceName) on $($service.ComputerName)")) { - # Get a fresh CIM instance via the DCOM session to avoid issues with deserialized - # CIM objects crossing runspace boundaries without their session context. - if ($cimSession) { + # Fall back to default protocol (WinRM) if DCOM is unavailable + $null = $splatCimSession.Remove("SessionOption") try { - $splatGetFreshCim = @{ - CimSession = $cimSession - Namespace = "root\cimv2" - Query = "SELECT * FROM Win32_Service WHERE Name = '$($service.ServiceName)'" - } - $freshCimObj = Get-CimInstance @splatGetFreshCim - if ($freshCimObj) { - $service._CimObject = $freshCimObj - } + $cimSession = New-CimSession @splatCimSession } catch { - # Fall back to using the existing deserialized CIM object if session refresh fails + $cimSession = $null } } - #Invoke corresponding CIM method - $invokeResult = Invoke-CimMethod -InputObject $service._CimObject -MethodName $methodName - $invokeResults += [psobject]@{ - InvokeResult = $invokeResult - ServiceState = $invokeResult.State - ServiceExitCode = $invokeResult.ReturnValue - CheckPending = $true - Service = $service - } - } - } - - $startTime = Get-Date - if ($Pscmdlet.ShouldProcess("Waiting the services to $action on $computerName")) { - #Wait for the service to complete the action until timeout - while ($invokeResults.CheckPending -contains $true) { - foreach ($result in ($invokeResults | Where-Object CheckPending -eq $true)) { - try { - #Refresh Cim instance - not using Get-DbaCmObject because module is not loaded here, but it only refreshes existing object - if ($cimSession) { - $splatRefreshCim = @{ - CimSession = $cimSession - Namespace = "root\cimv2" - Query = "SELECT State FROM Win32_Service WHERE Name = '$($result.Service.ServiceName)'" - } - $refreshedCimObj = Get-CimInstance @splatRefreshCim - if ($refreshedCimObj) { - $result.Service._CimObject = $refreshedCimObj + $servicePriorityCollection = $group.ServicePriority | Select-Object -unique | Sort-Object -Property @{ Expression = { [int]$_ }; Descending = $action -ne "stop" } + foreach ($priority in $servicePriorityCollection) { + $services = $group | Where-Object { $_.ServicePriority -eq $priority } + $servicesToRestart = @() + foreach ($service in $services) { + if ("dbatools.DbaSqlService" -in $service.PSObject.TypeNames) { + $cimObject = $service._CimObject + if (($cimObject.State -eq "Running" -and $action -eq "start") -or ($cimObject.State -eq "Stopped" -and $action -eq "stop")) { + $service | Add-Member -Force -NotePropertyName Status -NotePropertyValue "Successful" -PassThru | + Add-Member -Force -NotePropertyName Message -NotePropertyValue "The service is already $actionText, no action required" -PassThru + } elseif ($cimObject.StartMode -eq "Disabled" -and $action -in "start", "restart") { + $service | Add-Member -Force -NotePropertyName Status -NotePropertyValue "Failed" -PassThru | + Add-Member -Force -NotePropertyName Message -NotePropertyValue "The service is disabled and cannot be $actionText" -PassThru + } else { + $servicesToRestart += $service } } else { - $result.Service._CimObject = $result.Service._CimObject | Get-CimInstance + throw "Unknown object in pipeline - make sure to use Get-DbaService cmdlet" } - } catch { - $result.ServiceExitCode = -3 - $result.ServiceState = 'Unknown' - $result.CheckPending = $false - continue } - $result.ServiceState = $result.Service._CimObject.State - #Failed or succeeded - if ($result.ServiceExitCode -ne 0 -or $result.ServiceState -eq $desiredState) { - $result.CheckPending = $false - continue + #Set desired $action + if ($action -in "start", "restart") { + $methodName = "StartService" + $desiredState = "Running" + $undesiredState = "Stopped" + } elseif ($action -eq "stop") { + $methodName = "StopService" + $desiredState = "Stopped" + $undesiredState = "Running" } - #Failed after being in the Pending state - if ($result.CheckPending -and $result.ServiceState -eq $undesiredState) { - $result.ServiceExitCode = -2 - $result.CheckPending = $false - continue + $invokeResults = @() + foreach ($service in $servicesToRestart) { + if ($Pscmdlet.ShouldProcess("Sending $action request to service $($service.ServiceName) on $($service.ComputerName)")) { + # Get a fresh CIM instance via the DCOM session to avoid issues with deserialized + # CIM objects crossing runspace boundaries without their session context. + if ($cimSession) { + try { + $splatGetFreshCim = @{ + CimSession = $cimSession + Namespace = "root\cimv2" + Query = "SELECT * FROM Win32_Service WHERE Name = '$($service.ServiceName)'" + } + $freshCimObj = Get-CimInstance @splatGetFreshCim + if ($freshCimObj) { + $service._CimObject = $freshCimObj + } + } catch { + # Fall back to using the existing deserialized CIM object if session refresh fails + } + } + #Invoke corresponding CIM method + $invokeResult = Invoke-CimMethod -InputObject $service._CimObject -MethodName $methodName + $invokeResults += [psobject]@{ + InvokeResult = $invokeResult + ServiceState = $invokeResult.State + ServiceExitCode = $invokeResult.ReturnValue + CheckPending = $true + Service = $service + } + } + } + + $startTime = Get-Date + if ($Pscmdlet.ShouldProcess("Waiting the services to $action on $computerName")) { + #Wait for the service to complete the action until timeout + while ($invokeResults.CheckPending -contains $true) { + foreach ($result in ($invokeResults | Where-Object CheckPending -eq $true)) { + try { + #Refresh Cim instance - not using Get-DbaCmObject because module is not loaded here, but it only refreshes existing object + if ($cimSession) { + $splatRefreshCim = @{ + CimSession = $cimSession + Namespace = "root\cimv2" + Query = "SELECT State FROM Win32_Service WHERE Name = '$($result.Service.ServiceName)'" + } + $refreshedCimObj = Get-CimInstance @splatRefreshCim + if ($refreshedCimObj) { + $result.Service._CimObject = $refreshedCimObj + } + } else { + $result.Service._CimObject = $result.Service._CimObject | Get-CimInstance + } + } catch { + $result.ServiceExitCode = -3 + $result.ServiceState = "Unknown" + $result.CheckPending = $false + continue + } + $result.ServiceState = $result.Service._CimObject.State + #Failed or succeeded + if ($result.ServiceExitCode -ne 0 -or $result.ServiceState -eq $desiredState) { + $result.CheckPending = $false + continue + } + #Failed after being in the Pending state + if ($result.CheckPending -and $result.ServiceState -eq $undesiredState) { + $result.ServiceExitCode = -2 + $result.CheckPending = $false + continue + } + #Timed out + if ($timeout -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -gt $timeout) { + $result.ServiceExitCode = -1 + $result.CheckPending = $false + continue + } + #Still pending - leave CheckPending as is and run again + } + Start-Sleep -Milliseconds 200 + } } - #Timed out - if ($timeout -gt 0 -and ((Get-Date) - $startTime).TotalSeconds -gt $timeout) { - $result.ServiceExitCode = -1 - $result.CheckPending = $false - continue + foreach ($result in $invokeResults) { + #Add status + $status = switch ($result.ServiceExitCode) { + 0 { "Successful" } + 10 { "Successful " } #Already running - FullText service is started automatically + default { "Failed" } + } + Add-Member -Force -InputObject $result.Service -NotePropertyName Status -NotePropertyValue $status + #Add error message + $errorMessageFromReturnValue = if ($result.ServiceExitCode -in 0..($errorCodes.Length - 1)) { + $errorCodes[$result.ServiceExitCode] + } else { "Unknown error." } + $message = switch ($result.ServiceExitCode) { + -2 { "The service failed to $action." } + -1 { "The attempt to $action the service has timed out." } + 0 { "Service was successfully $actionText." } + default { "The attempt to $action the service returned the following error: $errorMessageFromReturnValue" } + } + Add-Member -Force -InputObject $result.Service -NotePropertyName Message -NotePropertyValue $message + # Refresh service state for the object + if ($result.ServiceState) { $result.Service.State = $result.ServiceState } + $result } - #Still pending - leave CheckPending as is and run again } - Start-Sleep -Milliseconds 200 - } - } - foreach ($result in $invokeResults) { - #Add status - $status = switch ($result.ServiceExitCode) { - 0 { 'Successful' } - 10 { 'Successful ' } #Already running - FullText service is started automatically - default { 'Failed' } - } - Add-Member -Force -InputObject $result.Service -NotePropertyName Status -NotePropertyValue $status - #Add error message - $errorMessageFromReturnValue = if ($result.ServiceExitCode -in 0..($errorCodes.Length - 1)) { - $errorCodes[$result.ServiceExitCode] - } else { "Unknown error." } - $message = switch ($result.ServiceExitCode) { - -2 { "The service failed to $action." } - -1 { "The attempt to $action the service has timed out." } - 0 { "Service was successfully $actionText." } - default { "The attempt to $action the service returned the following error: $errorMessageFromReturnValue" } + } finally { + if ($cimSession) { + Remove-CimSession -CimSession $cimSession -ErrorAction "SilentlyContinue" + } } - Add-Member -Force -InputObject $result.Service -NotePropertyName Message -NotePropertyValue $message - # Refresh service state for the object - if ($result.ServiceState) { $result.Service.State = $result.ServiceState } - $result } - } - # Clean up the CIM session created for DCOM connections - if ($cimSession) { - Remove-CimSession -CimSession $cimSession -ErrorAction "SilentlyContinue" - $cimSession = $null - } -} -$actionText = switch ($action) { stop { 'stopped' }; start { 'started' }; restart { 'restarted' } } -$errorCodes = Get-DbaServiceErrorMessage -} + $actionText = switch ($action) { stop { 'stopped' }; start { 'started' }; restart { 'restarted' } } + $errorCodes = Get-DbaServiceErrorMessage + } process { #Group services for each computer @@ -286,4 +293,4 @@ process { } end { } -} +} \ No newline at end of file diff --git a/public/Add-DbaAgDatabase.ps1 b/public/Add-DbaAgDatabase.ps1 index 1486837cde5d..26b547d5053a 100644 --- a/public/Add-DbaAgDatabase.ps1 +++ b/public/Add-DbaAgDatabase.ps1 @@ -379,41 +379,75 @@ function Add-DbaAgDatabase { if ($db.EncryptionEnabled -and $db.HasDatabaseEncryptionKey -and $db.DatabaseEncryptionKey.EncryptorType -eq "ServerCertificate") { $encryptorName = $db.DatabaseEncryptionKey.EncryptorName Write-Message -Level Verbose -Message "Database $($db.Name) is TDE-encrypted using certificate '$encryptorName'. Checking secondary replicas." - if ($SharedPath) { - $failure = $false - foreach ($replicaName in $replicaServerSMO.Keys) { - $replicaServer = $replicaServerSMO[$replicaName] - $existingCert = Get-DbaDbCertificate -SqlInstance $replicaServer -Database master -Certificate $encryptorName - if (-not $existingCert) { - if ($Pscmdlet.ShouldProcess($replicaServer, "Copy TDE certificate '$encryptorName' from primary to replica $replicaName")) { - try { - Write-Message -Level Verbose -Message "TDE certificate '$encryptorName' not found on $replicaName. Copying from primary." - $splatTdeCert = @{ - Source = $server - Destination = $replicaServer - Database = "master" - Certificate = $encryptorName - SharedPath = $SharedPath - EnableException = $true - } - if ($MasterKeySecurePassword) { - $splatTdeCert.MasterKeyPassword = $MasterKeySecurePassword - } - $null = Copy-DbaDbCertificate @splatTdeCert - } catch { - $failure = $true - Stop-Function -Message "Failed to copy TDE certificate '$encryptorName' to replica $replicaName." -ErrorRecord $_ -Continue + + try { + $sourceTdeCert = Get-DbaDbCertificate -SqlInstance $server -Database "master" -Certificate $encryptorName -EnableException | Select-Object -First 1 + } catch { + Stop-Function -Message "Failed to validate TDE certificate '$encryptorName' on primary instance $($server.Name)." -ErrorRecord $_ -Continue + } + + if (-not $sourceTdeCert) { + Stop-Function -Message "Database $($db.Name) is encrypted by certificate '$encryptorName', but that certificate was not found in master on $($server.Name)." -Continue + } + + if (-not $sourceTdeCert.PrivateKeyExists) { + Stop-Function -Message "Database $($db.Name) is encrypted by certificate '$encryptorName', but the certificate on $($server.Name) does not have an accessible private key." -Continue + } + + $failure = $false + foreach ($replicaName in $replicaServerSMO.Keys) { + $replicaServer = $replicaServerSMO[$replicaName] + try { + $existingCert = Get-DbaDbCertificate -SqlInstance $replicaServer -Database "master" -Certificate $encryptorName -EnableException | Select-Object -First 1 + } catch { + $failure = $true + Stop-Function -Message "Failed to validate TDE certificate '$encryptorName' on replica $replicaName." -ErrorRecord $_ -Continue + } + + if (-not $existingCert) { + if (-not $SharedPath) { + $failure = $true + Stop-Function -Message "Replica $replicaName is missing TDE certificate '$encryptorName'. Provide -SharedPath and optionally -MasterKeySecurePassword to copy it automatically, or pre-stage the matching certificate before adding the database." -Continue + } + + if ($Pscmdlet.ShouldProcess($replicaServer, "Copy TDE certificate '$encryptorName' from primary to replica $replicaName")) { + try { + Write-Message -Level Verbose -Message "TDE certificate '$encryptorName' not found on $replicaName. Copying from primary." + $splatTdeCert = @{ + Source = $server + Destination = $replicaServer + Database = "master" + Certificate = $encryptorName + SharedPath = $SharedPath + EnableException = $true + } + if ($MasterKeySecurePassword) { + $splatTdeCert.MasterKeyPassword = $MasterKeySecurePassword } + $null = Copy-DbaDbCertificate @splatTdeCert + } catch { + $failure = $true + Stop-Function -Message "Failed to copy TDE certificate '$encryptorName' to replica $replicaName." -ErrorRecord $_ -Continue } - } else { - Write-Message -Level Verbose -Message "TDE certificate '$encryptorName' already exists on replica $replicaName." } + continue } - if ($failure) { - Stop-Function -Message "Failed to copy TDE certificate to all replicas for database $($db.Name)." -Continue + + if (-not $existingCert.PrivateKeyExists) { + $failure = $true + Stop-Function -Message "TDE certificate '$encryptorName' exists on replica $replicaName but does not include a private key. Restore or automatic seeding cannot use it." -Continue + } + + if ($existingCert.Thumbprint -ne $sourceTdeCert.Thumbprint) { + $failure = $true + Stop-Function -Message "TDE certificate '$encryptorName' on replica $replicaName does not match the primary certificate on $($server.Name)." -Continue } - } else { - Write-Message -Level Warning -Message "Database $($db.Name) is TDE-encrypted with certificate '$encryptorName', but no SharedPath was provided. The TDE certificate must exist on all secondary replicas before the database can be added. Use -SharedPath and optionally -MasterKeySecurePassword to enable automatic certificate copying." + + Write-Message -Level Verbose -Message "TDE certificate '$encryptorName' already exists on replica $replicaName and matches the primary certificate." + } + + if ($failure) { + Stop-Function -Message "Failed to validate or copy TDE certificate '$encryptorName' for all replicas of database $($db.Name)." -Continue } } diff --git a/public/Add-DbaComputerCertificate.ps1 b/public/Add-DbaComputerCertificate.ps1 index ef18a8c884ee..fec54ae4ea4a 100644 --- a/public/Add-DbaComputerCertificate.ps1 +++ b/public/Add-DbaComputerCertificate.ps1 @@ -286,7 +286,7 @@ function Add-DbaComputerCertificate { if ($isCollection -and $collectionData) { foreach ($computer in $ComputerName) { if ($PSCmdlet.ShouldProcess("$computer", "Attempting to import cert collection")) { - if ($flags -contains "UserProtected" -and -not $computer.IsLocalHost) { + if ("UserProtected" -in $Flag -and -not $computer.IsLocalHost) { Stop-Function -Message "UserProtected flag is only valid for localhost because it causes a prompt, skipping for $computer" -Continue } try { @@ -309,7 +309,7 @@ function Add-DbaComputerCertificate { foreach ($computer in $ComputerName) { if ($PSCmdlet.ShouldProcess("$computer", "Attempting to import cert")) { - if ($flags -contains "UserProtected" -and -not $computer.IsLocalHost) { + if ("UserProtected" -in $Flag -and -not $computer.IsLocalHost) { Stop-Function -Message "UserProtected flag is only valid for localhost because it causes a prompt, skipping for $computer" -Continue } try { diff --git a/public/Backup-DbaDatabase.ps1 b/public/Backup-DbaDatabase.ps1 index c012a980f4ae..cc37704c571f 100644 --- a/public/Backup-DbaDatabase.ps1 +++ b/public/Backup-DbaDatabase.ps1 @@ -71,7 +71,8 @@ function Backup-DbaDatabase { Specifies the number of files to stripe the backup across for improved performance. Higher values increase backup speed but require more disk space and coordination during restores. Automatically overridden when multiple Path values are provided. Typically use 2-4 files for optimal performance. - When using StorageBaseUrl (S3/Azure), an explicit FileCount allows striping multiple backup files into the same bucket/container. + When using a single StorageBaseUrl (S3/Azure), an explicit FileCount allows striping multiple backup files into the same bucket/container. + Multiple StorageBaseUrl values determine the stripe count. .PARAMETER CreateFolder Creates a separate subdirectory for each database within the backup path for better organization. @@ -670,7 +671,7 @@ function Backup-DbaDatabase { } } } - if ($FileCount -eq 0) { + if ($StorageBaseUrl.Count -gt 1 -or $FileCount -eq 0) { $FileCount = $StorageBaseUrl.count } $Path = $StorageBaseUrl @@ -870,17 +871,18 @@ function Backup-DbaDatabase { $FinalBackupPath[0] = $FinalBackupPath[0] + $slash + $BackupFinalName } - # Auto-detect dbname token to prevent duplication when using CreateFolder + ReplaceInName + # Auto-detect dbname token in the directory path to prevent duplication when using CreateFolder + ReplaceInName if ($CreateFolder -and $ReplaceInName -and -not $NoAppendDbNameInPath) { $containsDbNameToken = $false foreach ($pathToCheck in $FinalBackupPath) { - if ($pathToCheck -match "\bdbname\b") { + $directoryPathToCheck = Split-Path -Path $pathToCheck -Parent + if ($directoryPathToCheck -and $directoryPathToCheck -match "(^|[\\/])dbname([\\/]|$)") { $containsDbNameToken = $true break } } if ($containsDbNameToken) { - Write-Message -Level Verbose -Message "Path contains 'dbname' token with ReplaceInName. Automatically skipping database folder creation to prevent duplication." + Write-Message -Level Verbose -Message "Directory path contains 'dbname' token with ReplaceInName. Automatically skipping database folder creation to prevent duplication." $NoAppendDbNameInPath = $true } } diff --git a/public/Compare-DbaDbSchema.ps1 b/public/Compare-DbaDbSchema.ps1 index 5742fc1138b2..cebf0f26bb3f 100644 --- a/public/Compare-DbaDbSchema.ps1 +++ b/public/Compare-DbaDbSchema.ps1 @@ -109,13 +109,13 @@ function Compare-DbaDbSchema { return } - if (-not (Test-Path -Path $SourcePath)) { - Stop-Function -Message "Source DACPAC file not found: $SourcePath" + if ((Test-Bound -Not -ParameterName TargetSqlInstance) -and (Test-Bound -Not -ParameterName TargetPath)) { + Stop-Function -Message "You must specify either -TargetSqlInstance (with -TargetDatabase) or -TargetPath." return } - if ((Test-Bound -Not -ParameterName TargetSqlInstance) -and (Test-Bound -Not -ParameterName TargetPath)) { - Stop-Function -Message "You must specify either -TargetSqlInstance (with -TargetDatabase) or -TargetPath." + if ((Test-Bound -ParameterName TargetSqlInstance) -and (Test-Bound -ParameterName TargetPath)) { + Stop-Function -Message "Specify either -TargetSqlInstance or -TargetPath, not both." return } @@ -139,11 +139,17 @@ function Compare-DbaDbSchema { process { if (Test-FunctionInterrupt) { return } + if (-not (Test-Path -Path $SourcePath)) { + Stop-Function -Message "Source DACPAC file not found: $SourcePath" + return + } + + $sourcePathFull = (Resolve-Path -Path $SourcePath).Path $timeStamp = (Get-Date).ToString("yyMMdd_HHmmss_f") $reportFile = Join-Path -Path $OutputPath -ChildPath "Compare-DbaDbSchema_$timeStamp.xml" # Build sqlpackage arguments - $sqlPackageArgs = "/action:deployreport /of:True /sf:""$SourcePath"" /op:""$reportFile""" + $sqlPackageArgs = "/action:deployreport /of:True /sf:""$sourcePathFull"" /op:""$reportFile""" if (Test-Bound -ParameterName TargetSqlInstance) { try { @@ -161,12 +167,13 @@ function Compare-DbaDbSchema { $sqlPackageArgs += " /tcs:""$connStringEscaped""" $targetDescription = "$($targetServer.DomainInstanceName)\$TargetDatabase" } else { + $targetPathFull = (Resolve-Path -Path $TargetPath).Path $targetDbName = [System.IO.Path]::GetFileNameWithoutExtension($TargetPath) - $sqlPackageArgs += " /tf:""$TargetPath"" /tdn:""$targetDbName""" + $sqlPackageArgs += " /tf:""$targetPathFull"" /tdn:""$targetDbName""" $targetDescription = $TargetPath } - Write-Message -Level Verbose -Message "Running sqlpackage with args: $sqlPackageArgs" + Write-Message -Level Verbose -Message "Running sqlpackage DeployReport for $sourcePathFull against $targetDescription." try { $startInfo = New-Object System.Diagnostics.ProcessStartInfo @@ -208,8 +215,6 @@ function Compare-DbaDbSchema { return } - $sourcePathFull = (Resolve-Path -Path $SourcePath).Path - foreach ($operation in $report.DeploymentReport.Operations.Operation) { $operationName = $operation.Name foreach ($item in $operation.Item) { @@ -236,4 +241,4 @@ function Compare-DbaDbSchema { Write-Message -Level Verbose -Message "Deployment report kept at $reportFile" } } -} +} \ No newline at end of file diff --git a/public/Compare-DbaLogin.ps1 b/public/Compare-DbaLogin.ps1 index ea4bc6d74769..801e09a846a3 100644 --- a/public/Compare-DbaLogin.ps1 +++ b/public/Compare-DbaLogin.ps1 @@ -86,7 +86,7 @@ function Compare-DbaLogin { Compares user-created logins between sql1 and sql2, excluding built-in system logins. .EXAMPLE - PS C:\> Compare-DbaLogin -Source sql1 -Destination sql2, sql3 -Login 'appuser', 'reportuser' + PS C:\> Compare-DbaLogin -Source sql1 -Destination sql2, sql3 -Login "appuser", "reportuser" Compares the specified logins between sql1 and both sql2 and sql3. #> @@ -129,11 +129,13 @@ function Compare-DbaLogin { if (Test-FunctionInterrupt) { return } foreach ($destInstance in $Destination) { + $destServer = $null try { $destServer = Connect-DbaInstance -SqlInstance $destInstance -SqlCredential $DestinationSqlCredential } catch { Stop-Function -Message "Failure connecting to $destInstance" -Category ConnectionError -ErrorRecord $_ -Target $destInstance -Continue } + if ($null -eq $destServer) { continue } $splatGetDest = @{ SqlInstance = $destServer diff --git a/public/Connect-DbaInstance.ps1 b/public/Connect-DbaInstance.ps1 index f6533fe14b35..6e2937384f15 100644 --- a/public/Connect-DbaInstance.ps1 +++ b/public/Connect-DbaInstance.ps1 @@ -492,6 +492,11 @@ function Connect-DbaInstance { process { if (Test-FunctionInterrupt) { return } + if ($AuthenticationType -in "ActiveDirectoryPassword", "ActiveDirectoryServicePrincipal" -and -not $SqlCredential) { + Stop-Function -Message "AuthenticationType $AuthenticationType requires SqlCredential." + return + } + # if tenant is specified with a GUID username such as 21f5633f-6776-4bab-b878-bbd5e3e5ed72 (for clientid) if ($Tenant -and -not $AccessToken -and $SqlCredential.UserName -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { @@ -781,6 +786,14 @@ function Connect-DbaInstance { $sqlConnectionInfo = New-Object -TypeName Microsoft.SqlServer.Management.Common.SqlConnectionInfo # Set properties of SqlConnectionInfo based on the used properties of the connection string. + $connectionString = $connectionString -replace "(?i)\bFailoverPartner\s*=", "Failover Partner=" + if ($connectionString -match "(?i)\bFailover Partner\s*=" -and $connectionString -notmatch "(?i)\b(?:Initial Catalog|Database)\s*=") { + if ($connectionString -match ";$") { + $connectionString += "Initial Catalog=master;" + } else { + $connectionString += ";Initial Catalog=master;" + } + } $csb = New-Object -TypeName Microsoft.Data.SqlClient.SqlConnectionStringBuilder -ArgumentList $connectionString if ($csb.ShouldSerialize('Data Source')) { Write-Message -Level Debug -Message "ServerName will be set to '$($csb.DataSource)'" @@ -856,6 +869,7 @@ function Connect-DbaInstance { $authType += 'integrated' } Write-Message -Level Verbose -Message "authentication method is '$authType'" + $authenticationTypeUsesSqlCredential = $AuthenticationType -in "ActiveDirectoryPassword", "ActiveDirectoryServicePrincipal" # Best way to get connection pooling to work is to use SqlConnectionInfo -> ServerConnection -> Server $sqlConnectionInfo = New-Object -TypeName Microsoft.SqlServer.Management.Common.SqlConnectionInfo -ArgumentList $serverName @@ -907,12 +921,8 @@ function Connect-DbaInstance { if ($AuthenticationType) { Write-Message -Level Debug -Message "Authentication will be set to '$AuthenticationType' (from AuthenticationType parameter)" $sqlConnectionInfo.Authentication = [Microsoft.SqlServer.Management.Common.SqlConnectionInfo+AuthenticationMethod]::$AuthenticationType - # ActiveDirectoryInteractive and ActiveDirectoryIntegrated require UseIntegratedSecurity=False - # to prevent the default Integrated Security=True from conflicting with Entra ID auth - if ($AuthenticationType -in 'ActiveDirectoryInteractive', 'ActiveDirectoryIntegrated', 'ActiveDirectoryDeviceCodeFlow', 'ActiveDirectoryManagedIdentity') { - Write-Message -Level Debug -Message "UseIntegratedSecurity will be set to '$false' for $AuthenticationType" - $sqlConnectionInfo.UseIntegratedSecurity = $false - } + Write-Message -Level Debug -Message "UseIntegratedSecurity will be set to '$false' for $AuthenticationType" + $sqlConnectionInfo.UseIntegratedSecurity = $false } elseif ($authType -eq 'azure integrated') { # Azure AD integrated security # TODO: This is not tested / How can we test that? @@ -994,7 +1004,7 @@ function Connect-DbaInstance { # We use ConnectionContext.StatementTimeout instead #SecurePassword Property securestring SecurePassword {get;set;} - if ($authType -in 'azure ad', 'azure sql', 'local sql') { + if ($authenticationTypeUsesSqlCredential -or $authType -in 'azure ad', 'azure sql', 'local sql') { Write-Message -Level Debug -Message "SecurePassword will be set" $sqlConnectionInfo.SecurePassword = $SqlCredential.Password } @@ -1015,10 +1025,10 @@ function Connect-DbaInstance { $sqlConnectionInfo.TrustServerCertificate = $TrustServerCertificate #UseIntegratedSecurity Property bool UseIntegratedSecurity {get;set;} - # $true is the default and it is automatically set to $false if we set a UserName, so we don't touch + # We rely on the default unless AuthenticationType already set it above or UserName changes it automatically. #UserName Property string UserName {get;set;} - if ($authType -in 'azure ad', 'azure sql', 'local sql') { + if ($authenticationTypeUsesSqlCredential -or $authType -in 'azure ad', 'azure sql', 'local sql') { Write-Message -Level Debug -Message "UserName will be set to '$username'" $sqlConnectionInfo.UserName = $username } @@ -1080,7 +1090,7 @@ function Connect-DbaInstance { Write-Message -Level Debug -Message "ServerConnection was built" } - if ($authType -eq 'local ad') { + if ($authType -eq 'local ad' -and -not $AuthenticationType) { if ($IsLinux -or $IsMacOS) { Stop-Function -Target $instance -Message "Cannot use Windows credentials to connect when host is Linux or OS X. Use kinit instead. See https://github.com/dataplat/dbatools/issues/7602 for more info." return @@ -1118,7 +1128,7 @@ function Connect-DbaInstance { } Write-Message -Level Debug -Message "Setting ConnectionContext.StatementTimeout to '$StatementTimeout'" $server.ConnectionContext.StatementTimeout = $StatementTimeout - if ($NonPooledConnection) { + if ($NonPooledConnection -and -not $server.ConnectionContext.NonPooledConnection) { Write-Message -Level Debug -Message "Setting ConnectionContext.NonPooledConnection to 'True'" $server.ConnectionContext.NonPooledConnection = $true } @@ -1172,8 +1182,9 @@ function Connect-DbaInstance { $connectionSucceeded = $true } catch { Write-Message -Level Debug -Message "Retry with TrustServerCertificate also failed: $($_.Exception.Message)" - # Use the original error for reporting since the retry also failed + # Keep the latest error details available for any follow-up retry and final reporting $connectionError = $_ + $errorMessage = $_.Exception.Message } } @@ -1182,19 +1193,21 @@ function Connect-DbaInstance { # The .NET SqlClient sends Failover Partner info from the server's TDS handshake to the # connection pool, and the pool then requires Initial Catalog to be set in the connection string. $isFailoverPartnerError = $errorMessage -match "Failover Partner" -and $errorMessage -match "Initial Catalog" - if ($isNewConnection -and $isFailoverPartnerError -and -not $connectionSucceeded -and $inputObjectType -eq 'String') { + if ($isNewConnection -and $isFailoverPartnerError -and -not $connectionSucceeded -and $inputObjectType -in "String", "ConnectionString", "RegisteredServer") { Write-Message -Level Verbose -Message "Connection failed because the server is configured for database mirroring (Failover Partner requires Initial Catalog). Retrying with Initial Catalog=master." Write-Message -Level Debug -Message "Original error: $errorMessage" try { # Add Initial Catalog=master to satisfy the Failover Partner connection string requirement - if ($server.ConnectionContext.SqlConnectionObject.ConnectionString -notmatch "Initial Catalog" -and $server.ConnectionContext.SqlConnectionObject.ConnectionString -notmatch "Database=") { - if ($server.ConnectionContext.SqlConnectionObject.ConnectionString -match ";$") { - $server.ConnectionContext.SqlConnectionObject.ConnectionString += "Initial Catalog=master;" + $retryConnectionString = $server.ConnectionContext.SqlConnectionObject.ConnectionString -replace "(?i)\bFailoverPartner\s*=", "Failover Partner=" + if ($retryConnectionString -notmatch "(?i)\b(?:Initial Catalog|Database)\s*=") { + if ($retryConnectionString -match ";$") { + $retryConnectionString += "Initial Catalog=master;" } else { - $server.ConnectionContext.SqlConnectionObject.ConnectionString += ";Initial Catalog=master;" + $retryConnectionString += ";Initial Catalog=master;" } } + $server.ConnectionContext.SqlConnectionObject.ConnectionString = $retryConnectionString # Retry the connection Write-Message -Level Debug -Message "Retrying connection with Initial Catalog=master for server with database mirroring" diff --git a/public/ConvertTo-DbaTimeline.ps1 b/public/ConvertTo-DbaTimeline.ps1 index c2068db302f8..14818e6120d2 100644 --- a/public/ConvertTo-DbaTimeline.ps1 +++ b/public/ConvertTo-DbaTimeline.ps1 @@ -171,7 +171,7 @@ function ConvertTo-DbaTimeline { $data = $InputObject | Select-Object @{ Name = "SqlInstance"; Expression = { $_.SqlInstance } }, @{ Name = "InstanceName"; Expression = { $_.InstanceName } }, @{ Name = "vLabel"; Expression = { "[" + $($_.SqlInstance -replace "\\", "\\\") + "] " + $_.Database } }, @{ Name = "hLabel"; Expression = { $_.Type } }, @{ Name = "StartDate"; Expression = { $(ConvertTo-JsDate($_.Start)) } }, @{ Name = "EndDate"; Expression = { $(ConvertTo-JsDate($_.End)) } } } elseif ($null -ne $InputObject[0].PSObject.Properties['EventClass'] -and $null -ne $InputObject[0].PSObject.Properties['ChangeInSize']) { $CallerName = "Find-DbaDbGrowthEvent" - $data = $InputObject | Select-Object @{ Name = "SqlInstance"; Expression = { $_.SqlInstance } }, @{ Name = "InstanceName"; Expression = { $_.InstanceName } }, @{ Name = "vLabel"; Expression = { "[" + $($_.SqlInstance -replace "\\", "\\\") + "] " + $_.DatabaseName } }, @{ Name = "hLabel"; Expression = { switch ([int]$_.EventClass) { 92 { "Data Grow" } 93 { "Log Grow" } 94 { "Data Shrink" } 95 { "Log Shrink" } default { "Unknown" } } } }, @{ Name = "Style"; Expression = { if ([int]$_.EventClass -in 92, 93) { "#36B300" } else { "#FF8C00" } } }, @{ Name = "StartDate"; Expression = { $(ConvertTo-JsDate($_.StartTime)) } }, @{ Name = "EndDate"; Expression = { $(ConvertTo-JsDate($_.EndTime)) } } + $data = $InputObject | Select-Object @{ Name = "SqlInstance"; Expression = { $_.SqlInstance } }, @{ Name = "InstanceName"; Expression = { $_.InstanceName } }, @{ Name = "vLabel"; Expression = { ("[" + $($_.SqlInstance -replace "\\", "\\\") + "] " + $($_.DatabaseName -replace "\\", "\\\")).Replace("'", "\'") } }, @{ Name = "hLabel"; Expression = { switch ([int]$_.EventClass) { 92 { "Data Grow" } 93 { "Log Grow" } 94 { "Data Shrink" } 95 { "Log Shrink" } default { "Unknown" } } } }, @{ Name = "Style"; Expression = { if ([int]$_.EventClass -in 92, 93) { "#36B300" } else { "#FF8C00" } } }, @{ Name = "StartDate"; Expression = { $(ConvertTo-JsDate($_.StartTime)) } }, @{ Name = "EndDate"; Expression = { $(ConvertTo-JsDate($_.EndTime)) } } } else { # sorry to be so formal, can't help it ;) Stop-Function -Message "Unsupported input data. To request support for additional commands, please file an issue at dbatools.io/issues and we'll take a look" diff --git a/public/Copy-DbaDbMail.ps1 b/public/Copy-DbaDbMail.ps1 index c9b614ef056f..bd4454632342 100644 --- a/public/Copy-DbaDbMail.ps1 +++ b/public/Copy-DbaDbMail.ps1 @@ -307,7 +307,7 @@ function Copy-DbaDbMail { $sql = "SELECT credentials.name AS credential_name, sysmail_server.account_id FROM sys.credentials JOIN msdb.dbo.sysmail_server ON credentials.credential_id = sysmail_server.credential_id" $credentialAccounts = @($sourceServer.Query($sql)) if ($credentialAccounts.Count -gt 0) { - $decryptedCredentials = Get-DecryptedObject -SqlInstance $sourceServer -Credential $Credential -Type Credential -EnableException | Where-Object { $_.Name -in $credentialAccounts.credential_name } + $decryptedCredentials = Get-DecryptedObject -SqlInstance $sourceServer -Credential $Credential -Type Credential -EnableException:$EnableException | Where-Object { $_.Name -in $credentialAccounts.credential_name } } } @@ -528,4 +528,4 @@ function Copy-DbaDbMail { $null = $sourceServer | Disconnect-DbaInstance -WhatIf:$false } } -} \ No newline at end of file +} diff --git a/public/Copy-DbaDbViewData.ps1 b/public/Copy-DbaDbViewData.ps1 index 28f5b4118c76..787961bc7767 100644 --- a/public/Copy-DbaDbViewData.ps1 +++ b/public/Copy-DbaDbViewData.ps1 @@ -51,6 +51,10 @@ function Copy-DbaDbViewData { Automatically creates the destination table if it doesn't exist, using the same structure as the source view. Essential for initial data migrations or when materializing view data into new tables where destination tables haven't been created yet. + .PARAMETER ScriptingOptionsObject + A scripting options object created by New-DbaScriptingOption that controls how the destination table is scripted when -AutoCreateTable is used. + Use this to control which table properties are included in the CREATE TABLE script, such as indexes, constraints, triggers, and extended properties. + .PARAMETER BatchSize Number of rows to process in each bulk copy batch. Defaults to 50000 rows. Reduce this value for memory-constrained systems or increase it for faster transfers when copying large view result sets with sufficient memory. @@ -216,6 +220,7 @@ function Copy-DbaDbViewData { [switch]$KeepNulls, [switch]$Truncate, [int]$BulkCopyTimeOut = 5000, + [Microsoft.SqlServer.Management.Smo.ScriptingOptions]$ScriptingOptionsObject, [Parameter(ValueFromPipeline)] [Microsoft.SqlServer.Management.Smo.TableViewBase[]]$InputObject, [switch]$EnableException diff --git a/public/Copy-DbaLinkedServer.ps1 b/public/Copy-DbaLinkedServer.ps1 index abe2f69004ce..fde5ec5415d3 100644 --- a/public/Copy-DbaLinkedServer.ps1 +++ b/public/Copy-DbaLinkedServer.ps1 @@ -152,7 +152,7 @@ function Copy-DbaLinkedServer { } } } else { - $sourcelogins = Get-DecryptedObject -SqlInstance $sourceServer -Credential $Credential -Type LinkedServer -EnableException + $sourcelogins = Get-DecryptedObject -SqlInstance $sourceServer -Credential $Credential -Type LinkedServer -EnableException:$EnableException } $serverlist = $sourceServer.LinkedServers diff --git a/public/Copy-DbaLogin.ps1 b/public/Copy-DbaLogin.ps1 index a03b83b22cbb..70c85e089e0e 100644 --- a/public/Copy-DbaLogin.ps1 +++ b/public/Copy-DbaLogin.ps1 @@ -48,7 +48,7 @@ function Copy-DbaLogin { .PARAMETER ExcludeDatabaseMapping Skips copying database-level permissions and role memberships, syncing only server-level roles and securables. - Use this when you want to sync server permissions (sysadmin membership, server securables, etc.) without iterating through all databases, which significantly improves performance on instances with many databases. + Use this when you want to sync server permissions (sysadmin membership, server securables, etc.) without iterating through all databases, which significantly improves performance on instances with many databases. When used with -OutFile, generated scripts also exclude database user mappings and permissions. .PARAMETER SyncSaName Renames the destination sa account to match the source sa account name if they differ. @@ -548,7 +548,19 @@ function Copy-DbaLogin { } if ($OutFile) { - return (Export-DbaLogin -SqlInstance $Source -SqlCredential $SourceSqlCredential -FilePath $OutFile -Login $loginsCollection -ObjectLevel:$ObjectLevel -ExcludeLogin $ExcludeLogin -EnableException:$EnableException) + $splatExportLogin = @{ + SqlInstance = $Source + SqlCredential = $SourceSqlCredential + FilePath = $OutFile + Login = $loginsCollection + ObjectLevel = $ObjectLevel + ExcludeLogin = $ExcludeLogin + EnableException = $EnableException + } + if ($ExcludeDatabaseMapping) { + $splatExportLogin.ExcludeDatabase = $true + } + return (Export-DbaLogin @splatExportLogin) } foreach ($loginObject in $loginsCollection) { $sourceServer = $loginObject.Parent @@ -601,4 +613,4 @@ function Copy-DbaLogin { } } } -} \ No newline at end of file +} diff --git a/public/Copy-DbaPolicyManagement.ps1 b/public/Copy-DbaPolicyManagement.ps1 index a9d2e30236fd..3c4bf3f164a1 100644 --- a/public/Copy-DbaPolicyManagement.ps1 +++ b/public/Copy-DbaPolicyManagement.ps1 @@ -155,22 +155,33 @@ function Copy-DbaPolicyManagement { $destSqlStoreConnection = New-Object Microsoft.SqlServer.Management.Sdk.Sfc.SqlStoreConnection $destSqlConn $destStore = New-Object Microsoft.SqlServer.Management.DMF.PolicyStore $destSqlStoreConnection + $storePoliciesToCopy = $storePolicies + $storeConditionsToCopy = $storeConditions + $storeObjectSetsToCopy = $storeObjectSets + if ($Policy) { - $storePolicies = $storePolicies | Where-Object Name -In $Policy + $storePoliciesToCopy = $storePoliciesToCopy | Where-Object Name -In $Policy } if ($ExcludePolicy) { - $storePolicies = $storePolicies | Where-Object Name -NotIn $ExcludePolicy + $storePoliciesToCopy = $storePoliciesToCopy | Where-Object Name -NotIn $ExcludePolicy } if ($Condition) { - $storeConditions = $storeConditions | Where-Object Name -In $Condition + $storeConditionsToCopy = $storeConditionsToCopy | Where-Object Name -In $Condition } if ($ExcludeCondition) { - $storeConditions = $storeConditions | Where-Object Name -NotIn $ExcludeCondition + $storeConditionsToCopy = $storeConditionsToCopy | Where-Object Name -NotIn $ExcludeCondition } if ($Policy -and $Condition) { - $storeConditions = $null - $storePolicies = $null + $storeConditionsToCopy = $null + $storePoliciesToCopy = $null + } + + if ($Policy -or $ExcludePolicy) { + $requiredObjectSets = $storePoliciesToCopy | + Select-Object -ExpandProperty ObjectSet -Unique | + Where-Object { $PSItem } + $storeObjectSetsToCopy = $storeObjectSetsToCopy | Where-Object Name -In $requiredObjectSets } <# @@ -178,7 +189,7 @@ function Copy-DbaPolicyManagement { #> Write-Message -Level Verbose -Message "Migrating categories" - $uniquePolicyCategories = $storePolicies | Select-Object -ExpandProperty PolicyCategory -Unique + $uniquePolicyCategories = $storePoliciesToCopy | Select-Object -ExpandProperty PolicyCategory -Unique $storeCategories = $sourceStore.PolicyCategories | Where-Object { $_.Name -in $uniquePolicyCategories } foreach ($category in $storeCategories) { $categoryName = $category.Name @@ -227,7 +238,7 @@ function Copy-DbaPolicyManagement { #> Write-Message -Level Verbose -Message "Migrating conditions" - foreach ($condition in $storeConditions) { + foreach ($condition in $storeConditionsToCopy) { $conditionName = $condition.Name $copyConditionStatus = [PSCustomObject]@{ @@ -296,7 +307,7 @@ function Copy-DbaPolicyManagement { #> Write-Message -Level Verbose -Message "Migrating object sets" - foreach ($objectSet in $storeObjectSets) { + foreach ($objectSet in $storeObjectSetsToCopy) { $objectSetName = $objectSet.Name $copyObjectSetStatus = [PSCustomObject]@{ @@ -344,7 +355,7 @@ function Copy-DbaPolicyManagement { #> Write-Message -Level Verbose -Message "Migrating policies" - foreach ($policy in $storePolicies) { + foreach ($policy in $storePoliciesToCopy) { $policyName = $policy.Name $copyPolicyStatus = [PSCustomObject]@{ diff --git a/public/Expand-DbaDbLogFile.ps1 b/public/Expand-DbaDbLogFile.ps1 index e80f68e00405..21429747068b 100644 --- a/public/Expand-DbaDbLogFile.ps1 +++ b/public/Expand-DbaDbLogFile.ps1 @@ -219,6 +219,120 @@ function Expand-DbaDbLogFile { $true } + function Get-VlfCountForGrowthPlan { + param ( + [long]$InitialSizeKB, + [long]$TargetSizeKB, + [long]$IncrementKB, + [int]$SqlMajorVersion + ) + + [int]$estimatedVlfCount = 0 + [long]$simulatedSizeKB = $InitialSizeKB + + while ($simulatedSizeKB -lt $TargetSizeKB) { + [long]$growthSizeKB = $TargetSizeKB - $simulatedSizeKB + + if ($growthSizeKB -gt $IncrementKB) { + $growthSizeKB = $IncrementKB + } + + if ($SqlMajorVersion -ge 12 -and $growthSizeKB -lt ($simulatedSizeKB / 8)) { + $estimatedVlfCount += 1 + } elseif ($growthSizeKB -lt (64 * 1024)) { + $estimatedVlfCount += 4 + } elseif ($growthSizeKB -lt (1024 * 1024)) { + $estimatedVlfCount += 8 + } else { + $estimatedVlfCount += 16 + } + + $simulatedSizeKB += $growthSizeKB + } + + return $estimatedVlfCount + } + + function Find-TargetVlfIncrementSize { + param ( + [long]$CurrentSizeKB, + [long]$TargetSizeKB, + [int]$AdditionalVlfsAllowed, + [int]$SqlMajorVersion + ) + + if ($TargetSizeKB -le $CurrentSizeKB -or $AdditionalVlfsAllowed -lt 1) { + return $null + } + + [long]$totalGrowthKB = $TargetSizeKB - $CurrentSizeKB + [long]$minIncrementKB = 1024 + [long]$fourVlfMinimumKB = $minIncrementKB + $searchRanges = @() + + if ($SqlMajorVersion -ge 12) { + [long]$maxOneVlfIncrementKB = [Math]::Floor(($CurrentSizeKB - 1) / 8) + + if ($maxOneVlfIncrementKB -ge $minIncrementKB) { + $searchRanges += @{ + Minimum = $minIncrementKB + Maximum = [Math]::Min($totalGrowthKB, $maxOneVlfIncrementKB) + } + } + + $fourVlfMinimumKB = [Math]::Max($minIncrementKB, $maxOneVlfIncrementKB + 1) + } + + if ($fourVlfMinimumKB -lt (64 * 1024)) { + $searchRanges += @{ + Minimum = $fourVlfMinimumKB + Maximum = [Math]::Min($totalGrowthKB, (64 * 1024) - 1) + } + } + + if ($totalGrowthKB -ge (64 * 1024)) { + $searchRanges += @{ + Minimum = 64 * 1024 + Maximum = [Math]::Min($totalGrowthKB, (1024 * 1024) - 1) + } + } + + if ($totalGrowthKB -ge (1024 * 1024)) { + $searchRanges += @{ + Minimum = 1024 * 1024 + Maximum = $totalGrowthKB + } + } + + foreach ($searchRange in $searchRanges) { + [long]$rangeMinimumKB = $searchRange.Minimum + [long]$rangeMaximumKB = $searchRange.Maximum + [long]$bestIncrementKB = 0 + + if ($rangeMaximumKB -lt $rangeMinimumKB) { + continue + } + + while ($rangeMinimumKB -le $rangeMaximumKB) { + [long]$candidateIncrementKB = [Math]::Floor(($rangeMinimumKB + $rangeMaximumKB) / 2) + $estimatedVlfCount = Get-VlfCountForGrowthPlan -InitialSizeKB $CurrentSizeKB -TargetSizeKB $TargetSizeKB -IncrementKB $candidateIncrementKB -SqlMajorVersion $SqlMajorVersion + + if ($estimatedVlfCount -le $AdditionalVlfsAllowed) { + $bestIncrementKB = $candidateIncrementKB + $rangeMaximumKB = $candidateIncrementKB - 1 + } else { + $rangeMinimumKB = $candidateIncrementKB + 1 + } + } + + if ($bestIncrementKB -gt 0) { + return $bestIncrementKB + } + } + + return $null + } + #Set base information Write-Message -Level Verbose -Message "Initialize the instance '$SqlInstance'." @@ -453,6 +567,7 @@ function Expand-DbaDbLogFile { while (($logfile.Size / 1024) -gt $ShrinkSize -and ++$backupRetries -lt 6) $currentSize = $logfile.Size + $currentSizeMB = $currentSize / 1024 Write-Message -Level Verbose -Message "TLog backup and truncate for database '$dbName' finished. Current TLog size after $backupRetries backups is $($currentSize/1024)MB" } } @@ -490,61 +605,15 @@ function Expand-DbaDbLogFile { $totalGrowthKB = $TargetLogSizeKB - $currentSize if ($totalGrowthKB -gt 0) { - $calculatedIncrementKB = $null - - # For SQL 2014+ (version 12+), a growth smaller than 1/8 of the current file size creates only 1 VLF - if ($server.Version.Major -ge 12) { - $oneVlfThresholdKB = [long]($currentSize / 8) - $maxIterationsAt1 = $additionalVlfsAllowed - if ($maxIterationsAt1 -gt 0) { - $requiredIncrementKB = [long][Math]::Ceiling($totalGrowthKB / $maxIterationsAt1) - if ($requiredIncrementKB -lt $oneVlfThresholdKB -and $requiredIncrementKB -ge 1024) { - $calculatedIncrementKB = $requiredIncrementKB - Write-Message -Level Verbose -Message "$step - SQL 2014+: using 1-VLF-per-growth strategy with increment $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB." - } - } - } - - # 4 VLFs per growth: increment < 64MB - if ($null -eq $calculatedIncrementKB) { - $maxIterationsAt4 = [Math]::Floor($additionalVlfsAllowed / 4) - if ($maxIterationsAt4 -gt 0) { - $requiredIncrementKB = [long][Math]::Ceiling($totalGrowthKB / $maxIterationsAt4) - if ($requiredIncrementKB -lt (64 * 1024)) { - $calculatedIncrementKB = [Math]::Max($requiredIncrementKB, 1024) - Write-Message -Level Verbose -Message "$step - Using 4-VLF-per-growth strategy with increment $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB." - } - } - } - - # 8 VLFs per growth: 64MB <= increment < 1024MB - if ($null -eq $calculatedIncrementKB) { - $maxIterationsAt8 = [Math]::Floor($additionalVlfsAllowed / 8) - if ($maxIterationsAt8 -gt 0) { - $requiredIncrementKB = [long][Math]::Ceiling($totalGrowthKB / $maxIterationsAt8) - if ($requiredIncrementKB -lt (1024 * 1024)) { - $calculatedIncrementKB = [Math]::Max($requiredIncrementKB, 64 * 1024) - Write-Message -Level Verbose -Message "$step - Using 8-VLF-per-growth strategy with increment $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB." - } - } - } - - # 16 VLFs per growth: increment >= 1024MB - if ($null -eq $calculatedIncrementKB) { - $maxIterationsAt16 = [Math]::Floor($additionalVlfsAllowed / 16) - if ($maxIterationsAt16 -gt 0) { - $requiredIncrementKB = [long][Math]::Ceiling($totalGrowthKB / $maxIterationsAt16) - $calculatedIncrementKB = [Math]::Max($requiredIncrementKB, 1024 * 1024) - Write-Message -Level Verbose -Message "$step - Using 16-VLF-per-growth strategy with increment $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB." - } - } + $calculatedIncrementKB = Find-TargetVlfIncrementSize -CurrentSizeKB $currentSize -TargetSizeKB $TargetLogSizeKB -AdditionalVlfsAllowed $additionalVlfsAllowed -SqlMajorVersion $server.Version.Major if ($null -eq $calculatedIncrementKB) { Write-Message -Level Warning -Message "$step - Cannot achieve target VLF count of $TargetVlfCount for database '$dbName': the VLF budget is too small for the required growth from $currentSizeMB MB to $TargetLogSize MB. Increase -TargetVlfCount or use -ShrinkLogFile to start from a lower base." continue } - Write-Message -Level Verbose -Message "$step - TargetVlfCount ${TargetVlfCount}: overriding increment size to $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB (was $([Math]::Round($LogIncrementSize / 1024.0, 2))MB)." + $estimatedAdditionalVlfCount = Get-VlfCountForGrowthPlan -InitialSizeKB $currentSize -TargetSizeKB $TargetLogSizeKB -IncrementKB $calculatedIncrementKB -SqlMajorVersion $server.Version.Major + Write-Message -Level Verbose -Message "$step - TargetVlfCount ${TargetVlfCount}: overriding increment size to $([Math]::Round($calculatedIncrementKB / 1024.0, 2))MB to add an estimated $estimatedAdditionalVlfCount VLFs (was $([Math]::Round($LogIncrementSize / 1024.0, 2))MB)." $LogIncrementSize = [int]$calculatedIncrementKB } } diff --git a/public/Export-DbaCredential.ps1 b/public/Export-DbaCredential.ps1 index df0ba99f8b03..333a3e7d81e1 100644 --- a/public/Export-DbaCredential.ps1 +++ b/public/Export-DbaCredential.ps1 @@ -147,7 +147,7 @@ function Export-DbaCredential { } } } else { - $credentials = Get-DecryptedObject -SqlInstance $server -Credential $Credential -Type Credential -EnableException + $credentials = Get-DecryptedObject -SqlInstance $server -Credential $Credential -Type Credential -EnableException:$EnableException } if ($Identity) { diff --git a/public/Export-DbaDacPackage.ps1 b/public/Export-DbaDacPackage.ps1 index 4e6ef960797f..cde83562a105 100644 --- a/public/Export-DbaDacPackage.ps1 +++ b/public/Export-DbaDacPackage.ps1 @@ -186,6 +186,10 @@ function Export-DbaDacPackage { foreach ($tableItem in $Table) { # Use Get-ObjectNameParts to correctly handle bracketed names like [Gross.Table.Name] $nameParts = Get-ObjectNameParts -ObjectName $tableItem + if (-not $nameParts.Parsed -or -not $nameParts.Name) { + Stop-Function -Message "Table value '$tableItem' is not a valid one-, two-, or three-part name. Use bracket quoting for names that contain periods." + return + } if ($nameParts.Schema) { $schemaName = $nameParts.Schema } else { @@ -224,7 +228,7 @@ WHERE database_id > 4 -- Exclude system databases (master=1, tempdb=2, model=3, AND state = 0 -- Only ONLINE databases (OnlyAccessible equivalent) "@ - $sqlParams = @{} + $sqlParams = @{ } # Add ExcludeDatabase filter if specified (using parameterized queries to prevent SQL injection) if ($ExcludeDatabase) { @@ -369,4 +373,4 @@ WHERE database_id > 4 -- Exclude system databases (master=1, tempdb=2, model=3, } } } -} +} \ No newline at end of file diff --git a/public/Export-DbaInstance.ps1 b/public/Export-DbaInstance.ps1 index 98b2ee830827..f6478ae600b1 100644 --- a/public/Export-DbaInstance.ps1 +++ b/public/Export-DbaInstance.ps1 @@ -163,6 +163,9 @@ function Export-DbaInstance { - userobjectsinsysdbs.sql: User-created objects in system databases - AvailabilityGroups.sql: Availability Groups configuration - OleDbProvider.sql: OLEDB provider configuration + - *.cer: Database certificate backups when -IncludeDbMasterKey is specified + - *.pvk: Database certificate private key backups when -IncludeDbMasterKey and -EncryptionPassword are specified + - *.key: Database master key backups when -IncludeDbMasterKey and -EncryptionPassword are specified Files are returned only if they were successfully created and are not excluded via the -Exclude parameter. The -ErrorAction Ignore used in Get-ChildItem means that if a file is not created, no error object is returned for that file. @@ -227,304 +230,362 @@ function Export-DbaInstance { $started = Get-Date $eol = [System.Environment]::NewLine - } - process { - if (Test-FunctionInterrupt) { return } - foreach ($instance in $SqlInstance) { - $stepCounter = 0 - try { - # Do we need a dedicated admin connection for password retrieval? - # If not both are excluded, we do - $dacNeeded = $Exclude -notcontains 'Credentials' -or $Exclude -notcontains 'LinkedServers' - # If passwords are excluded, we don't need a DAC - if ($ExcludePassword) { $dacNeeded = $false } - # Do we have a dedicated admin connection already? - $dacConnected = $instance.Type -eq 'Server' -and $instance.InputObject.Name -match '^ADMIN:' - - $dacOpened = $false - if ($dacNeeded) { - if ($dacConnected) { - Write-Message -Level Verbose -Message "Reusing dedicated admin connection for password retrieval." - $server = $instance.InputObject - } else { - Write-Message -Level Verbose -Message "Opening dedicated admin connection for password retrieval." - $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 10 -DedicatedAdminConnection -WarningAction SilentlyContinue - $dacOpened = $true - } - } else { - Write-Message -Level Verbose -Message "Opening or reusing normal connection because passwords are excluded." - $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 10 - } - } catch { - Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue + function Get-ExportedFileInfo { + param ( + [Parameter(Mandatory)] + [object]$Server, + [Parameter(Mandatory)] + [string]$SourcePath, + [Parameter(Mandatory)] + [string]$DestinationDirectory, + [switch]$CopyFromServer + ) + + if ([string]::IsNullOrWhiteSpace($SourcePath) -or $SourcePath -eq "Password required to export key") { + return } - if ($Force) { - # when the caller requests to overwrite existing scripts we won't add the dynamic timestamp to the folder name, so that a pre-existing location can be overwritten. - $exportPath = Join-DbaPath -Path $Path -Child "$($server.DomainInstanceName.replace('\', '$'))" - } else { - $timeNow = (Get-Date -UFormat (Get-DbatoolsConfigValue -FullName 'formatting.uformat')) - $exportPath = Join-DbaPath -Path $Path -Child "$($server.DomainInstanceName.replace('\', '$'))-$timeNow" - } + $targetPath = $SourcePath + if ($CopyFromServer) { + $sourcePathUnc = Join-AdminUnc -Servername $Server.ComputerName -Filepath $SourcePath + $targetPath = Join-Path -Path $DestinationDirectory -ChildPath (Split-Path -Path $SourcePath -Leaf) - # Ensure the export dir exists. - if (-not (Test-Path $exportPath)) { try { - $null = New-Item -ItemType Directory -Path $exportPath -Force -ErrorAction Stop + Copy-Item -Path $sourcePathUnc -Destination $targetPath -Force -ErrorAction Stop } catch { - Stop-Function -Message "Failure" -ErrorRecord $_ + Stop-Function -Message "Failed to copy staged export artifact $SourcePath from $($Server.DomainInstanceName) to $DestinationDirectory." -ErrorRecord $_ -Target $SourcePath return } + + try { + Remove-Item -Path $sourcePathUnc -Force -ErrorAction Stop + } catch { + Write-Message -Level Verbose -Message "Failed to remove staged export artifact $sourcePathUnc." + } } + Get-ChildItem -ErrorAction Ignore -Path $targetPath + } + } + process { + if (Test-FunctionInterrupt) { return } + foreach ($instance in $SqlInstance) { + $stepCounter = 0 try { - if ($Exclude -notcontains 'SpConfigure') { - Write-Message -Level Verbose -Message "Exporting SQL Server Configuration" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting SQL Server Configuration" - Export-DbaSpConfigure -SqlInstance $server -FilePath "$exportPath\sp_configure.sql" -EnableException:$EnableException - # no call to Get-ChildItem because Export-DbaSpConfigure does it + $server = $null + $dacOpened = $false + try { + # Do we need a dedicated admin connection for password retrieval? + # If not both are excluded, we do + $dacNeeded = $Exclude -notcontains 'Credentials' -or $Exclude -notcontains 'LinkedServers' + # If passwords are excluded, we don't need a DAC + if ($ExcludePassword) { $dacNeeded = $false } + + # Do we have a dedicated admin connection already? + $dacConnected = $instance.Type -eq 'Server' -and $instance.InputObject.Name -match '^ADMIN:' + + if ($dacNeeded) { + if ($dacConnected) { + Write-Message -Level Verbose -Message "Reusing dedicated admin connection for password retrieval." + $server = $instance.InputObject + } else { + Write-Message -Level Verbose -Message "Opening dedicated admin connection for password retrieval." + $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 10 -DedicatedAdminConnection -WarningAction SilentlyContinue + $dacOpened = $true + } + } else { + Write-Message -Level Verbose -Message "Opening or reusing normal connection because passwords are excluded." + $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 10 + } + } catch { + Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } - if ($Exclude -notcontains 'CustomErrors') { - Write-Message -Level Verbose -Message "Exporting custom errors (user defined messages)" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting custom errors (user defined messages)" - $null = Get-DbaCustomError -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\customererrors.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\customererrors.sql" + if ($Force) { + # when the caller requests to overwrite existing scripts we won't add the dynamic timestamp to the folder name, so that a pre-existing location can be overwritten. + $exportPath = Join-DbaPath -Path $Path -Child "$($server.DomainInstanceName.replace('\', '$'))" + } else { + $timeNow = (Get-Date -UFormat (Get-DbatoolsConfigValue -FullName 'formatting.uformat')) + $exportPath = Join-DbaPath -Path $Path -Child "$($server.DomainInstanceName.replace('\', '$'))-$timeNow" } - if ($Exclude -notcontains 'ServerRoles') { - Write-Message -Level Verbose -Message "Exporting server roles" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting server roles" - $null = Get-DbaServerRole -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\serverroles.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\serverroles.sql" + # Ensure the export dir exists. + if (-not (Test-Path $exportPath)) { + try { + $null = New-Item -ItemType Directory -Path $exportPath -Force -ErrorAction Stop + } catch { + Stop-Function -Message "Failure" -ErrorRecord $_ + return + } } - if ($Exclude -notcontains 'Credentials') { - Write-Message -Level Verbose -Message "Exporting SQL credentials" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting SQL credentials" - $null = Export-DbaCredential -SqlInstance $server -Credential $Credential -FilePath "$exportPath\credentials.sql" -ExcludePassword:$ExcludePassword -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\credentials.sql" - } + try { + $copyDbKeyExports = $false + if ($IncludeDbMasterKey) { + $copyDbKeyExports = -not (Test-DbaPath -SqlInstance $server -Path $exportPath) + if ($copyDbKeyExports) { + Write-Message -Level Verbose -Message "Export path $exportPath is not accessible from $($server.DomainInstanceName). Database certificates and keys will be staged on the SQL Server host and copied back to the export directory." + } + } - if ($Exclude -notcontains 'Logins') { - Write-Message -Level Verbose -Message "Exporting logins" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting logins" - Export-DbaLogin -SqlInstance $server -FilePath "$exportPath\logins.sql" -ExcludePassword:$ExcludePassword -NoPrefix:$NoPrefix -WarningAction SilentlyContinue -EnableException:$EnableException - # no call to Get-ChildItem because Export-DbaLogin does it - } + if ($Exclude -notcontains 'SpConfigure') { + Write-Message -Level Verbose -Message "Exporting SQL Server Configuration" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting SQL Server Configuration" + Export-DbaSpConfigure -SqlInstance $server -FilePath "$exportPath\sp_configure.sql" -EnableException:$EnableException + # no call to Get-ChildItem because Export-DbaSpConfigure does it + } - if ($Exclude -notcontains 'DatabaseMail') { - Write-Message -Level Verbose -Message "Exporting database mail" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database mail" - # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. - # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. - $null = Get-DbaDbMailConfig -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaDbMailAccount -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaDbMailProfile -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaDbMailServer -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\dbmail.sql" - } + if ($Exclude -notcontains 'CustomErrors') { + Write-Message -Level Verbose -Message "Exporting custom errors (user defined messages)" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting custom errors (user defined messages)" + $null = Get-DbaCustomError -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\customererrors.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\customererrors.sql" + } - if ($Exclude -notcontains 'CentralManagementServer') { - Write-Message -Level Verbose -Message "Exporting Central Management Server" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Central Management Server" - $outputFilePath = "$exportPath\regserver.xml" - $null = Export-DbaRegServer -SqlInstance $server -FilePath $outputFilePath -Overwrite:$Force -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path $outputFilePath - } + if ($Exclude -notcontains 'ServerRoles') { + Write-Message -Level Verbose -Message "Exporting server roles" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting server roles" + $null = Get-DbaServerRole -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\serverroles.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\serverroles.sql" + } - if ($Exclude -notcontains 'BackupDevices') { - Write-Message -Level Verbose -Message "Exporting Backup Devices" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Backup Devices" - $null = Get-DbaBackupDevice -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\backupdevices.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\backupdevices.sql" - } + if ($Exclude -notcontains 'Credentials') { + Write-Message -Level Verbose -Message "Exporting SQL credentials" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting SQL credentials" + $null = Export-DbaCredential -SqlInstance $server -Credential $Credential -FilePath "$exportPath\credentials.sql" -ExcludePassword:$ExcludePassword -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\credentials.sql" + } - if ($Exclude -notcontains 'LinkedServers') { - Write-Message -Level Verbose -Message "Exporting linked servers" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting linked servers" - Export-DbaLinkedServer -SqlInstance $server -FilePath "$exportPath\linkedservers.sql" -Credential $Credential -ExcludePassword:$ExcludePassword -EnableException:$EnableException - # no call to Get-ChildItem because Export-DbaLinkedServer does it - } + if ($Exclude -notcontains 'Logins') { + Write-Message -Level Verbose -Message "Exporting logins" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting logins" + Export-DbaLogin -SqlInstance $server -FilePath "$exportPath\logins.sql" -ExcludePassword:$ExcludePassword -NoPrefix:$NoPrefix -WarningAction SilentlyContinue -EnableException:$EnableException + # no call to Get-ChildItem because Export-DbaLogin does it + } - if ($Exclude -notcontains 'SystemTriggers') { - Write-Message -Level Verbose -Message "Exporting System Triggers" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting System Triggers" - $null = Get-DbaInstanceTrigger -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\servertriggers.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $triggers = Get-Content -Path "$exportPath\servertriggers.sql" -Raw -ErrorAction Ignore - if ($triggers) { - $triggers = $triggers.ToString() -replace 'CREATE TRIGGER', "$BatchSeparator$($eol)CREATE TRIGGER" - $triggers = $triggers.ToString() -replace 'ENABLE TRIGGER', "$BatchSeparator$($eol)ENABLE TRIGGER" - $null = $triggers | Set-Content -Path "$exportPath\servertriggers.sql" -Force - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\servertriggers.sql" + if ($Exclude -notcontains 'DatabaseMail') { + Write-Message -Level Verbose -Message "Exporting database mail" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database mail" + # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. + # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. + $null = Get-DbaDbMailConfig -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaDbMailAccount -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaDbMailProfile -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaDbMailServer -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\dbmail.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\dbmail.sql" } - } - if ($Exclude -notcontains 'Databases') { - Write-Message -Level Verbose -Message "Exporting database restores" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database restores" - Get-DbaDbBackupHistory -SqlInstance $server -Last -WarningAction SilentlyContinue -EnableException:$EnableException | Restore-DbaDatabase -SqlInstance $server -NoRecovery:$NoRecovery -WithReplace -OutputScriptOnly -WarningAction SilentlyContinue -AzureCredential $AzureCredential -EnableException:$EnableException | Out-File -FilePath "$exportPath\databases.sql" - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\databases.sql" - } + if ($Exclude -notcontains 'CentralManagementServer') { + Write-Message -Level Verbose -Message "Exporting Central Management Server" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Central Management Server" + $outputFilePath = "$exportPath\regserver.xml" + $null = Export-DbaRegServer -SqlInstance $server -FilePath $outputFilePath -Overwrite:$Force -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path $outputFilePath + } - if ($Exclude -notcontains 'Audits') { - Write-Message -Level Verbose -Message "Exporting Audits" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Audits" - $null = Get-DbaInstanceAudit -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\audits.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\audits.sql" - } + if ($Exclude -notcontains 'BackupDevices') { + Write-Message -Level Verbose -Message "Exporting Backup Devices" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Backup Devices" + $null = Get-DbaBackupDevice -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\backupdevices.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\backupdevices.sql" + } - if ($Exclude -notcontains 'ServerAuditSpecifications') { - Write-Message -Level Verbose -Message "Exporting Server Audit Specifications" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Server Audit Specifications" - $null = Get-DbaInstanceAuditSpecification -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\auditspecs.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\auditspecs.sql" - } + if ($Exclude -notcontains 'LinkedServers') { + Write-Message -Level Verbose -Message "Exporting linked servers" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting linked servers" + Export-DbaLinkedServer -SqlInstance $server -FilePath "$exportPath\linkedservers.sql" -Credential $Credential -ExcludePassword:$ExcludePassword -EnableException:$EnableException + # no call to Get-ChildItem because Export-DbaLinkedServer does it + } - if ($Exclude -notcontains 'Endpoints') { - Write-Message -Level Verbose -Message "Exporting Endpoints" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Endpoints" - $null = Get-DbaEndpoint -SqlInstance $server -EnableException:$EnableException | Where-Object IsSystemObject -EQ $false | Export-DbaScript -FilePath "$exportPath\endpoints.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\endpoints.sql" - } + if ($Exclude -notcontains 'SystemTriggers') { + Write-Message -Level Verbose -Message "Exporting System Triggers" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting System Triggers" + $null = Get-DbaInstanceTrigger -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\servertriggers.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $triggers = Get-Content -Path "$exportPath\servertriggers.sql" -Raw -ErrorAction Ignore + if ($triggers) { + $triggers = $triggers.ToString() -replace 'CREATE TRIGGER', "$BatchSeparator$($eol)CREATE TRIGGER" + $triggers = $triggers.ToString() -replace 'ENABLE TRIGGER', "$BatchSeparator$($eol)ENABLE TRIGGER" + $null = $triggers | Set-Content -Path "$exportPath\servertriggers.sql" -Force + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\servertriggers.sql" + } + } - if ($Exclude -notcontains 'PolicyManagement' -and $PSVersionTable.PSEdition -eq "Core") { - Write-Message -Level Verbose -Message "Skipping Policy Management -- not supported by PowerShell Core" - } - if ($Exclude -notcontains 'PolicyManagement' -and $PSVersionTable.PSEdition -ne "Core") { - Write-Message -Level Verbose -Message "Exporting Policy Management" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Policy Management" + if ($Exclude -notcontains 'Databases') { + Write-Message -Level Verbose -Message "Exporting database restores" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database restores" + Get-DbaDbBackupHistory -SqlInstance $server -Last -WarningAction SilentlyContinue -EnableException:$EnableException | Restore-DbaDatabase -SqlInstance $server -NoRecovery:$NoRecovery -WithReplace -OutputScriptOnly -WarningAction SilentlyContinue -AzureCredential $AzureCredential -EnableException:$EnableException | Out-File -FilePath "$exportPath\databases.sql" + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\databases.sql" + } - $outputFilePath = "$exportPath\policymanagement.sql" - $scriptText = "" - $policyObjects = @() + if ($Exclude -notcontains 'Audits') { + Write-Message -Level Verbose -Message "Exporting Audits" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Audits" + $null = Get-DbaInstanceAudit -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\audits.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\audits.sql" + } - # the policy objects are a different set of classes and are not compatible with the SMO object usage in Export-DbaScript + if ($Exclude -notcontains 'ServerAuditSpecifications') { + Write-Message -Level Verbose -Message "Exporting Server Audit Specifications" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Server Audit Specifications" + $null = Get-DbaInstanceAuditSpecification -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\auditspecs.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\auditspecs.sql" + } - $policyObjects += Get-DbaPbmCondition -SqlInstance $server -EnableException:$EnableException - $policyObjects += Get-DbaPbmObjectSet -SqlInstance $server -EnableException:$EnableException - $policyObjects += Get-DbaPbmPolicy -SqlInstance $server -EnableException:$EnableException + if ($Exclude -notcontains 'Endpoints') { + Write-Message -Level Verbose -Message "Exporting Endpoints" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Endpoints" + $null = Get-DbaEndpoint -SqlInstance $server -EnableException:$EnableException | Where-Object IsSystemObject -EQ $false | Export-DbaScript -FilePath "$exportPath\endpoints.sql" -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\endpoints.sql" + } - foreach ($policyObject in $policyObjects) { - $tsqlScript = $policyObject.ScriptCreate() - $scriptText += $tsqlScript.GetScript() + "$eol$BatchSeparator$eol$eol" + if ($Exclude -notcontains 'PolicyManagement' -and $PSVersionTable.PSEdition -eq "Core") { + Write-Message -Level Verbose -Message "Skipping Policy Management -- not supported by PowerShell Core" } + if ($Exclude -notcontains 'PolicyManagement' -and $PSVersionTable.PSEdition -ne "Core") { + Write-Message -Level Verbose -Message "Exporting Policy Management" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Policy Management" - Set-Content -Path $outputFilePath -Value $scriptText + $outputFilePath = "$exportPath\policymanagement.sql" + $scriptText = "" + $policyObjects = @() - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\policymanagement.sql" - } + # the policy objects are a different set of classes and are not compatible with the SMO object usage in Export-DbaScript - if ($Exclude -notcontains 'ResourceGovernor') { - Write-Message -Level Verbose -Message "Exporting Resource Governor" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Resource Governor" - # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. - # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. - $null = Get-DbaRgClassifierFunction -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaRgResourcePool -SqlInstance $server -EnableException:$EnableException | Where-Object Name -NotIn 'default', 'internal' | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaRgWorkloadGroup -SqlInstance $server -EnableException:$EnableException | Where-Object Name -NotIn 'default', 'internal' | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaResourceGovernor -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\resourcegov.sql" - } + $policyObjects += Get-DbaPbmCondition -SqlInstance $server -EnableException:$EnableException + $policyObjects += Get-DbaPbmObjectSet -SqlInstance $server -EnableException:$EnableException + $policyObjects += Get-DbaPbmPolicy -SqlInstance $server -EnableException:$EnableException - if ($Exclude -notcontains 'ExtendedEvents') { - Write-Message -Level Verbose -Message "Exporting Extended Events" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Extended Events" - $null = Get-DbaXESession -SqlInstance $server -EnableException:$EnableException | Export-DbaXESession -FilePath "$exportPath\extendedevents.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\extendedevents.sql" - } + foreach ($policyObject in $policyObjects) { + $tsqlScript = $policyObject.ScriptCreate() + $scriptText += $tsqlScript.GetScript() + "$eol$BatchSeparator$eol$eol" + } - if ($Exclude -notcontains 'AgentServer') { - Write-Message -Level Verbose -Message "Exporting job server" - - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting job server" - # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. - # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. - $null = Get-DbaAgentJobCategory -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaAgentOperator -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaAgentAlert -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaAgentProxy -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaAgentSchedule -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - $null = Get-DbaAgentJob -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\sqlagent.sql" - } + Set-Content -Path $outputFilePath -Value $scriptText - if ($Exclude -notcontains 'ReplicationSettings') { - Write-Message -Level Verbose -Message "Exporting replication settings" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting replication settings" + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\policymanagement.sql" + } - try { - $null = Export-DbaReplServerSetting -SqlInstance $instance -SqlCredential $SqlCredential -FilePath "$exportPath\replication.sql" -EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\replication.sql" - } catch { - Write-Message -Level Verbose -Message "Replication failed, skipping" + if ($Exclude -notcontains 'ResourceGovernor') { + Write-Message -Level Verbose -Message "Exporting Resource Governor" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Resource Governor" + # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. + # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. + $null = Get-DbaRgClassifierFunction -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaRgResourcePool -SqlInstance $server -EnableException:$EnableException | Where-Object Name -NotIn 'default', 'internal' | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaRgWorkloadGroup -SqlInstance $server -EnableException:$EnableException | Where-Object Name -NotIn 'default', 'internal' | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaResourceGovernor -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\resourcegov.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\resourcegov.sql" } - } - if ($Exclude -notcontains 'SysDbUserObjects') { - Write-Message -Level Verbose -Message "Exporting user objects in system databases (this can take a minute)." - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting user objects in system databases (this can take a minute)." - $outputFile = "$exportPath\userobjectsinsysdbs.sql" - $sysDbUserObjects = Export-DbaSysDbUserObject -SqlInstance $server -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -PassThru -EnableException:$EnableException - Set-Content -Path $outputFile -Value $sysDbUserObjects # this approach is needed because -Append is used in Export-DbaSysDbUserObject.ps1 - Get-ChildItem -ErrorAction Ignore -Path $outputFile - } + if ($Exclude -notcontains 'ExtendedEvents') { + Write-Message -Level Verbose -Message "Exporting Extended Events" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting Extended Events" + $null = Get-DbaXESession -SqlInstance $server -EnableException:$EnableException | Export-DbaXESession -FilePath "$exportPath\extendedevents.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\extendedevents.sql" + } - if ($Exclude -notcontains 'AvailabilityGroups') { - Write-Message -Level Verbose -Message "Exporting availability group" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting availability groups" - $null = Get-DbaAvailabilityGroup -SqlInstance $server -WarningAction SilentlyContinue -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\AvailabilityGroups.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\AvailabilityGroups.sql" - } + if ($Exclude -notcontains 'AgentServer') { + Write-Message -Level Verbose -Message "Exporting job server" + + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting job server" + # The first invocation to Export-DbaScript needs to have -Append:$false so that the previous file contents are discarded. Otherwise, the file would end up with duplicate SQL. + # The subsequent calls to Export-DbaScript need to have -Append:$true because this is a multi-step export and the objects are written to the same file. + $null = Get-DbaAgentJobCategory -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$false -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaAgentOperator -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaAgentAlert -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaAgentProxy -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaAgentSchedule -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + $null = Get-DbaAgentJob -SqlInstance $server -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\sqlagent.sql" -Append:$true -BatchSeparator $BatchSeparator -ScriptingOptionsObject $ScriptingOption -NoPrefix:$NoPrefix -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\sqlagent.sql" + } - if ($Exclude -notcontains 'OleDbProvider') { - Write-Message -Level Verbose -Message "Exporting OLEDB Providers" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting OLEDB Providers" - $null = Get-DbaOleDbProvider -SqlInstance $server -WarningAction SilentlyContinue -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\OleDbProvider.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -EnableException:$EnableException - Get-ChildItem -ErrorAction Ignore -Path "$exportPath\oledbprovider.sql" - } + if ($Exclude -notcontains 'ReplicationSettings') { + Write-Message -Level Verbose -Message "Exporting replication settings" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting replication settings" - if ($IncludeDbMasterKey -and $Exclude -notcontains 'DbCertificates') { - Write-Message -Level Verbose -Message "Exporting database certificates" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database certificates" - $splatDbCert = @{ - SqlInstance = $server - Path = $exportPath - EnableException = $EnableException + try { + $null = Export-DbaReplServerSetting -SqlInstance $instance -SqlCredential $SqlCredential -FilePath "$exportPath\replication.sql" -EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\replication.sql" + } catch { + Write-Message -Level Verbose -Message "Replication failed, skipping" + } } - if ($EncryptionPassword) { - $splatDbCert["EncryptionPassword"] = $EncryptionPassword + + if ($Exclude -notcontains 'SysDbUserObjects') { + Write-Message -Level Verbose -Message "Exporting user objects in system databases (this can take a minute)." + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting user objects in system databases (this can take a minute)." + $outputFile = "$exportPath\userobjectsinsysdbs.sql" + $sysDbUserObjects = Export-DbaSysDbUserObject -SqlInstance $server -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -PassThru -EnableException:$EnableException + Set-Content -Path $outputFile -Value $sysDbUserObjects # this approach is needed because -Append is used in Export-DbaSysDbUserObject.ps1 + Get-ChildItem -ErrorAction Ignore -Path $outputFile } - if ($DecryptionPassword) { - $splatDbCert["DecryptionPassword"] = $DecryptionPassword + + if ($Exclude -notcontains 'AvailabilityGroups') { + Write-Message -Level Verbose -Message "Exporting availability group" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting availability groups" + $null = Get-DbaAvailabilityGroup -SqlInstance $server -WarningAction SilentlyContinue -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\AvailabilityGroups.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\AvailabilityGroups.sql" } - Backup-DbaDbCertificate @splatDbCert - } - if ($IncludeDbMasterKey -and $EncryptionPassword) { - Write-Message -Level Verbose -Message "Exporting database master keys" - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database master keys" - $splatMasterKey = @{ - SqlInstance = $server - Path = $exportPath - SecurePassword = $EncryptionPassword - EnableException = $EnableException + if ($Exclude -notcontains 'OleDbProvider') { + Write-Message -Level Verbose -Message "Exporting OLEDB Providers" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting OLEDB Providers" + $null = Get-DbaOleDbProvider -SqlInstance $server -WarningAction SilentlyContinue -EnableException:$EnableException | Export-DbaScript -FilePath "$exportPath\OleDbProvider.sql" -BatchSeparator $BatchSeparator -NoPrefix:$NoPrefix -ScriptingOptionsObject $ScriptingOption -EnableException:$EnableException + Get-ChildItem -ErrorAction Ignore -Path "$exportPath\oledbprovider.sql" } - Backup-DbaDbMasterKey @splatMasterKey - } elseif ($IncludeDbMasterKey -and -not $EncryptionPassword) { - Write-Message -Level Warning -Message "IncludeDbMasterKey was specified but no EncryptionPassword was provided. Skipping database master key export." - } - } catch { - Stop-Function -Message "Failure" -ErrorRecord $_ -Continue - } - if ($dacOpened) { - $null = $server | Disconnect-DbaInstance -WhatIf:$false - } + if ($IncludeDbMasterKey -and $Exclude -notcontains 'DbCertificates') { + Write-Message -Level Verbose -Message "Exporting database certificates" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database certificates" + $splatDbCert = @{ + SqlInstance = $server + EnableException = $EnableException + } + if (-not $copyDbKeyExports) { + $splatDbCert["Path"] = $exportPath + } + if ($EncryptionPassword) { + $splatDbCert["EncryptionPassword"] = $EncryptionPassword + } + if ($DecryptionPassword) { + $splatDbCert["DecryptionPassword"] = $DecryptionPassword + } + $databaseCertificates = Backup-DbaDbCertificate @splatDbCert + foreach ($databaseCertificate in $databaseCertificates) { + Get-ExportedFileInfo -Server $server -SourcePath $databaseCertificate.Path -DestinationDirectory $exportPath -CopyFromServer:$copyDbKeyExports + Get-ExportedFileInfo -Server $server -SourcePath $databaseCertificate.Key -DestinationDirectory $exportPath -CopyFromServer:$copyDbKeyExports + } + } - Write-Progress -Activity "Performing Instance Export for $instance" -Completed + if ($IncludeDbMasterKey -and $EncryptionPassword) { + Write-Message -Level Verbose -Message "Exporting database master keys" + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Exporting database master keys" + $splatMasterKey = @{ + SqlInstance = $server + SecurePassword = $EncryptionPassword + EnableException = $EnableException + } + if (-not $copyDbKeyExports) { + $splatMasterKey["Path"] = $exportPath + } + $databaseMasterKeys = Backup-DbaDbMasterKey @splatMasterKey + foreach ($databaseMasterKey in $databaseMasterKeys) { + Get-ExportedFileInfo -Server $server -SourcePath $databaseMasterKey.Filename -DestinationDirectory $exportPath -CopyFromServer:$copyDbKeyExports + } + } elseif ($IncludeDbMasterKey -and -not $EncryptionPassword) { + Write-Message -Level Warning -Message "IncludeDbMasterKey was specified but no EncryptionPassword was provided. Skipping database master key export." + } + } catch { + Stop-Function -Message "Failure" -ErrorRecord $_ -Continue + } + } finally { + Write-Progress -Activity "Performing Instance Export for $instance" -Completed + if ($dacOpened -and $null -ne $server) { + $null = $server | Disconnect-DbaInstance -WhatIf:$false + } + } } } end { diff --git a/public/Export-DbaLinkedServer.ps1 b/public/Export-DbaLinkedServer.ps1 index e78a2d350749..c07d83a38bb1 100644 --- a/public/Export-DbaLinkedServer.ps1 +++ b/public/Export-DbaLinkedServer.ps1 @@ -167,7 +167,7 @@ function Export-DbaLinkedServer { $sql += $InputObject.Script() } else { try { - $decrypted = Get-DecryptedObject -SqlInstance $server -Credential $Credential -Type LinkedServer -EnableException + $decrypted = Get-DecryptedObject -SqlInstance $server -Credential $Credential -Type LinkedServer -EnableException:$EnableException } catch { Stop-Function -Continue -Message "Failure" -ErrorRecord $_ } diff --git a/public/Export-DbaLogin.ps1 b/public/Export-DbaLogin.ps1 index 25a2ee289e2f..a285ce4af87a 100644 --- a/public/Export-DbaLogin.ps1 +++ b/public/Export-DbaLogin.ps1 @@ -513,6 +513,43 @@ function Export-DbaLogin { $scriptOptions.IncludeDatabaseContext = $false $scriptOptions.IncludeIfNotExists = $true + $userRoles = foreach ($role in $sourceDb.Roles) { + if ($role.EnumMembers() -contains $dbUserName) { + $role + } + } + + $roleDefinitionScripts = @() + $rolePermissionScripts = @() + if ($IncludeRolePermissions) { + $roleScriptingOptions = New-DbaScriptingOption + $roleScriptingOptions.ContinueScriptingOnError = $false + $roleScriptingOptions.IncludeDatabaseContext = $false + $roleScriptingOptions.IncludeIfNotExists = $true + + foreach ($role in ($userRoles | Where-Object { $_.IsFixedRole -eq $false })) { + $roleDefinitionScript = @($role.Script($roleScriptingOptions)) + $splatExportRole = @{ + SqlInstance = $server + Database = $dbName + Role = $role.Name + ScriptingOptionsObject = $roleScriptingOptions + Passthru = $true + NoPrefix = $true + BatchSeparator = "" + } + try { + $roleScript = @(Export-DbaDbRole @splatExportRole) + if ($roleScript) { + $roleDefinitionScripts += $roleDefinitionScript + $rolePermissionScripts += $roleScript | Select-Object -Skip $roleDefinitionScript.Count + } + } catch { + Write-Message -Level Warning -Message "Failed to export permissions for role $($role.Name) in database $dbName : $($_.Exception.Message)" + } + } + } + if ($ObjectLevel) { # Exporting all permissions $scriptOptions.AllowSystemObjects = $true @@ -529,35 +566,15 @@ function Export-DbaLogin { $scriptOptions.NoCommandTerminator = $true $exportSplat.ExcludeGoBatchSeparator = $true } + if ($rolePermissionScripts) { + $outsql += $rolePermissionScripts + } try { $userScript = Export-DbaUser @exportSplat -Passthru -EnableException $outsql += $userScript } catch { Stop-Function -Message "Failed to extract permissions for user $dbUserName in database $dbName" -Continue -ErrorRecord $_ } - - if ($IncludeRolePermissions) { - foreach ($role in $sourceDb.Roles) { - if ($role.IsFixedRole -eq $false -and $role.EnumMembers() -contains $dbUserName) { - $splatExportRole = @{ - SqlInstance = $server - Database = $dbName - Role = $role.Name - Passthru = $true - NoPrefix = $true - BatchSeparator = "" - } - try { - $roleScript = Export-DbaDbRole @splatExportRole - if ($roleScript) { - $outsql += $roleScript - } - } catch { - Write-Message -Level Warning -Message "Failed to export permissions for role $($role.Name) in database $dbName : $($_.Exception.Message)" - } - } - } - } } else { try { $sql = $server.Databases[$dbName].Users[$dbUserName].Script($scriptOptions) @@ -566,40 +583,22 @@ function Export-DbaLogin { Write-Message -Level Warning -Message "User cannot be found in selected database" } + if ($roleDefinitionScripts) { + $outsql += $roleDefinitionScripts + } + if ($rolePermissionScripts) { + $outsql += $rolePermissionScripts + } + # Skipping updating dbowner # Database Roles: db_owner, db_datareader, etc - foreach ($role in $sourceDb.Roles) { - if ($role.EnumMembers() -contains $dbUserName) { - $roleName = $role.Name - if (($server.VersionMajor -lt 11 -and [string]::IsNullOrEmpty($destinationVersion)) -or ($DestinationVersion -in "SQLServer2000", "SQLServer2005", "SQLServer2008/2008R2")) { - $outsql += "EXEC sp_addrolemember @rolename=N'$roleName', @membername=N'$dbUserName'" - } else { - $outsql += "ALTER ROLE [$roleName] ADD MEMBER [$dbUserName]" - } - } - } - - if ($IncludeRolePermissions) { - foreach ($role in $sourceDb.Roles) { - if ($role.IsFixedRole -eq $false -and $role.EnumMembers() -contains $dbUserName) { - $splatExportRole = @{ - SqlInstance = $server - Database = $dbName - Role = $role.Name - Passthru = $true - NoPrefix = $true - BatchSeparator = "" - } - try { - $roleScript = Export-DbaDbRole @splatExportRole - if ($roleScript) { - $outsql += $roleScript - } - } catch { - Write-Message -Level Warning -Message "Failed to export permissions for role $($role.Name) in database $dbName : $($_.Exception.Message)" - } - } + foreach ($role in $userRoles) { + $roleName = $role.Name + if (($server.VersionMajor -lt 11 -and [string]::IsNullOrEmpty($destinationVersion)) -or ($DestinationVersion -in "SQLServer2000", "SQLServer2005", "SQLServer2008/2008R2")) { + $outsql += "EXEC sp_addrolemember @rolename=N'$roleName', @membername=N'$dbUserName'" + } else { + $outsql += "ALTER ROLE [$roleName] ADD MEMBER [$dbUserName]" } } diff --git a/public/Export-DbaScript.ps1 b/public/Export-DbaScript.ps1 index ccf2e08465f1..5d6dae9f2f79 100644 --- a/public/Export-DbaScript.ps1 +++ b/public/Export-DbaScript.ps1 @@ -231,12 +231,15 @@ function Export-DbaScript { if ($shorttype -eq "AvailabilityGroup") { if ((Get-Member -InputObject $object -Name IsDistributedAvailabilityGroup -ErrorAction SilentlyContinue) -and $object.IsDistributedAvailabilityGroup) { Write-Message -Level Verbose -Message "Detected Distributed Availability Group '$($object.Name)'. Generating T-SQL script manually as SMO scripting does not support Distributed AGs." + $escapedDagName = ([string]$object.Name).Replace("]", "]]") $dagReplicaScripts = foreach ($replica in $object.AvailabilityReplicas) { $availMode = if ($replica.AvailabilityMode -eq "SynchronousCommit") { "SYNCHRONOUS_COMMIT" } else { "ASYNCHRONOUS_COMMIT" } $seedMode = if ($replica.SeedingMode -eq "Automatic") { "AUTOMATIC" } else { "MANUAL" } - " N'$($replica.Name)' WITH$eol ($eol LISTENER_URL = N'$($replica.EndpointUrl)',$eol AVAILABILITY_MODE = $availMode,$eol FAILOVER_MODE = MANUAL,$eol SEEDING_MODE = $seedMode$eol )" + $escapedReplicaName = ([string]$replica.Name).Replace("'", "''") + $escapedEndpointUrl = ([string]$replica.EndpointUrl).Replace("'", "''") + " N'$escapedReplicaName' WITH$eol ($eol LISTENER_URL = N'$escapedEndpointUrl',$eol AVAILABILITY_MODE = $availMode,$eol FAILOVER_MODE = MANUAL,$eol SEEDING_MODE = $seedMode$eol )" } - $dagScript = "CREATE AVAILABILITY GROUP [$($object.Name)]$eol WITH (DISTRIBUTED)$eol AVAILABILITY GROUP ON$eol$($dagReplicaScripts -join ",$eol");" + $dagScript = "CREATE AVAILABILITY GROUP [$escapedDagName]$eol WITH (DISTRIBUTED)$eol AVAILABILITY GROUP ON$eol$($dagReplicaScripts -join ",$eol");" } else { Write-Message -Level Verbose -Message "Invoking .Script() as a workaround for https://github.com/dataplat/dbatools/issues/5913." try { diff --git a/public/Export-DbaUser.ps1 b/public/Export-DbaUser.ps1 index 29ecea18bb9f..031897cc5047 100644 --- a/public/Export-DbaUser.ps1 +++ b/public/Export-DbaUser.ps1 @@ -563,7 +563,17 @@ function Export-DbaUser { } #Schema Ownership - foreach ($schema in $db.Schemas | Where-Object { $_.Owner -eq $dbuser.Name -and @("sa", "dbo", "information_schema", "sys", "guest") -notcontains $_.Name }) { + $ownedSchemas = @() + if ($db.Parent.VersionMajor -gt 8) { + $ownedSchemas = $db.Schemas | Where-Object { $_.Owner -eq $dbuser.Name -and @("sa", "dbo", "information_schema", "sys", "guest") -notcontains $_.Name } + } + + if ($scriptVersion -eq "Version80" -and @($ownedSchemas).Count -gt 0) { + Stop-Function -Message "This user may be using functionality from $($versionName[$db.CompatibilityLevel.ToString()]) that does not exist on the destination version ($versionNameDesc)." -Continue -Target $db + $ownedSchemas = @() + } + + foreach ($schema in $ownedSchemas) { if ($Template) { $ownerName = "{templateUser}" } else { diff --git a/public/Find-DbaInstance.ps1 b/public/Find-DbaInstance.ps1 index 01056c0f3e18..13479860a1ee 100644 --- a/public/Find-DbaInstance.ps1 +++ b/public/Find-DbaInstance.ps1 @@ -300,6 +300,7 @@ function Find-DbaInstance { $pingReply = $null $sPNs = @() $ports = @() + $browserFallbackPorts = @() $browseResult = $null $services = @() #Variable marked as unused by PSScriptAnalyzer @@ -351,8 +352,19 @@ function Find-DbaInstance { Write-ProgressHelper -Activity "Processing: $($computer)" -StepNumber ($stepCounter++) -Message "Probing Browser service" $browseResult = Get-SQLInstanceBrowserUDP -ComputerName $computer -EnableException Write-Message -Level Verbose -Message "Browser returned $($browseResult.Count) instance(s): $(($browseResult | ForEach-Object { "$($_.InstanceName):$($_.TCPPort)" }) -join ', ')" - # Filter port 0 - Browser returns 0 for instances that don't report a TCP port (default instances) - $ports = $browseResult.TCPPort | Where-Object { $_ -gt 0 } | Test-TcpPort -ComputerName $computer + $portsToScan = @() + $browserReportedPorts = $browseResult.TCPPort | Where-Object { $_ -gt 0 } + if ($browserReportedPorts) { + $portsToScan += $browserReportedPorts + } + if ($browseResult | Where-Object { -not $_.TCPPort }) { + $browserFallbackPorts = $TCPPort | Select-Object -Unique + Write-Message -Level Verbose -Message "Browser has instance(s) without TCPPort, adding fallback ports: $($browserFallbackPorts -join ', ')" + $portsToScan += $browserFallbackPorts + } + if ($portsToScan) { + $ports = $portsToScan | Select-Object -Unique | Test-TcpPort -ComputerName $computer + } Write-Message -Level Verbose -Message "Port test results from Browser: $(($ports | ForEach-Object { "Port $($_.Port)=$($_.IsOpen)" }) -join ', ')" } catch { Write-Message -Level Verbose -Message "Browser scan failed: $_" @@ -363,8 +375,9 @@ function Find-DbaInstance { # (e.g. SQL Server 2022+ where Browser is deprecated, or default instances # which don't report a TCP port via Browser UDP) if (-not $ports) { - Write-Message -Level Verbose -Message "No port info from Browser, falling back to default ports: $($TCPPort -join ', ')" - $ports = $TCPPort | Test-TcpPort -ComputerName $computer + $browserFallbackPorts = $TCPPort | Select-Object -Unique + Write-Message -Level Verbose -Message "No port info from Browser, falling back to default ports: $($browserFallbackPorts -join ', ')" + $ports = $browserFallbackPorts | Test-TcpPort -ComputerName $computer Write-Message -Level Verbose -Message "Fallback port test results: $(($ports | ForEach-Object { "Port $($_.Port)=$($_.IsOpen)" }) -join ', ')" } } else { @@ -471,8 +484,9 @@ function Find-DbaInstance { } else { # Default instance - Browser doesn't report a specific TCP port, # check if any of the fallback ports we tested is open - Write-Message -Level Verbose -Message "Browser has no TCPPort (default instance), checking PortsScanned for any open port: $(($object.PortsScanned | ForEach-Object { "Port $($_.Port)=$($_.IsOpen)" }) -join ', ')" - $object.PortsScanned | Where-Object IsOpen | Select-Object -First 1 | ForEach-Object { + $defaultPortResults = $object.PortsScanned | Where-Object { $_.Port -in $browserFallbackPorts } + Write-Message -Level Verbose -Message "Browser has no TCPPort (default instance), checking fallback PortsScanned for any open port: $(($defaultPortResults | ForEach-Object { "Port $($_.Port)=$($_.IsOpen)" }) -join ', ')" + $defaultPortResults | Where-Object IsOpen | Select-Object -First 1 | ForEach-Object { $object.Port = $_.Port $object.TcpConnected = $true Write-Message -Level Verbose -Message "Found open port $($_.Port), TcpConnected set to True" diff --git a/public/Find-DbaObject.ps1 b/public/Find-DbaObject.ps1 index 0f1373bb6114..51faf087cf0e 100644 --- a/public/Find-DbaObject.ps1 +++ b/public/Find-DbaObject.ps1 @@ -44,7 +44,7 @@ function Find-DbaObject { - ScalarFunction: Scalar-valued functions (sys.objects type FN) - TableValuedFunction: Inline and multi-statement table-valued functions (sys.objects type IF/TF) - Synonym: Synonyms (sys.objects type SN) - - Trigger: SQL triggers (sys.objects type TR) + - Trigger: Object-level DML triggers plus database DDL SQL triggers - All: All of the above (default) .PARAMETER IncludeColumns @@ -86,7 +86,7 @@ function Find-DbaObject { - ComputerName: The computer name of the SQL Server instance - SqlInstance: The SQL Server instance name - Database: The database containing the matched object - - Schema: The schema of the matched object + - Schema: The schema of the matched object (null for database DDL triggers) - Name: The name of the matched object - ObjectType: The SQL Server type description (e.g., USER_TABLE, VIEW, SQL_STORED_PROCEDURE) - MatchType: "ObjectName" when the object name matched, "ColumnName" when a column name matched @@ -159,7 +159,9 @@ function Find-DbaObject { $typeFilter = ($typeCodes | Select-Object -Unique) -join ", " } + $includeDatabaseTriggers = "All" -in $ObjectType -or "Trigger" -in $ObjectType $sysFilter = if ($IncludeSystemObjects) { "" } else { "AND o.is_ms_shipped = 0" } + $triggerFilter = if ($IncludeSystemObjects) { "" } else { "AND tr.is_ms_shipped = 0" } $sqlObjects = " SELECT @@ -173,6 +175,22 @@ function Find-DbaObject { WHERE o.type IN ($typeFilter) $sysFilter" + if ($includeDatabaseTriggers) { + $sqlObjects += " + UNION ALL + SELECT + CAST(NULL AS sysname) AS SchemaName, + tr.name AS ObjectName, + RTRIM(tr.type) AS ObjectTypeCode, + tr.type_desc AS ObjectType, + tr.create_date AS CreateDate, + tr.modify_date AS LastModified + FROM sys.triggers tr + WHERE tr.parent_class = 0 + AND tr.type = 'TR' + $triggerFilter" + } + $sqlColumns = " SELECT OBJECT_SCHEMA_NAME(c.object_id) AS SchemaName, diff --git a/public/Format-DbaBackupInformation.ps1 b/public/Format-DbaBackupInformation.ps1 index ed8cbb53854d..683ed9d4bae5 100644 --- a/public/Format-DbaBackupInformation.ps1 +++ b/public/Format-DbaBackupInformation.ps1 @@ -220,7 +220,7 @@ function Format-DbaBackupInformation { $History.Database = $DatabaseNamePrefix + $History.Database # Capture rename values before entering the ForEach-Object pipeline to ensure correct - # scoping and to use [regex]::Escape so database names with special chars are treated literally + # scoping and to treat both database names literally during file renames $originalDb = $History.OriginalDatabase $newDb = $History.Database @@ -245,7 +245,12 @@ function Format-DbaBackupInformation { $baseName = $baseName.Split($PathSep)[-1] if ($ReplaceDbNameInFile -eq $true) { - $baseName = $baseName -Replace ([regex]::Escape($originalDb)), $newDb + $baseName = [regex]::Replace( + $baseName, + [regex]::Escape($originalDb), + [System.Text.RegularExpressions.MatchEvaluator] { param($match) $newDb }, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + ) } # Determine restore directory based on file type diff --git a/public/Get-DbaAgRingBuffer.ps1 b/public/Get-DbaAgRingBuffer.ps1 index 01d9bcdbe06b..8de60dd9cc71 100644 --- a/public/Get-DbaAgRingBuffer.ps1 +++ b/public/Get-DbaAgRingBuffer.ps1 @@ -114,46 +114,50 @@ function Get-DbaAgRingBuffer { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } - $currentTimestamp = ($server.Query("SELECT cpu_ticks / CONVERT(FLOAT, (cpu_ticks / ms_ticks)) AS TimeStamp FROM sys.dm_os_sys_info"))[0] - Write-Message -Level Verbose -Message "Using current timestamp of $currentTimestamp" + try { + [long]$currentTimestamp = ($server.Query("SELECT cpu_ticks / CONVERT(FLOAT, (cpu_ticks / ms_ticks)) AS TimeStamp FROM sys.dm_os_sys_info")).TimeStamp + Write-Message -Level Verbose -Message "Using current timestamp of $currentTimestamp" - if ($RingBufferType) { - $typeList = ($RingBufferType | ForEach-Object { "N'$_'" }) -join ", " - } else { - $typeList = "N'RING_BUFFER_HADRDBMGR_API', N'RING_BUFFER_HADRDBMGR_STATE', N'RING_BUFFER_HADRDBMGR_COMMIT', N'RING_BUFFER_HADR_TRANSPORT_STATE'" - } + if ($RingBufferType) { + $typeList = ($RingBufferType | ForEach-Object { "N'$_'" }) -join ", " + } else { + $typeList = "N'RING_BUFFER_HADRDBMGR_API', N'RING_BUFFER_HADRDBMGR_STATE', N'RING_BUFFER_HADRDBMGR_COMMIT', N'RING_BUFFER_HADR_TRANSPORT_STATE'" + } - $sql = "WITH HadrRingBuffer AS - ( + $sql = "WITH HadrRingBuffer AS + ( + SELECT + ring_buffer_type, + timestamp, + CONVERT(XML, record) AS record + FROM sys.dm_os_ring_buffers + WHERE ring_buffer_type IN ($typeList) + ) SELECT + SERVERPROPERTY('ServerName') AS ServerName, ring_buffer_type, - timestamp, - CONVERT(XML, record) AS record - FROM sys.dm_os_ring_buffers - WHERE ring_buffer_type IN ($typeList) - ) - SELECT - SERVERPROPERTY('ServerName') AS ServerName, - ring_buffer_type, - record.value('(./Record/@id)[1]', 'int') AS record_id, - DATEADD(ms, -1 * ($currentTimestamp - [timestamp]), GETDATE()) AS EventTime, - record - FROM HadrRingBuffer - WHERE DATEADD(ms, -1 * ($currentTimestamp - [timestamp]), GETDATE()) > DATEADD(MINUTE, -$CollectionMinutes, GETDATE()) - ORDER BY EventTime DESC;" - - Write-Message -Level Verbose -Message "Executing SQL Statement: $sql" - foreach ($row in $server.Query($sql)) { - [PSCustomObject]@{ - ComputerName = $server.ComputerName - InstanceName = $server.ServiceName - SqlInstance = $server.DomainInstanceName - RingBufferType = $row.ring_buffer_type - RecordId = $row.record_id - EventTime = $row.EventTime - Record = $row.record + record.value('(./Record/@id)[1]', 'int') AS record_id, + DATEADD(ms, -1 * ($currentTimestamp - [timestamp]), GETDATE()) AS EventTime, + record + FROM HadrRingBuffer + WHERE DATEADD(ms, -1 * ($currentTimestamp - [timestamp]), GETDATE()) > DATEADD(MINUTE, -$CollectionMinutes, GETDATE()) + ORDER BY EventTime DESC;" + + Write-Message -Level Verbose -Message "Executing SQL Statement: $sql" + foreach ($row in $server.Query($sql)) { + [PSCustomObject]@{ + ComputerName = $server.ComputerName + InstanceName = $server.ServiceName + SqlInstance = $server.DomainInstanceName + RingBufferType = $row.ring_buffer_type + RecordId = $row.record_id + EventTime = $row.EventTime + Record = $row.record + } } + } catch { + Stop-Function -Message "Failed to query HADR ring buffer data." -Category InvalidOperation -ErrorRecord $_ -Target $instance -Continue } } } -} +} \ No newline at end of file diff --git a/public/Get-DbaDbIdentity.ps1 b/public/Get-DbaDbIdentity.ps1 index 6cdb751ba315..f40a81396299 100644 --- a/public/Get-DbaDbIdentity.ps1 +++ b/public/Get-DbaDbIdentity.ps1 @@ -130,10 +130,16 @@ function Get-DbaDbIdentity { try { $query = $StringBuilder.ToString() $nameParts = Get-ObjectNameParts -ObjectName $tbl - if ($nameParts.Schema) { - $tblIdentifier = "[$($nameParts.Schema)].[$($nameParts.Name)]" + if ($nameParts.Name) { + $escapedTableName = $nameParts.Name.Replace("]", "]]") + if ($nameParts.Schema) { + $escapedTableSchema = $nameParts.Schema.Replace("]", "]]") + $tblIdentifier = "[$escapedTableSchema].[$escapedTableName]" + } else { + $tblIdentifier = "[$escapedTableName]" + } } else { - $tblIdentifier = "[$($nameParts.Name)]" + $tblIdentifier = $tbl } $query = $query.Replace('#options#', "'$($tblIdentifier)'") diff --git a/public/Get-DbaDbOrphanUser.ps1 b/public/Get-DbaDbOrphanUser.ps1 index 4e2c4ffe583c..704f416ae7d2 100644 --- a/public/Get-DbaDbOrphanUser.ps1 +++ b/public/Get-DbaDbOrphanUser.ps1 @@ -97,13 +97,13 @@ function Get-DbaDbOrphanUser { } catch { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } - $DatabaseCollection = $server.Databases | Where-Object IsAccessible + $DatabaseCollection = @($server.Databases | Where-Object IsAccessible) if ($Database) { - $DatabaseCollection = $DatabaseCollection | Where-Object Name -In $Database + $DatabaseCollection = @($DatabaseCollection | Where-Object Name -In $Database) } if ($ExcludeDatabase) { - $DatabaseCollection = $DatabaseCollection | Where-Object Name -NotIn $ExcludeDatabase + $DatabaseCollection = @($DatabaseCollection | Where-Object Name -NotIn $ExcludeDatabase) } if ($DatabaseCollection.Count -gt 0) { @@ -111,14 +111,18 @@ function Get-DbaDbOrphanUser { try { Write-Message -Level Verbose -Message "Validating users on database '$db'." $UsersToWork = @() - # In contained databases (Partial or Full), SQL users authenticate directly to the database - # without requiring a server-level login, so they are not considered orphaned - if ($db.ContainmentType.ToString() -eq "None") { - $UsersToWork += $db.Users | Where-Object { ($_.Login -eq "") -and ($_.ID -gt 4) -and ($_.Sid.Length -eq 16) -and ($_.LoginType -in 'SqlLogin', 'Certificate') } + # ContainmentType is SQL Server 2012+ only, so keep the legacy path for older versions. + $isContainedDatabase = ( + $server.versionMajor -gt 10 -and + $null -ne $db.ContainmentType -and + $db.ContainmentType -ne [Microsoft.SqlServer.Management.Smo.ContainmentType]::None + ) + if (-not $isContainedDatabase) { + $UsersToWork += $db.Users | Where-Object { ($_.Login -eq "") -and ($_.ID -gt 4) -and ($_.Sid.Length -eq 16) -and ($_.LoginType -in "SqlLogin", "Certificate") } } else { Write-Message -Level Verbose -Message "Skipping SQL login orphan check on contained database '$db' (ContainmentType: $($db.ContainmentType))." } - $UsersToWork += $db.Users | Where-Object { ($_.Login -notin $server.Logins.Name) -and ($_.ID -gt 4) -and ($_.Sid.Length -gt 16 -and $_.LoginType -in 'WindowsUser', 'WindowsGroup') } + $UsersToWork += $db.Users | Where-Object { ($_.Login -notin $server.Logins.Name) -and ($_.ID -gt 4) -and ($_.Sid.Length -gt 16 -and $_.LoginType -in "WindowsUser", "WindowsGroup") } if ($UsersToWork.Count -gt 0) { Write-Message -Level Verbose -Message "Orphan users found" foreach ($user in $UsersToWork) { diff --git a/public/Get-DbaDbPageInfo.ps1 b/public/Get-DbaDbPageInfo.ps1 index 93ccf7071113..c9c9251c9608 100644 --- a/public/Get-DbaDbPageInfo.ps1 +++ b/public/Get-DbaDbPageInfo.ps1 @@ -116,15 +116,34 @@ function Get-DbaDbPageInfo { INNER JOIN sys.schemas AS ss ON ss.schema_id = st.schema_id" if ($Schema) { - $sql = "$sql WHERE ss.name IN ('$($Schema -join "','")')" + $schemaNames = $Schema | ForEach-Object { $_.Replace("'", "''") } + $sql = "$sql WHERE ss.name IN (N'$($schemaNames -join "','")')" } if ($Table) { - $tableNames = $Table | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - if ($schema) { - $sql = "$sql AND st.name IN ('$($tableNames -join "','")')" + $tableParts = $Table | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $tableWhereClauses = foreach ($tablePart in $tableParts) { + $tableName = ([string]$tablePart.Name).Replace("'", "''") + $clauseParts = @("st.name = N'$tableName'") + + if ($tablePart.Schema) { + $schemaName = ([string]$tablePart.Schema).Replace("'", "''") + $clauseParts += "ss.name = N'$schemaName'" + } + + if ($tablePart.Database) { + $databaseName = ([string]$tablePart.Database).Replace("'", "''") + $clauseParts += "DB_NAME() = N'$databaseName'" + } + + "($($clauseParts -join " AND "))" + } + + $tableWhereClause = $tableWhereClauses -join " OR " + if ($Schema) { + $sql = "$sql AND ($tableWhereClause)" } else { - $sql = "$sql WHERE st.name IN ('$($tableNames -join "','")')" + $sql = "$sql WHERE $tableWhereClause" } } } diff --git a/public/Get-DbaDbRestoreHistory.ps1 b/public/Get-DbaDbRestoreHistory.ps1 index a738c24b1d81..3f71d28ad724 100644 --- a/public/Get-DbaDbRestoreHistory.ps1 +++ b/public/Get-DbaDbRestoreHistory.ps1 @@ -86,7 +86,7 @@ function Get-DbaDbRestoreHistory { - BackupStartDate: Timestamp when the backup operation started - BackupFinishDate: Timestamp when the backup operation completed - StopAt: The point-in-time stop value specified during the restore operation (NULL if not specified) - - LastRestorePoint: The effective point in time the database was restored to (StopAt if specified and earlier than BackupStartDate, otherwise BackupStartDate) + - LastRestorePoint: The effective point in time the database was restored to (StopAt if specified, otherwise BackupStartDate) All properties from the underlying DataRow object are accessible using Select-Object *. @@ -188,10 +188,7 @@ function Get-DbaDbRestoreHistory { bs.backup_finish_date, bs.backup_finish_date AS BackupFinishDate, rsh.stop_at AS StopAt, - CASE - WHEN COALESCE(rsh.stop_at, '9999-12-31') < bs.backup_start_date THEN rsh.stop_at - ELSE bs.backup_start_date - END AS LastRestorePoint + COALESCE(rsh.stop_at, bs.backup_start_date) AS LastRestorePoint " } diff --git a/public/Get-DbaDbTable.ps1 b/public/Get-DbaDbTable.ps1 index 787ef569e002..df9ef9f45f84 100644 --- a/public/Get-DbaDbTable.ps1 +++ b/public/Get-DbaDbTable.ps1 @@ -69,8 +69,8 @@ function Get-DbaDbTable { - Database: The database name containing the table - Schema: The schema name that contains the table - Name: The table name - - IndexSpaceUsed: Total space used by indexes for the table (in KB) - - DataSpaceUsed: Space used by data for the table (in KB) + - IndexSpaceUsed: Total space used by indexes for the table (in KB, not available in Azure SQL Database) + - DataSpaceUsed: Space used by data for the table (in KB, not available in Azure SQL Database) - RowCount: Number of rows in the table - HasClusteredIndex: Boolean indicating if table has a clustered index @@ -181,12 +181,12 @@ function Get-DbaDbTable { # Downside: If some other properties were already read outside of this command in the used SMO, they are cleared. # Build property list based on SQL Server version # Note: FullTextIndex is a complex object (not a scalar property) and cannot be initialized via ClearAndInitialize - $properties = [System.Collections.ArrayList]@('Schema', 'Name', 'RowCount', 'HasClusteredIndex') + $properties = [System.Collections.ArrayList]@("Schema", "Name", "RowCount", "HasClusteredIndex") # Azure SQL does not support IndexSpaceUsed and DataSpaceUsed via the SMO enumerator if ($server.DatabaseEngineType -ne "SqlAzureDatabase") { - $null = $properties.Add('IndexSpaceUsed') - $null = $properties.Add('DataSpaceUsed') + $null = $properties.Add("IndexSpaceUsed") + $null = $properties.Add("DataSpaceUsed") } # IsPartitioned available in SQL Server 2005+ (VersionMajor 9+) @@ -219,7 +219,7 @@ function Get-DbaDbTable { # and the ClearAndInitialize optimization is enabled via config. # This avoids loading ALL tables when only specific ones are requested $urnFilter = '' - if (($fqTns -or $Schema) -and (Get-DbatoolsConfigValue -FullName 'commands.get-dbadbtable.clearandinitialize')) { + if (($fqTns -or $Schema) -and (Get-DbatoolsConfigValue -FullName "commands.get-dbadbtable.clearandinitialize")) { $filterConditions = [System.Collections.ArrayList]@() # Add schema filter conditions from -Schema parameter @@ -284,10 +284,12 @@ function Get-DbaDbTable { } } - try { - $db.Tables.ClearAndInitialize($urnFilter, [string[]]$properties) - } catch { - Write-Message -Level Verbose -Message "ClearAndInitialize failed: $_" + if (Get-DbatoolsConfigValue -FullName "commands.get-dbadbtable.clearandinitialize") { + try { + $db.Tables.ClearAndInitialize($urnFilter, [string[]]$properties) + } catch { + Write-Message -Level Verbose -Message "ClearAndInitialize failed: $_" + } } if ($fqTns) { @@ -323,7 +325,15 @@ function Get-DbaDbTable { $sqlTable | Add-Member -Force -MemberType NoteProperty -Name Database -Value $db.Name # Build default properties list based on SQL Server version - $defaultProps = [System.Collections.ArrayList]@("ComputerName", "InstanceName", "SqlInstance", "Database", "Schema", "Name", "IndexSpaceUsed", "DataSpaceUsed", "RowCount", "HasClusteredIndex") + $defaultProps = [System.Collections.ArrayList]@("ComputerName", "InstanceName", "SqlInstance", "Database", "Schema", "Name") + + if ($server.DatabaseEngineType -ne "SqlAzureDatabase") { + $null = $defaultProps.Add("IndexSpaceUsed") + $null = $defaultProps.Add("DataSpaceUsed") + } + + $null = $defaultProps.Add("RowCount") + $null = $defaultProps.Add("HasClusteredIndex") # Add version-specific properties in version order if ($server.VersionMajor -ge 9) { diff --git a/public/Get-DbaLastBackup.ps1 b/public/Get-DbaLastBackup.ps1 index 0f12c6e55880..b4eaac6f7ccb 100644 --- a/public/Get-DbaLastBackup.ps1 +++ b/public/Get-DbaLastBackup.ps1 @@ -155,6 +155,11 @@ function Get-DbaLastBackup { } } + if (-not $dbs) { + Write-Message -Level Verbose -Message "No databases remain to process for $instance after filtering" + continue + } + # Get-DbaDbBackupHistory -Last would make the job in one query but SMO's (and this) report the last backup of this type regardless of the chain $FullHistory = Get-DbaDbBackupHistory -SqlInstance $server -Database $dbs.Name -LastFull -IncludeCopyOnly -Raw $DiffHistory = Get-DbaDbBackupHistory -SqlInstance $server -Database $dbs.Name -LastDiff -IncludeCopyOnly -Raw diff --git a/public/Get-DbaRegServer.ps1 b/public/Get-DbaRegServer.ps1 index 4f76a0477859..56b3ea11770d 100644 --- a/public/Get-DbaRegServer.ps1 +++ b/public/Get-DbaRegServer.ps1 @@ -32,7 +32,7 @@ function Get-DbaRegServer { Specifies a pattern for filtering registered servers using regular expressions. Use this when you need to match servers by pattern, such as "^prod" or ".*-db$". This parameter supports standard .NET regular expression syntax and matches against both Name and ServerName properties. - + .PARAMETER ExcludeServerName Excludes registered servers with specific server instance names (the actual SQL Server connection strings). Use this when you want to retrieve most servers but skip certain instances like those under maintenance or decommissioned. @@ -199,14 +199,18 @@ function Get-DbaRegServer { } $servers = @() + $serverstores = @() $serverToServerStore = @{ } foreach ($instance in $SqlInstance) { + $serverstore = $null try { $serverstore = Get-DbaRegServerStore -SqlInstance $instance -SqlCredential $SqlCredential -EnableException } catch { Stop-Function -Message "Cannot access Central Management Server '$instance'." -ErrorRecord $_ -Continue + continue } + $serverstores += $serverstore if ($Group) { $groupservers = Get-DbaRegServerGroup -SqlInstance $instance -SqlCredential $SqlCredential -Group $Group -ExcludeGroup $ExcludeGroup @@ -289,7 +293,7 @@ function Get-DbaRegServer { Write-Message -Level Verbose -Message "Filtering by pattern for $Pattern" $servers = $servers | Where-Object { & $matchesPattern $_.Name $_.ServerName $Pattern } } - + if ($ExcludeServerName) { Write-Message -Level Verbose -Message "Excluding servers: $ExcludeServerName" $servers = $servers | Where-Object ServerName -notin $ExcludeServerName @@ -379,22 +383,25 @@ function Get-DbaRegServer { Select-DefaultView -InputObject $server -Property $defaults } - if ($IncludeSelf -and $SqlInstance -and $serverstore) { - Write-Message -Level Verbose -Message "Adding CMS instance" - $self = [PSCustomObject]@{ - Name = "CMS Instance" - ServerName = $serverstore.SqlInstance - Group = $null - Description = $null - Source = "Central Management Servers" - ComputerName = $serverstore.ComputerName - InstanceName = $serverstore.InstanceName - SqlInstance = $serverstore.SqlInstance - FQDN = $null - IPAddress = $null + if ($IncludeSelf -and $serverstores) { + foreach ($currentServerStore in $serverstores) { + Write-Message -Level Verbose -Message "Adding CMS instance" + $self = [PSCustomObject]@{ + Name = "CMS Instance" + ServerName = $currentServerStore.SqlInstance + Group = $null + Description = $null + Source = "Central Management Servers" + ComputerName = $currentServerStore.ComputerName + InstanceName = $currentServerStore.InstanceName + SqlInstance = $currentServerStore.SqlInstance + ParentServer = $currentServerStore.ParentServer + FQDN = $null + IPAddress = $null + } + $self | Add-Member -MemberType ScriptMethod -Name ToString -Value { $this.ServerName } -Force + Select-DefaultView -InputObject $self -Property $defaults } - $self | Add-Member -MemberType ScriptMethod -Name ToString -Value { $this.ServerName } -Force - Select-DefaultView -InputObject $self -Property $defaults } } } \ No newline at end of file diff --git a/public/Get-DbaReplSubscription.ps1 b/public/Get-DbaReplSubscription.ps1 index 7d72c387d25b..f262def4474f 100644 --- a/public/Get-DbaReplSubscription.ps1 +++ b/public/Get-DbaReplSubscription.ps1 @@ -152,7 +152,7 @@ function Get-DbaReplSubscription { } # Track subscriptions already emitted to avoid duplicates from the distribution DB check - $foundSubscriptionKeys = @{} + $foundSubscriptionKeys = @{ } try { foreach ($subs in $publications.Subscriptions) { @@ -209,7 +209,8 @@ function Get-DbaReplSubscription { a.subscriber_name AS SubscriberName, a.subscriber_db AS SubscriptionDBName, p.publisher_db AS DatabaseName, - p.publication AS PublicationName + p.publication AS PublicationName, + p.publication_id AS PublicationId FROM MSdistribution_agents a INNER JOIN MSsubscriptions s ON s.agent_id = a.id AND s.subscription_type = 1 INNER JOIN MSpublications p ON p.publication_id = s.publication_id @@ -222,13 +223,21 @@ function Get-DbaReplSubscription { } $distPullSubs = Invoke-DbaQuery @splatDistQuery - # Build a lookup of the publications we queried so we only include relevant subscriptions - $publicationKeys = @{} + # Prefer publication IDs when available so publications with the same name on other publishers + # sharing the same distributor are not returned for this publisher. + $publicationIds = @{ } + $publicationKeys = @{ } foreach ($pub in $publications) { + if ($null -ne $pub.PubId) { + $publicationIds["$($pub.PubId)"] = $true + } + $pubKey = "$($pub.DatabaseName)|$($pub.Name)" $publicationKeys[$pubKey] = $true } + $usePublicationIdLookup = $publicationIds.Count -eq @($publications).Count -and $publicationIds.Count -gt 0 + # Convert SubscriberName filter to strings for comparison $subscriberNameStrings = @() if ($SubscriberName) { @@ -237,8 +246,12 @@ function Get-DbaReplSubscription { foreach ($distSub in $distPullSubs) { # Only process subscriptions for publications we already queried - $pubKey = "$($distSub.DatabaseName)|$($distSub.PublicationName)" - if (-not $publicationKeys.ContainsKey($pubKey)) { continue } + if ($usePublicationIdLookup) { + if (-not $publicationIds.ContainsKey("$($distSub.PublicationId)")) { continue } + } else { + $pubKey = "$($distSub.DatabaseName)|$($distSub.PublicationName)" + if (-not $publicationKeys.ContainsKey($pubKey)) { continue } + } # Apply subscriber name filter if ($subscriberNameStrings -and $distSub.SubscriberName -notin $subscriberNameStrings) { continue } diff --git a/public/Get-DbaService.ps1 b/public/Get-DbaService.ps1 index 9a9a1d6e5fc3..dc8d1d3d3f07 100644 --- a/public/Get-DbaService.ps1 +++ b/public/Get-DbaService.ps1 @@ -177,10 +177,12 @@ function Get-DbaService { @{ Name = "Launchpad"; Id = 12 }, @{ Name = "Unknown"; Id = 8 } ) + $reportingServiceTypes = @("SSRS", "PowerBI") if ($PsCmdlet.ParameterSetName -match 'Search') { if ($Type) { $searchClause = "" - foreach ($itemType in $Type) { + $sqlServiceTypes = @($Type | Where-Object { $PSItem -notin $reportingServiceTypes }) + foreach ($itemType in $sqlServiceTypes) { foreach ($id in ($ServiceIdMap | Where-Object { $_.Name -eq $itemType }).Id) { if ($searchClause) { $searchClause += ' OR ' } $searchClause += "SQLServiceType = $id" @@ -218,8 +220,10 @@ function Get-DbaService { $namespaces = @( ) $services = @() $outputServices = @() + $reportingServices = @() + $includeReportingServices = !$Type -or @($Type | Where-Object { $PSItem -in $reportingServiceTypes }).Count -gt 0 - if (!$Type -or 'SSRS' -in $Type -or 'PowerBI' -in $Type) { + if ($includeReportingServices) { Write-Message -Level Verbose -Message "Getting SQL Reporting Server services on $computer" -Target $computer $reportingServices = Get-DbaReportingService -ComputerName $resolvedComputerName -InstanceName $InstanceName -Credential $Credential -ServiceName $ServiceName if ($Type) { @@ -229,25 +233,27 @@ function Get-DbaService { } } - Write-Message -Level Verbose -Message "Getting SQL Server namespaces on $computer" -Target $computer - try { - $namespaces = Get-DbaCmObject -ComputerName $resolvedComputerName -Credential $Credential -Namespace root\Microsoft\SQLServer -Query "Select Name FROM __NAMESPACE WHERE Name Like 'ComputerManagement%'" -EnableException | Sort-Object Name -Descending - Write-Message -Level Verbose -Message "The following namespaces have been found: $($namespaces.Name -join ', ')." - } catch { - Write-Message -Level Verbose -Message "No namespaces found in relevant namespace on $computer." - } - - foreach ($namespace in $namespaces) { + if ($searchClause) { + Write-Message -Level Verbose -Message "Getting SQL Server namespaces on $computer" -Target $computer try { - Write-Message -Level Verbose -Message "Getting Cim class SqlService in Namespace $($namespace.Name) on $computer." -Target $computer - foreach ($service in (Get-DbaCmObject -ComputerName $resolvedComputerName -Credential $Credential -Namespace "root\Microsoft\SQLServer\$($namespace.Name)" -Query "SELECT * FROM SqlService WHERE $searchClause" -EnableException)) { - Write-Message -Level Verbose -Message "Found service $($service.ServiceName) in namespace $($namespace.Name)." - $services += $service - } - # Use highest namespace available, so break if services have been found - break + $namespaces = Get-DbaCmObject -ComputerName $resolvedComputerName -Credential $Credential -Namespace root\Microsoft\SQLServer -Query "Select Name FROM __NAMESPACE WHERE Name Like 'ComputerManagement%'" -EnableException | Sort-Object Name -Descending + Write-Message -Level Verbose -Message "The following namespaces have been found: $($namespaces.Name -join ', ')." } catch { - Write-Message -Level Verbose -Message "Failed to acquire services from namespace $($namespace.Name)." -Target $Computer -ErrorRecord $_ + Write-Message -Level Verbose -Message "No namespaces found in relevant namespace on $computer." + } + + foreach ($namespace in $namespaces) { + try { + Write-Message -Level Verbose -Message "Getting Cim class SqlService in Namespace $($namespace.Name) on $computer." -Target $computer + foreach ($service in (Get-DbaCmObject -ComputerName $resolvedComputerName -Credential $Credential -Namespace "root\Microsoft\SQLServer\$($namespace.Name)" -Query "SELECT * FROM SqlService WHERE $searchClause" -EnableException)) { + Write-Message -Level Verbose -Message "Found service $($service.ServiceName) in namespace $($namespace.Name)." + $services += $service + } + # Use highest namespace available, so break if services have been found + break + } catch { + Write-Message -Level Verbose -Message "Failed to acquire services from namespace $($namespace.Name)." -Target $Computer -ErrorRecord $_ + } } } diff --git a/public/Get-DbaStartupParameter.ps1 b/public/Get-DbaStartupParameter.ps1 index b14fb175beb9..046f921abc65 100644 --- a/public/Get-DbaStartupParameter.ps1 +++ b/public/Get-DbaStartupParameter.ps1 @@ -100,8 +100,18 @@ function Get-DbaStartupParameter { $ogInstance = $args[2] $Simple = $args[3] - $wmisvc = $wmi.Services | Where-Object DisplayName -eq "SQL Server ($instanceName)" - if (-not $wmisvc) { return } + $serviceDisplayName = "SQL Server ($instanceName)" + $wmisvc = @($wmi.Services | Where-Object DisplayName -eq $serviceDisplayName) + + if ($wmisvc.Count -eq 0) { + throw "SQL Server service '$serviceDisplayName' was not found on $computerName." + } + + if ($wmisvc.Count -gt 1) { + throw "Multiple SQL Server services named '$serviceDisplayName' were found on $computerName." + } + + $wmisvc = $wmisvc[0] $params = $wmisvc.StartupParameters -split ';' diff --git a/public/Get-DbaWaitStatistic.ps1 b/public/Get-DbaWaitStatistic.ps1 index 7626e41089e4..5952f0af9c72 100644 --- a/public/Get-DbaWaitStatistic.ps1 +++ b/public/Get-DbaWaitStatistic.ps1 @@ -134,12 +134,28 @@ function Get-DbaWaitStatistic { [PSCredential]$SqlCredential, [int]$Threshold = 95, [switch]$IncludeIgnorable, + [ValidateScript( { -not [string]::IsNullOrWhiteSpace($_) -and $_.Trim() -match "^[A-Za-z0-9_]+$" })] [string[]]$ExcludeWaitType, + [ValidateScript( { -not [string]::IsNullOrWhiteSpace($_) -and $_.Trim() -match "^[A-Za-z0-9_]+$" })] [string[]]$IncludeWaitType, [switch]$EnableException ) begin { + # Normalize user-supplied wait types before building the filter list. + $normalizedExcludeWaitType = @() + if ($ExcludeWaitType) { + $normalizedExcludeWaitType = foreach ($waitType in $ExcludeWaitType) { + $waitType.Trim().ToUpperInvariant() + } + } + + $normalizedIncludeWaitType = @() + if ($IncludeWaitType) { + $normalizedIncludeWaitType = foreach ($waitType in $IncludeWaitType) { + $waitType.Trim().ToUpperInvariant() + } + } $details = [PSCustomObject]@{ CXPACKET = "This indicates parallelism, not necessarily that there's a problem. The coordinator thread in a parallel query always accumulates these waits. If the parallel threads are not given equal amounts of work to do, or one thread blocks, the waiting threads will also accumulate CXPACKET waits, which will make them aggregate a lot faster - this is a problem. One thread may have a lot more to do than the others, and so the whole query is blocked while the long-running thread completes. If this is combined with a high number of PAGEIOLATCH_XX waits, it could be large parallel table scans going on because of incorrect non-clustered indexes, or a bad query plan. If neither of these are the issue, you might want to try setting MAXDOP to 4, 2, or 1 for the offending queries (or possibly the whole instance). Make sure that if you have a NUMA system that you try setting MAXDOP to the number of cores in a single NUMA node first to see if that helps the problem. You also need to consider the MAXDOP effect on a mixed-load system. Play with the cost threshold for parallelism setting (bump it up to, say, 25) before reducing the MAXDOP of the whole instance. And don't forget Resource Governor in Enterprise Edition of SQL Server 2008 onward that allows DOP governing for a particular group of connections to the server." @@ -871,8 +887,8 @@ function Get-DbaWaitStatistic { } # Add user-specified exclusions - if ($ExcludeWaitType) { - foreach ($waitType in $ExcludeWaitType) { + if ($normalizedExcludeWaitType) { + foreach ($waitType in $normalizedExcludeWaitType) { if ($ignorable -notcontains $waitType) { $null = $ignorable.Add($waitType) } @@ -880,8 +896,8 @@ function Get-DbaWaitStatistic { } # Remove user-specified inclusions from ignorable list - if ($IncludeWaitType) { - foreach ($waitType in $IncludeWaitType) { + if ($normalizedIncludeWaitType) { + foreach ($waitType in $normalizedIncludeWaitType) { if ($ignorable -contains $waitType) { $null = $ignorable.Remove($waitType) } diff --git a/public/Import-DbaCsv.ps1 b/public/Import-DbaCsv.ps1 index 7604b0facae5..eb5da19bbc3c 100644 --- a/public/Import-DbaCsv.ps1 +++ b/public/Import-DbaCsv.ps1 @@ -157,8 +157,8 @@ function Import-DbaCsv { Use this when your source files contain blank lines for formatting that should not create empty rows in your table. .PARAMETER SupportsMultiline - Allows field values to span multiple lines when properly quoted, such as addresses or comments with embedded line breaks. - Enable this when your CSV contains multi-line text data that should be preserved as single field values. + Controls whether properly quoted field values may span multiple lines, such as addresses or comments with embedded line breaks. + In Strict QuoteMode, RFC 4180 multiline handling is enabled by default. Specify -SupportsMultiline:$false to force single-line parsing, or -SupportsMultiline to enable it explicitly in Lenient mode. .PARAMETER UseColumnDefault Applies table column default values when CSV fields are missing or empty. @@ -603,6 +603,13 @@ function Import-DbaCsv { Write-Message -Level Warning -Message "Both SampleRows and DetectColumnTypes specified. DetectColumnTypes (full scan) takes precedence for zero-risk type detection." } + $supportsMultilineSpecified = $PSBoundParameters.ContainsKey("SupportsMultiline") + if ($QuoteMode -eq "Strict" -and -not $supportsMultilineSpecified) { + $allowMultilineFields = $true + } else { + $allowMultilineFields = $SupportsMultiline.IsPresent + } + function New-SqlTable { <# .SYNOPSIS @@ -642,12 +649,7 @@ function Import-DbaCsv { $options.MaxDecompressedSize = $MaxDecompressedSize $options.SkipRows = $SkipRows $options.DuplicateHeaderBehavior = [Dataplat.Dbatools.Csv.Reader.DuplicateHeaderBehavior]::$DuplicateHeaderBehavior - # RFC 4180 allows CR/LF inside quoted fields, so enable multiline by default in Strict mode - if ($QuoteMode -eq "Strict" -and -not $PSBoundParameters.ContainsKey("SupportsMultiline")) { - $options.AllowMultilineFields = $true - } else { - $options.AllowMultilineFields = $SupportsMultiline.IsPresent - } + $options.AllowMultilineFields = $allowMultilineFields try { $reader = [Dataplat.Dbatools.Csv.Reader.CsvDataReader]::new($Path, $options) @@ -1136,12 +1138,7 @@ WHERE c.object_id = OBJECT_ID(@tableName) $inferOptions.Escape = $Escape $inferOptions.Comment = $Comment $inferOptions.Encoding = [System.Text.Encoding]::$Encoding - # RFC 4180 allows CR/LF inside quoted fields, so enable multiline by default in Strict mode - if ($QuoteMode -eq "Strict" -and -not $PSBoundParameters.ContainsKey("SupportsMultiline")) { - $inferOptions.AllowMultilineFields = $true - } else { - $inferOptions.AllowMultilineFields = $SupportsMultiline.IsPresent - } + $inferOptions.AllowMultilineFields = $allowMultilineFields if ($PSBoundParameters.DateTimeFormats) { $inferOptions.DateTimeFormats = $DateTimeFormats } @@ -1337,12 +1334,7 @@ WHERE c.object_id = OBJECT_ID(@tableName) $csvOptions.CollectParseErrors = $CollectParseErrors.IsPresent $csvOptions.MaxParseErrors = $MaxParseErrors $csvOptions.SkipEmptyLines = $SkipEmptyLine.IsPresent - # RFC 4180 allows CR/LF inside quoted fields, so enable multiline by default in Strict mode - if ($QuoteMode -eq "Strict" -and -not $PSBoundParameters.ContainsKey("SupportsMultiline")) { - $csvOptions.AllowMultilineFields = $true - } else { - $csvOptions.AllowMultilineFields = $SupportsMultiline.IsPresent - } + $csvOptions.AllowMultilineFields = $allowMultilineFields $csvOptions.UseColumnDefaults = $UseColumnDefault.IsPresent if ($PSBoundParameters.MaxQuotedFieldLength) { $csvOptions.MaxQuotedFieldLength = $MaxQuotedFieldLength @@ -1558,4 +1550,4 @@ WHERE c.object_id = OBJECT_ID(@tableName) $totaltime = [math]::Round($scriptelapsed.Elapsed.TotalSeconds, 2) Write-Message -Level Verbose -Message "Total Elapsed Time for everything: $totaltime seconds" } -} \ No newline at end of file +} diff --git a/public/Import-DbaXESessionTemplate.ps1 b/public/Import-DbaXESessionTemplate.ps1 index fb9f39f6b3c6..3a7179d3453c 100644 --- a/public/Import-DbaXESessionTemplate.ps1 +++ b/public/Import-DbaXESessionTemplate.ps1 @@ -198,31 +198,61 @@ function Import-DbaXESessionTemplate { try { $basename = (Get-ChildItem $file).Basename - $contents = Get-Content $file -Raw -ErrorAction Stop + $templateXml = [xml](Get-Content $file -Raw -ErrorAction Stop) + $namespaceUri = $templateXml.DocumentElement.NamespaceURI + $eventSessionNode = $templateXml.SelectSingleNode("/*[local-name()='event_sessions']/*[local-name()='event_session']") + if (-not $eventSessionNode) { + throw "No event_session element found in template $file." + } + + $eventFileTargetNode = $eventSessionNode.SelectSingleNode("*[local-name()='target' and @name='event_file']") + $eventFileTargetExists = $null -ne $eventFileTargetNode - if ($contents -notmatch 'name="event_file"') { + if (-not $eventFileTargetExists) { # No event_file target found in template - add one so TargetFilePath is honored Write-Message -Level Verbose -Message "No event_file target found in template, adding one with TargetFilePath." - if (Test-Bound -ParameterName TargetFileMetadataPath) { - $newTarget = " " - } else { - $newTarget = " " - } - $contents = $contents.Replace("", "$newTarget") - } else { - # Perform replace on existing event_file target parameters - $xelphrase = 'name="filename" value="' - $xemphrase = 'name="metadatafile" value="' - $contents = $contents.Replace($xelphrase, "$xelphrase$TargetFilePath") - if (Test-Bound -ParameterName TargetFileMetadataPath) { - $contents = $contents.Replace($xemphrase, "$xemphrase$TargetFileMetadataPath") + $eventFileTargetNode = $templateXml.CreateElement("target", $namespaceUri) + $null = $eventFileTargetNode.SetAttribute("package", "package0") + $null = $eventFileTargetNode.SetAttribute("name", "event_file") + } + + $filenameParameterNode = $eventFileTargetNode.SelectSingleNode("*[local-name()='parameter' and @name='filename']") + if (-not $filenameParameterNode) { + $filenameParameterNode = $templateXml.CreateElement("parameter", $namespaceUri) + $null = $filenameParameterNode.SetAttribute("name", "filename") + $null = $eventFileTargetNode.AppendChild($filenameParameterNode) + } + + $filenameValue = $filenameParameterNode.GetAttribute("value") + if ([string]::IsNullOrWhiteSpace($filenameValue)) { + $filenameValue = $basename + } + $null = $filenameParameterNode.SetAttribute("value", "$TargetFilePath$filenameValue") + + if (Test-Bound -ParameterName TargetFileMetadataPath) { + $metadataParameterNode = $eventFileTargetNode.SelectSingleNode("*[local-name()='parameter' and @name='metadatafile']") + if ($metadataParameterNode) { + $metadataValue = $metadataParameterNode.GetAttribute("value") + if ([string]::IsNullOrWhiteSpace($metadataValue)) { + $metadataValue = $basename + } + $null = $metadataParameterNode.SetAttribute("value", "$TargetFileMetadataPath$metadataValue") + } elseif (-not $eventFileTargetExists) { + $metadataParameterNode = $templateXml.CreateElement("parameter", $namespaceUri) + $null = $metadataParameterNode.SetAttribute("name", "metadatafile") + $null = $metadataParameterNode.SetAttribute("value", "$TargetFileMetadataPath$basename") + $null = $eventFileTargetNode.AppendChild($metadataParameterNode) } } + if (-not $eventFileTargetExists) { + $null = $eventSessionNode.AppendChild($eventFileTargetNode) + } + $temp = ([System.IO.Path]::GetTempPath()).TrimEnd("").TrimEnd("\").TrimEnd("/") $tempfile = Join-DbaPath $temp $basename - $null = Set-Content -Path $tempfile -Value $contents -Encoding UTF8 - $xml = [xml](Get-Content $tempfile -ErrorAction Stop) + $null = Set-Content -Path $tempfile -Value $templateXml.OuterXml -Encoding UTF8 + $xml = $templateXml $file = $tempfile } catch { Stop-Function -Message "Failure" -ErrorRecord $_ -Target $file -Continue diff --git a/public/Install-DbaMaintenanceSolution.ps1 b/public/Install-DbaMaintenanceSolution.ps1 index a56186b99298..d760148a782c 100644 --- a/public/Install-DbaMaintenanceSolution.ps1 +++ b/public/Install-DbaMaintenanceSolution.ps1 @@ -50,7 +50,8 @@ function Install-DbaMaintenanceSolution { Without this switch, only the stored procedures are installed and must be scheduled manually or called from custom jobs. .PARAMETER AutoScheduleJobs - Automatically creates optimized job schedules for backup operations. Valid values: WeeklyFull, DailyFull, NoDiff, FifteenMinuteLog, HourlyLog. + Automatically creates optimized job schedules for backup operations when InstallJobs is specified. Valid values: WeeklyFull, DailyFull, NoDiff, FifteenMinuteLog, HourlyLog. + Specify exactly one full backup cadence, WeeklyFull or DailyFull, and optionally combine it with NoDiff, FifteenMinuteLog, or HourlyLog. WeeklyFull creates weekly full backups, daily differentials, and 15-minute log backups. DailyFull skips differentials. Use HourlyLog for less frequent transaction log backups. System databases are always backed up daily regardless of user database schedule. Automatically resolves schedule conflicts by adjusting start times. @@ -223,7 +224,7 @@ function Install-DbaMaintenanceSolution { >> SqlInstance = "localhost" >> InstallJobs = $true >> CleanupTime = 720 - >> AutoSchedule = "WeeklyFull" + >> AutoScheduleJobs = "WeeklyFull" >> } >> Install-DbaMaintenanceSolution @params @@ -324,7 +325,7 @@ function Install-DbaMaintenanceSolution { return } - if ($BackupLocation -eq "NUL" -and $Verify -notin "ForceOff", "Remove") { + if ($InstallJobs -and $BackupLocation -eq "NUL" -and $Verify -notin "ForceOff", "Remove") { Stop-Function -Message "Verify is not supported when backing up to NUL. Either backup to a different directory or set -Verify to 'ForceOff' or 'Remove'." return } @@ -334,6 +335,21 @@ function Install-DbaMaintenanceSolution { return } + if (Test-Bound -ParameterName AutoScheduleJobs) { + if (-not $InstallJobs) { + Stop-Function -Message "AutoScheduleJobs is only useful when installing jobs. To create and schedule SQL Agent jobs, please use '-InstallJobs' in addition to AutoScheduleJobs." + return + } + + $hasWeeklyFull = "WeeklyFull" -in $AutoScheduleJobs + $hasDailyFull = "DailyFull" -in $AutoScheduleJobs + + if ($hasWeeklyFull -eq $hasDailyFull) { + Stop-Function -Message "AutoScheduleJobs requires exactly one full backup schedule. Specify either 'WeeklyFull' or 'DailyFull'." + return + } + } + if ($ReplaceExisting -eq $true) { Write-ProgressHelper -ExcludePercent -Message "If Ola Hallengren's scripts are found, we will drop and recreate them" } @@ -710,15 +726,15 @@ function Install-DbaMaintenanceSolution { if ("HourlyLog" -in $AutoScheduleJobs) { $logparams = @{ - SqlInstance = $server - Job = "DatabaseBackup - USER_DATABASES - LOG" - Schedule = "Hourly Log Backup" - FrequencyType = "Daily" - FrequencyInterval = 1 - FrequencySubDayType = "Hours" - FrequencySubDayInterval = 1 - StartTime = "000000" - Force = $true + SqlInstance = $server + Job = "DatabaseBackup - USER_DATABASES - LOG" + Schedule = "Hourly Log Backup" + FrequencyType = "Daily" + FrequencyInterval = 1 + FrequencySubDayType = "Hours" + FrequencySubDayInterval = 1 + StartTime = "000000" + Force = $true } } else { $logparams = @{ diff --git a/public/Invoke-DbaAdvancedRestore.ps1 b/public/Invoke-DbaAdvancedRestore.ps1 index 36340d487f74..18435d61d6c0 100644 --- a/public/Invoke-DbaAdvancedRestore.ps1 +++ b/public/Invoke-DbaAdvancedRestore.ps1 @@ -134,7 +134,8 @@ function Invoke-DbaAdvancedRestore { .PARAMETER StopAtLsn Log Sequence Number (LSN) in the transaction log at which to stop the restore operation. Use this for precise point-in-time recovery to an exact LSN, which provides more granular control than timestamp-based recovery. - The LSN value can be obtained from sys.fn_dblog, backup headers, or error logs. Combine with -StopBefore to stop just before the specified LSN. + Accepts either the numeric restore format used by SQL Server or the colon-delimited format returned by sys.fn_dblog. + Combine with -StopBefore to stop just before the specified LSN. .PARAMETER Checksum Enables backup checksum verification during restore operations. Forces the restore to verify backup checksums and fail if checksums are not present. @@ -262,10 +263,42 @@ function Invoke-DbaAdvancedRestore { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $SqlInstance return } - if ($KeepCDC -and ($NoRecovery -or ('' -ne $StandbyDirectory))) { + if ($KeepCDC -and ($NoRecovery -or ("" -ne $StandbyDirectory))) { Stop-Function -Category InvalidArgument -Message "KeepCDC cannot be specified with Norecovery or Standby as it needs recovery to work" return } + if ($ErrorBrokerConversations -and ($NoRecovery -or ("" -ne $StandbyDirectory))) { + Stop-Function -Category InvalidArgument -Message "ErrorBrokerConversations cannot be specified with Norecovery or Standby as it needs recovery to work" + return + } + if (-not [string]::IsNullOrWhiteSpace($StopAtLsn)) { + $stopAtLsnValue = $StopAtLsn.Trim() + if ($stopAtLsnValue -like "lsn:*") { + $stopAtLsnValue = $stopAtLsnValue.Substring(4) + } + if ($stopAtLsnValue -like "0x*") { + $stopAtLsnValue = $stopAtLsnValue.Substring(2) + } + if ($stopAtLsnValue -notmatch "^[0-9]+$") { + $splatLsnConversion = @{ + LSN = $stopAtLsnValue + EnableException = $true + } + $message = "StopAtLsn must be a numeric restore LSN or a colon-delimited value such as 00000030:00000f28:0001." + try { + $convertedLsn = Convert-DbaLSN @splatLsnConversion + } catch { + Stop-Function -Category InvalidArgument -Message $message -ErrorRecord $_ + return + } + if ($null -eq $convertedLsn -or [string]::IsNullOrWhiteSpace($convertedLsn.Numeric)) { + Stop-Function -Category InvalidArgument -Message $message + return + } + $stopAtLsnValue = $convertedLsn.Numeric + } + $StopAtLsn = $stopAtLsnValue + } if ($null -ne $PageRestore) { Write-Message -Message "Doing Page Recovery" -Level Verbose @@ -424,15 +457,22 @@ function Invoke-DbaAdvancedRestore { if ($Pscmdlet.ShouldProcess($SqlInstance, "Restoring $database to $SqlInstance based on these files: $($backup.FullName -join ', ')")) { try { $restoreComplete = $true + $executeAsLogin = $null + if ($ExecuteAs -ne "" -and $BackupCnt -eq 1) { + $executeAsLogin = $ExecuteAs.Replace("'", "''") + } if (($KeepCDC -or $ErrorBrokerConversations) -and $restore.NoRecovery -eq $false) { $script = $restore.Script($server) $withOptions = @() - if ($KeepCDC) { $withOptions += 'KEEP_CDC' } - if ($ErrorBrokerConversations) { $withOptions += 'ERROR_BROKER_CONVERSATIONS' } - if ($script -like '*WITH*') { - $script = $script.TrimEnd() + ' , ' + ($withOptions -join ' , ') + if ($KeepCDC) { $withOptions += "KEEP_CDC" } + if ($ErrorBrokerConversations) { $withOptions += "ERROR_BROKER_CONVERSATIONS" } + if ($script -like "*WITH*") { + $script = $script.TrimEnd() + " , " + ($withOptions -join " , ") } else { - $script = $script.TrimEnd() + ' WITH ' + ($withOptions -join ' , ') + $script = $script.TrimEnd() + " WITH " + ($withOptions -join " , ") + } + if ($null -ne $executeAsLogin) { + $script = "EXECUTE AS LOGIN='$executeAsLogin'; " + $script } if ($true -ne $OutputScriptOnly) { Write-Progress -id 1 -activity "Restoring $database to $SqlInstance - Backup $BackupCnt of $($Backups.count)" -percentcomplete 0 -status ([System.String]::Format("Progress: {0} %", 0)) @@ -449,8 +489,8 @@ function Invoke-DbaAdvancedRestore { } } elseif ($OutputScriptOnly) { $script = $restore.Script($server) - if ($ExecuteAs -ne '' -and $BackupCnt -eq 1) { - $script = "EXECUTE AS LOGIN='$ExecuteAs'; " + $script + if ($null -ne $executeAsLogin) { + $script = "EXECUTE AS LOGIN='$executeAsLogin'; " + $script } } elseif ($VerifyOnly) { Write-Message -Message "VerifyOnly restore" -Level Verbose @@ -473,9 +513,9 @@ function Invoke-DbaAdvancedRestore { } Write-Progress -id 2 -ParentId 1 -Activity "Restore $($backup.FullName -Join ',')" -percentcomplete 0 $script = $restore.Script($server) - if ($ExecuteAs -ne '' -and $BackupCnt -eq 1) { + if ($null -ne $executeAsLogin) { Write-Progress -id 1 -activity "Restoring $database to $SqlInstance - Backup $BackupCnt of $($Backups.count)" -percentcomplete 0 -status ([System.String]::Format("Progress: {0} %", 0)) - $script = "EXECUTE AS LOGIN='$ExecuteAs'; " + $script + $script = "EXECUTE AS LOGIN='$executeAsLogin'; " + $script $null = $server.ConnectionContext.ExecuteNonQuery($script) Write-Progress -id 1 -activity "Restoring $database to $SqlInstance - Backup $BackupCnt of $($Backups.count)" -status "Complete" -Completed } else { diff --git a/public/Invoke-DbaBalanceDataFiles.ps1 b/public/Invoke-DbaBalanceDataFiles.ps1 index 06cb2588df48..b1d9f164f5d5 100644 --- a/public/Invoke-DbaBalanceDataFiles.ps1 +++ b/public/Invoke-DbaBalanceDataFiles.ps1 @@ -266,15 +266,32 @@ function Invoke-DbaBalanceDataFiles { # Check the tables parameter if ($Table) { - if ($Table -notin $db.Table) { + $tableParts = $Table | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $missingTables = foreach ($tablePart in $tableParts) { + $matchingTable = $db.Tables | Where-Object { + $_.Name -eq $tablePart.Name -and + $tablePart.Schema -in ($_.Schema, $null) -and + $tablePart.Database -in ($_.Parent.Name, $null) + } + if (-not $matchingTable) { + $tablePart.InputValue + } + } + + if ($missingTables) { # Set the success flag $success = $false Stop-Function -Message "One or more tables cannot be found in database $db on instance $instance" -Target $instance -Continue } - $tableNames = $Table | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - $tableCollection = $db.Tables | Where-Object { $_.Name -in $tableNames } + $tableCollection = foreach ($tablePart in $tableParts) { + $db.Tables | Where-Object { + $_.Name -eq $tablePart.Name -and + $tablePart.Schema -in ($_.Schema, $null) -and + $tablePart.Database -in ($_.Parent.Name, $null) + } + } } else { $tableCollection = $db.Tables } @@ -297,6 +314,10 @@ function Invoke-DbaBalanceDataFiles { Stop-Function -Message "FileGroup '$TargetFileGroup' is read-only in database $db on instance $instance" -Target $instance -Continue continue } + if ($targetFG.Files.Count -lt 1) { + Stop-Function -Message "FileGroup '$TargetFileGroup' does not contain any data files in database $db on instance $instance" -Target $instance -Continue + continue + } # When a target filegroup is specified, all tables are eligible Write-Message -Message "Target filegroup '$TargetFileGroup' specified - all tables with clustered indexes are eligible" -Level Verbose @@ -324,7 +345,7 @@ function Invoke-DbaBalanceDataFiles { Write-Message -Message "Processing table $tbl" -Level Verbose # Chck the tables and get the clustered indexes - if ($tableCollection.Indexes.Count -lt 1) { + if (@($tbl.Indexes).Count -lt 1) { # Set the success flag $success = $false @@ -332,7 +353,7 @@ function Invoke-DbaBalanceDataFiles { } else { # Get all the clustered indexes for the table - $clusteredIndexes = $tableCollection.Indexes | Where-Object { $_.IndexType -eq 'ClusteredIndex' } + $clusteredIndexes = @($tbl.Indexes | Where-Object { $_.IndexType -eq 'ClusteredIndex' }) if ($clusteredIndexes.Count -lt 1) { # Set the success flag diff --git a/public/Invoke-DbaDbDataMasking.ps1 b/public/Invoke-DbaDbDataMasking.ps1 index b7acb3492644..90685c9d09ee 100644 --- a/public/Invoke-DbaDbDataMasking.ps1 +++ b/public/Invoke-DbaDbDataMasking.ps1 @@ -351,6 +351,7 @@ function Invoke-DbaDbDataMasking { $dbTable = $db.Tables | Where-Object { $_.Schema -eq $tableobject.Schema -and $_.Name -eq $tableobject.Name } [bool]$cleanupIdentityColumn = $false + [bool]$cleanupMaskingIndex = $false # The masking index name used for cleanup checks $maskingIndexName = "NIX__$($dbTable.Schema)_$($dbTable.Name)_Masking" @@ -402,15 +403,29 @@ function Invoke-DbaDbDataMasking { } Invoke-DbaQuery @queryParams + $cleanupMaskingIndex = $true } catch { Stop-Function -Message "Could not add identity index to table [$($dbTable.Schema)].[$($dbTable.Name)]" -Continue } } + $actionIdentityValues = @() + try { if ($WhatIfPreference) { # In WhatIf mode, only get the row count without modifying the table structure - $query = "SELECT COUNT(*) AS RowCount FROM [$($tableobject.Schema)].[$($tableobject.Name)]" + if ($tableobject.FilterQuery) { + $trimmedFilterQuery = ($tableobject.FilterQuery).Trim() + + if ($trimmedFilterQuery.EndsWith(";")) { + $trimmedFilterQuery = $trimmedFilterQuery.Substring(0, $trimmedFilterQuery.Length - 1) + } + + $query = "SELECT COUNT(*) AS RowCount FROM ($trimmedFilterQuery) AS [dbatools_masking_source]" + } else { + $query = "SELECT COUNT(*) AS RowCount FROM [$($tableobject.Schema)].[$($tableobject.Name)]" + } + $rowCount = ($db.Query($query)).RowCount $data = New-Object object[] $rowCount } elseif (-not $tableobject.FilterQuery) { @@ -440,6 +455,8 @@ function Invoke-DbaDbDataMasking { # Get the data [array]$data = $db.Query($query) + + $actionIdentityValues = @($data | ForEach-Object { $PSItem.$identityColumn } | Where-Object { $null -ne $PSItem } | Select-Object -Unique) } } catch { Stop-Function -Message "Failure retrieving the data from table [$($tableobject.Schema)].[$($tableobject.Name)]" -Target $Database -ErrorRecord $_ -Continue @@ -447,7 +464,9 @@ function Invoke-DbaDbDataMasking { #region unique indexes # Check if the table contains unique indexes - if ($tableobject.HasUniqueIndex) { + if ($WhatIfPreference -and $tableobject.HasUniqueIndex) { + Write-Message -Level Verbose -Message "Skipping unique value preparation for [$($tableobject.Schema)].[$($tableobject.Name)] because -WhatIf is active" + } elseif ($tableobject.HasUniqueIndex) { # Loop through the rows and generate a unique value for each row Write-Message -Level Verbose -Message "Generating unique values for [$($tableobject.Schema)].[$($tableobject.Name)]" @@ -1149,17 +1168,17 @@ function Invoke-DbaDbDataMasking { } } } - # Add FilterQuery WHERE clause to restrict which rows are updated - if ($validAction -and $tableobject.FilterQuery) { - $filterParts = ($tableobject.FilterQuery) -split "WHERE", 2, "ignorecase" - if ($filterParts.Count -gt 1) { - $filterWhereClause = $filterParts[1].Trim().TrimEnd(";") - $query = $query.TrimEnd(";") + " WHERE $filterWhereClause;" + # Apply actions only to the rows returned by FilterQuery + if ($validAction -and $tableobject.FilterQuery -and $actionIdentityValues.Count -ge 1) { + for ($batchStart = 0; $batchStart -lt $actionIdentityValues.Count; $batchStart += $BatchSize) { + $batchEnd = [System.Math]::Min($batchStart + $BatchSize - 1, $actionIdentityValues.Count - 1) + $identityBatch = $actionIdentityValues[$batchStart .. $batchEnd] -join ", " + $null = $stringBuilder.AppendLine($query.TrimEnd(";") + " WHERE [$identityColumn] IN ($identityBatch);") } } # Add the query to the rest - if ($validAction) { + if ($validAction -and -not $tableobject.FilterQuery) { $null = $stringBuilder.AppendLine($query) } } @@ -1277,18 +1296,20 @@ function Invoke-DbaDbDataMasking { $null = $elapsed.Reset() } - # Clean up the masking index (always runs, regardless of -WhatIf or errors during masking) - try { - # Refresh the indexes to make sure to have the latest list - $dbTable.Indexes.Refresh() + # Clean up the masking index created for this masking run + if ($cleanupMaskingIndex) { + try { + # Refresh the indexes to make sure to have the latest list + $dbTable.Indexes.Refresh() - # Check if the index is there - if ($dbTable.Indexes.Name -contains $maskingIndexName) { - Write-Message -Level verbose -Message "Removing identity index from table [$($dbTable.Schema)].[$($dbTable.Name)]" - $dbTable.Indexes[$($maskingIndexName)].Drop() + # Check if the index is there + if ($dbTable.Indexes.Name -contains $maskingIndexName) { + Write-Message -Level verbose -Message "Removing identity index from table [$($dbTable.Schema)].[$($dbTable.Name)]" + $dbTable.Indexes[$($maskingIndexName)].Drop() + } + } catch { + Stop-Function -Message "Could not remove identity index from table [$($dbTable.Schema)].[$($dbTable.Name)]" -Continue } - } catch { - Stop-Function -Message "Could not remove identity index from table [$($dbTable.Schema)].[$($dbTable.Name)]" -Continue } # Clean up the identity column (always runs, regardless of -WhatIf or errors during masking) diff --git a/public/Invoke-DbaDbDbccUpdateUsage.ps1 b/public/Invoke-DbaDbDbccUpdateUsage.ps1 index 1d41b9f4bbf6..d681da7ad586 100644 --- a/public/Invoke-DbaDbDbccUpdateUsage.ps1 +++ b/public/Invoke-DbaDbDbccUpdateUsage.ps1 @@ -163,30 +163,36 @@ function Invoke-DbaDbDbccUpdateUsage { try { $query = $StringBuilder.ToString() if (Test-Bound -ParameterName Table) { - if ($Table -notmatch '^\d+$') { + if ($Table -notmatch "^\d+$") { $tableNameParts = Get-ObjectNameParts -ObjectName $Table - if ($tableNameParts.Schema) { - $tableIdentifier = "[$($tableNameParts.Schema)].[$($tableNameParts.Name)]" + if ($tableNameParts.Name) { + $escapedTableName = $tableNameParts.Name.Replace("]", "]]") + if ($tableNameParts.Schema) { + $escapedTableSchema = $tableNameParts.Schema.Replace("]", "]]") + $tableIdentifier = "[$escapedTableSchema].[$escapedTableName]" + } else { + $tableIdentifier = "[$escapedTableName]" + } } else { - $tableIdentifier = "[$($tableNameParts.Name)]" + $tableIdentifier = $Table } } if (Test-Bound -ParameterName Index) { - if ($Table -match '^\d+$') { - if ($Index -match '^\d+$') { + if ($Table -match "^\d+$") { + if ($Index -match "^\d+$") { $query = $query.Replace('#options#', "'$($db.name)', $Table, $Index") } else { $query = $query.Replace('#options#', "'$($db.name)', $Table, '$Index'") } } else { - if ($Index -match '^\d+$') { + if ($Index -match "^\d+$") { $query = $query.Replace('#options#', "'$($db.name)', '$tableIdentifier', $Index") } else { $query = $query.Replace('#options#', "'$($db.name)', '$tableIdentifier', '$Index'") } } } else { - if ($Table -match '^\d+$') { + if ($Table -match "^\d+$") { $query = $query.Replace('#options#', "'$($db.name)', $Table") } else { $query = $query.Replace('#options#', "'$($db.name)', '$tableIdentifier'") diff --git a/public/Invoke-DbaDbDecryptObject.ps1 b/public/Invoke-DbaDbDecryptObject.ps1 index ae69fe45f779..bee9c60610f8 100644 --- a/public/Invoke-DbaDbDecryptObject.ps1 +++ b/public/Invoke-DbaDbDecryptObject.ps1 @@ -198,7 +198,7 @@ function Invoke-DbaDbDecryptObject { # Try to connect to instance try { # Do we have a dedicated admin connection already? - $dacConnected = $instance.Type -eq 'Server' -and $instance.InputObject.Name -match '^ADMIN:' + $dacConnected = $instance.Type -eq "Server" -and $instance.InputObject.ConnectionContext.ServerInstance -match "^ADMIN:" $dacOpened = $false if ($dacConnected) { Write-Message -Level Verbose -Message "Reusing dedicated admin connection." diff --git a/public/Invoke-DbaDbLogShipping.ps1 b/public/Invoke-DbaDbLogShipping.ps1 index 2de1f6cde261..0eeccb3d0867 100644 --- a/public/Invoke-DbaDbLogShipping.ps1 +++ b/public/Invoke-DbaDbLogShipping.ps1 @@ -648,12 +648,18 @@ function Invoke-DbaDbLogShipping { $SharedPath = $AzureBaseUrl $LocalPath = $AzureBaseUrl } else { - # Check the backup network path - Write-Message -Message "Testing backup network path $SharedPath" -Level Verbose - if ((Test-DbaPath -Path $SharedPath -SqlInstance $SourceSqlInstance -SqlCredential $SourceSqlCredential) -ne $true) { - Stop-Function -Message "Backup network path $SharedPath is not valid or can't be reached." -Target $SourceSqlInstance - return - } elseif ($SharedPath -notmatch $RegexUnc) { + if (-not $IgnoreFileChecks) { + # Check the backup network path + Write-Message -Message "Testing backup network path $SharedPath" -Level Verbose + if ((Test-DbaPath -Path $SharedPath -SqlInstance $SourceSqlInstance -SqlCredential $SourceSqlCredential) -ne $true) { + Stop-Function -Message "Backup network path $SharedPath is not valid or can't be reached." -Target $SourceSqlInstance + return + } + } else { + Write-Message -Message "Skipping backup network path validation for $SharedPath because -IgnoreFileChecks was specified." -Level Verbose + } + + if ($SharedPath -notmatch $RegexUnc) { Stop-Function -Message "Backup network path $SharedPath has to be in the form of \\server\share." -Target $SourceSqlInstance return } @@ -1016,68 +1022,68 @@ function Invoke-DbaDbLogShipping { if (Test-DbaPath -Path $CopyDestinationFolder -SqlInstance $destInstance -SqlCredential $DestinationSqlCredential) { Write-Message -Message "Copy destination $CopyDestinationFolder already exists" -Level Verbose } else { - # Check if force is being used - if (-not $Force) { - # Set up the confirm part - $message = "The copy destination is missing. Do you want to use the default $($CopyDestinationFolder)?" - $choiceYes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Answer Yes." - $choiceNo = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Answer No." - $options = [System.Management.Automation.Host.ChoiceDescription[]]($choiceYes, $choiceNo) - $result = $host.ui.PromptForChoice($title, $message, $options, 0) - - # Check the result from the confirm - switch ($result) { - # If yes - 0 { - # Try to create the new directory - try { - # If the destination server is remote and the credential is set - if (-not $IsDestinationLocal -and $DestinationCredential) { - Invoke-Command2 -ComputerName $DestinationServerName -Credential $DestinationCredential -ScriptBlock { - Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose - $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force + # Check if force is being used + if (-not $Force) { + # Set up the confirm part + $message = "The copy destination is missing. Do you want to use the default $($CopyDestinationFolder)?" + $choiceYes = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Answer Yes." + $choiceNo = New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Answer No." + $options = [System.Management.Automation.Host.ChoiceDescription[]]($choiceYes, $choiceNo) + $result = $host.ui.PromptForChoice($title, $message, $options, 0) + + # Check the result from the confirm + switch ($result) { + # If yes + 0 { + # Try to create the new directory + try { + # If the destination server is remote and the credential is set + if (-not $IsDestinationLocal -and $DestinationCredential) { + Invoke-Command2 -ComputerName $DestinationServerName -Credential $DestinationCredential -ScriptBlock { + Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose + $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force + } } - } - # If the server is local and the credential is set - elseif ($DestinationCredential) { - Invoke-Command2 -Credential $DestinationCredential -ScriptBlock { + # If the server is local and the credential is set + elseif ($DestinationCredential) { + Invoke-Command2 -Credential $DestinationCredential -ScriptBlock { + Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose + $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force + } + } + # If the server is local and the credential is not set + else { Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force } + Write-Message -Message "Copy destination $CopyDestinationFolder created." -Level Verbose + } catch { + $setupResult = "Failed" + $comment = "Something went wrong creating the copy destination folder" + Stop-Function -Message "Something went wrong creating the copy destination folder $CopyDestinationFolder. `n$_" -Target $destInstance -ErrorRecord $_ + return } - # If the server is local and the credential is not set - else { - Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose - $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force - } - Write-Message -Message "Copy destination $CopyDestinationFolder created." -Level Verbose - } catch { + } + 1 { $setupResult = "Failed" - $comment = "Something went wrong creating the copy destination folder" - Stop-Function -Message "Something went wrong creating the copy destination folder $CopyDestinationFolder. `n$_" -Target $destInstance -ErrorRecord $_ + $comment = "Copy destination is a mandatory parameter" + Stop-Function -Message "Copy destination is a mandatory parameter. Please make sure the value is entered." -Target $destInstance return } - } - 1 { + } # switch + } # if not force + else { + # Try to create the copy destination on the local server + try { + Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose + $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force + Write-Message -Message "Copy destination $CopyDestinationFolder created." -Level Verbose + } catch { $setupResult = "Failed" - $comment = "Copy destination is a mandatory parameter" - Stop-Function -Message "Copy destination is a mandatory parameter. Please make sure the value is entered." -Target $destInstance + $comment = "Something went wrong creating the copy destination folder" + Stop-Function -Message "Something went wrong creating the copy destination folder $CopyDestinationFolder. `n$_" -Target $destInstance -ErrorRecord $_ return } - } # switch - } # if not force - else { - # Try to create the copy destination on the local server - try { - Write-Message -Message "Creating copy destination folder $CopyDestinationFolder" -Level Verbose - $null = New-Item -Path $CopyDestinationFolder -ItemType Directory -Force:$Force - Write-Message -Message "Copy destination $CopyDestinationFolder created." -Level Verbose - } catch { - $setupResult = "Failed" - $comment = "Something went wrong creating the copy destination folder" - Stop-Function -Message "Something went wrong creating the copy destination folder $CopyDestinationFolder. `n$_" -Target $destInstance -ErrorRecord $_ - return - } } # else not force } # if test path copy destination } # else not Azure @@ -1615,31 +1621,31 @@ function Invoke-DbaDbLogShipping { if ($NoRecovery -or (-not $Standby)) { if ($Force) { $splatRestore = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - Path = $BackupPath - DestinationFilePrefix = $SecondaryDatabasePrefix - DestinationFileSuffix = $SecondaryDatabaseSuffix - DestinationDataDirectory = $DatabaseRestoreDataFolder - DestinationLogDirectory = $DatabaseRestoreLogFolder - DatabaseName = $SecondaryDatabase - DirectoryRecurse = $true - NoRecovery = $true - WithReplace = $true + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + Path = $BackupPath + DestinationFilePrefix = $SecondaryDatabasePrefix + DestinationFileSuffix = $SecondaryDatabaseSuffix + DestinationDataDirectory = $DatabaseRestoreDataFolder + DestinationLogDirectory = $DatabaseRestoreLogFolder + DatabaseName = $SecondaryDatabase + DirectoryRecurse = $true + NoRecovery = $true + WithReplace = $true } $null = Restore-DbaDatabase @splatRestore } else { $splatRestore = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - Path = $BackupPath - DestinationFilePrefix = $SecondaryDatabasePrefix - DestinationFileSuffix = $SecondaryDatabaseSuffix - DestinationDataDirectory = $DatabaseRestoreDataFolder - DestinationLogDirectory = $DatabaseRestoreLogFolder - DatabaseName = $SecondaryDatabase - DirectoryRecurse = $true - NoRecovery = $true + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + Path = $BackupPath + DestinationFilePrefix = $SecondaryDatabasePrefix + DestinationFileSuffix = $SecondaryDatabaseSuffix + DestinationDataDirectory = $DatabaseRestoreDataFolder + DestinationLogDirectory = $DatabaseRestoreLogFolder + DatabaseName = $SecondaryDatabase + DirectoryRecurse = $true + NoRecovery = $true } $null = Restore-DbaDatabase @splatRestore } @@ -1653,29 +1659,29 @@ function Invoke-DbaDbLogShipping { # Check if credentials need to be used if ($DestinationSqlCredential) { $splatRestoreStandby = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - Path = $BackupPath - DestinationFilePrefix = $SecondaryDatabasePrefix - DestinationFileSuffix = $SecondaryDatabaseSuffix - DestinationDataDirectory = $DatabaseRestoreDataFolder - DestinationLogDirectory = $DatabaseRestoreLogFolder - DatabaseName = $SecondaryDatabase - DirectoryRecurse = $true - StandbyDirectory = $StandbyDirectory + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + Path = $BackupPath + DestinationFilePrefix = $SecondaryDatabasePrefix + DestinationFileSuffix = $SecondaryDatabaseSuffix + DestinationDataDirectory = $DatabaseRestoreDataFolder + DestinationLogDirectory = $DatabaseRestoreLogFolder + DatabaseName = $SecondaryDatabase + DirectoryRecurse = $true + StandbyDirectory = $StandbyDirectory } $null = Restore-DbaDatabase @splatRestoreStandby } else { $splatRestoreStandby = @{ - SqlInstance = $destInstance - Path = $BackupPath - DestinationFilePrefix = $SecondaryDatabasePrefix - DestinationFileSuffix = $SecondaryDatabaseSuffix - DestinationDataDirectory = $DatabaseRestoreDataFolder - DestinationLogDirectory = $DatabaseRestoreLogFolder - DatabaseName = $SecondaryDatabase - DirectoryRecurse = $true - StandbyDirectory = $StandbyDirectory + SqlInstance = $destInstance + Path = $BackupPath + DestinationFilePrefix = $SecondaryDatabasePrefix + DestinationFileSuffix = $SecondaryDatabaseSuffix + DestinationDataDirectory = $DatabaseRestoreDataFolder + DestinationLogDirectory = $DatabaseRestoreLogFolder + DatabaseName = $SecondaryDatabase + DirectoryRecurse = $true + StandbyDirectory = $StandbyDirectory } $null = Restore-DbaDatabase @splatRestoreStandby } @@ -1700,21 +1706,21 @@ function Invoke-DbaDbLogShipping { Write-Message -Message "Configuring logshipping for primary database" -Level Verbose $splatPrimary = @{ - SqlInstance = $SourceSqlInstance - SqlCredential = $SourceSqlCredential - Database = $($db.Name) - BackupDirectory = $DatabaseLocalPath - BackupJob = $DatabaseBackupJob - BackupRetention = $BackupRetention - BackupShare = $DatabaseSharedPath - BackupThreshold = $BackupThreshold - CompressBackup = $BackupCompression - HistoryRetention = $HistoryRetention - MonitorServer = $PrimaryMonitorServer - MonitorServerSecurityMode = $PrimaryMonitorServerSecurityMode - MonitorCredential = $PrimaryMonitorCredential - ThresholdAlertEnabled = $PrimaryThresholdAlertEnabled - Force = $Force + SqlInstance = $SourceSqlInstance + SqlCredential = $SourceSqlCredential + Database = $($db.Name) + BackupDirectory = $DatabaseLocalPath + BackupJob = $DatabaseBackupJob + BackupRetention = $BackupRetention + BackupShare = $DatabaseSharedPath + BackupThreshold = $BackupThreshold + CompressBackup = $BackupCompression + HistoryRetention = $HistoryRetention + MonitorServer = $PrimaryMonitorServer + MonitorServerSecurityMode = $PrimaryMonitorServerSecurityMode + MonitorCredential = $PrimaryMonitorCredential + ThresholdAlertEnabled = $PrimaryThresholdAlertEnabled + Force = $Force } # Add Azure credential if provided (for storage account key authentication) @@ -1736,33 +1742,33 @@ function Invoke-DbaDbLogShipping { #Variable $BackupJobSchedule marked as unused by PSScriptAnalyzer replaced with $null for catching output $splatBackupSchedule = @{ - SqlInstance = $SourceSqlInstance - SqlCredential = $SourceSqlCredential - Job = $DatabaseBackupJob - Schedule = $DatabaseBackupSchedule - FrequencyType = $BackupScheduleFrequencyType - FrequencyInterval = $BackupScheduleFrequencyInterval - FrequencySubdayType = $BackupScheduleFrequencySubdayType - FrequencySubdayInterval = $BackupScheduleFrequencySubdayInterval - FrequencyRelativeInterval = $BackupScheduleFrequencyRelativeInterval - FrequencyRecurrenceFactor = $BackupScheduleFrequencyRecurrenceFactor - StartDate = $BackupScheduleStartDate - EndDate = $BackupScheduleEndDate - StartTime = $BackupScheduleStartTime - EndTime = $BackupScheduleEndTime - Force = $Force + SqlInstance = $SourceSqlInstance + SqlCredential = $SourceSqlCredential + Job = $DatabaseBackupJob + Schedule = $DatabaseBackupSchedule + FrequencyType = $BackupScheduleFrequencyType + FrequencyInterval = $BackupScheduleFrequencyInterval + FrequencySubdayType = $BackupScheduleFrequencySubdayType + FrequencySubdayInterval = $BackupScheduleFrequencySubdayInterval + FrequencyRelativeInterval = $BackupScheduleFrequencyRelativeInterval + FrequencyRecurrenceFactor = $BackupScheduleFrequencyRecurrenceFactor + StartDate = $BackupScheduleStartDate + EndDate = $BackupScheduleEndDate + StartTime = $BackupScheduleStartTime + EndTime = $BackupScheduleEndTime + Force = $Force } $null = New-DbaAgentSchedule @splatBackupSchedule Write-Message -Message "Configuring logshipping from primary to secondary database." -Level Verbose $splatPrimarySecondary = @{ - SqlInstance = $SourceSqlInstance - SqlCredential = $SourceSqlCredential - PrimaryDatabase = $($db.Name) - SecondaryDatabase = $SecondaryDatabase - SecondaryServer = $destInstance - SecondarySqlCredential = $DestinationSqlCredential + SqlInstance = $SourceSqlInstance + SqlCredential = $SourceSqlCredential + PrimaryDatabase = $($db.Name) + SecondaryDatabase = $SecondaryDatabase + SecondaryServer = $destInstance + SecondarySqlCredential = $DestinationSqlCredential } New-DbaLogShippingPrimarySecondary @splatPrimarySecondary } catch { @@ -1783,20 +1789,20 @@ function Invoke-DbaDbLogShipping { Write-Message -Message "Configuring logshipping from secondary database $SecondaryDatabase to primary database $db." -Level Verbose $splatSecondaryPrimary = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - BackupSourceDirectory = $DatabaseSharedPath - BackupDestinationDirectory = $DatabaseCopyDestinationFolder - CopyJob = $DatabaseCopyJob - FileRetentionPeriod = $BackupRetention - MonitorServer = $SecondaryMonitorServer - MonitorServerSecurityMode = $SecondaryMonitorServerSecurityMode - MonitorCredential = $SecondaryMonitorCredential - PrimaryServer = $SourceSqlInstance - PrimarySqlCredential = $SourceSqlCredential - PrimaryDatabase = $($db.Name) - RestoreJob = $DatabaseRestoreJob - Force = $Force + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + BackupSourceDirectory = $DatabaseSharedPath + BackupDestinationDirectory = $DatabaseCopyDestinationFolder + CopyJob = $DatabaseCopyJob + FileRetentionPeriod = $BackupRetention + MonitorServer = $SecondaryMonitorServer + MonitorServerSecurityMode = $SecondaryMonitorServerSecurityMode + MonitorCredential = $SecondaryMonitorCredential + PrimaryServer = $SourceSqlInstance + PrimarySqlCredential = $SourceSqlCredential + PrimaryDatabase = $($db.Name) + RestoreJob = $DatabaseRestoreJob + Force = $Force } # Add Azure credential if provided (for storage account key authentication) @@ -1817,21 +1823,21 @@ function Invoke-DbaDbLogShipping { Write-Message -Message "Create copy job schedule $DatabaseCopySchedule" -Level Verbose #Variable $CopyJobSchedule marked as unused by PSScriptAnalyzer replaced with $null for catching output $splatCopySchedule = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - Job = $DatabaseCopyJob - Schedule = $DatabaseCopySchedule - FrequencyType = $CopyScheduleFrequencyType - FrequencyInterval = $CopyScheduleFrequencyInterval - FrequencySubdayType = $CopyScheduleFrequencySubdayType - FrequencySubdayInterval = $CopyScheduleFrequencySubdayInterval - FrequencyRelativeInterval = $CopyScheduleFrequencyRelativeInterval - FrequencyRecurrenceFactor = $CopyScheduleFrequencyRecurrenceFactor - StartDate = $CopyScheduleStartDate - EndDate = $CopyScheduleEndDate - StartTime = $CopyScheduleStartTime - EndTime = $CopyScheduleEndTime - Force = $Force + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + Job = $DatabaseCopyJob + Schedule = $DatabaseCopySchedule + FrequencyType = $CopyScheduleFrequencyType + FrequencyInterval = $CopyScheduleFrequencyInterval + FrequencySubdayType = $CopyScheduleFrequencySubdayType + FrequencySubdayInterval = $CopyScheduleFrequencySubdayInterval + FrequencyRelativeInterval = $CopyScheduleFrequencyRelativeInterval + FrequencyRecurrenceFactor = $CopyScheduleFrequencyRecurrenceFactor + StartDate = $CopyScheduleStartDate + EndDate = $CopyScheduleEndDate + StartTime = $CopyScheduleStartTime + EndTime = $CopyScheduleEndTime + Force = $Force } $null = New-DbaAgentSchedule @splatCopySchedule } @@ -1840,42 +1846,42 @@ function Invoke-DbaDbLogShipping { #Variable $RestoreJobSchedule marked as unused by PSScriptAnalyzer replaced with $null for catching output $splatRestoreSchedule = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - Job = $DatabaseRestoreJob - Schedule = $DatabaseRestoreSchedule - FrequencyType = $RestoreScheduleFrequencyType - FrequencyInterval = $RestoreScheduleFrequencyInterval - FrequencySubdayType = $RestoreScheduleFrequencySubdayType - FrequencySubdayInterval = $RestoreScheduleFrequencySubdayInterval - FrequencyRelativeInterval = $RestoreScheduleFrequencyRelativeInterval - FrequencyRecurrenceFactor = $RestoreScheduleFrequencyRecurrenceFactor - StartDate = $RestoreScheduleStartDate - EndDate = $RestoreScheduleEndDate - StartTime = $RestoreScheduleStartTime - EndTime = $RestoreScheduleEndTime - Force = $Force + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + Job = $DatabaseRestoreJob + Schedule = $DatabaseRestoreSchedule + FrequencyType = $RestoreScheduleFrequencyType + FrequencyInterval = $RestoreScheduleFrequencyInterval + FrequencySubdayType = $RestoreScheduleFrequencySubdayType + FrequencySubdayInterval = $RestoreScheduleFrequencySubdayInterval + FrequencyRelativeInterval = $RestoreScheduleFrequencyRelativeInterval + FrequencyRecurrenceFactor = $RestoreScheduleFrequencyRecurrenceFactor + StartDate = $RestoreScheduleStartDate + EndDate = $RestoreScheduleEndDate + StartTime = $RestoreScheduleStartTime + EndTime = $RestoreScheduleEndTime + Force = $Force } $null = New-DbaAgentSchedule @splatRestoreSchedule Write-Message -Message "Configuring logshipping for secondary database." -Level Verbose $splatSecondaryDatabase = @{ - SqlInstance = $destInstance - SqlCredential = $DestinationSqlCredential - SecondaryDatabase = $SecondaryDatabase - PrimaryServer = $SourceSqlInstance - PrimarySqlCredential = $SourceSqlCredential - PrimaryDatabase = $($db.Name) - RestoreDelay = $RestoreDelay - RestoreMode = $DatabaseStatus - DisconnectUsers = $DisconnectUsers - RestoreThreshold = $RestoreThreshold - ThresholdAlertEnabled = $SecondaryThresholdAlertEnabled - HistoryRetention = $HistoryRetention - MonitorServer = $SecondaryMonitorServer - MonitorServerSecurityMode = $SecondaryMonitorServerSecurityMode - MonitorCredential = $SecondaryMonitorCredential + SqlInstance = $destInstance + SqlCredential = $DestinationSqlCredential + SecondaryDatabase = $SecondaryDatabase + PrimaryServer = $SourceSqlInstance + PrimarySqlCredential = $SourceSqlCredential + PrimaryDatabase = $($db.Name) + RestoreDelay = $RestoreDelay + RestoreMode = $DatabaseStatus + DisconnectUsers = $DisconnectUsers + RestoreThreshold = $RestoreThreshold + ThresholdAlertEnabled = $SecondaryThresholdAlertEnabled + HistoryRetention = $HistoryRetention + MonitorServer = $SecondaryMonitorServer + MonitorServerSecurityMode = $SecondaryMonitorServerSecurityMode + MonitorCredential = $SecondaryMonitorCredential } New-DbaLogShippingSecondaryDatabase @splatSecondaryDatabase diff --git a/public/Invoke-DbaDbPiiScan.ps1 b/public/Invoke-DbaDbPiiScan.ps1 index aaf28392e841..7c3cb60a79c6 100644 --- a/public/Invoke-DbaDbPiiScan.ps1 +++ b/public/Invoke-DbaDbPiiScan.ps1 @@ -276,20 +276,38 @@ function Invoke-DbaDbPiiScan { # Filter the tables if needed if ($Table) { - $tableNames = $Table | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - $tables = $db.Tables | Where-Object Name -In $tableNames + $tableParts = $Table | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $tables = @(foreach ($tablePart in $tableParts) { + $db.Tables | Where-Object { + $_.Name -eq $tablePart.Name -and + $tablePart.Schema -in ($_.Schema, $null) -and + $tablePart.Database -in ($db.Name, $null) + } + }) } else { - $tables = $db.Tables + $tables = @($db.Tables) } if ($ExcludeTable) { - $excludeTableNames = $ExcludeTable | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - $tables = $tables | Where-Object Name -NotIn $excludeTableNames + $excludeTableParts = $ExcludeTable | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $tables = @($tables | Where-Object { + $tableObject = $PSItem + -not ($excludeTableParts | Where-Object { + $_.Name -eq $tableObject.Name -and + $_.Schema -in ($tableObject.Schema, $null) -and + $_.Database -in ($db.Name, $null) + }) + }) } # Filter the tables based on the column if ($Column) { - $tables = $tables | Where-Object { $ColumnNames = $_.Columns.Name; $Column | Where-Object { $_ -in $ColumnNames } } + $tables = @($tables | Where-Object { $ColumnNames = $_.Columns.Name; $Column | Where-Object { $_ -in $ColumnNames } }) + } + + if ($tables.Count -eq 0) { + Write-Message -Level Verbose -Message "No tables to scan in database $dbName" + continue } $tableNumber = 1 @@ -465,4 +483,4 @@ function Invoke-DbaDbPiiScan { $piiScanResults } # End process -} +} \ No newline at end of file diff --git a/public/Invoke-DbaDbShrink.ps1 b/public/Invoke-DbaDbShrink.ps1 index b06e4f550c45..d688452af27a 100644 --- a/public/Invoke-DbaDbShrink.ps1 +++ b/public/Invoke-DbaDbShrink.ps1 @@ -344,10 +344,10 @@ function Invoke-DbaDbShrink { Write-Message -Level Verbose -Message ('Shrinking {0} to {1}' -f $file.Name, $shrinkSizeKB) $targetMB = [int]$shrinkSizeKB.Megabyte $shrinkSqlArgs = switch ($ShrinkMethod) { - 'EmptyFile' { "N'$escapedFileName', EMPTYFILE" } - 'NoTruncate' { "N'$escapedFileName', $targetMB, NOTRUNCATE" } + 'EmptyFile' { "N'$escapedFileName', EMPTYFILE" } + 'NoTruncate' { "N'$escapedFileName', $targetMB, NOTRUNCATE" } 'TruncateOnly' { "N'$escapedFileName', $targetMB, TRUNCATEONLY" } - default { "N'$escapedFileName', $targetMB" } + default { "N'$escapedFileName', $targetMB" } } $null = $instance.Query("DBCC SHRINKFILE ($shrinkSqlArgs)$walp", $db.name) $file.Refresh() @@ -360,10 +360,10 @@ function Invoke-DbaDbShrink { } else { $targetMB = [int]$desiredFileSizeKB.Megabyte $shrinkSqlArgs = switch ($ShrinkMethod) { - 'EmptyFile' { "N'$escapedFileName', EMPTYFILE" } - 'NoTruncate' { "N'$escapedFileName', $targetMB, NOTRUNCATE" } + 'EmptyFile' { "N'$escapedFileName', EMPTYFILE" } + 'NoTruncate' { "N'$escapedFileName', $targetMB, NOTRUNCATE" } 'TruncateOnly' { "N'$escapedFileName', $targetMB, TRUNCATEONLY" } - default { "N'$escapedFileName', $targetMB" } + default { "N'$escapedFileName', $targetMB" } } $null = $instance.Query("DBCC SHRINKFILE ($shrinkSqlArgs)$walp", $db.name) $file.Refresh() @@ -372,7 +372,12 @@ function Invoke-DbaDbShrink { } catch { $success = $false $errorDetails = $_.Exception.Message - Stop-Function -Message "Shrink operation failed for file $($file.Name): $errorDetails" -ErrorRecord $_ + $failureMessage = "Shrink operation failed for file $($file.Name): $errorDetails" + if ($EnableException) { + Stop-Function -Message $failureMessage -EnableException $EnableException -ErrorRecord $_ + } else { + Write-Message -Level Warning -Message $failureMessage -ErrorRecord $_ + } } finally { $instance.ConnectionContext.StatementTimeout = $previousStatementTimeout } @@ -403,11 +408,9 @@ function Invoke-DbaDbShrink { $ts = [TimeSpan]::FromSeconds($timSpan.TotalSeconds) $elapsed = "{0:HH:mm:ss}" -f ([datetime]$ts.Ticks) - $notesText = 'Database shrinks can cause massive index fragmentation and negatively impact performance. You should now run DBCC INDEXDEFRAG or ALTER INDEX ... REORGANIZE' + $notesText = "Database shrinks can cause massive index fragmentation and negatively impact performance. You should now run DBCC INDEXDEFRAG or ALTER INDEX ... REORGANIZE" if ($errorDetails) { $notesText = "$errorDetails | $notesText" - } else { - $notesText = $notesText } $object = [PSCustomObject]@{ diff --git a/public/New-DbaComputerCertificate.ps1 b/public/New-DbaComputerCertificate.ps1 index e94ae080ae35..3d6d4ee79069 100644 --- a/public/New-DbaComputerCertificate.ps1 +++ b/public/New-DbaComputerCertificate.ps1 @@ -76,6 +76,7 @@ function New-DbaComputerCertificate { Specifies how the certificate's private key should be handled during import operations. Defaults to "Exportable, PersistKeySet" allowing the key to be backed up and persisted on disk. Use "NonExportable" for high-security environments where private keys should never leave the machine. + When copying certificates to remote computers, the temporary source certificate remains exportable so the destination import can honor the requested flags. "UserProtected" requires interactive confirmation and only works on localhost installations. .PARAMETER Dns @@ -94,6 +95,9 @@ function New-DbaComputerCertificate { Document Encryption (1.3.6.1.4.1.311.10.3.11) and IKE Intermediate (1.3.6.1.5.5.8.2.2) Extended Key Usage OIDs required by Always Encrypted, instead of the default Server Authentication OID (1.3.6.1.5.5.7.3.1). + For CA-signed certificates, specify -CertificateTemplate with a template configured + for Always Encrypted column master keys. The default WebServer template is intended + for TLS server certificates and is not suitable for this switch. .PARAMETER HashAlgorithm Specifies the cryptographic hash algorithm used for certificate signing. @@ -237,6 +241,11 @@ function New-DbaComputerCertificate { $flags = $Flag -join "," } + if ($DocumentEncryptionCert -and -not $SelfSigned -and -not $PSBoundParameters.ContainsKey("CertificateTemplate")) { + Stop-Function -Message "DocumentEncryptionCert requires -SelfSigned or an explicit -CertificateTemplate configured for Always Encrypted column master keys. The default WebServer template is intended for TLS server certificates." + return + } + $englishCodes = 9, 1033, 2057, 3081, 4105, 5129, 6153, 7177, 8201, 9225 if ($englishCodes -notcontains (Get-DbaCmObject -ClassName Win32_OperatingSystem).OSLanguage) { Stop-Function -Message "Currently, this command is only supported in English OS locales. OS Locale detected: $([System.Globalization.CultureInfo]::GetCultureInfo([int](Get-DbaCmObject Win32_OperatingSystem).OSLanguage).DisplayName)`nWe apologize for the inconvenience and look into providing universal language support in future releases." @@ -393,8 +402,8 @@ function New-DbaComputerCertificate { Add-Content $certCfg "Subject = ""CN=$fqdn""" Add-Content $certCfg "KeySpec = 1" Add-Content $certCfg "KeyLength = $KeyLength" - # Set Exportable based on Flag parameter - if NonExportable is specified, set to FALSE - if ("NonExportable" -in $Flag) { + # Keep the source cert exportable whenever it must be copied to another host. + if ("NonExportable" -in $Flag -and -not $ClusterInstanceName -and $computer.IsLocalHost) { Add-Content $certCfg "Exportable = FALSE" } else { Add-Content $certCfg "Exportable = TRUE" diff --git a/public/New-DbaDatabase.ps1 b/public/New-DbaDatabase.ps1 index 43aa5215c888..5582d47dd9cb 100644 --- a/public/New-DbaDatabase.ps1 +++ b/public/New-DbaDatabase.ps1 @@ -254,35 +254,38 @@ function New-DbaDatabase { # Detect Azure Blob Storage URLs to skip filesystem directory operations $dataPathIsAzure = $DataFilePath -like "https://*" - $logPathIsAzure = $LogFilePath -like "https://*" + $logPathIsAzure = $LogFilePath -like "https://*" + + $dataFileDirectoryPath = $DataFilePath + $logFileDirectoryPath = $LogFilePath # Trim trailing separators to avoid double-separators when concatenating file names - $DataFilePath = $DataFilePath.TrimEnd('\', '/') - $LogFilePath = $LogFilePath.TrimEnd('\', '/') + $dataFileNamePath = $DataFilePath.TrimEnd("\", "/") + $logFileNamePath = $LogFilePath.TrimEnd("\", "/") # Choose the path separator based on whether the path is an Azure Blob Storage URL $dataPathSeparator = if ($dataPathIsAzure) { "/" } else { "\" } - $logPathSeparator = if ($logPathIsAzure) { "/" } else { "\" } + $logPathSeparator = if ($logPathIsAzure) { "/" } else { "\" } - if (-not $logPathIsAzure -and -not (Test-DbaPath -SqlInstance $server -Path $LogFilePath)) { + if (-not $logPathIsAzure -and -not (Test-DbaPath -SqlInstance $server -Path $logFileDirectoryPath)) { try { - Write-Message -Message "Creating directory $LogFilePath" -Level Verbose - $null = New-DbaDirectory -SqlInstance $server -Path $LogFilePath -EnableException + Write-Message -Message "Creating directory $logFileDirectoryPath" -Level Verbose + $null = New-DbaDirectory -SqlInstance $server -Path $logFileDirectoryPath -EnableException } catch { - Stop-Function -Message "Error creating log file directory $LogFilePath" -Target $instance -Continue + Stop-Function -Message "Error creating log file directory $logFileDirectoryPath" -Target $instance -Continue } } - if (-not $dataPathIsAzure -and -not (Test-DbaPath -SqlInstance $server -Path $DataFilePath)) { + if (-not $dataPathIsAzure -and -not (Test-DbaPath -SqlInstance $server -Path $dataFileDirectoryPath)) { try { - Write-Message -Message "Creating directory $DataFilePath" -Level Verbose - $null = New-DbaDirectory -SqlInstance $server -Path $DataFilePath -EnableException + Write-Message -Message "Creating directory $dataFileDirectoryPath" -Level Verbose + $null = New-DbaDirectory -SqlInstance $server -Path $dataFileDirectoryPath -EnableException } catch { - Stop-Function -Message "Error creating secondary file directory $DataFilePath on $instance" -Target $instance -Continue + Stop-Function -Message "Error creating secondary file directory $dataFileDirectoryPath on $instance" -Target $instance -Continue } } - Write-Message -Message "Set local data path to $DataFilePath and local log path to $LogFilePath" -Level Verbose + Write-Message -Message "Set local data path to $dataFileDirectoryPath and local log path to $logFileDirectoryPath" -Level Verbose foreach ($dbName in $Name) { if ($server.Databases[$dbName].Name) { @@ -332,7 +335,7 @@ function New-DbaDatabase { #create the primary file $primaryfile = New-Object Microsoft.SqlServer.Management.Smo.DataFile($primaryfg, $primaryfilename) - $primaryfile.FileName = $DataFilePath + $dataPathSeparator + $primaryfilename + ".mdf" + $primaryfile.FileName = $dataFileNamePath + $dataPathSeparator + $primaryfilename + ".mdf" $primaryfile.IsPrimaryFile = $true if (Test-Bound -ParameterName PrimaryFilesize) { @@ -374,7 +377,7 @@ function New-DbaDatabase { } $tlog = New-Object Microsoft.SqlServer.Management.Smo.LogFile($newdb, $logname) - $tlog.FileName = $LogFilePath + $logPathSeparator + $logname + ".ldf" + $tlog.FileName = $logFileNamePath + $logPathSeparator + $logname + ".ldf" if (Test-Bound -ParameterName LogSize) { $tlog.Size = ($LogSize * 1024) @@ -432,7 +435,7 @@ function New-DbaDatabase { $secondaryfilename = "$($secondaryfilegroupname)_$($secondaryfgcount)" Write-Message -Message "Creating file name $secondaryfilename in filegroup $secondaryfilegroupname" -Level Verbose $secondaryfile = New-Object Microsoft.SQLServer.Management.Smo.Datafile($secondaryfg, $secondaryfilename) - $secondaryfile.FileName = $DataFilePath + $dataPathSeparator + $secondaryfilename + ".ndf" + $secondaryfile.FileName = $dataFileNamePath + $dataPathSeparator + $secondaryfilename + ".ndf" if (Test-Bound -ParameterName SecondaryFilesize) { $secondaryfile.Size = ($SecondaryFilesize * 1024) diff --git a/public/New-DbaDbMailAccount.ps1 b/public/New-DbaDbMailAccount.ps1 index 11509c563c67..09e37f5e522f 100644 --- a/public/New-DbaDbMailAccount.ps1 +++ b/public/New-DbaDbMailAccount.ps1 @@ -160,6 +160,7 @@ function New-DbaDbMailAccount { [string]$EmailAddress, [string]$ReplyToAddress, [string]$MailServer, + [ValidateRange(1, 65535)] [int]$Port, [switch]$EnableSSL, [switch]$UseDefaultCredentials, @@ -169,6 +170,16 @@ function New-DbaDbMailAccount { [switch]$EnableException ) process { + if ($UseDefaultCredentials.IsPresent -and (Test-Bound -ParameterName UserName, Password)) { + Stop-Function -Category InvalidArgument -Message "You cannot specify -UseDefaultCredentials with -UserName or -Password." + return + } + + if (Test-Bound -ParameterName UserName, Password -Min 1 -Max 1) { + Stop-Function -Category InvalidArgument -Message "You must specify both -UserName and -Password together." + return + } + foreach ($instance in $SqlInstance) { try { $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -MinimumVersion 10 @@ -224,4 +235,4 @@ function New-DbaDbMailAccount { } } } -} +} \ No newline at end of file diff --git a/public/New-DbaDbTable.ps1 b/public/New-DbaDbTable.ps1 index fd13fe7b4f69..62d1265d6c4d 100644 --- a/public/New-DbaDbTable.ps1 +++ b/public/New-DbaDbTable.ps1 @@ -22,6 +22,7 @@ function New-DbaDbTable { .PARAMETER Name Specifies the name for the new table. Must be a valid SQL Server identifier. + You can also pass a bracket-quoted name or a two-part schema.table name. Specify the target database with -Database or by piping in a database object. Use standard naming conventions like avoiding spaces and reserved keywords for better maintainability. .PARAMETER Schema @@ -472,6 +473,10 @@ function New-DbaDbTable { if (Test-Bound -ParameterName Name) { $parsedName = Get-ObjectNameParts -ObjectName $Name if ($parsedName.Parsed) { + if ($parsedName.Database) { + Stop-Function -Message "The -Name parameter only accepts one- or two-part names. Specify the database separately with -Database or by piping in a database object." + return + } if ($parsedName.Schema -and -not (Test-Bound -ParameterName Schema)) { $Schema = $parsedName.Schema } diff --git a/public/New-DbaFirewallRule.ps1 b/public/New-DbaFirewallRule.ps1 index 2fd19cde1e9d..bc330009f6cc 100644 --- a/public/New-DbaFirewallRule.ps1 +++ b/public/New-DbaFirewallRule.ps1 @@ -317,7 +317,7 @@ function New-DbaFirewallRule { # Try to get the program path for executable-based rule try { $service = Get-DbaService -ComputerName $instance.ComputerName -InstanceName $instance.InstanceName -Credential $Credential -Type Engine -EnableException - if ($service.BinaryPath -match '^"?(.+sqlservr\.exe)') { + if ($service.BinaryPath -match "^""?(.+sqlservr\.exe)(?:\s|""|$)") { $rule.Config.Program = $Matches[1] Write-Message -Level Verbose -Message "Creating program-based firewall rule targeting: $($Matches[1])" } else { @@ -376,7 +376,7 @@ function New-DbaFirewallRule { # Try to get the SQL Browser service executable path try { $browserService = Get-DbaService -ComputerName $instance.ComputerName -Credential $Credential -Type Browser -EnableException | Select-Object -First 1 - if ($browserService.BinaryPath -match '^"?(.+sqlbrowser\.exe)') { + if ($browserService.BinaryPath -match "^""?(.+sqlbrowser\.exe)(?:\s|""|$)") { $rule.Config.Program = $Matches[1] $rule.Config.Protocol = 'Any' Write-Message -Level Verbose -Message "Creating program-based firewall rule for Browser targeting: $($Matches[1])" diff --git a/public/New-DbaLogin.ps1 b/public/New-DbaLogin.ps1 index 145b5528075b..d542c57deef6 100644 --- a/public/New-DbaLogin.ps1 +++ b/public/New-DbaLogin.ps1 @@ -85,8 +85,8 @@ function New-DbaLogin { Prevents SID collision errors during login duplication and ensures unique security identifiers. .PARAMETER ExternalProvider - Configures the login for Azure Active Directory authentication in Azure SQL Database or Managed Instance. - Use with Azure AD user principal names or service principal names for cloud-integrated authentication. + Configures the login for Microsoft Entra authentication in Azure SQL Database, Azure SQL Managed Instance, or SQL Server 2022 and later. + Use with Microsoft Entra user principal names or service principal names supported by the target platform. .PARAMETER Force Removes any existing login with the same name before creating the new one. @@ -124,7 +124,7 @@ function New-DbaLogin { - InstanceName: The SQL Server instance name - SqlInstance: The full SQL Server instance name (computer\instance) - Name: The login account name - - LoginType: The type of login (SqlLogin, WindowsUser, WindowsGroup, Certificate, AsymmetricKey, or ExternalUser) + - LoginType: The type of login (SqlLogin, WindowsUser, WindowsGroup, Certificate, AsymmetricKey, ExternalUser, or ExternalGroup) - CreateDate: DateTime when the login was created - LastLogin: DateTime of the most recent connection (null if never connected or SQL Server 2000) - HasAccess: Boolean indicating if the login has permission to connect @@ -436,6 +436,7 @@ function New-DbaLogin { $newLogin.LoginType = $loginType $withParams = "" + $externalProviderAlterParams = "" if ($loginType -eq 'SqlLogin' -and $currentSid -and !$NewSid) { Write-Message -Level Verbose -Message "Setting $loginName SID" @@ -446,13 +447,21 @@ function New-DbaLogin { if ($loginType -in ("WindowsUser", "WindowsGroup", "SqlLogin", "ExternalUser", "ExternalGroup")) { if ($currentDefaultDatabase) { Write-Message -Level Verbose -Message "Setting $loginName default database to $currentDefaultDatabase" - $withParams += ", DEFAULT_DATABASE = [$currentDefaultDatabase]" + if ($loginType -in ("ExternalUser", "ExternalGroup")) { + $externalProviderAlterParams += ", DEFAULT_DATABASE = [$currentDefaultDatabase]" + } else { + $withParams += ", DEFAULT_DATABASE = [$currentDefaultDatabase]" + } $newLogin.DefaultDatabase = $currentDefaultDatabase } if ($currentLanguage) { Write-Message -Level Verbose -Message "Setting $loginName language to $currentLanguage" - $withParams += ", DEFAULT_LANGUAGE = [$currentLanguage]" + if ($loginType -in ("ExternalUser", "ExternalGroup")) { + $externalProviderAlterParams += ", DEFAULT_LANGUAGE = [$currentLanguage]" + } else { + $withParams += ", DEFAULT_LANGUAGE = [$currentLanguage]" + } $newLogin.Language = $currentLanguage } @@ -529,7 +538,7 @@ function New-DbaLogin { $sql = "CREATE LOGIN [$loginName] WITH PASSWORD = '$($SecurePassword | ConvertFrom-SecurePass)'" } elseif ($loginType -in ('ExternalUser', 'ExternalGroup') -and ($server.DatabaseEngineType -eq 'SqlAzureDatabase' -or $server.DatabaseEngineEdition -eq 'SqlManagedInstance' -or $server.VersionMajor -ge 16)) { # Azure SQL DB, Azure SQL Managed Instance, and SQL Server 2022+ support FROM EXTERNAL PROVIDER syntax - $sql = "CREATE LOGIN [$loginName] FROM EXTERNAL PROVIDER" + $withParams + $sql = "CREATE LOGIN [$loginName] FROM EXTERNAL PROVIDER" } elseif ($loginType -eq 'SqlLogin' ) { $sql = "CREATE LOGIN [$loginName] WITH PASSWORD = $currentHashedPassword HASHED" + $withParams } else { @@ -544,6 +553,16 @@ function New-DbaLogin { } } + if ($usedTsql -and $loginType -in ("ExternalUser", "ExternalGroup") -and $externalProviderAlterParams) { + try { + $sql = "ALTER LOGIN [$loginName] WITH " + $externalProviderAlterParams.TrimStart(',').Trim() + $null = $server.Query($sql) + $newLogin = $server.Logins[$loginName] + } catch { + Stop-Function -Message "Failed to configure $loginName on $instance after creation." -Category InvalidOperation -ErrorRecord $_ -Target $instance -Continue + } + } + #Process the Disabled property if ($currentDisabled) { try { diff --git a/public/Remove-DbaAgentJobSchedule.ps1 b/public/Remove-DbaAgentJobSchedule.ps1 index 23ef0a86f8b6..2c3923f58f54 100644 --- a/public/Remove-DbaAgentJobSchedule.ps1 +++ b/public/Remove-DbaAgentJobSchedule.ps1 @@ -139,38 +139,40 @@ function Remove-DbaAgentJobSchedule { $server = $jobObject.Parent.Parent foreach ($scheduleName in $Schedule) { - $jobSchedule = $jobObject.JobSchedules | Where-Object { $_.Name -eq $scheduleName } + $jobSchedules = @($jobObject.JobSchedules | Where-Object { $_.Name -eq $scheduleName }) - if (-not $jobSchedule) { + if (-not $jobSchedules) { Stop-Function -Message "Schedule '$scheduleName' is not attached to job '$($jobObject.Name)' on $($server.Name)" -Target $jobObject -Continue } - $output = [PSCustomObject]@{ - ComputerName = $server.ComputerName - InstanceName = $server.ServiceName - SqlInstance = $server.DomainInstanceName - Job = $jobObject.Name - Schedule = $scheduleName - ScheduleId = $jobSchedule.Id - ScheduleUid = $jobSchedule.ScheduleUid - Status = $null - IsDetached = $false - } + foreach ($jobSchedule in $jobSchedules) { + $output = [PSCustomObject]@{ + ComputerName = $server.ComputerName + InstanceName = $server.ServiceName + SqlInstance = $server.DomainInstanceName + Job = $jobObject.Name + Schedule = $scheduleName + ScheduleId = $jobSchedule.Id + ScheduleUid = $jobSchedule.ScheduleUid + Status = $null + IsDetached = $false + } - if ($PSCmdlet.ShouldProcess($server, "Detaching schedule '$scheduleName' from job '$($jobObject.Name)'")) { - try { - Write-Message -Level Verbose -Message "Detaching schedule '$scheduleName' from job '$($jobObject.Name)' on $($server.Name)" - $jobSchedule.Drop($true) - $output.Status = "Detached" - $output.IsDetached = $true - } catch { - Stop-Function -Message "Failed to detach schedule '$scheduleName' from job '$($jobObject.Name)' on $($server.Name)" -ErrorRecord $_ -Target $jobObject -Continue - $output.Status = (Get-ErrorMessage -Record $_) + if ($PSCmdlet.ShouldProcess($server, "Detaching schedule '$scheduleName' from job '$($jobObject.Name)'")) { + try { + Write-Message -Level Verbose -Message "Detaching schedule '$scheduleName' from job '$($jobObject.Name)' on $($server.Name)" + $jobSchedule.Drop($true) + $output.Status = "Detached" + $output.IsDetached = $true + } catch { + Stop-Function -Message "Failed to detach schedule '$scheduleName' from job '$($jobObject.Name)' on $($server.Name)" -ErrorRecord $_ -Target $jobObject + $output.Status = (Get-ErrorMessage -Record $_) + } } - } - $output + $output + } } } } -} +} \ No newline at end of file diff --git a/public/Remove-DbaDbTableData.ps1 b/public/Remove-DbaDbTableData.ps1 index bf0908c4ff04..91f687f24a13 100644 --- a/public/Remove-DbaDbTableData.ps1 +++ b/public/Remove-DbaDbTableData.ps1 @@ -218,12 +218,26 @@ function Remove-DbaDbTableData { if (Test-Bound Table) { $nameParts = Get-ObjectNameParts -ObjectName $Table + if (-not $nameParts.Parsed) { + Stop-Function -Message "Please check you are using proper one-, two-, or three-part names. If your table name contains special characters you must use [ ] to wrap the name. The value $Table could not be parsed as a valid table name." + return + } + + $quotedTableName = "[" + $nameParts.Name.Replace("]", "]]") + "]" if ($nameParts.Database) { - $bracketedTable = "[$($nameParts.Database)].[$($nameParts.Schema)].[$($nameParts.Name)]" + $quotedDatabaseName = "[" + $nameParts.Database.Replace("]", "]]") + "]" + + if ($nameParts.Schema) { + $quotedSchemaName = "[" + $nameParts.Schema.Replace("]", "]]") + "]" + $bracketedTable = "$quotedDatabaseName.$quotedSchemaName.$quotedTableName" + } else { + $bracketedTable = "$quotedDatabaseName..$quotedTableName" + } } elseif ($nameParts.Schema) { - $bracketedTable = "[$($nameParts.Schema)].[$($nameParts.Name)]" + $quotedSchemaName = "[" + $nameParts.Schema.Replace("]", "]]") + "]" + $bracketedTable = "$quotedSchemaName.$quotedTableName" } else { - $bracketedTable = "[$($nameParts.Name)]" + $bracketedTable = $quotedTableName } $sql += " DELETE TOP ($BatchSize) FROM $bracketedTable;" } elseif (Test-Bound DeleteSql) { diff --git a/public/Remove-DbaInstanceList.ps1 b/public/Remove-DbaInstanceList.ps1 index cd49ce5ec3dd..8dacf732df5b 100644 --- a/public/Remove-DbaInstanceList.ps1 +++ b/public/Remove-DbaInstanceList.ps1 @@ -6,8 +6,7 @@ function Remove-DbaInstanceList { .DESCRIPTION Removes SQL Server instance names from the user-maintained list that is pre-loaded into the dbatools tab completion cache for the -SqlInstance parameter. The instances are - removed from the stored configuration but remain in the current session's TEPP cache - until the module is reloaded. + removed from the stored configuration and from the current session's autocomplete cache. Use Add-DbaInstanceList to add instances to the list and Get-DbaInstanceList to view the current list. @@ -86,6 +85,13 @@ function Remove-DbaInstanceList { $updated = $current | Where-Object { $toRemove -notcontains $_ } if ($null -eq $updated) { $updated = @() } Set-DbatoolsConfig -FullName "TabExpansion.KnownInstances" -Value @($updated) + + $cache = @([Dataplat.Dbatools.TabExpansion.TabExpansionHost]::Cache["sqlinstance"]) + if ($cache.Count -gt 0) { + $cache = $cache | Where-Object { $toRemove -notcontains $_ } + [Dataplat.Dbatools.TabExpansion.TabExpansionHost]::Cache["sqlinstance"] = @($cache) + } + if ($Register) { Register-DbatoolsConfig -FullName "TabExpansion.KnownInstances" -Scope $Scope } diff --git a/public/Save-DbaKbUpdate.ps1 b/public/Save-DbaKbUpdate.ps1 index bddf774e75c3..bd27250244a3 100644 --- a/public/Save-DbaKbUpdate.ps1 +++ b/public/Save-DbaKbUpdate.ps1 @@ -149,7 +149,7 @@ function Save-DbaKbUpdate { if (-not $UseWebRequest -and (Get-Command Start-BitsTransfer -ErrorAction Ignore)) { try { - Start-BitsTransfer -Source $link -Destination $file + Start-BitsTransfer -Source $link -Destination $file -ErrorAction Stop } catch { Write-Message -Level Verbose -Message "Start-BitsTransfer failed, falling back to Invoke-WebRequest: $PSItem" Write-Progress -Activity "Downloading $fileName" -Id 1 diff --git a/public/Set-DbaDbCompression.ps1 b/public/Set-DbaDbCompression.ps1 index df1a44877a6d..eefdceb2fbc9 100644 --- a/public/Set-DbaDbCompression.ps1 +++ b/public/Set-DbaDbCompression.ps1 @@ -264,8 +264,14 @@ function Set-DbaDbCompression { if ($Pscmdlet.ShouldProcess($db, "Applying $CompressionType compression")) { $tables = $server.Databases[$($db.name)].Tables if ($Table) { - $tableNames = $Table | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - $tables = $tables | Where-Object Name -in $tableNames + $tableParts = $Table | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $tables = foreach ($tablePart in $tableParts) { + $server.Databases[$($db.name)].Tables | Where-Object { + $_.Name -eq $tablePart.Name -and + $tablePart.Schema -in ($_.Schema, $null) -and + $tablePart.Database -in ($db.Name, $null) + } + } } foreach ($obj in $tables | Where-Object { !$_.IsMemoryOptimized -and !$_.HasSparseColumn }) { @@ -360,6 +366,7 @@ function Set-DbaDbCompression { } } foreach ($index in $($server.Databases[$($db.name)].Views | Where-Object { $_.Indexes }).Indexes) { + $parentView = $index.Parent foreach ($p in $($index.PhysicalPartitions | Where-Object { $_.DataCompression -ne $CompressionType })) { Write-Message -Level Verbose -Message "Compressing $($index.IndexType) $($index.Name) Partition $($p.PartitionNumber)" try { @@ -383,15 +390,15 @@ function Set-DbaDbCompression { $index.Rebuild() } } catch { - Stop-Function -Message "Compression failed for $instance - $db - table $($obj.Schema).$($obj.Name) - index $($index.Name) - partition $($p.PartitionNumber)" -Target $db -ErrorRecord $_ -Continue + Stop-Function -Message "Compression failed for $instance - $db - view $($parentView.Schema).$($parentView.Name) - index $($index.Name) - partition $($p.PartitionNumber)" -Target $db -ErrorRecord $_ -Continue } [PSCustomObject]@{ ComputerName = $server.ComputerName InstanceName = $server.ServiceName SqlInstance = $server.DomainInstanceName Database = $db.Name - Schema = $obj.Schema - TableName = $obj.Name + Schema = $parentView.Schema + TableName = $parentView.Name IndexName = $index.Name Partition = $p.PartitionNumber IndexID = $index.Id diff --git a/public/Set-DbaDbIdentity.ps1 b/public/Set-DbaDbIdentity.ps1 index 8476f0721a94..87854c61fcd0 100644 --- a/public/Set-DbaDbIdentity.ps1 +++ b/public/Set-DbaDbIdentity.ps1 @@ -146,10 +146,16 @@ function Set-DbaDbIdentity { try { $query = $StringBuilder.ToString() $nameParts = Get-ObjectNameParts -ObjectName $tbl - if ($nameParts.Schema) { - $tblIdentifier = "[$($nameParts.Schema)].[$($nameParts.Name)]" + if ($nameParts.Name) { + $escapedTableName = $nameParts.Name.Replace("]", "]]") + if ($nameParts.Schema) { + $escapedTableSchema = $nameParts.Schema.Replace("]", "]]") + $tblIdentifier = "[$escapedTableSchema].[$escapedTableName]" + } else { + $tblIdentifier = "[$escapedTableName]" + } } else { - $tblIdentifier = "[$($nameParts.Name)]" + $tblIdentifier = $tbl } if (Test-Bound -Not -ParameterName ReSeedValue) { $query = $query.Replace('#options#', "'$($tblIdentifier)'") diff --git a/public/Set-DbaDbMailAccount.ps1 b/public/Set-DbaDbMailAccount.ps1 index 2b5cb9e2ce56..644978350cf3 100644 --- a/public/Set-DbaDbMailAccount.ps1 +++ b/public/Set-DbaDbMailAccount.ps1 @@ -137,6 +137,7 @@ function Set-DbaDbMailAccount { [string]$EmailAddress, [string]$ReplyToAddress, [string]$NewMailServerName, + [ValidateRange(1, 65535)] [int]$Port, [switch]$EnableSSL, [switch]$UseDefaultCredentials, @@ -145,6 +146,11 @@ function Set-DbaDbMailAccount { [switch]$EnableException ) process { + if ($UseDefaultCredentials.IsPresent -and (Test-Bound -ParameterName UserName, Password)) { + Stop-Function -Category InvalidArgument -Message "You cannot specify -UseDefaultCredentials with -UserName or -Password." + return + } + foreach ($instance in $SqlInstance) { $InputObject += Get-DbaDbMailAccount -SqlInstance $instance -SqlCredential $SqlCredential -Account $Account -EnableException:$EnableException } @@ -197,4 +203,4 @@ function Set-DbaDbMailAccount { } } } -} +} \ No newline at end of file diff --git a/public/Set-DbaNetworkCertificate.ps1 b/public/Set-DbaNetworkCertificate.ps1 index b74183e4f9f7..10ca3536e017 100644 --- a/public/Set-DbaNetworkCertificate.ps1 +++ b/public/Set-DbaNetworkCertificate.ps1 @@ -354,7 +354,16 @@ function Set-DbaNetworkCertificate { if ($RestartService) { Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Restarting SQL Server service for $instance" try { - $null = Restart-DbaService -SqlInstance $instance -Type Engine -Force -EnableException + $splatRestartService = @{ + SqlInstance = $instance + Type = "Engine" + Force = $true + EnableException = $true + } + if ($Credential) { + $splatRestartService.Credential = $Credential + } + $null = Restart-DbaService @splatRestartService } catch { $notes = "Failed to restart service" Write-Message -Level Warning -Message "$notes for instance $instance." diff --git a/public/Set-DbaPrivilege.ps1 b/public/Set-DbaPrivilege.ps1 index 1a5cfea4066f..ca12676d739c 100644 --- a/public/Set-DbaPrivilege.ps1 +++ b/public/Set-DbaPrivilege.ps1 @@ -133,7 +133,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Batch Logon Privileges on $env:ComputerName" } elseif ($BLline -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeBatchLogonRight = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeBatchLogonRight = ", "SeBatchLogonRight = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Batch Logon Privileges on $env:ComputerName" @@ -156,7 +156,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Instant File Initialization Privileges on $env:ComputerName" } elseif ($IFIline -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeManageVolumePrivilege = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeManageVolumePrivilege = ", "SeManageVolumePrivilege = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Instant File Initialization Privileges on $env:ComputerName" @@ -179,7 +179,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Lock Pages in Memory Privileges on $env:ComputerName" } elseif ($LPIMline -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeLockMemoryPrivilege = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeLockMemoryPrivilege = ", "SeLockMemoryPrivilege = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Lock Pages in Memory Privileges on $env:ComputerName" @@ -202,7 +202,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Security Log Privileges on $env:ComputerName" } elseif ($SALine -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeAuditPrivilege = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeAuditPrivilege = ", "SeAuditPrivilege = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Write to Security Log Privileges on $env:ComputerName" @@ -223,7 +223,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Service Logon Privileges on $env:ComputerName" } elseif ($SLline -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeServiceLogonRight = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeServiceLogonRight = ", "SeServiceLogonRight = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Service Logon Privileges on $env:ComputerName" @@ -244,7 +244,7 @@ function Convert-UserNameToSID ([string] `$Acc ) { <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Create Global Objects Privileges on $env:ComputerName" } elseif ($CGOline -notmatch $SID) { - (Get-Content $tempfile) -replace "(SeCreateGlobalPrivilege = .+)", "`$1,*$SID" | + (Get-Content $tempfile) -replace "SeCreateGlobalPrivilege = ", "SeCreateGlobalPrivilege = *$SID," | Set-Content $tempfile <# DO NOT use Write-Message as this is inside of a script block #> Write-Verbose "Added $acc to Create Global Objects Privileges on $env:ComputerName" @@ -270,4 +270,4 @@ function Convert-UserNameToSID ([string] `$Acc ) { } } } -} \ No newline at end of file +} diff --git a/public/Start-DbaDbEncryption.ps1 b/public/Start-DbaDbEncryption.ps1 index b536d3d87165..689ab20866e8 100644 --- a/public/Start-DbaDbEncryption.ps1 +++ b/public/Start-DbaDbEncryption.ps1 @@ -25,7 +25,7 @@ function Start-DbaDbEncryption { Use this when you need to encrypt specific databases instead of all user databases on the instance. .PARAMETER ExcludeDatabase - Specifies which databases to exclude from TDE encryption when using AllUserDatabases. + Specifies which databases to exclude from TDE encryption. Useful when you want to encrypt most databases but need to skip specific ones due to compatibility or business requirements. .PARAMETER EncryptorName @@ -489,7 +489,7 @@ function Start-DbaDbEncryption { # Step 3: Create a database encryption key in the target database if needed # This has to be done before parallel processing as New-DbaDbEncryptionKey uses Get-DbaDatabase internally # which uses the custom method .Query() that is not present in runspaces due to the way dbatools is loaded there. - foreach ($db in $InputObject) { + foreach ($db in $databases) { try { if ($db.HasDatabaseEncryptionKey) { Write-Message -Level Verbose -Message "$($db.Name) on $($db.Parent.Name) already has a database encryption key" @@ -549,7 +549,7 @@ function Start-DbaDbEncryption { Error = $_.Exception.Message } } finally { - $null = $server | Disconnect-DbaInstance + $null = $server | Disconnect-DbaInstance -WhatIf:$false } } diff --git a/public/Start-DbaMigration.ps1 b/public/Start-DbaMigration.ps1 index e2d7b20b7328..fbee9caaf23f 100644 --- a/public/Start-DbaMigration.ps1 +++ b/public/Start-DbaMigration.ps1 @@ -191,7 +191,7 @@ function Start-DbaMigration { https://dbatools.io/Start-DbaMigration .OUTPUTS - Object (output from Copy-DbaDatabase command when -Exclude Databases is not specified) + Object When databases are migrated (default behavior unless -Exclude Databases is used), this function returns the output from Copy-DbaDatabase. The specific object type and properties depend on the migration method selected: @@ -201,7 +201,7 @@ function Start-DbaMigration { When using -DetachAttach method: Returns database reattachment status objects showing which databases were successfully attached on destination servers. - No output is returned when -Exclude Databases is specified, as the function then only migrates server-level objects without providing pipeline output. + When -Exclude Databases is specified, most server-level migration operations do not return pipeline output. The exception is SSIS catalog migration, which returns MigrationObject status objects from Copy-DbaSsisCatalog when the source instance has an SSISDB catalog. All other migration operations (logins, SQL Agent jobs, configuration, etc.) perform their tasks without returning objects to the pipeline. Use -Verbose to see detailed progress messages for all migration steps. @@ -367,7 +367,7 @@ function Start-DbaMigration { if ($ExcludePassword) { $dacNeeded = $false } # Do we have a dedicated admin connection already? - $dacConnected = $Source.Type -eq 'Server' -and $Source.InputObject.Name -match '^ADMIN:' + $dacConnected = $Source.Type -eq "Server" -and $Source.InputObject.ConnectionContext.ServerInstance -match "^ADMIN:" $dacOpened = $false if ($dacNeeded) { @@ -380,6 +380,10 @@ function Start-DbaMigration { } else { Write-Message -Level Verbose -Message "Opening dedicated admin connection for password retrieval." $sourceServerDac = Connect-DbaInstance -SqlInstance $Source -SqlCredential $SourceSqlCredential -DedicatedAdminConnection -WarningAction SilentlyContinue + if (-not $sourceServerDac) { + Stop-Function -Message "Could not establish dedicated admin connection to $Source. Use -ExcludePassword to skip password migration." -Category ConnectionError -Target $Source + return + } $dacOpened = $true Write-Message -Level Verbose -Message "Opening or reusing additional normal connection for all commands that don't require DAC." $sourceServer = Connect-DbaInstance -SqlInstance $Source -SqlCredential $SourceSqlCredential @@ -394,6 +398,10 @@ function Start-DbaMigration { $sourceServer = Connect-DbaInstance -SqlInstance $Source -SqlCredential $SourceSqlCredential } } + if (-not $sourceServer) { + Stop-Function -Message "Could not connect to source instance $Source." -Category ConnectionError -Target $Source + return + } } catch { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $Source return @@ -603,10 +611,19 @@ function Start-DbaMigration { Copy-DbaExtendedStoredProcedure -Source $sourceserver -Destination $Destination -DestinationSqlCredential $DestinationSqlCredential } - if ($Exclude -notcontains 'SsisCatalog') { - Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Migrating SSIS catalog" - Write-Message -Level Verbose -Message "Migrating SSIS catalog" - Copy-DbaSsisCatalog -Source $sourceserver -Destination $Destination -DestinationSqlCredential $DestinationSqlCredential -Force:$Force + if ($Exclude -notcontains "SsisCatalog") { + $sourceHasSsisCatalog = $sourceServer.VersionMajor -ge 11 + if ($sourceHasSsisCatalog) { + $sourceHasSsisCatalog = $null -ne $sourceServer.Databases["SSISDB"] + } + + if ($sourceHasSsisCatalog) { + Write-ProgressHelper -StepNumber ($stepCounter++) -Message "Migrating SSIS catalog" + Write-Message -Level Verbose -Message "Migrating SSIS catalog" + Copy-DbaSsisCatalog -Source $sourceserver -Destination $Destination -DestinationSqlCredential $DestinationSqlCredential -Force:$Force + } else { + Write-Message -Level Verbose -Message "Skipping SSIS catalog migration because the source instance does not have an SSISDB catalog." + } } } end { diff --git a/public/Stop-DbaDbEncryption.ps1 b/public/Stop-DbaDbEncryption.ps1 index fd48120b7703..70d80c4c32bf 100644 --- a/public/Stop-DbaDbEncryption.ps1 +++ b/public/Stop-DbaDbEncryption.ps1 @@ -184,7 +184,7 @@ function Stop-DbaDbEncryption { Error = $_.Exception.Message } } finally { - $null = $server | Disconnect-DbaInstance + $null = $server | Disconnect-DbaInstance -WhatIf:$false } } diff --git a/public/Sync-DbaAvailabilityGroup.ps1 b/public/Sync-DbaAvailabilityGroup.ps1 index 25262d71ebf9..fbdb93397f6d 100644 --- a/public/Sync-DbaAvailabilityGroup.ps1 +++ b/public/Sync-DbaAvailabilityGroup.ps1 @@ -189,31 +189,10 @@ function Sync-DbaAvailabilityGroup { } if ($InputObject) { - # Do we need a dedicated admin connection for password retrieval? - # If passwords are excluded, we don't need a DAC - if ($ExcludePassword) { $dacNeeded = $false } else { $dacNeeded = $true } - - # Do we have a dedicated admin connection already? - $dacConnected = $InputObject.Parent.Name -match '^ADMIN:' - - if ($dacNeeded -and -not $dacConnected) { - Stop-Function -Message "Pipeline source must use a dedicated admin connection to retrieve passwords. Use -ExcludePassword to bypass this requirement if you don't need passwords." - return - } - - Write-Message -Level Verbose -Message "Reusing dedicated admin connection for password retrieval." $server = $InputObject.Parent } else { try { - $dacOpened = $false - if ($ExcludePassword) { - Write-Message -Level Verbose -Message "Opening normal connection because we don't need the passwords." - $server = Connect-DbaInstance -SqlInstance $Primary -SqlCredential $PrimarySqlCredential - } else { - Write-Message -Level Verbose -Message "Opening dedicated admin connection for password retrieval." - $server = Connect-DbaInstance -SqlInstance $Primary -SqlCredential $PrimarySqlCredential -DedicatedAdminConnection -WarningAction SilentlyContinue - $dacOpened = $true - } + $server = Connect-DbaInstance -SqlInstance $Primary -SqlCredential $PrimarySqlCredential } catch { Stop-Function -Message "Failure" -Category ConnectionError -ErrorRecord $_ -Target $Primary return @@ -376,6 +355,7 @@ function Sync-DbaAvailabilityGroup { Write-ProgressHelper -Activity $activity -StepNumber ($stepCounter++) -Message "Syncing Agent Jobs" $splatGetJob = @{ SqlInstance = $server + Type = "Local" } if (Test-Bound 'Job') { $splatGetJob['Job'] = $Job @@ -385,9 +365,6 @@ function Sync-DbaAvailabilityGroup { } $jobsToSync = Get-DbaAgentJob @splatGetJob - # Always exclude MSX jobs (CategoryID = 1) - they cannot be synced to secondary replicas - $jobsToSync = $jobsToSync | Where-Object CategoryID -ne 1 - $splatCopyJob = @{ Destination = $secondaries Force = $force @@ -402,9 +379,5 @@ function Sync-DbaAvailabilityGroup { Sync-DbaLoginPermission -Source $server -Destination $secondaries -Login $Login -ExcludeLogin $ExcludeLogin } } - - if ($dacOpened) { - $null = $server | Disconnect-DbaInstance -WhatIf:$false - } } } \ No newline at end of file diff --git a/public/Test-DbaAgPolicyState.ps1 b/public/Test-DbaAgPolicyState.ps1 index 5c885528c75c..17593dd893b6 100644 --- a/public/Test-DbaAgPolicyState.ps1 +++ b/public/Test-DbaAgPolicyState.ps1 @@ -318,7 +318,7 @@ function Test-DbaAgPolicyState { Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/availability-replica-does-not-have-a-healthy-role Policy Name: Availability Replica Role State Issue: Availability replica does not have a healthy role. - Category: Warning + Category: Critical Facet: Availability replica Name : AlwaysOnArReplicaRoleHealthCondition @@ -335,7 +335,7 @@ function Test-DbaAgPolicyState { Replica = $replica.Name Database = $null PolicyName = "Availability Replica Role State" - Category = "Warning" + Category = "Critical" Facet = "Availability replica" IsHealthy = $isHealthy Issue = if ($isHealthy) { $null } else { "Availability replica does not have a healthy role." } @@ -347,7 +347,7 @@ function Test-DbaAgPolicyState { Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/availability-replica-is-disconnected Policy Name: Availability Replica Connection State Issue: Availability replica is disconnected. - Category: Warning + Category: Critical Facet: Availability replica Name : AlwaysOnArReplicaConnectionHealthCondition @@ -364,7 +364,7 @@ function Test-DbaAgPolicyState { Replica = $replica.Name Database = $null PolicyName = "Availability Replica Connection State" - Category = "Warning" + Category = "Critical" Facet = "Availability replica" IsHealthy = $isHealthy Issue = if ($isHealthy) { $null } else { "Availability replica is disconnected." } @@ -399,23 +399,66 @@ function Test-DbaAgPolicyState { Issue = if ($isHealthy) { $null } else { "Availability replica is not joined." } Details = "JoinState is $($replica.JoinState)" } + + $replicaDatabaseReplicaStates = @($ag.DatabaseReplicaStates | Where-Object AvailabilityReplicaId -eq $replica.UniqueId) + + <# + Data synchronization state of some availability database is not healthy + Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/data-synchronization-state-of-some-availability-database-is-not-healthy + Policy Name: Availability Replica Data Synchronization State + Issue: Data synchronization state of some availability database is not healthy. + Category: Warning + Facet: Availability replica + #> + + if ($replica.AvailabilityMode -eq "SynchronousCommit") { + $unhealthyReplicaDatabaseStates = @($replicaDatabaseReplicaStates | Where-Object SynchronizationState -ne "Synchronized") + } else { + $unhealthyReplicaDatabaseStates = @($replicaDatabaseReplicaStates | Where-Object SynchronizationState -eq "NotSynchronizing") + } + $isHealthy = $unhealthyReplicaDatabaseStates.Count -eq 0 + if ($replicaDatabaseReplicaStates) { + $stateDetails = ($replicaDatabaseReplicaStates | ForEach-Object { + "$($_.AvailabilityDatabaseName):$($_.SynchronizationState)" + }) -join ", " + } else { + $stateDetails = "No availability database states found" + } + [PSCustomObject]@{ + ComputerName = $ag.ComputerName + InstanceName = $ag.InstanceName + SqlInstance = $ag.SqlInstance + AvailabilityGroup = $ag.Name + Replica = $replica.Name + Database = $null + PolicyName = "Availability Replica Data Synchronization State" + Category = "Warning" + Facet = "Availability replica" + IsHealthy = $isHealthy + Issue = if ($isHealthy) { $null } else { "Data synchronization state of some availability database is not healthy." } + Details = "AvailabilityMode is $($replica.AvailabilityMode); SynchronizationState(s): $stateDetails" + } } foreach ($databaseReplicaState in $ag.DatabaseReplicaStates) { <# Data synchronization state of availability database is not healthy Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/data-synchronization-state-of-availability-database-is-not-healthy - Policy Name: Availability Database Synchronization State + Policy Name: Availability Database Data Synchronization State Issue: Data synchronization state of availability database is not healthy. - Category: Critical + Category: Warning Facet: Availability database Name : AlwaysOnDbDataSynchronizationHealthCondition Facet : IAvailabilityDatabaseState - ExpressionNode : @SynchronizationState = Enum('Microsoft.SqlServer.Management.Smo.AvailabilityDatabaseSynchronizationState', 'Synchronized') OR @SynchronizationState = Enum('Microsoft.SqlServer.Management.Smo.AvailabilityDatabaseSynchronizationState', 'Synchronizing') + Expected State : Synchronized for synchronous-commit replicas, Synchronizing for all others #> - $isHealthy = $databaseReplicaState.SynchronizationState -in "Synchronized", "Synchronizing" + if ($databaseReplicaState.ReplicaAvailabilityMode -eq "SynchronousCommit") { + $isHealthy = $databaseReplicaState.SynchronizationState -eq "Synchronized" + } else { + $isHealthy = $databaseReplicaState.SynchronizationState -eq "Synchronizing" + } [PSCustomObject]@{ ComputerName = $ag.ComputerName InstanceName = $ag.InstanceName @@ -423,12 +466,12 @@ function Test-DbaAgPolicyState { AvailabilityGroup = $ag.Name Replica = $databaseReplicaState.AvailabilityReplicaServerName Database = $databaseReplicaState.AvailabilityDatabaseName - PolicyName = "Availability Database Synchronization State" - Category = "Critical" + PolicyName = "Availability Database Data Synchronization State" + Category = "Warning" Facet = "Availability database" IsHealthy = $isHealthy Issue = if ($isHealthy) { $null } else { "Data synchronization state of availability database is not healthy." } - Details = "SynchronizationState is $($databaseReplicaState.SynchronizationState)" + Details = "SynchronizationState is $($databaseReplicaState.SynchronizationState), ReplicaAvailabilityMode is $($databaseReplicaState.ReplicaAvailabilityMode)" } <# @@ -462,9 +505,9 @@ function Test-DbaAgPolicyState { <# Availability database is not joined to the availability group - Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/availability-database-is-not-joined + Documentation: https://learn.microsoft.com/en-us/sql/database-engine/availability-groups/windows/secondary-database-is-not-joined Policy Name: Availability Database Join State - Issue: Availability database is not joined to the availability group. + Issue: Secondary database is not joined. Category: Warning Facet: Availability database diff --git a/public/Test-DbaBackupInformation.ps1 b/public/Test-DbaBackupInformation.ps1 index 41b6278acce7..a02dab8e63b0 100644 --- a/public/Test-DbaBackupInformation.ps1 +++ b/public/Test-DbaBackupInformation.ps1 @@ -116,6 +116,15 @@ function Test-DbaBackupInformation { if (Test-FunctionInterrupt) { return } foreach ($bh in $BackupHistory) { + if ("IsVerified" -notin $bh.PSObject.Properties.Name) { + $splatVerifiedMember = @{ + InputObject = $bh + MemberType = "NoteProperty" + Name = "IsVerified" + Value = $false + } + Add-Member @splatVerifiedMember + } $InternalHistory += $bh } } @@ -213,7 +222,7 @@ function Test-DbaBackupInformation { if ($VerificationErrors -eq 0) { Write-Message -Message "Marking $Database as verified" -Level Verbose - $InternalHistory | Where-Object { $_.Database -eq $Database } | ForEach-Object { Add-Member -InputObject $_ -MemberType NoteProperty -Name IsVerified -Value $true -Force } + $InternalHistory | Where-Object { $_.Database -eq $Database } | ForEach-Object { $_.IsVerified = $true } } else { Write-Message -Message "Verification errors = $VerificationErrors - Has not Passed" -Level Verbose } diff --git a/public/Test-DbaCmConnection.ps1 b/public/Test-DbaCmConnection.ps1 index 81507db39aff..349e86ad6bf6 100644 --- a/public/Test-DbaCmConnection.ps1 +++ b/public/Test-DbaCmConnection.ps1 @@ -254,6 +254,14 @@ function Test-DbaCmConnection { #region Setup connection object $con = $ConnectionObject.Connection + + # Ensure CIM session options are initialized with the configured operation timeout + if ($null -eq $con.CimWinRMOptions) { + $con.CimWinRMOptions = New-DbaCimSessionOptionWithTimeout -Protocol Default + } + if ($null -eq $con.CimDCOMOptions) { + $con.CimDCOMOptions = New-DbaCimSessionOptionWithTimeout -Protocol Dcom + } #endregion Setup connection object #region Handle credentials diff --git a/public/Test-DbaDbCompression.ps1 b/public/Test-DbaDbCompression.ps1 index 962e54bdd9c5..eb9a2e4904b6 100644 --- a/public/Test-DbaDbCompression.ps1 +++ b/public/Test-DbaDbCompression.ps1 @@ -193,12 +193,30 @@ function Test-DbaDbCompression { Write-Message -Level System -Message "Bound parameters: $($PSBoundParameters.Keys -join ", ")" if ($Schema) { - $sqlSchemaWhere = "AND s.name IN ('$($Schema -join "','")')" + $schemaNames = $Schema | ForEach-Object { $_.Replace("'", "''") } + $sqlSchemaWhere = "AND s.name IN (N'$($schemaNames -join "','")')" } if ($Table) { - $tableNames = $Table | ForEach-Object { (Get-ObjectNameParts -ObjectName $_).Name } - $sqlTableWhere = "AND t.name IN ('$($tableNames -join "','")')" + $tableParts = $Table | ForEach-Object { Get-ObjectNameParts -ObjectName $_ } + $tableWhereClauses = foreach ($tablePart in $tableParts) { + $tableName = ([string]$tablePart.Name).Replace("'", "''") + $clauseParts = @("t.name = N'$tableName'") + + if ($tablePart.Schema) { + $schemaName = ([string]$tablePart.Schema).Replace("'", "''") + $clauseParts += "s.name = N'$schemaName'" + } + + if ($tablePart.Database) { + $databaseName = ([string]$tablePart.Database).Replace("'", "''") + $clauseParts += "DB_NAME() = N'$databaseName'" + } + + "($($clauseParts -join " AND "))" + } + + $sqlTableWhere = "AND ($($tableWhereClauses -join " OR "))" } if ($ResultSize) { diff --git a/public/Test-DbaInstantFileInitialization.ps1 b/public/Test-DbaInstantFileInitialization.ps1 index 0f33e17601a3..827938f15c7d 100644 --- a/public/Test-DbaInstantFileInitialization.ps1 +++ b/public/Test-DbaInstantFileInitialization.ps1 @@ -125,11 +125,13 @@ function Test-DbaInstantFileInitialization { foreach ($service in $services) { Write-Message -Level Verbose -Message "Checking IFI for service $($service.ServiceName) on $computer" - $serviceNameIFI = ($privileges | Where-Object User -eq "NT SERVICE\$($service.ServiceName)").InstantFileInitialization -eq $true + $serviceVirtualAccount = "NT SERVICE\$($service.ServiceName)" + $serviceNameIFI = ($privileges | Where-Object User -eq $serviceVirtualAccount).InstantFileInitialization -eq $true $startNameIFI = ($privileges | Where-Object User -eq $service.StartName).InstantFileInitialization -eq $true + $startNameUsesVirtualAccount = $service.StartName -eq $serviceVirtualAccount $isEnabled = $serviceNameIFI -or $startNameIFI - $isBestPractice = $serviceNameIFI -and -not $startNameIFI + $isBestPractice = $serviceNameIFI -and ($startNameUsesVirtualAccount -or -not $startNameIFI) [PSCustomObject]@{ ComputerName = $service.ComputerName @@ -144,4 +146,4 @@ function Test-DbaInstantFileInitialization { } } } -} +} \ No newline at end of file diff --git a/public/Test-DbaKerberos.ps1 b/public/Test-DbaKerberos.ps1 index 120036e491c9..e6ca486d6d94 100644 --- a/public/Test-DbaKerberos.ps1 +++ b/public/Test-DbaKerberos.ps1 @@ -6,7 +6,7 @@ function Test-DbaKerberos { .DESCRIPTION This function performs a comprehensive suite of diagnostic checks to troubleshoot Kerberos authentication issues for SQL Server instances. It addresses the most common causes of Kerberos authentication failures including SPN configuration problems, DNS issues, time synchronization errors, service account configuration, network connectivity problems, and security policy misconfigurations. - The function performs 20 checks across 9 categories (plus additional checks per AG listener): + The function performs up to 18 base checks across 9 categories (plus additional checks per AG listener): SPN (1-2+ checks): - SPN Registration - Verifies required SPNs are registered using Test-DbaSpn @@ -90,7 +90,7 @@ function Test-DbaKerberos { .OUTPUTS PSCustomObject - Returns one object per diagnostic check performed (typically 20-25+ checks depending on configuration) with the following properties: + Returns one object per diagnostic check performed (typically 12-18 checks depending on parameter set and configuration) with the following properties: - ComputerName (string) - The name of the computer or SQL Server host that was tested - InstanceName (string) - The SQL Server instance name if testing an instance; $null if testing at the computer level diff --git a/public/Test-DbaLastBackup.ps1 b/public/Test-DbaLastBackup.ps1 index 9c80db609b97..1d8029993711 100644 --- a/public/Test-DbaLastBackup.ps1 +++ b/public/Test-DbaLastBackup.ps1 @@ -335,6 +335,23 @@ function Test-DbaLastBackup { } $workItems = New-Object System.Collections.Generic.List[hashtable] + $databaseFilters = @() + $excludeDatabaseFilters = @() + $hasExactDatabaseFiltersOnly = $true + + if (Test-Bound "Database") { + $databaseFilters = @($Database | ForEach-Object { [string]$PSItem }) + foreach ($databaseFilter in $databaseFilters) { + if ($databaseFilter -match "[\*\?\[]") { + $hasExactDatabaseFiltersOnly = $false + break + } + } + } + + if (Test-Bound "ExcludeDatabase") { + $excludeDatabaseFilters = @($ExcludeDatabase | ForEach-Object { [string]$PSItem }) + } if (Test-Bound "Path") { if (-not (Test-Bound "Destination")) { @@ -377,8 +394,8 @@ function Test-DbaLastBackup { DirectoryRecurse = $true EnableException = $EnableException } - if (Test-Bound "Database") { - $splatGetBackupInfo.Add("DatabaseName", $Database) + if ($databaseFilters.Count -gt 0 -and $hasExactDatabaseFiltersOnly) { + $splatGetBackupInfo.Add("DatabaseName", $databaseFilters) } if (Test-Bound "StorageCredential") { $splatGetBackupInfo.Add("StorageCredential", $StorageCredential) @@ -396,20 +413,57 @@ function Test-DbaLastBackup { return } - if (Test-Bound "ExcludeDatabase") { - $allPathBackups = $allPathBackups | Where-Object { $_.Database -notin $ExcludeDatabase } + if ($databaseFilters.Count -gt 0) { + $allPathBackups = $allPathBackups | Where-Object { + $databaseMatch = $false + foreach ($databaseFilter in $databaseFilters) { + if ($PSItem.Database -like $databaseFilter) { + $databaseMatch = $true + break + } + } + $databaseMatch + } + } + + if ($excludeDatabaseFilters.Count -gt 0) { + $allPathBackups = $allPathBackups | Where-Object { + $excludeDatabase = $false + foreach ($excludeDatabaseFilter in $excludeDatabaseFilters) { + if ($PSItem.Database -like $excludeDatabaseFilter) { + $excludeDatabase = $true + break + } + } + -not $excludeDatabase + } } - $pathDatabaseGroups = $allPathBackups | Group-Object -Property Database + $allPathBackups = $allPathBackups | Select-Object *, @{ + Name = "SourceIdentity" + Expression = { + if ($PSItem.SqlInstance) { + [string]$PSItem.SqlInstance + } elseif ($PSItem.InstanceName) { + [string]$PSItem.InstanceName + } elseif ($PSItem.ComputerName) { + [string]$PSItem.ComputerName + } else { + "N/A" + } + } + } + $pathDatabaseGroups = $allPathBackups | Group-Object -Property Database, SourceIdentity foreach ($pathDbGroup in $pathDatabaseGroups) { - $dbName = $pathDbGroup.Name + $dbName = $pathDbGroup.Group[0].Database + $sourceIdentity = $pathDbGroup.Group[0].SourceIdentity $lastbackup = $pathDbGroup.Group if (-not ($lastbackup | Where-Object { $_.Type -eq "Full" -or $_.Type -eq "Database" })) { Write-Message -Level Verbose -Message "No full backup found for $dbName in the specified path(s)." [PSCustomObject]@{ - SourceServer = "N/A" + SourceServer = $sourceIdentity TestServer = $Destination Database = $dbName FileExists = $false @@ -433,7 +487,7 @@ function Test-DbaLastBackup { $totalSizeMB = ($lastbackup.TotalSize.Megabyte | Measure-Object -Sum).Sum if ($MaxSize -and $MaxSize -lt $totalSizeMB) { [PSCustomObject]@{ - SourceServer = "N/A" + SourceServer = $sourceIdentity TestServer = $Destination Database = $dbName FileExists = $null @@ -455,21 +509,21 @@ function Test-DbaLastBackup { } $null = $workItems.Add(@{ - DbName = $dbName - LastBackup = $lastbackup - Source = "N/A" - DestServer = $destserver - DestinationName = [string]$Destination - DestinationCredential = $DestinationSqlCredential - FileExists = $true - SkipRestoreResult = $null - SkipDbccResult = $null - TrustDbBackupHistory = $false - IgnoreDiffBackupInRestore = (Test-Bound "IgnoreDiffBackup") - RemoveArray = $null - EffectiveDataDirectory = $effectiveDataDirectory - EffectiveLogDirectory = $effectiveLogDirectory - }) + DbName = $dbName + LastBackup = $lastbackup + Source = $sourceIdentity + DestServer = $destserver + DestinationName = [string]$Destination + DestinationCredential = $DestinationSqlCredential + FileExists = $true + SkipRestoreResult = $null + SkipDbccResult = $null + TrustDbBackupHistory = $true + IgnoreDiffBackupInRestore = (Test-Bound "IgnoreDiffBackup") + RemoveArray = $null + EffectiveDataDirectory = $effectiveDataDirectory + EffectiveLogDirectory = $effectiveLogDirectory + }) } } @@ -478,11 +532,11 @@ function Test-DbaLastBackup { continue } - $sourceserver = $db.Parent - $source = $db.Parent.Name - $instance = [DbaInstanceParameter]$source - $copysuccess = $true - $dbName = $db.Name + $sourceserver = $db.Parent + $source = $db.Parent.Name + $instance = [DbaInstanceParameter]$source + $copysuccess = $true + $dbName = $db.Name $restoreresult = $null if (-not (Test-Bound -ParameterName Destination)) { @@ -539,7 +593,7 @@ function Test-DbaLastBackup { if ($instance -ne $destination -and -not $CopyFile) { $sourcerealname = $sourceserver.ComputerNetBiosName - $destrealname = $destserver.ComputerNetBiosName + $destrealname = $destserver.ComputerNetBiosName if ($CopyPath) { if ($CopyPath.StartsWith("\\") -eq $false -and $sourcerealname -ne $destrealname) { @@ -576,7 +630,7 @@ function Test-DbaLastBackup { if (Test-Bound "IgnoreLogBackup") { Write-Message -Level Verbose -Message "Skipping Log backups as requested." - $lastbackup = @() + $lastbackup = @() $lastbackup += $full = Get-DbaDbBackupHistory -SqlInstance $sourceserver -Database $dbName -IncludeCopyOnly:$IncludeCopyOnly -LastFull -DeviceType $DeviceType -WarningAction SilentlyContinue if (-not (Test-Bound "IgnoreDiffBackup")) { $diff = Get-DbaDbBackupHistory -SqlInstance $sourceserver -Database $dbName -IncludeCopyOnly:$IncludeCopyOnly -LastDiff -DeviceType $DeviceType -WarningAction SilentlyContinue @@ -638,18 +692,18 @@ function Test-DbaLastBackup { } $remotedestfile = "$remotedestdirectory\$filename" - $localdestfile = "$copyPath\$filename" + $localdestfile = "$copyPath\$filename" Write-Message -Level Verbose -Message "Destination directory is $destdirectory." Write-Message -Level Verbose -Message "Destination filename is $remotedestfile." try { Write-Message -Level Verbose -Message "Copying $sourcefile to $remotedestfile." Copy-Item -Path $sourcefile -Destination $remotedestfile -ErrorAction Stop - $backup.Path = $backup.Path.Replace($file, $localdestfile) + $backup.Path = $backup.Path.Replace($file, $localdestfile) $backup.FullName = $backup.Path.Replace($file, $localdestfile) - $removearray += $remotedestfile + $removearray += $remotedestfile } catch { - $backup.Path = $backup.Path.Replace($file, $sourcefile) + $backup.Path = $backup.Path.Replace($file, $sourcefile) $backup.FullName = $backup.Path.Replace($file, $sourcefile) } } @@ -661,36 +715,36 @@ function Test-DbaLastBackup { } } - $fileexists = $true + $fileexists = $true $skipRestoreResult = $null - $skipDbccResult = $null + $skipDbccResult = $null if (-not $copysuccess) { Write-Message -Level Verbose -Message "Failed to copy backups." $lastbackup = @{ Path = "Failed to copy backups" } - $fileexists = $false + $fileexists = $false $skipRestoreResult = "Skipped" - $skipDbccResult = "Skipped" + $skipDbccResult = "Skipped" } elseif (-not ($lastbackup | Where-Object { $_.type -eq "Full" })) { Write-Message -Level Verbose -Message "No full backup returned from lastbackup." $lastbackup = @{ Path = "Not found" } - $fileexists = $false + $fileexists = $false $skipRestoreResult = "Skipped" - $skipDbccResult = "Skipped" + $skipDbccResult = "Skipped" } elseif ($source -ne $destination -and $lastbackup[0].Path.StartsWith("\\") -eq $false -and -not $CopyFile) { Write-Message -Level Verbose -Message "Path not UNC and source does not match destination. Use -CopyFile to move the backup file." - $fileexists = "Skipped" + $fileexists = "Skipped" $skipRestoreResult = "Restore not located on shared location" - $skipDbccResult = "Skipped" + $skipDbccResult = "Skipped" } elseif (($lastbackup[0].Path | ForEach-Object { Test-DbaPath -SqlInstance $destserver -Path $_ }) -eq $false) { Write-Message -Level Verbose -Message "SQL Server cannot find backup." - $fileexists = $false + $fileexists = $false $skipRestoreResult = "Skipped" - $skipDbccResult = "Skipped" + $skipDbccResult = "Skipped" } if (-not $skipRestoreResult -and ($lastbackup[0].Path -like "http*" -or $lastbackup[0].Path -like "s3*")) { @@ -699,42 +753,42 @@ function Test-DbaLastBackup { } $null = $workItems.Add(@{ - DbName = $dbName - LastBackup = $lastbackup - Source = $source - DestServer = $destserver - DestinationName = $destination - DestinationCredential = $DestinationSqlCredential - FileExists = $fileexists - SkipRestoreResult = $skipRestoreResult - SkipDbccResult = $skipDbccResult - TrustDbBackupHistory = $true - IgnoreDiffBackupInRestore = $false - RemoveArray = $removearray - EffectiveDataDirectory = $effectiveDataDirectory - EffectiveLogDirectory = $effectiveLogDirectory - }) + DbName = $dbName + LastBackup = $lastbackup + Source = $source + DestServer = $destserver + DestinationName = $destination + DestinationCredential = $DestinationSqlCredential + FileExists = $fileexists + SkipRestoreResult = $skipRestoreResult + SkipDbccResult = $skipDbccResult + TrustDbBackupHistory = $true + IgnoreDiffBackupInRestore = $false + RemoveArray = $removearray + EffectiveDataDirectory = $effectiveDataDirectory + EffectiveLogDirectory = $effectiveLogDirectory + }) } # Shared restore loop - processes work items from both -Path and -InputObject paths foreach ($workItem in $workItems) { - $dbName = $workItem.DbName - $lastbackup = $workItem.LastBackup - $source = $workItem.Source - $destserver = $workItem.DestServer + $dbName = $workItem.DbName + $lastbackup = $workItem.LastBackup + $source = $workItem.Source + $destserver = $workItem.DestServer $destinationName = $workItem.DestinationName - $fileexists = $workItem.FileExists - $ogdbname = $dbName - $prefixedDbName = "$prefix$dbName" + $fileexists = $workItem.FileExists + $ogdbname = $dbName + $prefixedDbName = "$prefix$dbName" - $restoreresult = $null - $dbccresult = $null - $success = $null - $errormsg = $null - $dbccElapsed = $restoreElapsed = $startRestore = $endRestore = $startDbcc = $endDbcc = $dbccOutput = $null + $restoreresult = $null + $dbccresult = $null + $success = $null + $errormsg = $null + $dbccElapsed = $restoreElapsed = $startRestore = $endRestore = $startDbcc = $endDbcc = $dbccOutput = $null if ($workItem.SkipRestoreResult) { - $success = $workItem.SkipRestoreResult + $success = $workItem.SkipRestoreResult $dbccresult = $workItem.SkipDbccResult } else { $destdb = $destserver.Databases[$prefixedDbName] @@ -799,9 +853,9 @@ function Test-DbaLastBackup { $errormsg = Get-ErrorMessage -Record $_ } - $endRestore = Get-Date - $restorets = New-TimeSpan -Start $startRestore -End $endRestore - $ts = [timespan]::fromseconds($restorets.TotalSeconds) + $endRestore = Get-Date + $restorets = New-TimeSpan -Start $startRestore -End $endRestore + $ts = [timespan]::fromseconds($restorets.TotalSeconds) $restoreElapsed = "{0:HH:mm:ss}" -f ([datetime]$ts.Ticks) if ($restoreresult.RestoreComplete -eq $true) { @@ -823,14 +877,14 @@ function Test-DbaLastBackup { if ($success -eq "Success") { Write-Message -Level Verbose -Message "Starting DBCC." - $startDbcc = Get-Date - $dbccCheckResult = Start-DbccCheck -Server $destserver -DbName $prefixedDbName -MaxDop $MaxDop 3>$null + $startDbcc = Get-Date + $dbccCheckResult = Start-DbccCheck -Server $destserver -DbName $prefixedDbName -MaxDop $MaxDop -DetailedOutput 3>$null $dbccresult = $dbccCheckResult.Status $dbccOutput = $dbccCheckResult.Output - $endDbcc = Get-Date + $endDbcc = Get-Date - $dbccts = New-TimeSpan -Start $startDbcc -End $endDbcc - $ts = [timespan]::fromseconds($dbccts.TotalSeconds) + $dbccts = New-TimeSpan -Start $startDbcc -End $endDbcc + $ts = [timespan]::fromseconds($dbccts.TotalSeconds) $dbccElapsed = "{0:HH:mm:ss}" -f ([datetime]$ts.Ticks) } else { $dbccresult = "Skipped" diff --git a/public/Test-DbaNetworkCertificate.ps1 b/public/Test-DbaNetworkCertificate.ps1 index 3b693a798036..d1a3d68be8a2 100644 --- a/public/Test-DbaNetworkCertificate.ps1 +++ b/public/Test-DbaNetworkCertificate.ps1 @@ -212,7 +212,7 @@ function Test-DbaNetworkCertificate { $privateKeyType = if ($null -ne $cert.PrivateKey) { $cert.PrivateKey.GetType().FullName } else { $null } $privateKeyNumber = if ($cert.PrivateKey -is [System.Security.Cryptography.RSACryptoServiceProvider]) { $cert.PrivateKey.CspKeyContainerInfo.KeyNumber } else { $null } $privateKeyValid = $cert.PrivateKey -is [System.Security.Cryptography.RSACryptoServiceProvider] -and - $cert.PrivateKey.CspKeyContainerInfo.KeyNumber -eq [System.Security.Cryptography.KeyNumber]::Exchange + $cert.PrivateKey.CspKeyContainerInfo.KeyNumber -eq [System.Security.Cryptography.KeyNumber]::Exchange } catch { $privateKeyType = $null $privateKeyNumber = $null @@ -314,19 +314,25 @@ function Test-DbaNetworkCertificate { } else { # Check configured certificate validity $configuredThumbprint = $netConf.Certificate.Thumbprint + $configuredGenerated = $netConf.Certificate.Generated $configuredExpires = $netConf.Certificate.Expires - if ($configuredThumbprint) { - $configuredDaysValid = [int]($configuredExpires - (Get-Date)).TotalDays - $configuredCertificateValid = $configuredExpires -gt (Get-Date).AddDays($MinimumValidDays) + $currentDate = Get-Date + if ($configuredThumbprint -and $configuredExpires) { + $configuredDaysValid = [int]($configuredExpires - $currentDate).TotalDays } else { $configuredDaysValid = $null + } + + if ($configuredThumbprint -and $configuredGenerated -and $configuredExpires) { + $configuredCertificateValid = $configuredGenerated -lt $currentDate -and $configuredExpires -gt $currentDate.AddDays($MinimumValidDays) + } else { $configuredCertificateValid = $false } # Filter suitable certificates by MinimumValidDays. # Get-DbaNetworkConfiguration already filters for current validity (NotAfter > now), # but we additionally filter for MinimumValidDays. - $suitableCerts = $netConf.SuitableCertificate | Where-Object { $_.NotAfter -gt (Get-Date).AddDays($MinimumValidDays) } + $suitableCerts = $netConf.SuitableCertificate | Where-Object { $_.NotAfter -gt $currentDate.AddDays($MinimumValidDays) } $suitableCertCount = ($suitableCerts | Measure-Object).Count $suitableCertObjects = foreach ($cert in $suitableCerts) { [PSCustomObject]@{ @@ -357,4 +363,4 @@ function Test-DbaNetworkCertificate { } } } -} +} \ No newline at end of file diff --git a/public/Test-DbaPath.ps1 b/public/Test-DbaPath.ps1 index eb5642c78f78..48cf484371df 100644 --- a/public/Test-DbaPath.ps1 +++ b/public/Test-DbaPath.ps1 @@ -98,6 +98,11 @@ function Test-DbaPath { try { $batchresult = $server.ConnectionContext.ExecuteWithResults($sql) } catch { + if ($EnableException) { + Stop-Function -Message "xp_fileexist execution failed for path(s) on $instance." -ErrorRecord $_ -Target $instance + return + } + Write-Message -Level Verbose -Message "xp_fileexist execution failed for path(s) on $instance. The SQL Server service account may not have access to the specified path(s). Error: $_" if ($Path.Count -eq 1 -and $SqlInstance.Count -eq 1 -and (-not($RawPath -is [array]))) { return $false diff --git a/public/Update-DbaInstance.ps1 b/public/Update-DbaInstance.ps1 index 3e8883af73f3..402329e875c6 100644 --- a/public/Update-DbaInstance.ps1 +++ b/public/Update-DbaInstance.ps1 @@ -274,6 +274,12 @@ function Update-DbaInstance { } } } + if ($Path) { + $Path = $Path | + Where-Object { -not [string]::IsNullOrWhiteSpace($PSItem) } | + ForEach-Object { $PSItem.TrimEnd("/\") } | + Where-Object { -not [string]::IsNullOrWhiteSpace($PSItem) } + } if (-not $Path) { Stop-Function -Category InvalidArgument -Message "Path is required. Please provide a -Path to a folder containing (or to store) SQL Server updates, or configure a default with Set-DbatoolsConfig -Name Path.SQLServerUpdates -Value 'C:\patches'." return @@ -437,9 +443,6 @@ function Update-DbaInstance { process { if (Test-FunctionInterrupt) { return } - if ($Path) { - $Path = $Path.TrimEnd("/\") - } #Resolve all the provided names $resolvedComputers = @() $pathIsNetwork = $Path | Test-NetworkPath @@ -468,7 +471,12 @@ function Update-DbaInstance { ## Find the current version on the computer Write-ProgressHelper -ExcludePercent -Activity $activity -StepNumber 0 -Message "Gathering all SQL Server instance versions" try { - $components = Get-SQLInstanceComponent -ComputerName $resolvedName -Credential $Credential + $splatSqlInstanceComponent = @{ + ComputerName = $resolvedName + Credential = $Credential + Authentication = $Authentication + } + $components = Get-SQLInstanceComponent @splatSqlInstanceComponent } catch { Stop-Function -Message "Error while looking for SQL Server installations on $resolvedName" -Continue -ErrorRecord $_ } @@ -484,6 +492,7 @@ function Update-DbaInstance { $splatPendingReboot = @{ ComputerName = $resolvedName Credential = $Credential + Authentication = $Authentication NoPendingRename = $NoPendingRenameCheck } $restartNeeded = Test-PendingReboot @splatPendingReboot diff --git a/tests/Add-DbaAgDatabase.Tests.ps1 b/tests/Add-DbaAgDatabase.Tests.ps1 index 3391ffb78bdc..ddc4f4d5579f 100644 --- a/tests/Add-DbaAgDatabase.Tests.ps1 +++ b/tests/Add-DbaAgDatabase.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Add-DbaAgDatabase", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,72 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "TDE certificate validation" { + BeforeEach { + $script:primaryServer = [DbaInstanceParameter]"primary" + $script:secondaryServer = [DbaInstanceParameter]"secondary" + $script:mockDatabase = [PSCustomObject]@{ + Name = "agdb" + EncryptionEnabled = $true + HasDatabaseEncryptionKey = $true + DatabaseEncryptionKey = [PSCustomObject]@{ + EncryptorType = "ServerCertificate" + EncryptorName = "TdeCert" + } + } + $script:mockTestResult = [PSCustomObject]@{ + PrimaryServerSMO = $script:primaryServer + AvailabilityGroupSMO = [PSCustomObject]@{ + AvailabilityReplicas = @{ } + } + DatabaseSMO = $script:mockDatabase + ReplicaServerSMO = @{ + "secondary" = $script:secondaryServer + } + RestoreNeeded = @{ } + Backups = $null + } + $script:primaryCert = [PSCustomObject]@{ + Name = "TdeCert" + Thumbprint = "AAA" + PrivateKeyExists = $true + } + $script:secondaryCert = $null + + Mock Test-DbaAvailabilityGroup { $script:mockTestResult } + Mock Get-DbaDbCertificate { + param($SqlInstance, $Database, $Certificate, $EnableException) + + switch ($SqlInstance.FullName) { + "primary" { $script:primaryCert } + "secondary" { $script:secondaryCert } + } + } + Mock Copy-DbaDbCertificate { } + Mock Stop-Function { throw $Message } + } + + It "stops when a replica has a same-name TDE certificate with a different thumbprint" { + $script:secondaryCert = [PSCustomObject]@{ + Name = "TdeCert" + Thumbprint = "BBB" + PrivateKeyExists = $true + } + + { + Add-DbaAgDatabase -SqlInstance "primary" -AvailabilityGroup "ag1" -Database "agdb" -SharedPath "\\share\ag" + } | Should -Throw "*does not match the primary certificate*" + } + + It "stops when a replica is missing the TDE certificate and SharedPath was not provided" { + { + Add-DbaAgDatabase -SqlInstance "primary" -AvailabilityGroup "ag1" -Database "agdb" + } | Should -Throw "*Provide -SharedPath*" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Add-DbaComputerCertificate.Tests.ps1 b/tests/Add-DbaComputerCertificate.Tests.ps1 index 569353c65b0e..bcd1e22217cf 100644 --- a/tests/Add-DbaComputerCertificate.Tests.ps1 +++ b/tests/Add-DbaComputerCertificate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Add-DbaComputerCertificate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -24,6 +24,66 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Flag handling" { + It "Should not remote import when UserProtected is combined with NonExportable" { + if (-not (Get-Command New-SelfSignedCertificate -ErrorAction SilentlyContinue)) { + Set-ItResult -Skipped -Because "New-SelfSignedCertificate cmdlet not available on this system" + return + } + + if (-not (Get-Command Export-PfxCertificate -ErrorAction SilentlyContinue)) { + Set-ItResult -Skipped -Because "Export-PfxCertificate cmdlet not available on this system" + return + } + + $tempPath = "$($TestConfig.Temp)\$CommandName-Unit-$(Get-Random)" + $null = New-Item -Path $tempPath -ItemType Directory -Force + $pfxPath = "$tempPath\testcert.pfx" + $pfxPassword = ConvertTo-SecureString -String "Test123!@#" -AsPlainText -Force + $certSubject = "CN=DbaToolsTest-$(Get-Random)" + $selfSignedCert = $null + + try { + $splatNewCert = @{ + Subject = $certSubject + CertStoreLocation = "Cert:\CurrentUser\My" + KeyExportPolicy = "Exportable" + KeySpec = "Signature" + KeyLength = 2048 + KeyAlgorithm = "RSA" + HashAlgorithm = "SHA256" + NotAfter = (Get-Date).AddDays(1) + } + $selfSignedCert = New-SelfSignedCertificate @splatNewCert + $null = Export-PfxCertificate -Cert $selfSignedCert -FilePath $pfxPath -Password $pfxPassword + + Mock Invoke-Command2 { + throw "Invoke-Command2 should not be called when UserProtected is used for a remote computer." + } -ModuleName dbatools + + $splatAddCertificate = @{ + ComputerName = "dbatools-remote" + Path = $pfxPath + SecurePassword = $pfxPassword + Flag = @("UserProtected", "NonExportable") + Confirm = $false + ErrorAction = "SilentlyContinue" + } + $null = Add-DbaComputerCertificate @splatAddCertificate + + Should -Not -Invoke Invoke-Command2 -ModuleName dbatools + } finally { + if ($selfSignedCert) { + Remove-Item -Path "Cert:\CurrentUser\My\$($selfSignedCert.Thumbprint)" -ErrorAction SilentlyContinue + } + + if ($tempPath) { + Remove-Item -Path $tempPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Backup-DbaDatabase.Tests.ps1 b/tests/Backup-DbaDatabase.Tests.ps1 index 784069c0a1b8..b2a277f343cc 100644 --- a/tests/Backup-DbaDatabase.Tests.ps1 +++ b/tests/Backup-DbaDatabase.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Backup-DbaDatabase", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -416,6 +416,35 @@ Describe $CommandName -Tag IntegrationTests { } } + Context "Should handle StorageBaseUrl striping when OutputScriptOnly specified" { + BeforeAll { + $storageScriptServer = Connect-DbaInstance -SqlInstance $TestConfig.InstanceCopy1 + $s3StorageCredential = "dbatools_ci_s3" + $singleS3StorageBaseUrl = "s3://dbatools-test.s3.us-west-2.amazonaws.com/backups" + $multipleS3StorageBaseUrls = @( + "s3://dbatools-test.s3.us-west-2.amazonaws.com/backups-a", + "s3://dbatools-test.s3.us-west-2.amazonaws.com/backups-b" + ) + } + + It "Should respect explicit FileCount when a single StorageBaseUrl is used" -Skip:($storageScriptServer.VersionMajor -lt 16) { + $result = Backup-DbaDatabase -SqlInstance $TestConfig.InstanceCopy1 -Database master -StorageBaseUrl $singleS3StorageBaseUrl -StorageCredential $s3StorageCredential -FileCount 3 -OutputScriptOnly + + ([regex]::Matches($result, "URL = N'")).Count | Should -Be 3 + $result | Should -Match "-1-of-3\.bak'" + $result | Should -Match "-3-of-3\.bak'" + } + + It "Should let multiple StorageBaseUrl values determine the stripe count" -Skip:($storageScriptServer.VersionMajor -lt 16) { + $result = Backup-DbaDatabase -SqlInstance $TestConfig.InstanceCopy1 -Database master -StorageBaseUrl $multipleS3StorageBaseUrls -StorageCredential $s3StorageCredential -FileCount 4 -OutputScriptOnly + + ([regex]::Matches($result, "URL = N'")).Count | Should -Be 2 + $result | Should -Match "-1-of-2\.bak'" + $result | Should -Match "-2-of-2\.bak'" + $result | Should -Not -Match "-1-of-4\.bak'" + } + } + Context "Should handle an encrypted database when compression is specified" { BeforeAll { $sqlencrypt = @" @@ -495,6 +524,15 @@ go } } + Context "Test CreateFolder with ReplaceInName keeps db folder when only filename contains dbname" { + It "Should still append the database folder when only the file name contains dbname token" { + $results = Backup-DbaDatabase -SqlInstance $TestConfig.InstanceCopy1 -Database master -Path "$DestBackupDir\servername\instancename\backuptype" -BackupFileName "dbname-backuptype-timestamp.bak" -ReplaceInName -CreateFolder -BuildPath + $instanceName = ([DbaInstanceParameter]$TestConfig.InstanceCopy1).InstanceName + $serverName = ([DbaInstanceParameter]$TestConfig.InstanceCopy1).ComputerName + $results.BackupPath | Should -BeLike "$DestBackupDir\$serverName\$instanceName\Full\master\master-Full-*.bak" + } + } + Context "Test Backup Encryption with Certificate" { # TODO: Should the master key be created at lab startup like in instance3? BeforeAll { diff --git a/tests/Compare-DbaDbSchema.Tests.ps1 b/tests/Compare-DbaDbSchema.Tests.ps1 index 56dd17a0874e..43457621e3f3 100644 --- a/tests/Compare-DbaDbSchema.Tests.ps1 +++ b/tests/Compare-DbaDbSchema.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Compare-DbaDbSchema", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,6 +25,187 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + BeforeAll { + function New-MockCompareDbSchemaReader { + param( + [string]$Text + ) + + $reader = [PSCustomObject]@{ + Text = $Text + } + Add-Member -InputObject $reader -MemberType ScriptMethod -Name ReadToEnd -Value { + $this.Text + } -Force + $reader + } + + function New-MockCompareDbSchemaProcess { + $process = [PSCustomObject]@{ + StartInfo = $null + ExitCode = 0 + StandardOutput = New-MockCompareDbSchemaReader -Text "sqlpackage completed" + StandardError = New-MockCompareDbSchemaReader -Text "" + } + Add-Member -InputObject $process -MemberType ScriptMethod -Name Start -Value { + $true + } -Force + Add-Member -InputObject $process -MemberType ScriptMethod -Name WaitForExit -Value { + } -Force + $process + } + + function New-MockCompareDbSchemaStartInfo { + [PSCustomObject]@{ + FileName = $null + Arguments = $null + RedirectStandardError = $false + RedirectStandardOutput = $false + UseShellExecute = $false + CreateNoWindow = $false + } + } + + $script:mockDeploymentReport = @" + + + + + + + +"@ + } + + Context "Pipeline input and target validation" { + BeforeEach { + Mock Get-DbaSqlPackagePath { "C:\tools\sqlpackage.exe" } + Mock Test-ExportDirectory { } + Mock Test-FunctionInterrupt { $false } + Mock Test-Path { + param($Path) + $null -ne $Path + } + Mock Resolve-Path { + param($Path) + [PSCustomObject]@{ + Path = $Path + } + } + Mock Get-Content { + $script:mockDeploymentReport + } + Mock Remove-Item { } + function Write-Message { + param( + $Level, + $Message, + $ErrorRecord, + $EnableException + ) + } + Mock New-Object { + New-MockCompareDbSchemaStartInfo + } -ParameterFilter { + $TypeName -eq "System.Diagnostics.ProcessStartInfo" + } + Mock New-Object { + New-MockCompareDbSchemaProcess + } -ParameterFilter { + $TypeName -eq "System.Diagnostics.Process" + } + Mock Stop-Function { + throw $Message + } + } + + It "accepts SourcePath from pipeline property input" { + $inputObject = [PSCustomObject]@{ + Path = "C:\temp\source.dacpac" + } + + $result = $inputObject | Compare-DbaDbSchema -TargetPath "C:\temp\target.dacpac" -OutputPath "C:\temp\reports" + + $result.SourcePath | Should -Be "C:\temp\source.dacpac" + $result.Target | Should -Be "C:\temp\target.dacpac" + $result.Operation | Should -Be "Create" + $result.Type | Should -Be "Table" + Should -Invoke Test-Path -Times 1 -Exactly -ParameterFilter { + $Path -eq "C:\temp\source.dacpac" + } + } + + It "rejects using both target selectors at the same time" { + { + Compare-DbaDbSchema -SourcePath "C:\temp\source.dacpac" -TargetSqlInstance "sql1" -TargetDatabase "db1" -TargetPath "C:\temp\target.dacpac" -OutputPath "C:\temp\reports" + } | Should -Throw "*Specify either -TargetSqlInstance or -TargetPath, not both.*" + } + } + + Context "Verbose logging" { + BeforeEach { + $script:capturedVerboseMessages = @() + Mock Get-DbaSqlPackagePath { "C:\tools\sqlpackage.exe" } + Mock Test-ExportDirectory { } + Mock Test-FunctionInterrupt { $false } + Mock Test-Path { + param($Path) + $null -ne $Path + } + Mock Resolve-Path { + param($Path) + [PSCustomObject]@{ + Path = $Path + } + } + Mock Connect-DbaInstance { + [PSCustomObject]@{ + DomainInstanceName = "sql1" + ConnectionContext = [PSCustomObject]@{ + ConnectionString = "Data Source=sql1;User ID=sqluser;Password=SuperSecret!;Initial Catalog=master" + } + } + } + Mock Get-Content { + $script:mockDeploymentReport + } + Mock Remove-Item { } + function Write-Message { + param($Level, $Message) + if ($Level -eq "Verbose") { + $script:capturedVerboseMessages += $Message + } + } + Mock New-Object { + New-MockCompareDbSchemaStartInfo + } -ParameterFilter { + $TypeName -eq "System.Diagnostics.ProcessStartInfo" + } + Mock New-Object { + New-MockCompareDbSchemaProcess + } -ParameterFilter { + $TypeName -eq "System.Diagnostics.Process" + } + Mock Stop-Function { + throw $Message + } + } + + It "does not write SQL credentials to verbose output" { + $securePassword = ConvertTo-SecureString "P@ssw0rd!" -AsPlainText -Force + $credential = New-Object -TypeName PSCredential -ArgumentList "sqluser", $securePassword + + $null = Compare-DbaDbSchema -SourcePath "C:\temp\source.dacpac" -TargetSqlInstance "sql1" -TargetSqlCredential $credential -TargetDatabase "db1" -OutputPath "C:\temp\reports" + + ($script:capturedVerboseMessages -join [System.Environment]::NewLine) | Should -Not -Match "SuperSecret!" + ($script:capturedVerboseMessages -join [System.Environment]::NewLine) | Should -Not -Match "Password=" + } + } + } +} + Describe $CommandName -Tag IntegrationTests { BeforeAll { # We want to run all commands in the BeforeAll block with EnableException to ensure that the test fails if the setup fails. @@ -143,4 +324,4 @@ Describe $CommandName -Tag IntegrationTests { Test-Path -Path $result[0].ReportPath | Should -BeTrue } } -} +} \ No newline at end of file diff --git a/tests/Compare-DbaLogin.Tests.ps1 b/tests/Compare-DbaLogin.Tests.ps1 index 85ef1f666a5c..50ec1d582269 100644 --- a/tests/Compare-DbaLogin.Tests.ps1 +++ b/tests/Compare-DbaLogin.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Compare-DbaLogin", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,6 +25,71 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + Context "Destination connection failures" { + BeforeEach { + function New-MockCompareDbaLoginInstance { + param( + [string]$Name + ) + + $instance = [Dataplat.Dbatools.Parameter.DbaInstanceParameter]$Name + $instance | Add-Member -NotePropertyName Name -NotePropertyValue $Name -Force + $instance + } + + Mock Test-FunctionInterrupt { $false } + Mock Stop-Function { } + Mock Connect-DbaInstance { + switch ("$SqlInstance") { + "source1" { + New-MockCompareDbaLoginInstance -Name "source1" + } + "dest1" { + New-MockCompareDbaLoginInstance -Name "dest1" + } + "dest2" { + throw "dest2 unavailable" + } + } + } + Mock Get-DbaLogin { + switch ($SqlInstance.Name) { + "source1" { + [PSCustomObject]@{ + Name = "login1" + LoginType = "SqlLogin" + } + } + "dest1" { + [PSCustomObject]@{ + Name = "login1" + LoginType = "SqlLogin" + } + } + default { + throw "Unexpected Get-DbaLogin call for $($SqlInstance.Name)" + } + } + } + } + + It "skips failed destinations without reusing the previous connection" { + $result = Compare-DbaLogin -Source "source1" -Destination "dest1", "dest2" + + @($result).Count | Should -Be 1 + $result.SourceServer | Should -Be "source1" + $result.DestinationServer | Should -Be "dest1" + Should -Invoke Get-DbaLogin -Times 2 -Exactly + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -eq "Failure connecting to dest2" -and $Continue + } + } + } + } +} + Describe $CommandName -Tag IntegrationTests { BeforeAll { $PSDefaultParameterValues["*-Dba*:EnableException"] = $true diff --git a/tests/Connect-DbaInstance.Tests.ps1 b/tests/Connect-DbaInstance.Tests.ps1 index 38a1e17c2b26..2866aba453e7 100644 --- a/tests/Connect-DbaInstance.Tests.ps1 +++ b/tests/Connect-DbaInstance.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Connect-DbaInstance", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -55,6 +55,214 @@ Describe $CommandName -Tag UnitTests { (Get-Alias cdi) | Should -Not -BeNullOrEmpty } } + + Context "Failover partner retry behavior" { + BeforeAll { + function New-MockConnectionContext { + param( + [string]$ConnectionString, + [string[]]$AttemptErrors + ) + + $sqlConnectionObject = [PSCustomObject]@{ + ConnectionString = $ConnectionString + } + + $connectionContext = [PSCustomObject]@{ + ConnectionString = $ConnectionString + SqlConnectionObject = $sqlConnectionObject + AttemptErrors = $AttemptErrors + AttemptCount = 0 + StatementTimeout = 0 + } + + Add-Member -InputObject $connectionContext -Name ExecuteWithResults -MemberType ScriptMethod -Value { + param($Query) + $this.AttemptCount++ + $this.ConnectionString = $this.SqlConnectionObject.ConnectionString + $attemptIndex = $this.AttemptCount - 1 + if ($attemptIndex -lt $this.AttemptErrors.Count -and $this.AttemptErrors[$attemptIndex]) { + throw (New-Object -TypeName System.Exception -ArgumentList $this.AttemptErrors[$attemptIndex]) + } + } -Force + + $connectionContext + } + + function New-MockServer { + param( + [string]$ConnectionString, + [string[]]$AttemptErrors + ) + + [PSCustomObject]@{ + ConnectionContext = New-MockConnectionContext -ConnectionString $ConnectionString -AttemptErrors $AttemptErrors + } + } + + Mock Add-ConnectionHashValue { } -ModuleName dbatools + Mock New-Object { + [PSCustomObject]@{ } + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Common.ServerConnection" + } + Mock New-Object { + New-MockServer -ConnectionString $script:mockConnectionString -AttemptErrors $script:attemptErrors + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Server" + } + } + + It "retries connection string inputs when failover partner requires Initial Catalog" { + $script:mockConnectionString = "Data Source=sqlmirror;Integrated Security=True;Failover Partner=mirrorpartner" + $script:attemptErrors = @( + "Use of key 'Failover Partner' requires the key 'Initial Catalog' to be present." + ) + + $result = Connect-DbaInstance -SqlInstance $script:mockConnectionString -SqlConnectionOnly + + $result.ConnectionString | Should -Match "Initial Catalog=master" + } + + It "retries with Initial Catalog after a trust certificate retry exposes the failover partner requirement" { + $script:mockConnectionString = "Data Source=sqlmirror;Integrated Security=True;FailoverPartner=mirrorpartner;Trust Server Certificate=False" + $script:attemptErrors = @( + "The certificate chain was issued by an authority that is not trusted.", + "Use of key 'Failover Partner' requires the key 'Initial Catalog' to be present." + ) + + $result = Connect-DbaInstance -SqlInstance "sqlmirror" -FailoverPartner "mirrorpartner" -AllowTrustServerCertificate -TrustServerCertificate:$false -SqlConnectionOnly + + $result.ConnectionString | Should -Match "Trust Server Certificate=True" + $result.ConnectionString | Should -Match "Initial Catalog=master" + } + } + + Context "Access token connection behavior" { + BeforeAll { + function New-MockAccessTokenServer { + $sqlConnectionObject = [PSCustomObject]@{ + ConnectionString = "Data Source=sqltoken;Integrated Security=True" + } + $connectionContext = [PSCustomObject]@{ + ConnectionString = $sqlConnectionObject.ConnectionString + SqlConnectionObject = $sqlConnectionObject + StatementTimeout = 0 + } + + Add-Member -InputObject $connectionContext -Name NonPooledConnection -MemberType ScriptProperty -Value { + $true + } -SecondValue { + param($value) + $script:nonPooledConnectionSetterCalls++ + throw "Property NonPooledConnection cannot be changed or read after a connection string has been set." + } -Force + + Add-Member -InputObject $connectionContext -Name ExecuteWithResults -MemberType ScriptMethod -Value { + param($Query) + } -Force + + [PSCustomObject]@{ + ConnectionContext = $connectionContext + } + } + + Mock Add-ConnectionHashValue { } -ModuleName dbatools + Mock New-Object { + [PSCustomObject]@{ + ConnectionString = "Data Source=sqltoken;Integrated Security=True" + AccessToken = $null + } + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.Data.SqlClient.SqlConnection" + } + Mock New-Object { + [PSCustomObject]@{ } + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Common.ServerConnection" + } + Mock New-Object { + New-MockAccessTokenServer + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Server" + } + } + + It "does not reapply NonPooledConnection when AccessToken already uses a SqlConnection" { + $script:nonPooledConnectionSetterCalls = 0 + + $result = Connect-DbaInstance -SqlInstance "sqltoken" -AccessToken "token" -NonPooledConnection -SqlConnectionOnly + + $result.ConnectionString | Should -Be "Data Source=sqltoken;Integrated Security=True" + $script:nonPooledConnectionSetterCalls | Should -Be 0 + } + } + + Context "AuthenticationType behavior" { + BeforeAll { + function New-MockAuthenticationServer { + param( + $ServerConnection + ) + + $sqlConnectionObject = [PSCustomObject]@{ + ConnectionString = $ServerConnection.ConnectionString + } + $connectionContext = [PSCustomObject]@{ + ConnectionString = $sqlConnectionObject.ConnectionString + SqlConnectionObject = $sqlConnectionObject + StatementTimeout = 0 + } + + Add-Member -InputObject $connectionContext -Name ExecuteWithResults -MemberType ScriptMethod -Value { + param($Query) + } -Force + + [PSCustomObject]@{ + ConnectionContext = $connectionContext + } + } + + Mock Add-ConnectionHashValue { } -ModuleName dbatools + Mock New-Object { + $script:lastServerConnection = [PSCustomObject]@{ + ConnectionString = $ArgumentList[0].ConnectionString + ConnectAsUser = $false + ConnectAsUserName = $null + ConnectAsUserPassword = $null + } + $script:lastServerConnection + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Common.ServerConnection" + } + Mock New-Object { + New-MockAuthenticationServer -ServerConnection $ArgumentList[0] + } -ModuleName dbatools -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Server" + } + } + + It "requires SqlCredential when AuthenticationType uses password-based auth" { + Mock Stop-Function { } -ModuleName dbatools + + Connect-DbaInstance -SqlInstance "sqlauth" -AuthenticationType ActiveDirectoryPassword | Should -BeNullOrEmpty + + Should -Invoke Stop-Function -Times 1 -Exactly -ModuleName dbatools + } + + It "uses SqlConnectionInfo credentials for ActiveDirectoryPassword on non-Azure servers" { + $securePassword = ConvertTo-SecureString "password" -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential ("user@contoso.com", $securePassword) + + $result = Connect-DbaInstance -SqlInstance "sqlauth" -SqlCredential $credential -AuthenticationType ActiveDirectoryPassword -SqlConnectionOnly + + $result.ConnectionString | Should -Match "Authentication=ActiveDirectoryPassword" + $result.ConnectionString | Should -Match "User ID=user@contoso.com" + $result.ConnectionString | Should -Not -Match "Integrated Security=True" + $script:lastServerConnection.ConnectAsUser | Should -Be $false + $script:lastServerConnection.ConnectAsUserName | Should -BeNullOrEmpty + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/ConvertTo-DbaTimeline.Tests.ps1 b/tests/ConvertTo-DbaTimeline.Tests.ps1 index 58286ef9805d..94ec3c88ec04 100644 --- a/tests/ConvertTo-DbaTimeline.Tests.ps1 +++ b/tests/ConvertTo-DbaTimeline.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "ConvertTo-DbaTimeline", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -18,10 +18,47 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Growth event input" { + BeforeAll { + $growthEvent = [PSCustomObject]@{ + SqlInstance = "sql1" + InstanceName = "MSSQLSERVER" + EventClass = 92 + ChangeInSize = 16 + DatabaseName = "MyDb" + StartTime = [datetime]"2024-01-01T00:00:00" + EndTime = [datetime]"2024-01-01T00:01:00" + } + $growthEventWithQuote = [PSCustomObject]@{ + SqlInstance = "sql1" + InstanceName = "MSSQLSERVER" + EventClass = 92 + ChangeInSize = 16 + DatabaseName = "O'Reilly" + StartTime = [datetime]"2024-01-01T00:00:00" + EndTime = [datetime]"2024-01-01T00:01:00" + } + } + + It "Supports Find-DbaDbGrowthEvent style input" { + $result = $growthEvent | ConvertTo-DbaTimeline + + $result | Should -HaveCount 3 + $result[1] | Should -Match "Data Grow" + $result[2] | Should -Match ([regex]::Escape("Find-DbaDbGrowthEvent")) + } + + It "Escapes database names for JavaScript output" { + $result = $growthEventWithQuote | ConvertTo-DbaTimeline + + $result[1] | Should -BeLike "*O\'Reilly*" + } + } } <# Integration test should appear below and are custom to the command you are writing. Read https://github.com/dataplat/dbatools/blob/development/contributing.md#tests for more guidance. -#> \ No newline at end of file +#> diff --git a/tests/Copy-DbaDbViewData.Tests.ps1 b/tests/Copy-DbaDbViewData.Tests.ps1 index ecd26fd7b310..4838c2a507ba 100644 --- a/tests/Copy-DbaDbViewData.Tests.ps1 +++ b/tests/Copy-DbaDbViewData.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Copy-DbaDbViewData", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,7 @@ Describe $CommandName -Tag UnitTests { "Query", "SqlCredential", "SqlInstance", + "ScriptingOptionsObject", "Truncate", "View" ) diff --git a/tests/Copy-DbaLogin.Tests.ps1 b/tests/Copy-DbaLogin.Tests.ps1 index 9e882d37803c..7755b4456d38 100644 --- a/tests/Copy-DbaLogin.Tests.ps1 +++ b/tests/Copy-DbaLogin.Tests.ps1 @@ -33,6 +33,28 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "OutFile behavior" { + BeforeAll { + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Get-DbaLogin { + [PSCustomObject]@{ + Name = "tester" + } + } -ModuleName dbatools + Mock Export-DbaLogin { + $FilePath + } -ModuleName dbatools + } + + It "passes ExcludeDatabase to Export-DbaLogin when ExcludeDatabaseMapping is used" { + $null = Copy-DbaLogin -Source "sql1" -Login "tester" -OutFile "C:\temp\logins.sql" -ExcludeDatabaseMapping + + Should -Invoke Export-DbaLogin -Times 1 -Exactly -ModuleName dbatools -ParameterFilter { + $FilePath -eq "C:\temp\logins.sql" -and $ExcludeDatabase + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -342,4 +364,4 @@ Describe $CommandName -Tag IntegrationTests { $functionContent | Should -BeLike '*xp_logininfo*' } } -} \ No newline at end of file +} diff --git a/tests/Copy-DbaPolicyManagement.Tests.ps1 b/tests/Copy-DbaPolicyManagement.Tests.ps1 index 2d1587b557e9..55dc57bf3e40 100644 --- a/tests/Copy-DbaPolicyManagement.Tests.ps1 +++ b/tests/Copy-DbaPolicyManagement.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Copy-DbaPolicyManagement", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,4 +25,149 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Object set selection" { + It "copies only object sets required by selected policies" { + $executedQueries = InModuleScope dbatools { + function New-MockScriptedPbmObject { + param( + [string]$Name, + [string]$ScriptText, + [string]$ObjectSet, + [string]$PolicyCategory + ) + + $mockPbmObject = [PSCustomObject]@{ + Name = $Name + IsSystemObject = $false + ObjectSet = $ObjectSet + PolicyCategory = $PolicyCategory + ScriptText = $ScriptText + } + + $mockPbmObject | Add-Member -Force -MemberType ScriptMethod -Name ScriptCreate -Value { + $scriptResult = [PSCustomObject]@{ + ScriptText = $this.ScriptText + } + $scriptResult | Add-Member -Force -MemberType ScriptMethod -Name GetScript -Value { $this.ScriptText } + $scriptResult + } + + $mockPbmObject + } + + function New-MockPbmServer { + param( + [string]$Name + ) + + $mockServer = [PSCustomObject]@{ + Name = $Name + ConnectionContext = [PSCustomObject]@{ + SqlConnectionObject = "$Name-Connection" + } + } + + $mockServer | Add-Member -Force -MemberType ScriptMethod -Name Query -Value { + param($Sql) + $script:executedQueries += $Sql.Trim() + $null + } + + $mockServer + } + + function Add-PbmLibrary { } + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + [Parameter(ValueFromRemainingArguments)] + $RemainingArguments + ) + + process { + $InputObject + } + } + function Connect-DbaInstance { + param($SqlInstance) + + if ($SqlInstance -eq "source1") { + $script:mockSourceServer + } else { + $script:mockDestinationServer + } + } + function New-Object { + param( + [string]$TypeName, + [Parameter(ValueFromRemainingArguments)] + $ArgumentList + ) + + if ($TypeName -eq "Microsoft.SqlServer.Management.Sdk.Sfc.SqlStoreConnection") { + return [PSCustomObject]@{ } + } + + if ($TypeName -eq "Microsoft.SqlServer.Management.DMF.PolicyStore") { + $script:policyStoreCallCount++ + if ($script:policyStoreCallCount -eq 1) { + return $script:mockSourceStore + } + return $script:mockDestinationStore + } + + Microsoft.PowerShell.Utility\New-Object @PSBoundParameters + } + + $script:executedQueries = @() + $script:policyStoreCallCount = 0 + $script:mockSourceServer = New-MockPbmServer -Name "source1" + $script:mockDestinationServer = New-MockPbmServer -Name "destination1" + + $mockDestinationPolicies = @{ } + $mockDestinationPolicies | Add-Member -Force -MemberType ScriptMethod -Name Refresh -Value { } + + $mockDestinationConditions = @{ } + $mockDestinationConditions | Add-Member -Force -MemberType ScriptMethod -Name Refresh -Value { } + + $mockDestinationObjectSets = @{ } + $mockDestinationObjectSets | Add-Member -Force -MemberType ScriptMethod -Name Refresh -Value { } + + $mockDestinationPolicyCategories = @{ } + $mockDestinationPolicyCategories | Add-Member -Force -MemberType ScriptMethod -Name Refresh -Value { } + + $script:mockSourceStore = [PSCustomObject]@{ + Policies = @( + (New-MockScriptedPbmObject -Name "PolicyA" -ObjectSet "ObjectSetA" -PolicyCategory "PolicyCategoryA" -ScriptText "CREATE POLICY [PolicyA]"), + (New-MockScriptedPbmObject -Name "PolicyB" -ObjectSet "ObjectSetB" -PolicyCategory "PolicyCategoryB" -ScriptText "CREATE POLICY [PolicyB]") + ) + Conditions = @() + ObjectSets = @( + (New-MockScriptedPbmObject -Name "ObjectSetA" -ScriptText "CREATE OBJECT SET [ObjectSetA]"), + (New-MockScriptedPbmObject -Name "ObjectSetB" -ScriptText "CREATE OBJECT SET [ObjectSetB]") + ) + PolicyCategories = @() + } + + $script:mockDestinationStore = [PSCustomObject]@{ + Policies = $mockDestinationPolicies + Conditions = $mockDestinationConditions + ObjectSets = $mockDestinationObjectSets + PolicyCategories = $mockDestinationPolicyCategories + } + + $null = Copy-DbaPolicyManagement -Source "source1" -Destination "destination1" -Policy "PolicyA" + + $script:executedQueries + } + + $executedQueries | Should -Contain "CREATE OBJECT SET [ObjectSetA]" + $executedQueries | Should -Contain "CREATE POLICY [PolicyA]" + $executedQueries | Should -Not -Contain "CREATE OBJECT SET [ObjectSetB]" + } + } } \ No newline at end of file diff --git a/tests/Expand-DbaDbLogFile.Tests.ps1 b/tests/Expand-DbaDbLogFile.Tests.ps1 index b07dad888e72..e4cd04f999ff 100644 --- a/tests/Expand-DbaDbLogFile.Tests.ps1 +++ b/tests/Expand-DbaDbLogFile.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Expand-DbaDbLogFile", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -28,6 +28,97 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "TargetVlfCount planning" { + BeforeEach { + $script:appliedSizes = @() + $script:measureCallCount = 0 + $script:warningMessages = @() + + $script:mockLogFile = [PSCustomObject]@{ + ID = 2 + Name = "testdb_log" + Size = 80 * 1024 + FileName = "C:\temp\testdb_log.ldf" + } + $script:mockLogFile | Add-Member -MemberType ScriptMethod -Name Alter -Value { + $script:appliedSizes += $this.Size + } + $script:mockLogFile | Add-Member -MemberType ScriptMethod -Name Refresh -Value { } + + $script:mockDatabase = [PSCustomObject]@{ + Name = "testdb" + ID = 42 + IsAccessible = $true + LogFiles = @($script:mockLogFile) + RecoveryModel = [Microsoft.SqlServer.Management.Smo.RecoveryModel]::Full + } + + $script:mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + Name = "sql1" + Version = [PSCustomObject]@{ + Major = 12 + } + Databases = @($script:mockDatabase) + } + + function Test-FunctionInterrupt { + $false + } + function Resolve-DbaComputerName { + "sql1" + } + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject + ) + + process { + $InputObject + } + } + function Write-Message { + param($Level, $Message) + + if ($Level -eq "Warning") { + $script:warningMessages += $Message + } + } + Mock Connect-DbaInstance { + $script:mockServer + } + Mock Measure-DbaDbVirtualLogFile { + $script:measureCallCount += 1 + + if ($script:measureCallCount -eq 1) { + [PSCustomObject]@{ + Total = 10 + } + } else { + [PSCustomObject]@{ + Total = 15 + } + } + } + } + + It "Uses a smaller final growth when that keeps VLFs within TargetVlfCount" { + $results = Expand-DbaDbLogFile -SqlInstance "sql1" -Database "testdb" -TargetLogSize 150 -TargetVlfCount 15 -ExcludeDiskSpaceValidation + + $results | Should -HaveCount 1 + $script:appliedSizes | Should -HaveCount 2 + $script:appliedSizes[0] | Should -BeGreaterThan (80 * 1024) + $script:appliedSizes[0] | Should -BeLessThan (150 * 1024) + $script:appliedSizes[-1] | Should -Be (150 * 1024) + $script:warningMessages | Should -BeNullOrEmpty + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Export-DbaCredential.Tests.ps1 b/tests/Export-DbaCredential.Tests.ps1 index 09d23663a5af..267c56be4a65 100644 --- a/tests/Export-DbaCredential.Tests.ps1 +++ b/tests/Export-DbaCredential.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaCredential", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,6 +25,44 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Decryption behavior" { + BeforeAll { + Mock Test-ExportDirectory { } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Connect-DbaInstance { + New-Object Microsoft.SqlServer.Management.Smo.Server "sql1" + } -ModuleName dbatools + Mock Disconnect-DbaInstance { } -ModuleName dbatools + Mock Get-ExportFilePath { "C:\temp\credentials.sql" } -ModuleName dbatools + Mock Get-DecryptedObject { + [PSCustomObject]@{ + Name = "cred1" + Quotename = "[cred1]" + Identity = "cred1identity" + Password = "Password1!" + MappedClassType = $null + ProviderName = $null + } + } -ModuleName dbatools + } + + It "Should not force decryption errors to throw by default" { + $null = Export-DbaCredential -SqlInstance "sql1" -Passthru + + Assert-MockCalled -CommandName Get-DecryptedObject -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + -not $EnableException + } + } + + It "Should request terminating decryption errors when EnableException is specified" { + $null = Export-DbaCredential -SqlInstance "sql1" -Passthru -EnableException + + Assert-MockCalled -CommandName Get-DecryptedObject -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + $EnableException + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -93,7 +131,7 @@ Describe $CommandName -Tag IntegrationTests { Context "Should export all credentials" { BeforeAll { - $exportFile = Export-DbaCredential -SqlInstance $TestConfig.InstanceSingle + $exportFile = Export-DbaCredential -SqlInstance $TestConfig.InstanceSingle -EnableException $exportResults = Get-Content -Path $exportFile -Raw } @@ -118,9 +156,10 @@ Describe $CommandName -Tag IntegrationTests { BeforeAll { $specificFilePath = "$env:USERPROFILE\Documents\dbatoolsci_credential.sql" $splatExportSpecific = @{ - SqlInstance = $TestConfig.InstanceSingle - Identity = $captainCredIdentity - FilePath = $specificFilePath + SqlInstance = $TestConfig.InstanceSingle + Identity = $captainCredIdentity + FilePath = $specificFilePath + EnableException = $true } $null = Export-DbaCredential @splatExportSpecific $specificResults = Get-Content -Path $specificFilePath @@ -143,10 +182,11 @@ Describe $CommandName -Tag IntegrationTests { BeforeAll { $appendFilePath = "$env:USERPROFILE\Documents\dbatoolsci_credential.sql" $splatExportAppend = @{ - SqlInstance = $TestConfig.InstanceSingle - Identity = $hulkCredIdentity - FilePath = $appendFilePath - Append = $true + SqlInstance = $TestConfig.InstanceSingle + Identity = $hulkCredIdentity + FilePath = $appendFilePath + Append = $true + EnableException = $true } $null = Export-DbaCredential @splatExportAppend $appendResults = Get-Content -Path $appendFilePath @@ -173,6 +213,7 @@ Describe $CommandName -Tag IntegrationTests { Identity = $captainCredIdentity FilePath = $excludePasswordFilePath ExcludePassword = $true + EnableException = $true } $null = Export-DbaCredential @splatExportNoPassword $excludePasswordResults = Get-Content -Path $excludePasswordFilePath diff --git a/tests/Export-DbaDacPackage.Tests.ps1 b/tests/Export-DbaDacPackage.Tests.ps1 index 1754c5a1e520..2a70454ed0f4 100644 --- a/tests/Export-DbaDacPackage.Tests.ps1 +++ b/tests/Export-DbaDacPackage.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaDacPackage", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,31 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + Context "Table validation" { + BeforeEach { + Mock Test-ExportDirectory { } + Mock Test-FunctionInterrupt { $false } + Mock Connect-DbaInstance { + throw "Connect-DbaInstance should not be called for invalid table filters" + } + Mock Stop-Function { + throw $Message + } + } + + It "rejects invalid table names before connecting" { + { + Export-DbaDacPackage -SqlInstance "sql1" -Database "db1" -Table "a.b.c.d" -Path "C:\temp" + } | Should -Throw "*not a valid one-, two-, or three-part name*" + + Should -Invoke Connect-DbaInstance -Times 0 -Exactly + } + } + } +} + Describe $CommandName -Tag IntegrationTests { BeforeAll { # We want to run all commands in the BeforeAll block with EnableException to ensure that the test fails if the setup fails. diff --git a/tests/Export-DbaInstance.Tests.ps1 b/tests/Export-DbaInstance.Tests.ps1 index 04c0d15d238f..718f427952bc 100644 --- a/tests/Export-DbaInstance.Tests.ps1 +++ b/tests/Export-DbaInstance.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaInstance", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -31,6 +31,365 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "DAC cleanup behavior" { + It "Should disconnect an opened DAC connection when credential export fails" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Disconnect-DbaInstance", + "Export-DbaCredential", + "Stop-Function", + "Test-ExportDirectory", + "Test-FunctionInterrupt", + "Write-Message", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Write-ProgressHelper { } + function Test-Path { $true } + function Stop-Function { + param( + $Message, + $ErrorRecord + ) + throw "$Message | inner: $($ErrorRecord.Exception.Message)" + } + function Connect-DbaInstance { + $server = [PSCustomObject]@{ + DomainInstanceName = "sql1" + } + $server.PSObject.TypeNames.Clear() + $server.PSObject.TypeNames.Add("Microsoft.SqlServer.Management.Smo.Server") + $server + } + function Export-DbaCredential { throw "credential export failed" } + function Disconnect-DbaInstance { $script:dacDisconnected = $true } + + $script:dacDisconnected = $false + $excludedObjects = @( + "AgentServer", + "Audits", + "AvailabilityGroups", + "BackupDevices", + "CentralManagementServer", + "CustomErrors", + "DatabaseMail", + "Databases", + "Endpoints", + "ExtendedEvents", + "LinkedServers", + "Logins", + "PolicyManagement", + "ReplicationSettings", + "ResourceGovernor", + "ServerAuditSpecifications", + "ServerRoles", + "SpConfigure", + "SysDbUserObjects", + "SystemTriggers", + "OleDbProvider" + ) + + { Export-DbaInstance -SqlInstance "sql1" -Path "C:\temp" -Exclude $excludedObjects -Force } | Should -Throw "*credential export failed*" + + $script:dacDisconnected | Should -BeTrue + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + Remove-Item -Path Function:\Test-Path -ErrorAction Ignore + } + } + } + + It "Should complete progress when credential export failure continues" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Disconnect-DbaInstance", + "Export-DbaCredential", + "Stop-Function", + "Test-ExportDirectory", + "Test-FunctionInterrupt", + "Write-Message", + "Write-Progress", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Write-ProgressHelper { } + function Write-Progress { + param( + $Activity, + [switch]$Completed + ) + if ($Completed) { + $script:progressCompleted = $true + } + } + function Test-Path { $true } + function Stop-Function { + param( + $Message, + $ErrorRecord, + [switch]$Continue + ) + if ($Continue) { + continue + } + throw "$Message | inner: $($ErrorRecord.Exception.Message)" + } + function Connect-DbaInstance { + $server = [PSCustomObject]@{ + DomainInstanceName = "sql1" + } + $server.PSObject.TypeNames.Clear() + $server.PSObject.TypeNames.Add("Microsoft.SqlServer.Management.Smo.Server") + $server + } + function Export-DbaCredential { throw "credential export failed" } + function Disconnect-DbaInstance { $script:dacDisconnected = $true } + + $script:dacDisconnected = $false + $script:progressCompleted = $false + $excludedObjects = @( + "AgentServer", + "Audits", + "AvailabilityGroups", + "BackupDevices", + "CentralManagementServer", + "CustomErrors", + "DatabaseMail", + "Databases", + "Endpoints", + "ExtendedEvents", + "LinkedServers", + "Logins", + "PolicyManagement", + "ReplicationSettings", + "ResourceGovernor", + "ServerAuditSpecifications", + "ServerRoles", + "SpConfigure", + "SysDbUserObjects", + "SystemTriggers", + "OleDbProvider" + ) + + $null = Export-DbaInstance -SqlInstance "sql1" -Path "C:\temp" -Exclude $excludedObjects -Force + + $script:dacDisconnected | Should -BeTrue + $script:progressCompleted | Should -BeTrue + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + Remove-Item -Path Function:\Test-Path -ErrorAction Ignore + } + } + } + } + + Context "Database key export behavior" { + It "Should stage certificate and master key exports and still return FileInfo objects" { + InModuleScope dbatools { + $functionNames = @( + "Backup-DbaDbCertificate", + "Backup-DbaDbMasterKey", + "Connect-DbaInstance", + "Copy-Item", + "Get-ChildItem", + "Join-AdminUnc", + "Remove-Item", + "Stop-Function", + "Test-DbaPath", + "Test-ExportDirectory", + "Test-FunctionInterrupt", + "Test-Path", + "Write-Message", + "Write-Progress", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Write-Progress { } + function Write-ProgressHelper { } + function Test-Path { $true } + function Stop-Function { + param( + $Message, + $ErrorRecord + ) + if ($ErrorRecord) { + throw "$Message | inner: $($ErrorRecord.Exception.Message)" + } + throw $Message + } + function Connect-DbaInstance { + $server = [PSCustomObject]@{ + ComputerName = "sql1" + DomainInstanceName = "sql1" + } + $server.PSObject.TypeNames.Clear() + $server.PSObject.TypeNames.Add("Microsoft.SqlServer.Management.Smo.Server") + $server + } + function Test-DbaPath { + param( + $SqlInstance, + $Path + ) + $Path -ne "C:\temp\sql1" + } + function Join-AdminUnc { + param( + $Servername, + $Filepath + ) + $adminSharePath = $Filepath.Substring(0, 1) + [char]36 + $Filepath.Substring(2) + "\\$Servername\$adminSharePath" + } + function Copy-Item { + param( + $Path, + $Destination, + [switch]$Force, + $ErrorAction + ) + $script:copiedFiles += [PSCustomObject]@{ + Path = $Path + Destination = $Destination + } + } + function Remove-Item { + param( + $Path, + [switch]$Force, + $ErrorAction + ) + if ("$Path" -like "\\sql1\*") { + $script:removedFiles += $Path + } + } + function Get-ChildItem { + param( + $Path, + $ErrorAction + ) + New-Object -TypeName System.IO.FileInfo -ArgumentList $Path + } + function Backup-DbaDbCertificate { + $script:certificateBackupParams = $PSBoundParameters + [PSCustomObject]@{ + Path = "D:\sqlbackup\cert1.cer" + Key = "D:\sqlbackup\cert1.pvk" + } + } + function Backup-DbaDbMasterKey { + $script:masterKeyBackupParams = $PSBoundParameters + [PSCustomObject]@{ + Filename = "D:\sqlbackup\db1-masterkey.key" + } + } + + $script:certificateBackupParams = $null + $script:masterKeyBackupParams = $null + $script:copiedFiles = @() + $script:removedFiles = @() + $excludedObjects = @( + "AgentServer", + "Audits", + "AvailabilityGroups", + "BackupDevices", + "CentralManagementServer", + "Credentials", + "CustomErrors", + "DatabaseMail", + "Databases", + "Endpoints", + "ExtendedEvents", + "LinkedServers", + "Logins", + "PolicyManagement", + "ReplicationSettings", + "ResourceGovernor", + "ServerAuditSpecifications", + "ServerRoles", + "SpConfigure", + "SysDbUserObjects", + "SystemTriggers", + "OleDbProvider" + ) + $encryptionPassword = ConvertTo-SecureString -String "P@ssw0rd!" -AsPlainText -Force + + $results = Export-DbaInstance -SqlInstance "sql1" -Path "C:\temp" -IncludeDbMasterKey -EncryptionPassword $encryptionPassword -Exclude $excludedObjects -Force + + $script:certificateBackupParams.ContainsKey("Path") | Should -BeFalse + $script:masterKeyBackupParams.ContainsKey("Path") | Should -BeFalse + @($results).Count | Should -Be 3 + $results.FullName | Should -Be @( + "C:\temp\sql1\cert1.cer", + "C:\temp\sql1\cert1.pvk", + "C:\temp\sql1\db1-masterkey.key" + ) + foreach ($result in $results) { + $result.GetType().FullName | Should -Be "System.IO.FileInfo" + } + $script:copiedFiles.Destination | Should -Be $results.FullName + $script:removedFiles | Should -Be @( + "\\sql1\D$\sqlbackup\cert1.cer", + "\\sql1\D$\sqlbackup\cert1.pvk", + "\\sql1\D$\sqlbackup\db1-masterkey.key" + ) + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Export-DbaLinkedServer.Tests.ps1 b/tests/Export-DbaLinkedServer.Tests.ps1 index 8171df3ebf75..87cdf271a6c8 100644 --- a/tests/Export-DbaLinkedServer.Tests.ps1 +++ b/tests/Export-DbaLinkedServer.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaLinkedServer", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -26,6 +26,49 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Decryption behavior" -Skip:($IsLinux -or $IsMacOS) { + BeforeAll { + Mock Test-ExportDirectory { } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Connect-DbaInstance { + $mockLinkedServer = New-Object Microsoft.SqlServer.Management.Smo.LinkedServer + $mockLinkedServer.Name = "linked1" + $mockLinkedServer | Add-Member -MemberType ScriptMethod -Name Script -Value { + "EXEC sp_addlinkedserver @server=N'linked1'" + } -Force + + $server = New-Object Microsoft.SqlServer.Management.Smo.Server "sql1" + $server | Add-Member -MemberType NoteProperty -Name LinkedServers -Value @($mockLinkedServer) -Force + $server + } -ModuleName dbatools + Mock Disconnect-DbaInstance { } -ModuleName dbatools + Mock Get-ExportFilePath { "C:\temp\linkedservers.sql" } -ModuleName dbatools + Mock Get-DecryptedObject { + [PSCustomObject]@{ + Name = "linked1" + Identity = "remoteuser" + Password = "Password1!" + } + } -ModuleName dbatools + } + + It "Should not force decryption errors to throw by default" { + $null = Export-DbaLinkedServer -SqlInstance "sql1" -Passthru + + Assert-MockCalled -CommandName Get-DecryptedObject -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + -not $EnableException + } + } + + It "Should request terminating decryption errors when EnableException is specified" { + $null = Export-DbaLinkedServer -SqlInstance "sql1" -Passthru -EnableException + + Assert-MockCalled -CommandName Get-DecryptedObject -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + $EnableException + } + } + } } <# Integration test should appear below and are custom to the command you are writing. diff --git a/tests/Export-DbaLogin.Tests.ps1 b/tests/Export-DbaLogin.Tests.ps1 index c39b6535efc5..654456ff946b 100644 --- a/tests/Export-DbaLogin.Tests.ps1 +++ b/tests/Export-DbaLogin.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaLogin", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -37,6 +37,315 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "IncludeRolePermissions scripting" { + BeforeAll { + if (-not ("ExportDbaLoginRoleTest.MockServer" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Collections; +using System.Collections.Generic; + +namespace ExportDbaLoginRoleTest { + public class MockCollection : IEnumerable { + private Dictionary items = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void Add(string name, T item) { + items[name] = item; + } + + public T this[string name] { + get { return items[name]; } + } + + public IEnumerator GetEnumerator() { + return items.Values.GetEnumerator(); + } + } + + public class MockMapping { + public string DBName { get; set; } + public string UserName { get; set; } + public string LoginName { get; set; } + } + + public class MockUser { + public string Name { get; set; } + public string[] Scripts { get; set; } + + public string[] Script(object scriptingOptions) { + return Scripts; + } + } + + public class MockRole { + public string Name { get; set; } + public bool IsFixedRole { get; set; } + public string[] Members { get; set; } + public string[] RoleScripts { get; set; } + + public string[] EnumMembers() { + return Members ?? Array.Empty(); + } + + public string[] Script(object scriptingOptions) { + return RoleScripts; + } + } + + public class MockCredential { + public string Identity { get; set; } + public string Name { get; set; } + } + + public class MockServerRole { + public string Name { get; set; } + public string[] Members { get; set; } + + public string[] EnumMemberNames() { + return Members ?? Array.Empty(); + } + + public string[] EnumServerRoleMembers() { + return Members ?? Array.Empty(); + } + } + + public class MockJob { + public string OwnerLoginName { get; set; } + } + + public class MockJobServer { + public List Jobs { get; set; } + + public MockJobServer() { + Jobs = new List(); + } + } + + public class MockLogin { + public string Name { get; set; } + public string DefaultDatabase { get; set; } + public string Language { get; set; } + public bool PasswordPolicyEnforced { get; set; } + public bool PasswordExpirationEnabled { get; set; } + public string LoginType { get; set; } + public bool IsDisabled { get; set; } + public bool DenyWindowsLogin { get; set; } + public MockMapping[] DatabaseMappings { get; set; } + + public MockMapping[] EnumDatabaseMappings() { + return DatabaseMappings ?? Array.Empty(); + } + } + + public class MockDatabase { + public string Name { get; set; } + public bool IsAccessible { get; set; } + public string CompatibilityLevel { get; set; } + public MockCollection Roles { get; set; } + public MockCollection Users { get; set; } + public MockMapping[] LoginMappings { get; set; } + + public MockDatabase() { + Roles = new MockCollection(); + Users = new MockCollection(); + } + + public MockMapping[] EnumLoginMappings() { + return LoginMappings ?? Array.Empty(); + } + + public object[] EnumDatabasePermissions(string userName) { + return Array.Empty(); + } + } + + public class MockServer { + public string Name { get; set; } + public int VersionMajor { get; set; } + public List Logins { get; set; } + public List Roles { get; set; } + public MockJobServer JobServer { get; set; } + public List Credentials { get; set; } + public MockCollection Databases { get; set; } + + public MockServer() { + Logins = new List(); + Roles = new List(); + JobServer = new MockJobServer(); + Credentials = new List(); + Databases = new MockCollection(); + } + + public object[] EnumServerPermissions(string userName) { + return Array.Empty(); + } + } +} +"@ + } + } + + It "Should script role permissions before role membership for non-ObjectLevel export" { + InModuleScope dbatools { + function Write-Message { } + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + function Export-DbaDbRole { + @( + "CREATE ROLE [app_role]", + "GRANT SELECT ON SCHEMA::[dbo] TO [app_role]" + ) + } + function Export-DbaUser { + @( + "CREATE ROLE [app_role]", + "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'app_user') CREATE USER [app_user] FOR LOGIN [CONTOSO\app_login]", + "ALTER ROLE [app_role] ADD MEMBER [app_user]" + ) + } + function Connect-DbaInstance { + $mapping = New-Object ExportDbaLoginRoleTest.MockMapping + $mapping.DBName = "db1" + $mapping.UserName = "app_user" + $mapping.LoginName = "CONTOSO\app_login" + + $user = New-Object ExportDbaLoginRoleTest.MockUser + $user.Name = "app_user" + $user.Scripts = @( + "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'app_user') CREATE USER [app_user] FOR LOGIN [CONTOSO\app_login]" + ) + + $role = New-Object ExportDbaLoginRoleTest.MockRole + $role.Name = "app_role" + $role.IsFixedRole = $false + $role.Members = @("app_user") + $role.RoleScripts = @("CREATE ROLE [app_role]") + + $database = New-Object ExportDbaLoginRoleTest.MockDatabase + $database.Name = "db1" + $database.IsAccessible = $true + $database.CompatibilityLevel = "Version160" + $database.LoginMappings = @($mapping) + $database.Users.Add("app_user", $user) + $database.Roles.Add("app_role", $role) + + $login = New-Object ExportDbaLoginRoleTest.MockLogin + $login.Name = "CONTOSO\app_login" + $login.DefaultDatabase = "master" + $login.Language = "us_english" + $login.PasswordPolicyEnforced = $true + $login.PasswordExpirationEnabled = $true + $login.LoginType = "WindowsUser" + $login.IsDisabled = $false + $login.DenyWindowsLogin = $false + $login.DatabaseMappings = @($mapping) + + $server = New-Object ExportDbaLoginRoleTest.MockServer + $server.Name = "mockserver" + $server.VersionMajor = 16 + $server.Logins.Add($login) + $server.Databases.Add("db1", $database) + + $server + } + + $results = Export-DbaLogin -SqlInstance "mockserver" -Login "CONTOSO\app_login" -Database "db1" -IncludeRolePermissions -Passthru + + $createRoleIndex = $results.IndexOf("CREATE ROLE [app_role]") + $grantIndex = $results.IndexOf("GRANT SELECT ON SCHEMA::[dbo] TO [app_role]") + $membershipIndex = $results.IndexOf("ALTER ROLE [app_role] ADD MEMBER [app_user]") + + $createRoleIndex | Should -BeGreaterThan -1 + $grantIndex | Should -BeGreaterThan -1 + $membershipIndex | Should -BeGreaterThan -1 + $createRoleIndex | Should -BeLessThan $membershipIndex + $grantIndex | Should -BeLessThan $membershipIndex + } + } + + It "Should not duplicate role creation in ObjectLevel export" { + InModuleScope dbatools { + function Write-Message { } + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + $script:exportDbaDbRoleCalls = 0 + $script:exportDbaUserCalls = 0 + function Export-DbaDbRole { + $script:exportDbaDbRoleCalls++ + @( + "CREATE ROLE [app_role]", + "GRANT SELECT ON SCHEMA::[dbo] TO [app_role]" + ) + } + function Export-DbaUser { + $script:exportDbaUserCalls++ + @( + "CREATE ROLE [app_role]", + "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'app_user') CREATE USER [app_user] FOR LOGIN [CONTOSO\app_login]", + "ALTER ROLE [app_role] ADD MEMBER [app_user]" + ) + } + function Connect-DbaInstance { + $mapping = New-Object ExportDbaLoginRoleTest.MockMapping + $mapping.DBName = "db1" + $mapping.UserName = "app_user" + $mapping.LoginName = "CONTOSO\app_login" + + $user = New-Object ExportDbaLoginRoleTest.MockUser + $user.Name = "app_user" + $user.Scripts = @( + "IF NOT EXISTS (SELECT * FROM sys.database_principals WHERE name = N'app_user') CREATE USER [app_user] FOR LOGIN [CONTOSO\app_login]" + ) + + $role = New-Object ExportDbaLoginRoleTest.MockRole + $role.Name = "app_role" + $role.IsFixedRole = $false + $role.Members = @("app_user") + $role.RoleScripts = @("CREATE ROLE [app_role]") + + $database = New-Object ExportDbaLoginRoleTest.MockDatabase + $database.Name = "db1" + $database.IsAccessible = $true + $database.CompatibilityLevel = "Version160" + $database.LoginMappings = @($mapping) + $database.Users.Add("app_user", $user) + $database.Roles.Add("app_role", $role) + + $login = New-Object ExportDbaLoginRoleTest.MockLogin + $login.Name = "CONTOSO\app_login" + $login.DefaultDatabase = "master" + $login.Language = "us_english" + $login.PasswordPolicyEnforced = $true + $login.PasswordExpirationEnabled = $true + $login.LoginType = "WindowsUser" + $login.IsDisabled = $false + $login.DenyWindowsLogin = $false + $login.DatabaseMappings = @($mapping) + + $server = New-Object ExportDbaLoginRoleTest.MockServer + $server.Name = "mockserver" + $server.VersionMajor = 16 + $server.Logins.Add($login) + $server.Databases.Add("db1", $database) + + $server + } + + $results = Export-DbaLogin -SqlInstance "mockserver" -Login "CONTOSO\app_login" -Database "db1" -ObjectLevel -IncludeRolePermissions -Passthru + $createRoleMatches = [regex]::Matches($results, [regex]::Escape("CREATE ROLE [app_role]")) + $grantIndex = $results.IndexOf("GRANT SELECT ON SCHEMA::[dbo] TO [app_role]") + $membershipIndex = $results.IndexOf("ALTER ROLE [app_role] ADD MEMBER [app_user]") + + $createRoleMatches.Count | Should -Be 1 + $grantIndex | Should -BeGreaterThan -1 + $membershipIndex | Should -BeGreaterThan -1 + $script:exportDbaUserCalls | Should -Be 1 + $script:exportDbaDbRoleCalls | Should -Be 1 + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -179,15 +488,39 @@ Describe $CommandName -Tag IntegrationTests { Context "Executes with IncludeRolePermissions" { It "Should include role permissions in non-ObjectLevel export" { $results = Export-DbaLogin -SqlInstance $server -Login $login4 -Database $dbname1 -IncludeRolePermissions -Passthru -WarningAction SilentlyContinue + $createRolePattern = [regex]::Escape("CREATE ROLE [$role4]") + if ($server.VersionMajor -lt 11) { + $membershipPattern = [regex]::Escape("@rolename=N'$role4', @membername=N'$user4'") + } else { + $membershipPattern = [regex]::Escape("ALTER ROLE [$role4] ADD MEMBER [$user4]") + } + $createRoleMatches = [regex]::Matches($results, $createRolePattern) + $membershipMatch = [regex]::Match($results, $membershipPattern) + $results | Should -Match "GRANT SELECT ON SCHEMA::\[dbo\]" $results | Should -Match "GRANT EXECUTE ON SCHEMA::\[dbo\]" $results | Should -Match ([regex]::Escape("[$role4]")) + $createRoleMatches.Count | Should -Be 1 + $membershipMatch.Success | Should -BeTrue + $createRoleMatches[0].Index | Should -BeLessThan $membershipMatch.Index } It "Should include role permissions in ObjectLevel export" { $results = Export-DbaLogin -SqlInstance $server -Login $login4 -Database $dbname1 -ObjectLevel -IncludeRolePermissions -Passthru -WarningAction SilentlyContinue + $createRolePattern = [regex]::Escape("CREATE ROLE [$role4]") + if ($server.VersionMajor -lt 11) { + $membershipPattern = [regex]::Escape("@rolename=N'$role4', @membername=N'$user4'") + } else { + $membershipPattern = [regex]::Escape("ALTER ROLE [$role4] ADD MEMBER [$user4]") + } + $createRoleMatches = [regex]::Matches($results, $createRolePattern) + $membershipMatch = [regex]::Match($results, $membershipPattern) + $results | Should -Match "GRANT SELECT ON SCHEMA::\[dbo\]" $results | Should -Match "GRANT EXECUTE ON SCHEMA::\[dbo\]" $results | Should -Match ([regex]::Escape("[$role4]")) + $createRoleMatches.Count | Should -Be 1 + $membershipMatch.Success | Should -BeTrue + $createRoleMatches[0].Index | Should -BeLessThan $membershipMatch.Index } It "Should not include role permissions without the switch" { $results = Export-DbaLogin -SqlInstance $server -Login $login4 -Database $dbname1 -Passthru -WarningAction SilentlyContinue diff --git a/tests/Export-DbaScript.Tests.ps1 b/tests/Export-DbaScript.Tests.ps1 index 29d38a575539..34caad627160 100644 --- a/tests/Export-DbaScript.Tests.ps1 +++ b/tests/Export-DbaScript.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaScript", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -26,6 +26,36 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Distributed Availability Group scripting" { + BeforeAll { + Mock Test-ExportDirectory { } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + + $mockServer = New-Object Microsoft.SqlServer.Management.Smo.Server "sql1" + $mockServer | Add-Member -MemberType NoteProperty -Name Urn -Value ([PSCustomObject]@{ Type = "Server" }) -Force + + $mockReplica = New-Object Microsoft.SqlServer.Management.Smo.AvailabilityReplica + $mockReplica | Add-Member -MemberType NoteProperty -Name Name -Value "AG'1" -Force + $mockReplica | Add-Member -MemberType NoteProperty -Name EndpointUrl -Value "TCP://listen'er:5022" -Force + $mockReplica | Add-Member -MemberType NoteProperty -Name AvailabilityMode -Value "AsynchronousCommit" -Force + $mockReplica | Add-Member -MemberType NoteProperty -Name SeedingMode -Value "Automatic" -Force + + $mockDag = New-Object Microsoft.SqlServer.Management.Smo.AvailabilityGroup + $mockDag | Add-Member -MemberType NoteProperty -Name Name -Value "DAG]One" -Force + $mockDag | Add-Member -MemberType NoteProperty -Name Parent -Value $mockServer -Force + $mockDag | Add-Member -MemberType NoteProperty -Name IsDistributedAvailabilityGroup -Value $true -Force + $mockDag | Add-Member -MemberType NoteProperty -Name AvailabilityReplicas -Value @($mockReplica) -Force + } + + It "Should escape names and endpoint URLs when scripting distributed availability groups" { + $results = Export-DbaScript -InputObject $mockDag -Passthru -NoPrefix + + $results | Should -Match "CREATE AVAILABILITY GROUP \[DAG\]\]One\]" + $results | Should -Match "N'AG''1' WITH" + $results | Should -Match "LISTENER_URL = N'TCP://listen''er:5022'" + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Export-DbaUser.Tests.ps1 b/tests/Export-DbaUser.Tests.ps1 index 7008b71f4207..5a0d3e763010 100644 --- a/tests/Export-DbaUser.Tests.ps1 +++ b/tests/Export-DbaUser.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Export-DbaUser", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -32,6 +32,88 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Schema ownership compatibility" { + BeforeEach { + function Write-Message { } + function Write-ProgressHelper { } + function Test-ExportDirectory { } + function Test-FunctionInterrupt { $false } + + Mock Get-ExportFilePath { "C:\temp\mock-export.sql" } + Mock Stop-Function { } + + $mockServerBase = New-Object Microsoft.SqlServer.Management.Smo.Server + $mockServer = New-MockObject -InputObject $mockServerBase -Properties @{ + Name = "mockserver" + VersionMajor = 16 + } + + $mockServiceBroker = [PSCustomObject]@{ + MessageTypes = @() + Routes = @() + ServiceContracts = @() + Services = @() + } + + $mockUser = [PSCustomObject]@{ + Name = "app_user" + IsSystemObject = $false + Login = "app_login" + } + $mockUser | Add-Member -Force -MemberType ScriptMethod -Name Script -Value { + param($ScriptingOptionsObject) + @("CREATE USER [app_user] FOR LOGIN [app_login]") + } + + $mockSchema = [PSCustomObject]@{ + Name = "app_schema" + Owner = "app_user" + } + + $mockDatabaseBase = New-Object Microsoft.SqlServer.Management.Smo.Database + $mockDatabaseBase.Name = "appdb" + $mockDatabase = New-MockObject -InputObject $mockDatabaseBase -Properties @{ + Parent = $mockServer + CompatibilityLevel = "Version160" + Users = @($mockUser) + Roles = @() + Schemas = @($mockSchema) + ApplicationRoles = @() + Assemblies = @() + Certificates = @() + DatabaseRoles = @() + FullTextCatalogs = @() + FullTextStopLists = @() + SearchPropertyLists = @() + RemoteServiceBindings = @() + AsymmetricKeys = @() + SymmetricKeys = @() + XmlSchemaCollections = @() + ServiceBroker = $mockServiceBroker + } -Methods @{ + EnumDatabasePermissions = { @() } + EnumObjectPermissions = { + param($UserName) + @() + } + } + + Mock Get-DbaDatabase { $mockDatabase } + } + + It "Stops instead of emitting invalid schema ownership for SQL Server 2000 destinations" { + $results = Export-DbaUser -SqlInstance "mockserver" -Database "appdb" -User "app_user" -DestinationVersion SQLServer2000 -Passthru + + $results | Should -Not -Match "ALTER AUTHORIZATION ON SCHEMA::" + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -like "*does not exist on the destination version (SQLServer2000)*" -and + $Continue + } + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -229,5 +311,12 @@ Describe $CommandName -Tag IntegrationTests { $results = Export-DbaUser -SqlInstance $TestConfig.InstanceSingle -Database $dbname -User $user -Template -Passthru $results | Should -BeLike "*ALTER AUTHORIZATION ON SCHEMA::[[]$schema] TO [[]``{templateUser``}]*" } + + It "Warns when schema ownership cannot be scripted to SQL Server 2000" { + $WarnVar = $null + $results = Export-DbaUser -SqlInstance $TestConfig.InstanceSingle -Database $dbname -User $user -DestinationVersion SQLServer2000 -Passthru -WarningVariable WarnVar -WarningAction SilentlyContinue + $results | Should -Not -Match "ALTER AUTHORIZATION ON SCHEMA::" + $WarnVar | Should -Match "does not exist on the destination version" + } } } \ No newline at end of file diff --git a/tests/Find-DbaInstance.Tests.ps1 b/tests/Find-DbaInstance.Tests.ps1 index 38e858085230..7e8f99970141 100644 --- a/tests/Find-DbaInstance.Tests.ps1 +++ b/tests/Find-DbaInstance.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Find-DbaInstance", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,6 +25,104 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + BeforeAll { + function New-MockFindDbaInstanceUdpClient { + param( + [byte[]]$ResponseBytes + ) + + $udpClient = [PSCustomObject]@{ + Client = [PSCustomObject]@{ + ReceiveTimeout = 0 + Blocking = $false + } + ResponseBytes = $ResponseBytes + } + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Connect -Value { + param( + $ComputerName, + $Port + ) + } -Force + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Send -Value { + param( + [byte[]]$Buffer, + [int]$Count + ) + + $Count + } -Force + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Receive -Value { + param([ref]$RemoteEndPoint) + + $this.ResponseBytes + } -Force + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Close -Value { + } -Force + $udpClient + } + + function New-MockFindDbaInstanceTcpClient { + $tcpClient = [PSCustomObject]@{ + Connected = $false + } + Add-Member -InputObject $tcpClient -MemberType ScriptMethod -Name Connect -Value { + param( + $ComputerName, + $Port + ) + + $script:tcpConnectPorts += $Port + $this.Connected = $Port -in @(1433, 51433) + } -Force + Add-Member -InputObject $tcpClient -MemberType ScriptMethod -Name Dispose -Value { + } -Force + $tcpClient + } + } + + Context "Browser scan handling" { + BeforeEach { + $script:tcpConnectPorts = @() + $script:browserResponseBytes = [System.Text.Encoding]::ASCII.GetBytes( + "ServerName;sqlhost;InstanceName;MSSQLSERVER;IsClustered;No;Version;16.0.1000.6;ServerName;sqlhost;InstanceName;DEV;IsClustered;No;Version;16.0.1000.6;tcp;51433" + ) + + Mock Test-FunctionInterrupt { $false } + function Write-ProgressHelper { + } + function Write-Message { + } + Mock New-Object { + New-MockFindDbaInstanceUdpClient -ResponseBytes $script:browserResponseBytes + } -ParameterFilter { + $TypeName -eq "System.Net.Sockets.UdpClient" + } + Mock New-Object { + New-MockFindDbaInstanceTcpClient + } -ParameterFilter { + $TypeName -eq "Net.Sockets.TcpClient" + } + } + + It "scans fallback ports for default instances without reusing named instance ports" { + $results = Find-DbaInstance -ComputerName "sqlhost" -ScanType Browser + $defaultInstance = $results | Where-Object InstanceName -eq "MSSQLSERVER" + $namedInstance = $results | Where-Object InstanceName -eq "DEV" + + $defaultInstance | Should -Not -BeNullOrEmpty + $namedInstance | Should -Not -BeNullOrEmpty + $script:tcpConnectPorts | Should -Contain 1433 + $script:tcpConnectPorts | Should -Contain 51433 + $defaultInstance.Port | Should -Be 1433 + $defaultInstance.TcpConnected | Should -Be $true + $namedInstance.Port | Should -Be 51433 + $namedInstance.TcpConnected | Should -Be $true + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Find-DbaObject.Tests.ps1 b/tests/Find-DbaObject.Tests.ps1 index 99fa1b890b4b..a158165350e9 100644 --- a/tests/Find-DbaObject.Tests.ps1 +++ b/tests/Find-DbaObject.Tests.ps1 @@ -63,6 +63,15 @@ CREATE PROCEDURE dbo.usp_GetServiceOrders AS SELECT * FROM dbo.ServiceOrder; GO + +CREATE TRIGGER trg_ServiceAudit +ON DATABASE +FOR CREATE_TABLE +AS +BEGIN + SET NOCOUNT ON; +END; +GO "@ $splatCreateObjects = @{ SqlInstance = $TestConfig.InstanceSingle @@ -124,6 +133,19 @@ GO $results.Name | Should -Contain "usp_GetServiceOrders" } + It "Should find database DDL triggers whose names match the pattern" { + $splatFind = @{ + SqlInstance = $TestConfig.InstanceSingle + Database = $testDbName + Pattern = "ServiceAudit" + ObjectType = "Trigger" + } + $results = Find-DbaObject @splatFind + $results | Should -Not -BeNullOrEmpty + $results.Name | Should -Contain "trg_ServiceAudit" + $results.Schema | Should -BeNullOrEmpty + } + It "Should return MatchType of ObjectName for object name matches" { $splatFind = @{ SqlInstance = $TestConfig.InstanceSingle diff --git a/tests/Format-DbaBackupInformation.Tests.ps1 b/tests/Format-DbaBackupInformation.Tests.ps1 index 15c9bd456de1..175b2da71c11 100644 --- a/tests/Format-DbaBackupInformation.Tests.ps1 +++ b/tests/Format-DbaBackupInformation.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Format-DbaBackupInformation", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -81,6 +81,20 @@ Describe $CommandName -Tag IntegrationTests { } } + Context "Rename a Database using ReplaceDbNameInFile with special chars in the new name" { + BeforeAll { + $databaseName = "Pester$" + "&" + $history = Get-DbaBackupInformation -Import -Path $PSScriptRoot\ObjectDefinitions\BackupRestore\RawInput\RestoreTimeClean.xml + $output = Format-DbaBackupInformation -BackupHistory $history -ReplaceDatabaseName $databaseName -ReplaceDbNameInFile + $logFiles = $output | Select-Object -ExpandProperty FileList | Where-Object { $_.Type -eq "L" } + } + + It "Should rename log file names literally" { + ($logFiles | Where-Object { $_.PhysicalName.Contains($databaseName) }).Count | Should -BeGreaterThan 0 + ($logFiles | Where-Object { $_.PhysicalName -like "*RestoreTimeClean*" }).Count | Should -Be 0 + } + } + Context "Rename 2 dbs using a hash" { BeforeAll { $history = Get-DbaBackupInformation -Import -Path $PSScriptRoot\ObjectDefinitions\BackupRestore\RawInput\ContinuePointTest.xml diff --git a/tests/Get-DbaAgRingBuffer.Tests.ps1 b/tests/Get-DbaAgRingBuffer.Tests.ps1 index 597f105c179c..bd4313381833 100644 --- a/tests/Get-DbaAgRingBuffer.Tests.ps1 +++ b/tests/Get-DbaAgRingBuffer.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaAgRingBuffer", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -20,6 +20,75 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Query handling" { + BeforeAll { + $script:lastQuery = $null + $script:throwRingBufferQuery = $false + + $script:mockTimestampTable = New-Object System.Data.DataTable + $null = $script:mockTimestampTable.Columns.Add("TimeStamp", [double]) + $timestampRow = $script:mockTimestampTable.NewRow() + $timestampRow.TimeStamp = 123456 + $script:mockTimestampTable.Rows.Add($timestampRow) + + $script:mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + } + + $script:mockServer | Add-Member -Force -MemberType ScriptMethod -Name Query -Value { + param($Sql) + + if ($Sql -like "*sys.dm_os_sys_info*") { + $script:mockTimestampTable + } elseif ($script:throwRingBufferQuery) { + throw "ring buffer query failed" + } else { + $script:lastQuery = $Sql + @() + } + } + } + + BeforeEach { + $script:lastQuery = $null + $script:throwRingBufferQuery = $false + } + + It "uses the scalar timestamp value when building the HADR ring buffer query" { + Mock Connect-DbaInstance { + $script:mockServer + } + + $null = Get-DbaAgRingBuffer -SqlInstance "sql1" + + $script:lastQuery | Should -Match "DATEADD\(ms, -1 \* \(123456 - \[timestamp\]\), GETDATE\(\)\)" + $script:lastQuery | Should -Not -Match "System\.Data\.DataRow" + } + + It "routes HADR ring buffer query failures through Stop-Function" { + Mock Connect-DbaInstance { + $script:mockServer + } + Mock Stop-Function { + param( + $Message, + $Target, + $ErrorRecord + ) + + throw "$Message | inner: $($ErrorRecord.Exception.Message) | target: $Target" + } + + $script:throwRingBufferQuery = $true + + { Get-DbaAgRingBuffer -SqlInstance "sql1" } | Should -Throw "*Failed to query HADR ring buffer data.*ring buffer query failed*target: sql1*" + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -44,4 +113,4 @@ Describe $CommandName -Tag IntegrationTests { } } } -} +} \ No newline at end of file diff --git a/tests/Get-DbaDbIdentity.Tests.ps1 b/tests/Get-DbaDbIdentity.Tests.ps1 index 6813d282e634..8686fb8c538e 100644 --- a/tests/Get-DbaDbIdentity.Tests.ps1 +++ b/tests/Get-DbaDbIdentity.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaDbIdentity", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -20,6 +20,50 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + $script:lastQuery = $null + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + IsAccessible = $true + } + $script:mockServer = [PSCustomObject]@{ + Name = "sql1" + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + Databases = @($script:mockDatabase) + } + + function Invoke-DbaQuery { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + $Query, + $Database, + [switch]$MessagesToOutput + ) + + process { + $script:lastQuery = $Query + "Checking identity information: current identity value '5', current column value '5'." + } + } + Mock Connect-DbaInstance { $script:mockServer } + } + + It "escapes closing brackets in normalized table names" { + $script:lastQuery = $null + + $result = Get-DbaDbIdentity -SqlInstance "sql1" -Database "db1" -Table "[dbo].[Bad]]Name]" + + $script:lastQuery | Should -Be "DBCC CHECKIDENT('[dbo].[Bad]]Name]', NORESEED)" + $result.Cmd | Should -Be "DBCC CHECKIDENT('[dbo].[Bad]]Name]', NORESEED)" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaDbOrphanUser.Tests.ps1 b/tests/Get-DbaDbOrphanUser.Tests.ps1 index 9e650a5ce1f0..410f3da26092 100644 --- a/tests/Get-DbaDbOrphanUser.Tests.ps1 +++ b/tests/Get-DbaDbOrphanUser.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaDbOrphanUser", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -20,6 +20,80 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Contained database handling" { + BeforeAll { + $script:sqlOrphanUser = [PSCustomObject]@{ + Login = "" + ID = 5 + Sid = [byte[]](1..16) + LoginType = "SqlLogin" + Name = "sql_orphan" + } + $script:windowsOrphanUser = [PSCustomObject]@{ + Login = "CONTOSO\win_orphan" + ID = 6 + Sid = [byte[]](1..20) + LoginType = "WindowsUser" + Name = "CONTOSO\win_orphan" + } + $script:baseServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + Logins = @() + } + } + + It "skips SQL login orphan detection for contained databases on SQL Server 2012 and newer" { + $containedDatabase = [PSCustomObject]@{ + Name = "containeddb" + IsAccessible = $true + ContainmentType = [Microsoft.SqlServer.Management.Smo.ContainmentType]::Partial + Users = @($script:sqlOrphanUser, $script:windowsOrphanUser) + } + $server = $script:baseServer | Select-Object * + $server | Add-Member -NotePropertyName versionMajor -NotePropertyValue 11 -Force + $server | Add-Member -NotePropertyName Databases -NotePropertyValue @($containedDatabase) -Force + + Mock Connect-DbaInstance { + $server + } + Mock Stop-Function { + throw "Stop-Function called" + } + + $results = @(Get-DbaDbOrphanUser -SqlInstance "sql2012") + + $results.Count | Should -Be 1 + $results[0].User | Should -Be "CONTOSO\win_orphan" + } + + It "does not require ContainmentType on pre-SQL 2012 servers" { + $legacyDatabase = [PSCustomObject]@{ + Name = "legacydb" + IsAccessible = $true + Users = @($script:sqlOrphanUser) + } + $server = $script:baseServer | Select-Object * + $server | Add-Member -NotePropertyName versionMajor -NotePropertyValue 10 -Force + $server | Add-Member -NotePropertyName Databases -NotePropertyValue @($legacyDatabase) -Force + + Mock Connect-DbaInstance { + $server + } + Mock Stop-Function { + throw "Stop-Function called" + } + + $results = @(Get-DbaDbOrphanUser -SqlInstance "sql2008") + + $results.Count | Should -Be 1 + $results[0].User | Should -Be "sql_orphan" + } + } + } } diff --git a/tests/Get-DbaDbPageInfo.Tests.ps1 b/tests/Get-DbaDbPageInfo.Tests.ps1 index e98a325b8998..d587dda3d8ee 100644 --- a/tests/Get-DbaDbPageInfo.Tests.ps1 +++ b/tests/Get-DbaDbPageInfo.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaDbPageInfo", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -22,6 +22,41 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + $script:lastQuery = $null + $script:mockDatabase = New-Object -TypeName Microsoft.SqlServer.Management.Smo.Database + $script:mockDatabase.Name = "db1" + $script:mockDatabase | Add-Member -Force -MemberType NoteProperty -Name Parent -Value ([PSCustomObject]@{ + VersionMajor = 16 + }) + $script:mockDatabase | Add-Member -Force -MemberType ScriptMethod -Name ExecuteWithResults -Value { + param($Sql) + $script:lastQuery = $Sql + [PSCustomObject]@{ + Tables = @(@()) + } + } + + $script:mockServer = [DbaInstanceParameter]"sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @($script:mockDatabase) + + Mock Connect-DbaInstance { $script:mockServer } + } + + It "honors schema-qualified -Table input" { + $script:lastQuery = $null + + $null = Get-DbaDbPageInfo -SqlInstance "sql1" -Database "db1" -Table "db1.sales.Customer" + $normalizedQuery = $script:lastQuery -replace "\s+", " " + + $normalizedQuery | Should -Match "st\.name = N'Customer'\s+AND\s+ss\.name = N'sales'\s+AND\s+DB_NAME\(\) = N'db1'" + $normalizedQuery | Should -Not -Match "st\.name IN" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaDbRestoreHistory.Tests.ps1 b/tests/Get-DbaDbRestoreHistory.Tests.ps1 index 2c214fe32b16..526a629b939e 100644 --- a/tests/Get-DbaDbRestoreHistory.Tests.ps1 +++ b/tests/Get-DbaDbRestoreHistory.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaDbRestoreHistory", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -27,6 +27,47 @@ Describe $CommandName -Tag UnitTests { } +Describe $CommandName -Tag UnitTests { + InModuleScope "dbatools" { + BeforeAll { + $script:capturedQuery = $null + $script:mockServer = [PSCustomObject]@{ + ComputerName = "sql01" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql01" + ConnectionContext = [PSCustomObject]@{ } + } + Add-Member -InputObject $script:mockServer.ConnectionContext -Name ExecuteWithResults -MemberType ScriptMethod -Value { + param($Query) + $script:capturedQuery = $Query + [PSCustomObject]@{ + Tables = [PSCustomObject]@{ + Rows = @() + } + } + } -Force + + Mock Connect-DbaInstance { + $script:mockServer + } + } + + Context "LastRestorePoint query generation" { + BeforeEach { + $script:capturedQuery = $null + } + + It "uses StopAt whenever it is present" { + $null = Get-DbaDbRestoreHistory -SqlInstance "sql01" -Database "db1" + $normalizedQuery = $script:capturedQuery -replace "\s+", " " + + $normalizedQuery | Should -Match "COALESCE\(rsh\.stop_at,\s*bs\.backup_start_date\)\s+AS\s+LastRestorePoint" + $normalizedQuery | Should -Not -Match "COALESCE\(rsh\.stop_at,\s*'9999-12-31'\)\s*<\s*bs\.backup_start_date" + } + } + } +} + Describe $CommandName -Tag IntegrationTests { BeforeAll { diff --git a/tests/Get-DbaDbTable.Tests.ps1 b/tests/Get-DbaDbTable.Tests.ps1 index 2e3cae8bf8aa..56161f1bc994 100644 --- a/tests/Get-DbaDbTable.Tests.ps1 +++ b/tests/Get-DbaDbTable.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaDbTable", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -24,6 +24,78 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Configuration handling" { + It "Only calls ClearAndInitialize when the config is enabled" { + $commandAst = (Get-Command $CommandName).ScriptBlock.Ast + $clearAndInitializeCalls = $commandAst.FindAll( { + param($Ast) + + $Ast -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $Ast.Member.Extent.Text -eq "ClearAndInitialize" + }, $true) + + $clearAndInitializeCalls.Count | Should -Be 1 + + $parentAst = $clearAndInitializeCalls[0].Parent + while ($parentAst -and $parentAst -isnot [System.Management.Automation.Language.IfStatementAst]) { + $parentAst = $parentAst.Parent + } + + $parentAst | Should -Not -BeNullOrEmpty + $parentAst.Clauses[0].Item1.Extent.Text | Should -Match "Get-DbatoolsConfigValue" + $parentAst.Clauses[0].Item1.Extent.Text | Should -Match "commands.get-dbadbtable.clearandinitialize" + } + } + + Context "Azure SQL handling" { + It "Only adds space usage properties to the default view for non-Azure instances" { + $commandAst = (Get-Command $CommandName).ScriptBlock.Ast + $defaultPropsAssignments = $commandAst.FindAll( { + param($Ast) + + $Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $Ast.Left -is [System.Management.Automation.Language.VariableExpressionAst] -and + $Ast.Left.VariablePath.UserPath -eq "defaultProps" + }, $true) + + $defaultPropsAssignments.Count | Should -Be 1 + + $expectedDefaultProps = @" +[System.Collections.ArrayList]@("ComputerName", "InstanceName", "SqlInstance", "Database", "Schema", "Name") +"@.Trim() + $defaultPropsAssignments[0].Right.Expression.Extent.Text | Should -Be $expectedDefaultProps + + $spacePropertyAdds = $commandAst.FindAll( { + param($Ast) + + $Ast -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and + $Ast.Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and + $Ast.Expression.VariablePath.UserPath -eq "defaultProps" -and + $Ast.Member.Extent.Text -eq "Add" -and + $Ast.Arguments.Count -eq 1 -and + $Ast.Arguments[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and + $Ast.Arguments[0].Value -in ("IndexSpaceUsed", "DataSpaceUsed") + }, $true) + + $spacePropertyAdds.Count | Should -Be 2 + + foreach ($spacePropertyAdd in $spacePropertyAdds) { + $parentAst = $spacePropertyAdd.Parent + while ($parentAst -and $parentAst -isnot [System.Management.Automation.Language.IfStatementAst]) { + $parentAst = $parentAst.Parent + } + + $parentAst | Should -Not -BeNullOrEmpty + $conditionAst = $parentAst.Clauses[0].Item1.PipelineElements[0].Expression + + $conditionAst.Left.Expression.VariablePath.UserPath | Should -Be "server" + $conditionAst.Left.Member.Extent.Text | Should -Be "DatabaseEngineType" + $conditionAst.Operator | Should -Be "Ine" + $conditionAst.Right.Value | Should -Be "SqlAzureDatabase" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaLastBackup.Tests.ps1 b/tests/Get-DbaLastBackup.Tests.ps1 index be0913a3c3b9..9c077248e998 100644 --- a/tests/Get-DbaLastBackup.Tests.ps1 +++ b/tests/Get-DbaLastBackup.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaLastBackup", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -21,6 +21,39 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Replica filtering" { + It "skips backup history queries when filtering removes every database" { + $mockServer = [PSCustomObject]@{ + IsHadrEnabled = $true + Databases = @( + [PSCustomObject]@{ + Name = "agdb" + } + ) + } + Add-Member -InputObject $mockServer -Name Query -MemberType ScriptMethod -Value { + param($query) + + @( + [PSCustomObject]@{ + DatabaseName = "agdb" + } + ) + } + Mock Connect-DbaInstance { + $mockServer + } + Mock Get-DbaDbBackupHistory { } + + $results = @(Get-DbaLastBackup -SqlInstance "sql1" -Database "agdb" -ExcludeReplica) + + $results | Should -BeNullOrEmpty + Assert-MockCalled -CommandName Get-DbaDbBackupHistory -Exactly 0 -Scope It -ModuleName dbatools + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaNetworkConfiguration.Tests.ps1 b/tests/Get-DbaNetworkConfiguration.Tests.ps1 index 6233bab11837..0e2a5e69ea42 100644 --- a/tests/Get-DbaNetworkConfiguration.Tests.ps1 +++ b/tests/Get-DbaNetworkConfiguration.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaNetworkConfiguration", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -63,12 +63,13 @@ Describe $CommandName -Tag IntegrationTests { Context "Command returns correct certificate information" { BeforeAll { - $certificate = New-DbaComputerCertificate -ComputerName $TestConfig.InstanceSingle -SelfSigned -KeyLength 2048 -HashAlgorithm Sha256 -EnableException + $computerName = ([DbaInstanceParameter]$TestConfig.InstanceSingle).ComputerName + $certificate = New-DbaComputerCertificate -ComputerName $computerName -SelfSigned -KeyLength 2048 -HashAlgorithm Sha256 -EnableException $results = Get-DbaNetworkConfiguration -SqlInstance $TestConfig.InstanceSingle -EnableException } AfterAll { - $null = Remove-DbaComputerCertificate -Thumbprint $certificate.Thumbprint -EnableException + $null = Remove-DbaComputerCertificate -ComputerName $computerName -Thumbprint $certificate.Thumbprint -EnableException } It "Should return a suitable certificate thumbprint" { diff --git a/tests/Get-DbaNetworkEncryption.Tests.ps1 b/tests/Get-DbaNetworkEncryption.Tests.ps1 index 43b64cb80353..3ede333f5b2a 100644 --- a/tests/Get-DbaNetworkEncryption.Tests.ps1 +++ b/tests/Get-DbaNetworkEncryption.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaNetworkEncryption", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -17,6 +17,220 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + BeforeAll { + function New-MockNetworkEncryptionReadTask { + $task = [PSCustomObject]@{ } + Add-Member -InputObject $task -MemberType ScriptMethod -Name Wait -Value { + param($Timeout) + + $true + } -Force + $task + } + + function New-MockNetworkEncryptionStream { + param( + [byte[]]$ReadBytes + ) + + $stream = [PSCustomObject]@{ + Position = 0 + ReadBytes = $ReadBytes + } + Add-Member -InputObject $stream -MemberType ScriptMethod -Name Write -Value { + param( + [byte[]]$Buffer, + [int]$Offset, + [int]$Count + ) + } -Force + Add-Member -InputObject $stream -MemberType ScriptMethod -Name Read -Value { + param( + [byte[]]$Buffer, + [int]$Offset, + [int]$Count + ) + + $remaining = $this.ReadBytes.Length - $this.Position + if ($remaining -le 0) { + return 0 + } + + $copyCount = [Math]::Min($Count, $remaining) + [Array]::Copy($this.ReadBytes, $this.Position, $Buffer, $Offset, $copyCount) + $this.Position += $copyCount + $copyCount + } -Force + Add-Member -InputObject $stream -MemberType ScriptMethod -Name Dispose -Value { + } -Force + $stream + } + + function New-MockNetworkEncryptionUdpClient { + $udpClient = [PSCustomObject]@{ + Client = [PSCustomObject]@{ + SendTimeout = 0 + ReceiveTimeout = 0 + } + } + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Send -Value { + param( + [byte[]]$Buffer, + [int]$Count + ) + + $Count + } -Force + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Receive -Value { + param([ref]$RemoteEndPoint) + + $script:sqlBrowserResponseBytes + } -Force + Add-Member -InputObject $udpClient -MemberType ScriptMethod -Name Dispose -Value { + } -Force + $udpClient + } + + function New-MockNetworkEncryptionTcpClient { + param( + $Stream + ) + + $tcpClient = [PSCustomObject]@{ + Stream = $Stream + } + Add-Member -InputObject $tcpClient -MemberType ScriptMethod -Name ConnectAsync -Value { + param( + $ComputerName, + $Port + ) + + $script:tcpConnectTarget = "${ComputerName}:$Port" + New-MockNetworkEncryptionReadTask + } -Force + Add-Member -InputObject $tcpClient -MemberType ScriptMethod -Name GetStream -Value { + $this.Stream + } -Force + Add-Member -InputObject $tcpClient -MemberType ScriptMethod -Name Dispose -Value { + } -Force + $tcpClient + } + + function New-MockNetworkEncryptionSslStream { + param( + $RemoteCertificate + ) + + $sslStream = [PSCustomObject]@{ + RemoteCertificate = $RemoteCertificate + } + Add-Member -InputObject $sslStream -MemberType ScriptMethod -Name AuthenticateAsClient -Value { + param($ComputerName) + } -Force + Add-Member -InputObject $sslStream -MemberType ScriptMethod -Name Dispose -Value { + } -Force + $sslStream + } + + function Get-MockNetworkEncryptionSqlBrowserResponseBytes { + param( + [string]$ComputerName, + [string]$InstanceName, + [int]$Port, + [string]$PipeName + ) + + $rawResponse = "ServerName;$ComputerName;InstanceName;$InstanceName;tcp;$Port;np;$PipeName;;" + $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($rawResponse) + $responseBytes = New-Object byte[] ($rawBytes.Length + 3) + $responseBytes[0] = 0x05 + $lengthBytes = [System.BitConverter]::GetBytes([UInt16]$rawBytes.Length) + $responseBytes[1] = $lengthBytes[0] + $responseBytes[2] = $lengthBytes[1] + [Array]::Copy($rawBytes, 0, $responseBytes, 3, $rawBytes.Length) + $responseBytes + } + + function Get-MockNetworkEncryptionPreLoginResponseBytes { + [byte[]]@( + 0x04, 0x01, 0x00, 0x0f, 0x00, 0x00, 0x01, 0x00, + 0x01, 0x00, 0x06, 0x00, 0x01, 0xff, 0x03 + ) + } + } + + Context "TLS helper behavior" { + BeforeEach { + $script:tcpConnectTarget = $null + $script:mockCertificate = [PSCustomObject]@{ + Subject = "CN=sql01" + Issuer = "CN=test-ca" + Thumbprint = "ABC123" + NotAfter = (Get-Date).AddDays(30) + NotBefore = (Get-Date).AddDays(-1) + DnsNameList = @("sql01") + SerialNumber = "123456" + } + $script:sqlBrowserResponseBytes = Get-MockNetworkEncryptionSqlBrowserResponseBytes -ComputerName "sql01" -InstanceName "MSSQLSERVER" -Port 1433 -PipeName "\\sql01\pipe\sql\query" + $script:tcpStream = New-MockNetworkEncryptionStream -ReadBytes (Get-MockNetworkEncryptionPreLoginResponseBytes) + + Mock Add-Type { } + function Write-Message { + param( + $Level, + $Message + ) + } + Mock New-Object { + New-MockNetworkEncryptionUdpClient + } -ParameterFilter { + $TypeName -eq "System.Net.Sockets.UdpClient" + } + Mock New-Object { + New-MockNetworkEncryptionTcpClient -Stream $script:tcpStream + } -ParameterFilter { + $TypeName -eq "System.Net.Sockets.TcpClient" + } + Mock New-Object { + throw "Named pipe fallback should not be used when SQL Browser returns a TCP port." + } -ParameterFilter { + $TypeName -eq "System.IO.Pipes.NamedPipeClientStream" + } + Mock New-Object { + $ArgumentList[0] + } -ParameterFilter { + $TypeName -eq "TdsTlsStream" + } + Mock New-Object { + New-MockNetworkEncryptionSslStream -RemoteCertificate $script:mockCertificate + } -ParameterFilter { + $TypeName -eq "System.Net.Security.SslStream" + } + Mock New-Object { + $script:mockCertificate + } -ParameterFilter { + $TypeName -eq "System.Security.Cryptography.X509Certificates.X509Certificate2" + } + } + + It "prefers the SQL Browser TCP endpoint over named pipes" { + $result = Get-SqlServerTlsCertificate -ComputerName "sql01" -InstanceName "MSSQLSERVER" + + $result.Thumbprint | Should -Be "ABC123" + $script:tcpConnectTarget | Should -Be "sql01:1433" + } + + It "throws when the TDS pre-login response header ends early" { + $script:tcpStream = New-MockNetworkEncryptionStream -ReadBytes ([byte[]]@(0x04, 0x01, 0x00)) + + { + Get-SqlServerTlsCertificate -ComputerName "sql01" -InstanceName "MSSQLSERVER" -ErrorAction Stop + } | Should -Throw "*Unexpected EOF while reading the TDS pre-login response header from sql01*" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaRegServer.Tests.ps1 b/tests/Get-DbaRegServer.Tests.ps1 index 83faf46c3828..f9e50b8986a5 100644 --- a/tests/Get-DbaRegServer.Tests.ps1 +++ b/tests/Get-DbaRegServer.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaRegServer", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -28,6 +28,50 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "IncludeSelf" { + BeforeAll { + function Write-Message { } + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + [Parameter(ValueFromRemainingArguments)] + $RemainingArguments + ) + + process { + $InputObject + } + } + function Get-DbaRegServerGroup { $null } + function Get-DbaRegServerStore { + param( + $SqlInstance, + $SqlCredential, + [switch]$EnableException + ) + + [PSCustomObject]@{ + ComputerName = "$SqlInstance" + InstanceName = "MSSQLSERVER" + SqlInstance = "$SqlInstance" + ParentServer = "$SqlInstance" + } + } + } + + It "Should return a CMS instance object for each requested CMS" -Tag "IncludeSelf" { + $results = @(Get-DbaRegServer -SqlInstance @("cms1", "cms2") -Group "Production" -IncludeSelf) + $self = @($results | Where-Object Name -eq "CMS Instance") + + $self.Count | Should -Be 2 + $self.SqlInstance | Should -Be @("cms1", "cms2") + ($self | ForEach-Object { $_.ToString() }) | Should -Be @("cms1", "cms2") + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -146,6 +190,11 @@ Describe $CommandName -Tag IntegrationTests { $self.ToString() | Should -Be $self.ServerName } + It "Should include CMS instance for each requested CMS when IncludeSelf is used" { + $results = Get-DbaRegServer -SqlInstance @($TestConfig.InstanceSingle, $TestConfig.InstanceSingle) -IncludeSelf + @($results | Where-Object Name -eq "CMS Instance").Count | Should -Be 2 + } + # Property Comparisons will come later when we have the commands } } \ No newline at end of file diff --git a/tests/Get-DbaReplSubscription.Tests.ps1 b/tests/Get-DbaReplSubscription.Tests.ps1 index 578897adaf77..fbb5fb9f32e1 100644 --- a/tests/Get-DbaReplSubscription.Tests.ps1 +++ b/tests/Get-DbaReplSubscription.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaReplSubscription", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -23,6 +23,92 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Distribution database fallback" { + BeforeAll { + Mock Add-ReplicationLibrary { } + Mock Connect-ReplicationDB { + [PSCustomObject]@{ + Name = "SalesDb" + TransPublications = @( + [PSCustomObject]@{ + Name = "SalesPub" + Type = "Transactional" + DatabaseName = "SalesDb" + PubId = 42 + TransSubscriptions = @() + } + ) + MergePublications = @() + } + } + Mock Test-FunctionInterrupt { $false } + Mock Write-Message { } + Mock Select-DefaultView { $InputObject } + Mock Stop-Function { + throw "$Message :: $($ErrorRecord.Exception.Message)" + } + Mock Connect-DbaInstance { + $server = [DbaInstanceParameter]"Publisher01" + $server | Add-Member -Force -MemberType NoteProperty -Name ComputerName -Value "Publisher01" + $server | Add-Member -Force -MemberType NoteProperty -Name ServiceName -Value "MSSQLSERVER" + $server | Add-Member -Force -MemberType NoteProperty -Name DomainInstanceName -Value "Publisher01" + $server | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @( + [PSCustomObject]@{ + Name = "SalesDb" + ReplicationOptions = "Published" + IsAccessible = $true + IsSystemObject = $false + } + ) + $server | Add-Member -Force -MemberType NoteProperty -Name ConnectionContext -Value ([PSCustomObject]@{ + SqlConnectionObject = "FakeConnectionContext" + }) + $server + } + Mock New-Object { + [PSCustomObject]@{ + ConnectionContext = $null + IsPublisher = $true + DistributorInstalled = $true + DistributorAvailable = $true + DistributionServer = "Publisher01" + DistributionDatabase = "distribution" + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Replication.ReplicationServer" + } + Mock Invoke-DbaQuery { + @( + [PSCustomObject]@{ + SubscriberName = "Subscriber01" + SubscriptionDBName = "SalesSubscriberDb" + DatabaseName = "SalesDb" + PublicationName = "SalesPub" + PublicationId = 42 + }, + [PSCustomObject]@{ + SubscriberName = "Subscriber02" + SubscriptionDBName = "SalesSubscriberDb" + DatabaseName = "SalesDb" + PublicationName = "SalesPub" + PublicationId = 99 + } + ) + } + } + + It "Filters distribution-only pull subscriptions to the current publication id" { + $results = @(Get-DbaReplSubscription -SqlInstance "Publisher01") + + $results.Count | Should -Be 1 + $results.PublicationName | Should -Be "SalesPub" + $results.DatabaseName | Should -Be "SalesDb" + $results.SubscriberName | Should -Be "Subscriber01" + } + } + } } <# Integration tests for replication are in GitHub Actions and run from \tests\gh-actions-repl-*.ps1.ps1 diff --git a/tests/Get-DbaService.Tests.ps1 b/tests/Get-DbaService.Tests.ps1 index 258f96eb4085..0957df087225 100644 --- a/tests/Get-DbaService.Tests.ps1 +++ b/tests/Get-DbaService.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaService", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -32,6 +32,71 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope "dbatools" { + Context "Type filtering" { + BeforeAll { + Mock Resolve-DbaNetworkName { + [PSCustomObject]@{ + FullComputerName = "sql01" + } + } + + Mock Get-DbaReportingService { + [PSCustomObject]@{ + ComputerName = "sql01" + ServiceName = "PowerBIReportServer" + ServiceType = "PowerBI" + InstanceName = "PBIRS" + DisplayName = "Power BI Report Server" + StartName = "CONTOSO\\svc-pbirs" + State = "Running" + StartMode = "Automatic" + } + } + + Mock Get-DbaCmObject { + param( + $ComputerName, + $Credential, + $Namespace, + $ClassName, + $Query, + $EnableException + ) + + if ($Namespace -eq "root\Microsoft" -and $ClassName -eq "__NAMESPACE") { + [PSCustomObject]@{ + Name = "Microsoft" + } + } + } + + Mock Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + $Property, + $TypeName + ) + process { + $InputObject + } + } + } + + It "skips SqlService lookups when only PowerBI services are requested" { + $results = Get-DbaService -ComputerName "sql01" -Type PowerBI + + $results | Should -HaveCount 1 + $results.ServiceType | Should -Be "PowerBI" + Assert-MockCalled Get-DbaReportingService -Times 1 -Exactly -Scope It + Assert-MockCalled Get-DbaCmObject -Times 0 -Exactly -Scope It -ParameterFilter { $Namespace -eq "root\Microsoft\SQLServer" } + } + } + } +} + Describe $CommandName -Tag IntegrationTests { Context "Command actually works" { BeforeAll { diff --git a/tests/Get-DbaStartupParameter.Tests.ps1 b/tests/Get-DbaStartupParameter.Tests.ps1 index 8623b171b56d..3253daff020a 100644 --- a/tests/Get-DbaStartupParameter.Tests.ps1 +++ b/tests/Get-DbaStartupParameter.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaStartupParameter", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -19,6 +19,52 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "WMI service validation" { + It "Throws when the SQL Server service is not found" { + Mock Invoke-ManagedComputerCommand -MockWith { + param ( + $Server, + $Credential, + $ScriptBlock, + $ArgumentList + ) + $wmi = [PSCustomObject]@{ + Services = @() + } + & $ScriptBlock @ArgumentList + } -ModuleName dbatools + + { Get-DbaStartupParameter -SqlInstance "localhost" -EnableException } | Should -Throw + } + + It "Throws when multiple SQL Server services match the instance name" { + Mock Invoke-ManagedComputerCommand -MockWith { + param ( + $Server, + $Credential, + $ScriptBlock, + $ArgumentList + ) + $serviceDisplayName = "SQL Server ($($ArgumentList[1]))" + $wmi = [PSCustomObject]@{ + Services = @( + [PSCustomObject]@{ + DisplayName = $serviceDisplayName + StartupParameters = "-dC:\SQLData\master.mdf;-lC:\SQLLog\mastlog.ldf;-eC:\SQLLog\ERRORLOG" + }, + [PSCustomObject]@{ + DisplayName = $serviceDisplayName + StartupParameters = "-dD:\SQLData\master.mdf;-lD:\SQLLog\mastlog.ldf;-eD:\SQLLog\ERRORLOG" + } + ) + } + & $ScriptBlock @ArgumentList + } -ModuleName dbatools + + { Get-DbaStartupParameter -SqlInstance "localhost" -EnableException } | Should -Throw + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Get-DbaWaitStatistic.Tests.ps1 b/tests/Get-DbaWaitStatistic.Tests.ps1 index f21728cab36b..fd39219e1fec 100644 --- a/tests/Get-DbaWaitStatistic.Tests.ps1 +++ b/tests/Get-DbaWaitStatistic.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Get-DbaWaitStatistic", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -22,6 +22,51 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Wait type filter validation" { + BeforeAll { + $script:lastQuery = $null + $script:mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + } + $script:mockServer | Add-Member -Force -MemberType ScriptMethod -Name Query -Value { + param($Sql) + $script:lastQuery = $Sql + @() + } + } + + It "normalizes ExcludeWaitType values and still applies them when IncludeIgnorable is used" { + Mock Connect-DbaInstance { + $script:mockServer + } + + $null = Get-DbaWaitStatistic -SqlInstance "sql1" -IncludeIgnorable -ExcludeWaitType "cxpacket" + + $script:lastQuery | Should -Match "NOT IN \('CXPACKET'\)" + } + + It "removes IncludeWaitType values from the default ignorable filter" { + Mock Connect-DbaInstance { + $script:mockServer + } + + $null = Get-DbaWaitStatistic -SqlInstance "sql1" -IncludeWaitType "sos_work_dispatcher" + + $script:lastQuery | Should -Match "LAZYWRITER_SLEEP" + $script:lastQuery | Should -Not -Match "SOS_WORK_DISPATCHER" + } + + It "rejects invalid wait type names before connecting" { + { + Get-DbaWaitStatistic -SqlInstance "sql1" -ExcludeWaitType "CXPACKET'; DROP TABLE dbo.t;--" + } | Should -Throw + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -67,24 +112,4 @@ Describe $CommandName -Tag IntegrationTests { } } - Context "ExcludeWaitType parameter filters out additional wait types" { - BeforeAll { - $filteredResults = Get-DbaWaitStatistic -SqlInstance $TestConfig.InstanceSingle -Threshold 100 -IncludeIgnorable -ExcludeWaitType "CXPACKET", "CXCONSUMER" - } - - It "excludes specified wait types" { - $filteredResults.WaitType | Should -Not -Contain "CXPACKET" - $filteredResults.WaitType | Should -Not -Contain "CXCONSUMER" - } - } - - Context "IncludeWaitType parameter includes wait types from ignorable list" { - BeforeAll { - $resultsWith = Get-DbaWaitStatistic -SqlInstance $TestConfig.InstanceSingle -Threshold 100 -IncludeWaitType "SOS_WORK_DISPATCHER" - } - - It "includes specified wait type that would normally be ignored" { - $resultsWith.WaitType | Should -Contain "SOS_WORK_DISPATCHER" - } - } } \ No newline at end of file diff --git a/tests/Get-DecryptedObject.Tests.ps1 b/tests/Get-DecryptedObject.Tests.ps1 new file mode 100644 index 000000000000..f24f7dd46778 --- /dev/null +++ b/tests/Get-DecryptedObject.Tests.ps1 @@ -0,0 +1,76 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Get-DecryptedObject", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Error handling" { + It "Should route password query failures through Stop-Function" { + InModuleScope "dbatools" { + $typeData = Get-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" + $originalQuery = $typeData.Members["Query"].Script + $originalInvoke = $typeData.Members["Invoke"].Script + $functionNames = @( + "Invoke-Command2", + "Resolve-DbaComputerName", + "Stop-Function", + "Write-Message" + ) + $originalFunctions = @{ } + + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Invoke-Command2 { [byte[]](1..16) } + function Resolve-DbaComputerName { "sql1" } + function Stop-Function { + param( + $Message, + $Target, + $ErrorRecord + ) + + throw "$Message | inner: $($ErrorRecord.Exception.Message)" + } + function Write-Message { } + + Update-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" -MemberType ScriptProperty -MemberName DomainInstanceName -Value { "sql1" } -Force + Update-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" -MemberType ScriptProperty -MemberName ServiceInstanceId -Value { "MSSQL16.SQL1" } -Force + Update-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" -MemberType ScriptMethod -MemberName Query -Value { + param($sql) + + if ($sql -like "*sys.key_encryptions*") { + [PSCustomObject]@{ + smk = [byte[]](1, 2, 3, 4) + } + } else { + throw "password query failed" + } + } -Force + + $server = New-Object Microsoft.SqlServer.Management.Smo.Server "sql1" + + { Get-DecryptedObject -SqlInstance $server -Type Credential } | Should -Throw "*Can't execute password query on sql1.*password query failed*" + } finally { + Remove-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" + Update-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" -MemberType ScriptMethod -MemberName Query -Value $originalQuery + Update-TypeData -TypeName "Microsoft.SqlServer.Management.Smo.Server" -MemberType ScriptMethod -MemberName Invoke -Value $originalInvoke + + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/Import-DbaCsv.Tests.ps1 b/tests/Import-DbaCsv.Tests.ps1 index d249000074e8..e71bfe2c529d 100644 --- a/tests/Import-DbaCsv.Tests.ps1 +++ b/tests/Import-DbaCsv.Tests.ps1 @@ -70,6 +70,21 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Implementation regression" { + It "caches SupportsMultiline binding before helper scopes" { + $commandText = (Get-Command $CommandName).ScriptBlock.ToString() + $cachedBindingText = "supportsMultilineSpecified = " + [char]36 + "PSBoundParameters.ContainsKey(" + [char]34 + "SupportsMultiline" + [char]34 + ")" + $allowMultilineAssignmentText = "AllowMultilineFields = " + [char]36 + "allowMultilineFields" + + $supportsMultilineBoundChecks = ([regex]::Matches($commandText, [regex]::Escape("ContainsKey(""SupportsMultiline"")"))).Count + $supportsMultilineAssignments = ([regex]::Matches($commandText, [regex]::Escape($allowMultilineAssignmentText))).Count + + $supportsMultilineBoundChecks | Should -Be 1 + $supportsMultilineAssignments | Should -Be 3 + $commandText | Should -Match ([regex]::Escape($cachedBindingText)) + } + } } Describe $CommandName -Tag IntegrationTests { @@ -1115,4 +1130,4 @@ all,filled,here Remove-Item $filePath -ErrorAction SilentlyContinue } } -} \ No newline at end of file +} diff --git a/tests/Import-DbaXESessionTemplate.Tests.ps1 b/tests/Import-DbaXESessionTemplate.Tests.ps1 index 108be27915a5..a327729816ac 100644 --- a/tests/Import-DbaXESessionTemplate.Tests.ps1 +++ b/tests/Import-DbaXESessionTemplate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Import-DbaXESessionTemplate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -24,6 +24,17 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Implementation regression" { + It "uses XML node selection instead of global string replacement for event_file targets" { + $commandText = (Get-Command $CommandName).ScriptBlock.ToString() + + $commandText | Should -Match ([regex]::Escape("SelectSingleNode(""/*[local-name()='event_sessions']/*[local-name()='event_session']"")")) + $commandText | Should -Match ([regex]::Escape("SelectSingleNode(""*[local-name()='target' and @name='event_file']"")")) + $commandText | Should -Match ([regex]::Escape("SelectSingleNode(""*[local-name()='parameter' and @name='filename']"")")) + $commandText | Should -Not -Match ([regex]::Escape("Replace(")) + } + } } # TODO: We are testing the wrong command here diff --git a/tests/Install-DbaMaintenanceSolution.Tests.ps1 b/tests/Install-DbaMaintenanceSolution.Tests.ps1 index 8ab5b2633950..f3393089b8e8 100644 --- a/tests/Install-DbaMaintenanceSolution.Tests.ps1 +++ b/tests/Install-DbaMaintenanceSolution.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Install-DbaMaintenanceSolution", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -37,6 +37,90 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "BackupLocation validation" { + BeforeEach { + Mock Stop-Function { throw $Message } + Mock Connect-DbaInstance { throw "connect failed" } + Mock Get-DbatoolsConfigValue { "C:\temp" } + Mock Join-DbaPath { "C:\temp" } + Mock Test-Path { $true } + Mock Save-DbaCommunitySoftware { throw "should not download" } + } + + It "Allows NUL backup locations when jobs are not being installed" { + { + Install-DbaMaintenanceSolution -SqlInstance "sql1" -BackupLocation "NUL" + } | Should -Throw "*Error occurred while establishing connection to sql1*" + + Should -Invoke Connect-DbaInstance -Times 1 -Exactly + Should -Invoke Save-DbaCommunitySoftware -Times 0 -Exactly + Should -Invoke Stop-Function -Times 0 -Exactly -ParameterFilter { + $Message -like "Verify is not supported when backing up to NUL*" + } + } + + It "Blocks NUL backup locations when default job verification would still be enabled" { + { + Install-DbaMaintenanceSolution -SqlInstance "sql1" -BackupLocation "NUL" -InstallJobs + } | Should -Throw "*Verify is not supported when backing up to NUL*" + + Should -Invoke Connect-DbaInstance -Times 0 -Exactly + Should -Invoke Save-DbaCommunitySoftware -Times 0 -Exactly + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -like "Verify is not supported when backing up to NUL*" + } + } + } + + Context "AutoScheduleJobs validation" { + BeforeEach { + Mock Stop-Function { throw $Message } + Mock Connect-DbaInstance { throw "connect failed" } + Mock Get-DbatoolsConfigValue { "C:\temp" } + Mock Join-DbaPath { "C:\temp" } + Mock Test-Path { $true } + Mock Save-DbaCommunitySoftware { throw "should not download" } + } + + It "Requires InstallJobs when AutoScheduleJobs is specified" { + { + Install-DbaMaintenanceSolution -SqlInstance "sql1" -AutoScheduleJobs "WeeklyFull" + } | Should -Throw "*AutoScheduleJobs is only useful when installing jobs*" + + Should -Invoke Connect-DbaInstance -Times 0 -Exactly + Should -Invoke Save-DbaCommunitySoftware -Times 0 -Exactly + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -like "AutoScheduleJobs is only useful when installing jobs*" + } + } + + It "Requires exactly one full backup schedule option" { + { + Install-DbaMaintenanceSolution -SqlInstance "sql1" -InstallJobs -AutoScheduleJobs "HourlyLog" + } | Should -Throw "*AutoScheduleJobs requires exactly one full backup schedule*" + + Should -Invoke Connect-DbaInstance -Times 0 -Exactly + Should -Invoke Save-DbaCommunitySoftware -Times 0 -Exactly + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -like "AutoScheduleJobs requires exactly one full backup schedule*" + } + } + + It "Does not allow conflicting full backup schedule options" { + { + Install-DbaMaintenanceSolution -SqlInstance "sql1" -InstallJobs -AutoScheduleJobs "WeeklyFull", "DailyFull" + } | Should -Throw "*AutoScheduleJobs requires exactly one full backup schedule*" + + Should -Invoke Connect-DbaInstance -Times 0 -Exactly + Should -Invoke Save-DbaCommunitySoftware -Times 0 -Exactly + Should -Invoke Stop-Function -Times 1 -Exactly -ParameterFilter { + $Message -like "AutoScheduleJobs requires exactly one full backup schedule*" + } + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Invoke-DbaAdvancedRestore.Tests.ps1 b/tests/Invoke-DbaAdvancedRestore.Tests.ps1 index f741e748fcec..58cd669448a8 100644 --- a/tests/Invoke-DbaAdvancedRestore.Tests.ps1 +++ b/tests/Invoke-DbaAdvancedRestore.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaAdvancedRestore", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -41,6 +41,144 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "ErrorBrokerConversations behavior" { + BeforeAll { + function Add-TeppCacheItem { } + function New-MockRestore { + $restore = [PSCustomObject]@{ + NoRecovery = $false + StandbyFile = $null + Database = $null + ReplaceDatabase = $false + MaxTransferSize = $null + BufferCount = $null + Blocksize = $null + Checksum = $false + Restart = $false + KeepReplication = $false + Action = $null + FileNumber = $null + ToPointInTime = $null + StopBeforeMarkName = $null + StopAtMarkName = $null + StopBeforeMarkAfterDate = $null + StopAtMarkAfterDate = $null + RelocateFiles = (New-Object System.Collections.ArrayList) + Devices = (New-Object System.Collections.ArrayList) + } + Add-Member -InputObject $restore -Name Script -MemberType ScriptMethod -Value { + param($Server) + "RESTORE DATABASE [$($this.Database)] FROM DISK = 'C:\backups\test.bak' WITH REPLACE" + } -Force + $restore + } + + $script:mockServer = [PSCustomObject]@{ + Databases = @() + DatabaseEngineEdition = "SqlServer" + ConnectionContext = [PSCustomObject]@{ + TrueLogin = "dbatoolsci" + exists = $false + } + } + Add-Member -InputObject $script:mockServer.ConnectionContext -Name ExecuteNonQuery -MemberType ScriptMethod -Value { + param($Query) + $null + } -Force + Add-Member -InputObject $script:mockServer.ConnectionContext -Name Disconnect -MemberType ScriptMethod -Value { } -Force + + $script:backupHistory = [PSCustomObject]@{ + Database = "RestoreAsDb" + Type = "1" + FirstLsn = 1 + RestoreTime = (Get-Date).AddMinutes(-5) + RecoveryModel = "Full" + FileList = @( + [PSCustomObject]@{ + LogicalName = "RestoreAsDb" + PhysicalName = "C:\restore\RestoreAsDb.mdf" + } + ) + FullName = @("C:\backups\RestoreAsDb.bak") + Position = 1 + } + + function Test-FunctionInterrupt { $false } + function Write-Message { } + } + + BeforeEach { + Mock Connect-DbaInstance { $script:mockServer } + Mock New-Object { + $script:lastRestore = New-MockRestore + $script:lastRestore + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Restore" + } + Mock New-Object { + [PSCustomObject]@{ + LogicalFileName = $null + PhysicalFileName = $null + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.RelocateFile" + } + Mock New-Object { + [PSCustomObject]@{ + Name = $null + devicetype = $null + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.BackupDeviceItem" + } + } + + It "Should call Stop-Function when ErrorBrokerConversations is combined with NoRecovery" { + Mock Stop-Function { + throw $Message + } + + { Invoke-DbaAdvancedRestore -BackupHistory $script:backupHistory -SqlInstance "sql1" -NoRecovery -ErrorBrokerConversations } | Should -Throw "*ErrorBrokerConversations cannot be specified with Norecovery or Standby as it needs recovery to work*" + } + + It "Should prefix OutputScriptOnly with Execute As when ErrorBrokerConversations is specified" { + Mock Stop-Function { } + $output = Invoke-DbaAdvancedRestore -BackupHistory $script:backupHistory -SqlInstance "sql1" -OutputScriptOnly -ErrorBrokerConversations -ExecuteAs "RestoreAs" + $scriptOutput = $output | Select-Object -Last 1 + + $scriptOutput | Should -BeLike "EXECUTE AS LOGIN='RestoreAs'*ERROR_BROKER_CONVERSATIONS*" + Should -Invoke Stop-Function -Times 0 + } + + It "Should convert fn_dblog-style StopAtLsn values before scripting the restore" { + Mock Stop-Function { } + $null = Invoke-DbaAdvancedRestore -BackupHistory $script:backupHistory -SqlInstance "sql1" -OutputScriptOnly -StopAtLsn "00000014:000000f3:0001" + + $script:lastRestore.StopAtMarkName | Should -Be "lsn:20000000024300001" + $script:lastRestore.StopBeforeMarkName | Should -BeNullOrEmpty + Should -Invoke Stop-Function -Times 0 + } + + It "Should respect StopBefore when StopAtLsn already includes the SQL lsn prefix" { + Mock Stop-Function { } + $null = Invoke-DbaAdvancedRestore -BackupHistory $script:backupHistory -SqlInstance "sql1" -OutputScriptOnly -StopAtLsn "lsn:20000000024300001" -StopBefore + + $script:lastRestore.StopBeforeMarkName | Should -Be "lsn:20000000024300001" + $script:lastRestore.StopAtMarkName | Should -BeNullOrEmpty + Should -Invoke Stop-Function -Times 0 + } + + It "Should reject invalid StopAtLsn values" { + Mock Stop-Function { + throw $Message + } + + { Invoke-DbaAdvancedRestore -BackupHistory $script:backupHistory -SqlInstance "sql1" -OutputScriptOnly -StopAtLsn "bad-lsn" } | Should -Throw "*StopAtLsn must be a numeric restore LSN or a colon-delimited value*" + } + } + } } <# Integration test should appear below and are custom to the command you are writing. diff --git a/tests/Invoke-DbaBalanceDataFiles.Tests.ps1 b/tests/Invoke-DbaBalanceDataFiles.Tests.ps1 index 89c70fee0701..a3b63099c5fa 100644 --- a/tests/Invoke-DbaBalanceDataFiles.Tests.ps1 +++ b/tests/Invoke-DbaBalanceDataFiles.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaBalanceDataFiles", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -23,6 +23,208 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + if (-not ("InvokeDbaBalanceDataFilesTest.MockCollection[System.Object]" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Collections; +using System.Collections.Generic; + +namespace InvokeDbaBalanceDataFilesTest { + public class MockCollection : IEnumerable { + private Dictionary items = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void Add(string name, T item) { + items[name] = item; + } + + public T this[string name] { + get { + T value; + items.TryGetValue(name, out value); + return value; + } + } + + public IEnumerator GetEnumerator() { + return items.Values.GetEnumerator(); + } + } +} +"@ + } + + function Write-Message { } + + function New-MockBalanceIndex { + param( + [string]$Schema, + [string]$Name + ) + + $index = [PSCustomObject]@{ + Name = "PK_${Schema}_$Name" + TableSchema = $Schema + IndexType = "ClusteredIndex" + OnlineIndexOperation = $false + FileGroup = "PRIMARY" + } + $index | Add-Member -Force -MemberType ScriptMethod -Name Rebuild -Value { + $script:rebuiltSchemas += $this.TableSchema + } + + $index + } + + function New-MockBalanceTable { + param( + [object]$Database, + [string]$Schema, + [string]$Name + ) + + [PSCustomObject]@{ + Name = $Name + Schema = $Schema + Parent = $Database + Indexes = @(New-MockBalanceIndex -Schema $Schema -Name $Name) + } + } + } + + It "honors schema-qualified -Table input" { + $script:rebuiltSchemas = @() + $fileGroups = New-Object "InvokeDbaBalanceDataFilesTest.MockCollection[System.Object]" + $fileGroups.Add("PRIMARY", [PSCustomObject]@{ + Name = "PRIMARY" + Readonly = $false + Files = @( + [PSCustomObject]@{ + Name = "primaryfile" + } + ) + }) + + $mockDatabase = [PSCustomObject]@{ + Name = "db1" + FileGroups = $fileGroups + } + $mockDatabase | Add-Member -Force -MemberType ScriptMethod -Name ToString -Value { + $this.Name + } + $mockDatabase | Add-Member -Force -MemberType NoteProperty -Name Tables -Value @( + (New-MockBalanceTable -Database $mockDatabase -Schema "dbo" -Name "Customer"), + (New-MockBalanceTable -Database $mockDatabase -Schema "sales" -Name "Customer") + ) + + $mockDatabases = New-Object "InvokeDbaBalanceDataFilesTest.MockCollection[System.Object]" + $mockDatabases.Add("db1", $mockDatabase) + + $mockServer = [DbaInstanceParameter]"sql1" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name ComputerName -Value "sql1" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name ServiceName -Value "MSSQLSERVER" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name DomainInstanceName -Value "sql1" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value $mockDatabases + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Version -Value ([PSCustomObject]@{ + Major = 16 + }) + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Edition -Value "Enterprise" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name HostPlatform -Value "Linux" + $mockDataFiles = @( + [PSCustomObject]@{ + ID = 1 + LogicalName = "db1" + PhysicalName = "C:\db1.mdf" + Size = 10 + UsedSpace = 5 + AvailableSpace = 5 + TypeDescription = "ROWS" + }, + [PSCustomObject]@{ + ID = 2 + LogicalName = "db1_2" + PhysicalName = "C:\db1_2.ndf" + Size = 10 + UsedSpace = 5 + AvailableSpace = 5 + TypeDescription = "ROWS" + } + ) + + Mock Connect-DbaInstance { $mockServer } + Mock Get-DbaDbFile { $mockDataFiles } + Mock Stop-Function { throw $Message } + + $results = @(Invoke-DbaBalanceDataFiles -SqlInstance "sql1" -Database "db1" -Table "sales.Customer" -TargetFileGroup "PRIMARY" -RebuildOffline -Force) + + $results.Count | Should -Be 1 + $results[0].Success | Should -BeTrue + $script:rebuiltSchemas | Should -Be @("sales") + } + } + + Context "Target filegroup validation" { + It "fails when the target filegroup does not contain any data files" { + $fileGroups = New-Object "InvokeDbaBalanceDataFilesTest.MockCollection[System.Object]" + $fileGroups.Add("EMPTYFG", [PSCustomObject]@{ + Name = "EMPTYFG" + Readonly = $false + Files = @() + }) + + $mockDatabase = [PSCustomObject]@{ + Name = "db1" + FileGroups = $fileGroups + Tables = @() + } + $mockDatabase | Add-Member -Force -MemberType ScriptMethod -Name ToString -Value { + $this.Name + } + + $mockDatabases = New-Object "InvokeDbaBalanceDataFilesTest.MockCollection[System.Object]" + $mockDatabases.Add("db1", $mockDatabase) + + $mockServer = [DbaInstanceParameter]"sql1" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value $mockDatabases + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Version -Value ([PSCustomObject]@{ + Major = 16 + }) + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Edition -Value "Enterprise" + $mockServer | Add-Member -Force -MemberType NoteProperty -Name HostPlatform -Value "Linux" + $mockDataFiles = @( + [PSCustomObject]@{ + ID = 1 + LogicalName = "db1" + PhysicalName = "C:\db1.mdf" + Size = 10 + UsedSpace = 5 + AvailableSpace = 5 + TypeDescription = "ROWS" + }, + [PSCustomObject]@{ + ID = 2 + LogicalName = "db1_2" + PhysicalName = "C:\db1_2.ndf" + Size = 10 + UsedSpace = 5 + AvailableSpace = 5 + TypeDescription = "ROWS" + } + ) + + Mock Connect-DbaInstance { $mockServer } + Mock Get-DbaDbFile { $mockDataFiles } + Mock Stop-Function { throw $Message } + + { + Invoke-DbaBalanceDataFiles -SqlInstance "sql1" -Database "db1" -TargetFileGroup "EMPTYFG" -RebuildOffline -Force + } | Should -Throw "*does not contain any data files*" + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -64,6 +266,7 @@ Describe $CommandName -Tag IntegrationTests { $db.Query("insert into table2 (Name2) Values $($sqlvalues -join ',')") $db.Query("insert into table2 (Name2) Values $($sqlvalues -join ',')") + $db.Query("ALTER DATABASE [$dbname] ADD FILEGROUP [EMPTYFG]") $db.Query("ALTER DATABASE $dbname ADD FILE (NAME = secondfile, FILENAME = '$defaultdata\$dbname-secondaryfg.ndf') TO FILEGROUP [PRIMARY]") } @@ -87,4 +290,14 @@ Describe $CommandName -Tag IntegrationTests { $sizeUsedAfter | Should -BeLessThan $sizeUsedBefore } } -} \ No newline at end of file + + Context "Target filegroup validation" { + It "warns when the target filegroup does not contain any data files" { + $warningMessages = $null + $results = Invoke-DbaBalanceDataFiles -SqlInstance $server -Database $dbname -TargetFileGroup "EMPTYFG" -RebuildOffline -Force -WarningAction SilentlyContinue -WarningVariable warningMessages + + $results | Should -BeNullOrEmpty + ($warningMessages | Out-String) | Should -Match "does not contain any data files" + } + } +} diff --git a/tests/Invoke-DbaDbDataMasking.Tests.ps1 b/tests/Invoke-DbaDbDataMasking.Tests.ps1 index 994345f4bc17..f725578c6d87 100644 --- a/tests/Invoke-DbaDbDataMasking.Tests.ps1 +++ b/tests/Invoke-DbaDbDataMasking.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbDataMasking", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -34,6 +34,233 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "WhatIf behavior" { + BeforeAll { + Mock Stop-Function { throw "Stop-Function should not be called during WhatIf unit tests" } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Get-DbaRandomizedType { + [PSCustomObject]@{ + Type = "Name" + Subtype = "FirstName" + } + } -ModuleName dbatools + } + + BeforeEach { + $script:lastWhatIfQuery = $null + + $script:mockTables = [PSCustomObject]@{ + Name = "db1" + Tables = @( + [PSCustomObject]@{ + Name = "people" + Schema = "dbo" + HasUniqueIndex = $true + FilterQuery = $null + Columns = @( + [PSCustomObject]@{ + Name = "fname" + ColumnType = "varchar" + Action = $null + Composite = $null + } + ) + } + ) + } + + $script:mockTempDbTables = [PSCustomObject]@{ + Name = @() + } + $script:mockTempDbTables | Add-Member -MemberType ScriptMethod -Name Refresh -Value { $null } -Force + + $script:mockTempDb = [PSCustomObject]@{ + Tables = $script:mockTempDbTables + } + $script:mockTempDb | Add-Member -MemberType ScriptMethod -Name Query -Value { + param($query) + $null + } -Force + + $script:mockIndexes = [PSCustomObject]@{ + Name = @() + } + $script:mockIndexes | Add-Member -MemberType ScriptMethod -Name Refresh -Value { $null } -Force + + $script:mockDbTable = [PSCustomObject]@{ + Name = "people" + Schema = "dbo" + Columns = @( + [PSCustomObject]@{ + Name = "fname" + Identity = $false + DataType = "varchar" + } + ) + Indexes = $script:mockIndexes + } + + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + Tables = @($script:mockDbTable) + } + $script:mockDatabase | Add-Member -MemberType ScriptMethod -Name Query -Value { + param($query) + $script:lastWhatIfQuery = $query + + [PSCustomObject]@{ + RowCount = 2 + } + } -Force + + $script:mockServer = [PSCustomObject]@{ + VersionMajor = 16 + Databases = @{ + tempdb = $script:mockTempDb + db1 = $script:mockDatabase + } + } + + Mock Invoke-RestMethod { $script:mockTables } -ModuleName dbatools + Mock Connect-DbaInstance { $script:mockServer } -ModuleName dbatools + Mock Convert-DbaIndexToTable { [PSCustomObject]@{ } } -ModuleName dbatools + } + + It "does not prepare unique helper tables when WhatIf is used" { + $null = Invoke-DbaDbDataMasking -SqlInstance "sql1" -Database "db1" -FilePath "http://masking-config" -WhatIf + + Assert-MockCalled -CommandName Convert-DbaIndexToTable -Exactly 0 -Scope It -ModuleName dbatools + } + + It "uses FilterQuery when counting rows for WhatIf" { + $script:mockTables.Tables[0].HasUniqueIndex = $false + $script:mockTables.Tables[0].FilterQuery = "SELECT [fname] FROM [dbo].[people] WHERE [fname] LIKE 'J%'" + + $null = Invoke-DbaDbDataMasking -SqlInstance "sql1" -Database "db1" -FilePath "http://masking-config" -WhatIf + + $script:lastWhatIfQuery | Should -Be "SELECT COUNT(*) AS RowCount FROM (SELECT [fname] FROM [dbo].[people] WHERE [fname] LIKE 'J%') AS [dbatools_masking_source]" + } + } + + Context "Action filtering" { + BeforeAll { + Mock Stop-Function { + param($Message) + throw $Message + } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Get-DbaRandomizedType { + [PSCustomObject]@{ + Type = "Name" + Subtype = "FirstName" + } + } -ModuleName dbatools + Mock Write-ProgressHelper { } -ModuleName dbatools + } + + BeforeEach { + $script:mockTempDbTables = [PSCustomObject]@{ + Name = @() + } + $script:mockTempDbTables | Add-Member -MemberType ScriptMethod -Name Refresh -Value { $null } -Force + + $script:mockTempDb = [PSCustomObject]@{ + Tables = $script:mockTempDbTables + } + $script:mockTempDb | Add-Member -MemberType ScriptMethod -Name Query -Value { + param($query) + $null + } -Force + + $script:mockIndexes = [PSCustomObject]@{ + Name = @() + } + $script:mockIndexes | Add-Member -MemberType ScriptMethod -Name Refresh -Value { $null } -Force + + $script:mockDbTable = [PSCustomObject]@{ + Name = "people" + Schema = "dbo" + Columns = @( + [PSCustomObject]@{ + Name = "PersonId" + Identity = $true + DataType = "int" + }, + [PSCustomObject]@{ + Name = "fname" + Identity = $false + DataType = "varchar" + } + ) + Indexes = $script:mockIndexes + } + + $script:mockServer = [DbaInstanceParameter]"sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name ComputerName -Value "sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name ServiceName -Value "MSSQLSERVER" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name DomainInstanceName -Value "sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name VersionMajor -Value 16 + + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + Parent = $script:mockServer + Tables = @($script:mockDbTable) + } + $script:mockDatabase | Add-Member -MemberType ScriptMethod -Name Query -Value { + param($query) + @( + [PSCustomObject]@{ + PersonId = 1 + fname = "Joe" + } + ) + } -Force + + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @{ + tempdb = $script:mockTempDb + db1 = $script:mockDatabase + } + + $script:mockTables = [PSCustomObject]@{ + Name = "db1" + Tables = @( + [PSCustomObject]@{ + Name = "people" + Schema = "dbo" + HasUniqueIndex = $false + FilterQuery = "SELECT TOP 1 [fname] FROM [dbo].[people] ORDER BY [fname]" + Columns = @( + [PSCustomObject]@{ + Name = "fname" + ColumnType = "varchar" + Nullable = $true + Action = [PSCustomObject]@{ + Category = "Column" + Type = "Set" + Value = "masked" + } + Composite = $null + } + ) + } + ) + } + + Mock Invoke-RestMethod { $script:mockTables } -ModuleName dbatools + Mock Connect-DbaInstance { $script:mockServer } -ModuleName dbatools + Mock Invoke-DbaQuery { } -ModuleName dbatools + } + + It "uses the filtered row set when building action updates" { + $null = Invoke-DbaDbDataMasking -SqlInstance "sql1" -Database "db1" -FilePath "http://masking-config" -Confirm:$false + + Assert-MockCalled -CommandName Invoke-DbaQuery -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + $Query.Trim() -eq "UPDATE [dbo].[people] SET [fname] = 'masked' WHERE [PersonId] IN (1);" + } + } + } + } Describe $CommandName -Tag IntegrationTests { @@ -217,5 +444,6 @@ Describe $CommandName -Tag IntegrationTests { } Invoke-DbaQuery @splatQueryBit2 | Should -Be $null } + } } \ No newline at end of file diff --git a/tests/Invoke-DbaDbDbccUpdateUsage.Tests.ps1 b/tests/Invoke-DbaDbDbccUpdateUsage.Tests.ps1 index 4d17efc88c1d..27a432d74222 100644 --- a/tests/Invoke-DbaDbDbccUpdateUsage.Tests.ps1 +++ b/tests/Invoke-DbaDbDbccUpdateUsage.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbDbccUpdateUsage", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -23,6 +23,50 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + $script:lastQuery = $null + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + ID = 5 + IsAccessible = $true + } + $script:mockServer = [PSCustomObject]@{ + Name = "sql1" + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + Databases = @($script:mockDatabase) + } + + function Invoke-DbaQuery { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + $Query, + [switch]$MessagesToOutput + ) + + process { + $script:lastQuery = $Query + @("DBCC execution completed. If DBCC printed error messages, contact your system administrator.") + } + } + Mock Connect-DbaInstance { $script:mockServer } + } + + It "escapes closing brackets in normalized table names" { + $script:lastQuery = $null + + $result = Invoke-DbaDbDbccUpdateUsage -SqlInstance "sql1" -Database "db1" -Table "[dbo].[Bad]]Name]" -Confirm:$false + + $script:lastQuery | Should -Be "DBCC UPDATEUSAGE('db1', '[dbo].[Bad]]Name]')" + $result.Cmd | Should -Be "DBCC UPDATEUSAGE('db1', '[dbo].[Bad]]Name]')" + } + } + } } Describe $CommandName -Tag IntegrationTests { BeforeAll { diff --git a/tests/Invoke-DbaDbDecryptObject.Tests.ps1 b/tests/Invoke-DbaDbDecryptObject.Tests.ps1 index 77542701e10e..09a276248e2b 100644 --- a/tests/Invoke-DbaDbDecryptObject.Tests.ps1 +++ b/tests/Invoke-DbaDbDecryptObject.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbDecryptObject", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -22,6 +22,38 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "DAC reuse behavior" { + It "Should reuse an existing DAC server object without reconnecting or disconnecting it" { + InModuleScope dbatools { + function Test-FunctionInterrupt { $false } + function Get-DbaSpConfigure { + [PSCustomObject]@{ + ConfiguredValue = 1 + } + } + function Connect-DbaInstance { throw "Connect-DbaInstance should not be called for an existing DAC connection." } + function Disconnect-DbaInstance { throw "Disconnect-DbaInstance should not be called for a reused DAC connection." } + function Stop-Function { + param($Message, $ErrorRecord) + throw "$Message | inner: $($ErrorRecord.Exception.Message)" + } + function Write-Message { } + + $mockServer = New-Object Microsoft.SqlServer.Management.Smo.Server "sql1" + $mockServer.ConnectionContext.ServerInstance = "ADMIN:sql1" + $mockServer | Add-Member -NotePropertyName Databases -NotePropertyValue @() -Force + $mockInstance = [DbaInstanceParameter]"sql1" + $field = [DbaInstanceParameter].GetField("InputObject", [System.Reflection.BindingFlags]"NonPublic,Public,Instance,FlattenHierarchy") + $field.SetValue($mockInstance, $mockServer) + + $mockInstance.Type | Should -Be "Server" + $mockInstance.InputObject.ConnectionContext.ServerInstance | Should -Match "^ADMIN:" + + $null = Invoke-DbaDbDecryptObject -SqlInstance $mockInstance -Database "master" + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -311,6 +343,25 @@ SELECT 'áéíñóú¡¿' as SampleUTF8;" } } + Context "Decrypt object with an existing DAC connection" { + It "Should leave the caller DAC connection open" { + $dacServer = Connect-DbaInstance -SqlInstance $TestConfig.InstanceMulti1 -DedicatedAdminConnection -WarningAction SilentlyContinue + + try { + $null = $dacServer.Query("SELECT 1 AS TestConnection") + $dacServer.ConnectionContext.SqlConnectionObject.State | Should -Be "Open" + + $result = Invoke-DbaDbDecryptObject -SqlInstance $dacServer -Database $dbname -ObjectName dbatoolsci_test_vw + + $result.Script | Should -Be $setupView + $dacServer.ConnectionContext.ServerInstance | Should -Match "^ADMIN:" + $dacServer.ConnectionContext.SqlConnectionObject.State | Should -Be "Open" + } finally { + $null = $dacServer | Disconnect-DbaInstance + } + } + } + Context "Connect to an instance (ideally a remote instance) using a SqlCredential and decrypt an object" { It "Should be successful" { $result = Invoke-DbaDbDecryptObject -SqlInstance $TestConfig.InstanceMulti2 -SqlCredential $sqlCredential -Database $dbname -ObjectName dbatoolsci_test_remote_dac_vw -ExportDestination $tempDir diff --git a/tests/Invoke-DbaDbLogShipping.Tests.ps1 b/tests/Invoke-DbaDbLogShipping.Tests.ps1 index ea9a87e61eea..ba8e00179cb6 100644 --- a/tests/Invoke-DbaDbLogShipping.Tests.ps1 +++ b/tests/Invoke-DbaDbLogShipping.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbLogShipping", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -99,6 +99,106 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "IgnoreFileChecks" { + BeforeAll { + $script:mockSourceServer = [PSCustomObject]@{ + ConnectionContext = [PSCustomObject]@{ + StatementTimeout = 30 + } + Databases = @( + [PSCustomObject]@{ + Name = "db1" + RecoveryModel = "Full" + } + ) + DomainInstanceName = "source" + InstanceName = "MSSQLSERVER" + Name = "source" + Version = [PSCustomObject]@{ + Major = 15 + } + } + $script:mockDestinationServer = [PSCustomObject]@{ + ConnectionContext = [PSCustomObject]@{ + StatementTimeout = 30 + } + Databases = @( + [PSCustomObject]@{ + Name = "db1" + Status = "Restoring" + } + ) + DomainInstanceName = "dest" + InstanceName = "MSSQLSERVER" + IsAzure = $false + Name = "dest" + } + + Mock Connect-DbaInstance -ModuleName dbatools -MockWith { + param($SqlInstance) + + switch ($SqlInstance.FullName) { + "source" { return $script:mockSourceServer } + "dest" { return $script:mockDestinationServer } + default { throw "Unexpected instance $($SqlInstance.FullName)" } + } + } + Mock Get-DbaSpConfigure -ModuleName dbatools { + [PSCustomObject]@{ + ConfiguredValue = 0 + } + } + Mock Stop-Function -ModuleName dbatools { + param($Message) + throw $Message + } + Mock Test-DbaPath -ModuleName dbatools { + param($Path) + + if ($Path -eq "C:\copy") { + return $true + } + + if ($Path -eq "\\source\ls\db1") { + return $true + } + + if ($Path -eq "C:\copy\db1") { + return $true + } + + return $false + } + Mock Test-FunctionInterrupt -ModuleName dbatools { $false } + } + + It "Should skip the root backup share validation when IgnoreFileChecks is used" { + $splatLogShipping = @{ + SourceSqlInstance = "source" + DestinationSqlInstance = "dest" + Database = "db1" + SharedPath = "\\source\ls" + CopyDestinationFolder = "C:\copy" + NoInitialization = $true + IgnoreFileChecks = $true + Force = $true + WhatIf = $true + } + + $results = Invoke-DbaDbLogShipping @splatLogShipping + + $results.Result | Should -Be "Success" + Should -Invoke Test-DbaPath -ModuleName dbatools -Times 0 -Exactly -ParameterFilter { + $Path -eq "\\source\ls" + } + Should -Invoke Test-DbaPath -ModuleName dbatools -Times 1 -Exactly -ParameterFilter { + $Path -eq "\\source\ls\db1" + } + } + } + } } Describe $CommandName -Tag IntegrationTests -Skip { diff --git a/tests/Invoke-DbaDbPiiScan.Tests.ps1 b/tests/Invoke-DbaDbPiiScan.Tests.ps1 index 4ce23b7325bc..683766636ac7 100644 --- a/tests/Invoke-DbaDbPiiScan.Tests.ps1 +++ b/tests/Invoke-DbaDbPiiScan.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbPiiScan", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,89 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + function Write-Message { } + + function New-MockPiiTable { + param( + [string]$Schema, + [string]$Name, + [string]$ColumnName + ) + + [PSCustomObject]@{ + Name = $Name + Schema = $Schema + Columns = @( + [PSCustomObject]@{ + Name = $ColumnName + DataType = [PSCustomObject]@{ + Name = "geography" + } + } + ) + } + } + } + + It "honors schema-qualified -Table input" { + $mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + } + $mockDatabase = [PSCustomObject]@{ + Name = "db1" + Parent = $mockServer + Tables = @( + (New-MockPiiTable -Schema "dbo" -Name "Customer" -ColumnName "GeoDbo"), + (New-MockPiiTable -Schema "sales" -Name "Customer" -ColumnName "GeoSales") + ) + } + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @{ + db1 = $mockDatabase + } + + Mock Connect-DbaInstance { $mockServer } + + $results = @(Invoke-DbaDbPiiScan -SqlInstance "sql1" -Database "db1" -Table "sales.Customer") + + $results.Count | Should -Be 1 + $results[0].Schema | Should -Be "sales" + $results[0].Column | Should -Be "GeoSales" + } + + It "honors schema-qualified -ExcludeTable input" { + $mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + } + $mockDatabase = [PSCustomObject]@{ + Name = "db1" + Parent = $mockServer + Tables = @( + (New-MockPiiTable -Schema "dbo" -Name "Customer" -ColumnName "GeoDbo"), + (New-MockPiiTable -Schema "sales" -Name "Customer" -ColumnName "GeoSales") + ) + } + $mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @{ + db1 = $mockDatabase + } + + Mock Connect-DbaInstance { $mockServer } + + $results = @(Invoke-DbaDbPiiScan -SqlInstance "sql1" -Database "db1" -ExcludeTable "sales.Customer") + + $results.Count | Should -Be 1 + $results[0].Schema | Should -Be "dbo" + $results[0].Column | Should -Be "GeoDbo" + } + } + } } # returns different results when running with pwsh diff --git a/tests/Invoke-DbaDbShrink.Tests.ps1 b/tests/Invoke-DbaDbShrink.Tests.ps1 index f05fa49511f3..a3a5566f83fe 100644 --- a/tests/Invoke-DbaDbShrink.Tests.ps1 +++ b/tests/Invoke-DbaDbShrink.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Invoke-DbaDbShrink", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -31,6 +31,112 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Failure output handling" { + BeforeEach { + $script:warningMessages = @() + + function New-MockShrinkDatabase { + $dataFile = [PSCustomObject]@{ + Name = "testdb" + Size = 1024 + UsedSpace = 512 + } + $dataFile | Add-Member -MemberType ScriptMethod -Name Refresh -Value { } + + $server = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + VersionMajor = 16 + ConnectionContext = [PSCustomObject]@{ + StatementTimeout = 0 + } + } + + $server | Add-Member -MemberType ScriptMethod -Name Query -Value { + param($Sql, $DatabaseName) + + if ($Sql -like "DBCC SHRINKFILE*") { + throw "simulated shrink failure" + } + + @() + } + + $database = New-Object Microsoft.SqlServer.Management.Smo.Database + $database.Name = "testdb" + $database | Add-Member -Force -NotePropertyName IsAccessible -NotePropertyValue $true + $database | Add-Member -Force -NotePropertyName IsDatabaseSnapshot -NotePropertyValue $false + $database | Add-Member -Force -NotePropertyName Parent -NotePropertyValue $server + $database | Add-Member -Force -NotePropertyName FileGroups -NotePropertyValue ([PSCustomObject]@{ + Files = @($dataFile) + }) + $database | Add-Member -Force -NotePropertyName LogFiles -NotePropertyValue @() + + $database + } + + function Test-FunctionInterrupt { + $false + } + + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + $ExcludeProperty + ) + + process { + $InputObject + } + } + + function Write-Message { + param( + $Level, + $Message, + $ErrorRecord, + $EnableException + ) + + if ($Level -eq "Warning") { + $script:warningMessages += $Message + } + } + + Mock Stop-Function -ModuleName dbatools { } + } + + It "returns a failed result and warning without calling Stop-Function in friendly mode" { + $mockDatabase = New-MockShrinkDatabase + + $results = @(Invoke-DbaDbShrink -InputObject $mockDatabase -FileType Data -ExcludeIndexStats) + + $results | Should -HaveCount 1 + $results[0].Success | Should -Be $false + $results[0].Notes | Should -Match "simulated shrink failure" + ($script:warningMessages -join " ") | Should -Match "Shrink operation failed for file testdb: .*simulated shrink failure" + Assert-MockCalled -CommandName Stop-Function -Exactly 0 -Scope It + } + + It "still uses Stop-Function when EnableException is requested" { + $mockDatabase = New-MockShrinkDatabase + + Mock Stop-Function -ModuleName dbatools { + throw $Message + } + + { + Invoke-DbaDbShrink -InputObject $mockDatabase -FileType Data -ExcludeIndexStats -EnableException + } | Should -Throw "*Shrink operation failed for file testdb:*simulated shrink failure*" + + Assert-MockCalled -CommandName Stop-Function -Exactly 1 -Scope It + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -187,15 +293,15 @@ Describe $CommandName -Tag IntegrationTests { $blockConnStr = $server.ConnectionContext.ConnectionString $blockDbName = $db.Name $blockJob = Start-Job -ScriptBlock { - param($connStr, $dbName) + param($connStr, $dbName, $blockDuration) $conn = New-Object System.Data.SqlClient.SqlConnection($connStr) $conn.Open() $cmd = $conn.CreateCommand() $cmd.CommandTimeout = 60 - $cmd.CommandText = "USE [$dbName]; BEGIN TRAN; SELECT TOP 1 c1 FROM dbo.dbatoolsci_shrink_blocker WITH (TABLOCKX, HOLDLOCK); WAITFOR DELAY '00:00:20'; IF @@TRANCOUNT > 0 ROLLBACK TRAN" + $cmd.CommandText = "USE [$dbName]; BEGIN TRAN; SELECT TOP 1 c1 FROM dbo.dbatoolsci_shrink_blocker WITH (TABLOCKX, HOLDLOCK); WAITFOR DELAY '$blockDuration'; IF @@TRANCOUNT > 0 ROLLBACK TRAN" try { $cmd.ExecuteNonQuery() } catch { } $conn.Close() - } -ArgumentList $blockConnStr, $blockDbName + } -ArgumentList $blockConnStr, $blockDbName, "00:00:45" Start-Sleep -Seconds 2 @@ -209,14 +315,19 @@ Describe $CommandName -Tag IntegrationTests { Invoke-DbaDbShrink -SqlInstance $serverName -Database $dbName -FileType Data -WaitAtLowPriority } -ArgumentList $shrinkModulePath, $shrinkServerName, $shrinkDbName - Start-Sleep -Seconds 3 - # Verify the SQL text of the running shrink request contains WAIT_AT_LOW_PRIORITY - $sqlTextCount = ($server.Query("SELECT COUNT(*) AS C FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t WHERE t.text LIKE '%WAIT_AT_LOW_PRIORITY%'")).C + $sqlTextCount = 0 + for ($i = 1; $i -le 45; $i++) { + Start-Sleep -Seconds 1 + $sqlTextCount = ($server.Query("SELECT COUNT(*) AS C FROM sys.dm_exec_requests r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t WHERE t.text LIKE '%WAIT_AT_LOW_PRIORITY%'")).C + if ($sqlTextCount -gt 0) { + break + } + } - $null = $blockJob | Wait-Job -Timeout 30 + $null = $blockJob | Wait-Job -Timeout 60 $blockJob | Remove-Job -Force - $null = $shrinkJob | Wait-Job -Timeout 60 + $null = $shrinkJob | Wait-Job -Timeout 90 $shrinkJob | Remove-Job -Force $sqlTextCount | Should -BeGreaterThan 0 diff --git a/tests/Invoke-TlsWebRequest.Tests.ps1 b/tests/Invoke-TlsWebRequest.Tests.ps1 new file mode 100644 index 000000000000..6f256db6c4bb --- /dev/null +++ b/tests/Invoke-TlsWebRequest.Tests.ps1 @@ -0,0 +1,79 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Invoke-TlsWebRequest", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + BeforeAll { + if (-not ("InvokeTlsWebRequestTest.ProxyWithoutAddress" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Net; + +namespace InvokeTlsWebRequestTest { + public class ProxyWithoutAddress : IWebProxy { + public ICredentials Credentials { get; set; } + public Uri ProxyUri { get; set; } + + public ProxyWithoutAddress(string proxyUri) { + ProxyUri = new Uri(proxyUri); + } + + public Uri GetProxy(Uri destination) { + return ProxyUri; + } + + public bool IsBypassed(Uri host) { + return false; + } + } +} +"@ + } + + $originalDefaultWebProxy = [System.Net.WebRequest]::DefaultWebProxy + } + + AfterEach { + [System.Net.WebRequest]::DefaultWebProxy = $originalDefaultWebProxy + } + + AfterAll { + [System.Net.WebRequest]::DefaultWebProxy = $originalDefaultWebProxy + } + + Context "Proxy auto-detection" { + BeforeEach { + Mock Get-DbatoolsConfigValue { $false } -ParameterFilter { $FullName -eq "commands.invoke-tlswebrequest.disableautoproxy" } + Mock Invoke-WebRequest { "ok" } + } + + It "Should not replace a configured proxy that has no Address property" { + $configuredProxyCredentials = New-Object System.Net.NetworkCredential("proxyuser", "proxypass") + $configuredProxy = New-Object -TypeName "InvokeTlsWebRequestTest.ProxyWithoutAddress" -ArgumentList "http://configured-proxy:8080" + $configuredProxy.Credentials = $configuredProxyCredentials + [System.Net.WebRequest]::DefaultWebProxy = $configuredProxy + + Invoke-TlsWebRequest -Uri "https://example.com" + + [object]::ReferenceEquals([System.Net.WebRequest]::DefaultWebProxy, $configuredProxy) | Should -Be $true + [object]::ReferenceEquals([System.Net.WebRequest]::DefaultWebProxy.Credentials, $configuredProxyCredentials) | Should -Be $true + } + + It "Should not overwrite the default proxy when -Proxy is supplied" { + $configuredProxyCredentials = New-Object System.Net.NetworkCredential("proxyuser", "proxypass") + $configuredProxy = New-Object -TypeName "InvokeTlsWebRequestTest.ProxyWithoutAddress" -ArgumentList "http://configured-proxy:8080" + $configuredProxy.Credentials = $configuredProxyCredentials + [System.Net.WebRequest]::DefaultWebProxy = $configuredProxy + + Invoke-TlsWebRequest -Uri "https://example.com" -Proxy "http://override-proxy:8080" + + [object]::ReferenceEquals([System.Net.WebRequest]::DefaultWebProxy, $configuredProxy) | Should -Be $true + [object]::ReferenceEquals([System.Net.WebRequest]::DefaultWebProxy.Credentials, $configuredProxyCredentials) | Should -Be $true + } + } + } +} diff --git a/tests/New-DbaComputerCertificate.Tests.ps1 b/tests/New-DbaComputerCertificate.Tests.ps1 index e84611bc2391..f8d2b3a14db1 100644 --- a/tests/New-DbaComputerCertificate.Tests.ps1 +++ b/tests/New-DbaComputerCertificate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaComputerCertificate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -33,6 +33,61 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "DocumentEncryptionCert validation" { + BeforeAll { + Mock Stop-Function { throw $Message } + } + + It "requires SelfSigned or an explicit certificate template" { + { + New-DbaComputerCertificate -DocumentEncryptionCert + } | Should -Throw "*requires -SelfSigned or an explicit -CertificateTemplate*" + } + } + } + + Context "NonExportable handling" { + BeforeAll { + $script:remoteFqdn = "dbatools-review-remote.example" + $script:requestConfig = @() + + Mock Get-DbaCmObject -ModuleName "dbatools" { + [pscustomobject]@{ + OSLanguage = 1033 + } + } + Mock Resolve-DbaNetworkName -ModuleName "dbatools" { + [pscustomobject]@{ + Fqdn = $script:remoteFqdn + } + } + Mock Test-ElevationRequirement -ModuleName "dbatools" { $true } + Mock Set-Content -ModuleName "dbatools" { + param($Path, $Value) + $script:requestConfig = @($Value) + } + Mock Add-Content -ModuleName "dbatools" { + param($Path, $Value) + $script:requestConfig += $Value + } + } + + It "Keeps the source certificate exportable for remote installs when NonExportable is requested" { + $splatRemoteCertificate = @{ + ComputerName = "dbatools-review-remote" + CaServer = "dbatools-ca" + CaName = "dbatools-ca" + Flag = "NonExportable" + WhatIf = $true + } + $null = New-DbaComputerCertificate @splatRemoteCertificate + + $script:requestConfig | Should -Contain "Exportable = TRUE" + $script:requestConfig | Should -Not -Contain "Exportable = FALSE" + } + } } #Tests do not run in appveyor diff --git a/tests/New-DbaDatabase.Tests.ps1 b/tests/New-DbaDatabase.Tests.ps1 index 5a111ff9d66a..5edb65e839fa 100644 --- a/tests/New-DbaDatabase.Tests.ps1 +++ b/tests/New-DbaDatabase.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaDatabase", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -38,6 +38,144 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Path handling" { + BeforeAll { + function Write-Message { } + + function New-MockCollection { + $collection = [PSCustomObject]@{ + Items = @() + } + $collection | Add-Member -Force -MemberType ScriptMethod -Name Add -Value { + param($item) + $this.Items += $item + $null + } + + $collection + } + } + + BeforeEach { + $script:createdDataFiles = @() + $script:createdLogFiles = @() + $script:executedPathQueries = @() + + $script:modelPrimaryFile = [PSCustomObject]@{ + Size = 8192 + Growth = 1024 + GrowthType = "KB" + MaxSize = 1048576 + } + $script:modelLogFile = [PSCustomObject]@{ + Size = 1024 + Growth = 256 + GrowthType = "KB" + MaxSize = 1048576 + } + $script:mockServer = [DbaInstanceParameter]"sql1" + $script:mockConnectionContext = [PSCustomObject]@{ } + Add-Member -InputObject $script:mockConnectionContext -Force -MemberType ScriptMethod -Name ExecuteWithResults -Value { + param($sql) + $script:executedPathQueries += $sql + [PSCustomObject]@{ + Tables = [PSCustomObject]@{ + rows = @($true, $false) + } + } + } + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name Databases -Value @{ + model = [PSCustomObject]@{ + FileGroups = @{ + PRIMARY = [PSCustomObject]@{ + Files = @{ + modeldev = $script:modelPrimaryFile + } + } + } + LogFiles = @{ + modellog = $script:modelLogFile + } + } + } + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name VersionMajor -Value 16 + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name ConnectionContext -Value $script:mockConnectionContext + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name Name -Value "sql1" + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name ServiceName -Value "MSSQLSERVER" + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name ComputerName -Value "sql1" + Add-Member -InputObject $script:mockServer -Force -MemberType NoteProperty -Name DomainInstanceName -Value "sql1" + + Mock Connect-DbaInstance { $script:mockServer } -ModuleName dbatools + Mock Stop-Function { throw $Message } -ModuleName dbatools + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock New-Object { + [PSCustomObject]@{ + Name = $ArgumentList[1] + Filegroups = New-MockCollection + LogFiles = New-MockCollection + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Database" + } -ModuleName dbatools + Mock New-Object { + [PSCustomObject]@{ + Name = $ArgumentList[1] + Files = New-MockCollection + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.Filegroup" + } -ModuleName dbatools + Mock New-Object { + $dataFile = [PSCustomObject]@{ + Name = $ArgumentList[1] + FileName = $null + IsPrimaryFile = $false + Size = $null + Growth = $null + GrowthType = $null + MaxSize = $null + } + $script:createdDataFiles += $dataFile + $dataFile + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.DataFile" + } -ModuleName dbatools + Mock New-Object { + $logFile = [PSCustomObject]@{ + Name = $ArgumentList[1] + FileName = $null + Size = $null + Growth = $null + GrowthType = $null + MaxSize = $null + } + $script:createdLogFiles += $logFile + $logFile + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.LogFile" + } -ModuleName dbatools + } + + It "preserves rooted default paths for validation and creation" { + $null = New-DbaDatabase -SqlInstance "sql1" -Name "db1" -DataFilePath "C:\" -LogFilePath "L:\" -WhatIf + + $script:executedPathQueries | Should -Contain "EXEC master.dbo.xp_fileexist 'L:\'" + $script:executedPathQueries | Should -Contain "EXEC master.dbo.xp_fileexist 'C:\'" + $script:createdDataFiles[0].FileName | Should -Be "C:\db1.mdf" + $script:createdLogFiles[0].FileName | Should -Be "L:\db1_log.ldf" + } + + It "skips directory checks for Azure Blob Storage default paths" { + $null = New-DbaDatabase -SqlInstance "sql1" -Name "db1" -DataFilePath "https://storage.blob.core.windows.net/data/" -LogFilePath "https://storage.blob.core.windows.net/log/" -WhatIf + + $script:executedPathQueries | Should -BeNullOrEmpty + $script:createdDataFiles[0].FileName | Should -Be "https://storage.blob.core.windows.net/data/db1.mdf" + $script:createdLogFiles[0].FileName | Should -Be "https://storage.blob.core.windows.net/log/db1_log.ldf" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/New-DbaDbMailAccount.Tests.ps1 b/tests/New-DbaDbMailAccount.Tests.ps1 index 3b319370aa93..2abe338499bb 100644 --- a/tests/New-DbaDbMailAccount.Tests.ps1 +++ b/tests/New-DbaDbMailAccount.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaDbMailAccount", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,27 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Input validation" { + It "Should call Stop-Function when default credentials are combined with SMTP credentials" { + Mock Stop-Function { } + $securePassword = ConvertTo-SecureString "P@ssw0rd!" -AsPlainText -Force + + New-DbaDbMailAccount -SqlInstance "sql1" -Account "alerts" -EmailAddress "alerts@contoso.com" -UseDefaultCredentials -UserName "alerts@contoso.com" -Password $securePassword | Should -BeNullOrEmpty + + Should -Invoke Stop-Function -Times 1 -Exactly + } + + It "Should call Stop-Function when only one SMTP credential value is provided" { + Mock Stop-Function { } + + New-DbaDbMailAccount -SqlInstance "sql1" -Account "alerts" -EmailAddress "alerts@contoso.com" -UserName "alerts@contoso.com" | Should -BeNullOrEmpty + + Should -Invoke Stop-Function -Times 1 -Exactly + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/New-DbaDbTable.Tests.ps1 b/tests/New-DbaDbTable.Tests.ps1 index 892925aca565..34e855b5fd47 100644 --- a/tests/New-DbaDbTable.Tests.ps1 +++ b/tests/New-DbaDbTable.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaDbTable", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -73,6 +73,32 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Name parsing validation" { + BeforeEach { + Mock Get-DbaDatabase { + [PSCustomObject]@{ + Parent = "dbatoolsci" + } + } + Mock Stop-Function { throw $Message } + } + + It "Rejects three-part names so the database target stays explicit" { + $columnMap = @{ + Name = "testId" + Type = "int" + } + + { + New-DbaDbTable -SqlInstance "sql1" -Database "tempdb" -Name "otherdb.dbo.testtable" -ColumnMap $columnMap -WhatIf + } | Should -Throw "*Specify the database separately with -Database*" + + Should -Invoke Get-DbaDatabase -Times 0 -Exactly + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/New-DbaFirewallRule.Tests.ps1 b/tests/New-DbaFirewallRule.Tests.ps1 index cd22da008115..7b037dbc8171 100644 --- a/tests/New-DbaFirewallRule.Tests.ps1 +++ b/tests/New-DbaFirewallRule.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaFirewallRule", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -24,6 +24,61 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + Context "Program path extraction" { + BeforeEach { + Mock Invoke-Command2 { + [PSCustomObject]@{ + Successful = $true + CimInstance = [PSCustomObject]@{ + Status = "The rule was parsed successfully from the store" + } + Warning = $null + Error = $null + Exception = $null + } + } + } + + It "falls back to a port rule when the engine BinaryPath contains sqlservr.exe in a folder name" { + Mock Get-DbaNetworkConfiguration { + [PSCustomObject]@{ + TcpPort = "1433" + TcpDynamicPorts = "" + } + } + Mock Get-DbaService { + [PSCustomObject]@{ + BinaryPath = "{0}C:\Backups\sqlservr.exe\bin\realapp.exe{0} -sTEST" -f '"' + } + } + + $result = New-DbaFirewallRule -SqlInstance "sql01\test" -Type Engine -RuleType Program -Confirm:$false + + $result.Type | Should -Be "Engine" + $result.Program | Should -BeNullOrEmpty + $result.LocalPort | Should -Be "1433" + } + + It "falls back to the Browser port rule when BinaryPath contains sqlbrowser.exe in a folder name" { + Mock Get-DbaService { + [PSCustomObject]@{ + BinaryPath = "{0}C:\Backups\sqlbrowser.exe\bin\realapp.exe{0}" -f '"' + } + } + + $result = New-DbaFirewallRule -SqlInstance "sql01\test" -Type Browser -RuleType Program -Confirm:$false + + $result.Type | Should -Be "Browser" + $result.Program | Should -BeNullOrEmpty + $result.Protocol | Should -Be "UDP" + $result.LocalPort | Should -Be "1434" + } + } + } +} + Describe $CommandName -Tag IntegrationTests { # The context "RuleType Port (traditional port-based rules)" does not work with dynamic ports. # So we test at discovery time if dynamic ports are used and skip the tests if so. diff --git a/tests/New-DbaLogin.Tests.ps1 b/tests/New-DbaLogin.Tests.ps1 index cd9b7d026a60..49bfcb815564 100644 --- a/tests/New-DbaLogin.Tests.ps1 +++ b/tests/New-DbaLogin.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "New-DbaLogin", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -37,6 +37,97 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "External provider fallback" { + BeforeEach { + $script:queryStatements = @() + + $script:createdLogin = [PSCustomObject]@{ + Name = $null + DenyWindowsLogin = $null + MustChangePassword = $false + } + + $script:pendingLogin = [PSCustomObject]@{ + Name = "pending" + LoginType = $null + DefaultDatabase = $null + Language = $null + PasswordExpirationEnabled = $null + PasswordPolicyEnforced = $null + DenyWindowsLogin = $null + MustChangePassword = $false + AsymmetricKey = $null + Certificate = $null + Sid = $null + } + Add-Member -InputObject $script:pendingLogin -Name Set_Sid -MemberType ScriptMethod -Value { + param($sid) + $this.Sid = $sid + } -Force + Add-Member -InputObject $script:pendingLogin -Name Create -MemberType ScriptMethod -Value { + throw "SMO create failed for test" + } -Force + Add-Member -InputObject $script:pendingLogin -Name Refresh -MemberType ScriptMethod -Value { } -Force + Add-Member -InputObject $script:pendingLogin -Name AddCredential -MemberType ScriptMethod -Value { + param($credential) + $this.Credential = $credential + } -Force + Add-Member -InputObject $script:pendingLogin -Name Drop -MemberType ScriptMethod -Value { } -Force + Add-Member -InputObject $script:pendingLogin -Name Disable -MemberType ScriptMethod -Value { } -Force + Add-Member -InputObject $script:pendingLogin -Name Alter -MemberType ScriptMethod -Value { } -Force + Add-Member -InputObject $script:pendingLogin -Name ChangePassword -MemberType ScriptMethod -Value { + param($securePassword, $mustChange) + $this.MustChangePassword = $mustChange + } -Force + + $script:mockLogins = @{ } + Add-Member -InputObject $script:mockLogins -Name Refresh -MemberType ScriptMethod -Value { } -Force + + $script:mockServer = [DbaInstanceParameter]"sql2022" + Add-Member -InputObject $script:mockServer -Name IsAzure -MemberType NoteProperty -Value $false -Force + Add-Member -InputObject $script:mockServer -Name LoginMode -MemberType NoteProperty -Value ([Microsoft.SqlServer.Management.Smo.ServerLoginMode]::Mixed) -Force + Add-Member -InputObject $script:mockServer -Name DatabaseEngineType -MemberType NoteProperty -Value "Standalone" -Force + Add-Member -InputObject $script:mockServer -Name DatabaseEngineEdition -MemberType NoteProperty -Value "Enterprise" -Force + Add-Member -InputObject $script:mockServer -Name VersionMajor -MemberType NoteProperty -Value 16 -Force + Add-Member -InputObject $script:mockServer -Name Logins -MemberType NoteProperty -Value $script:mockLogins -Force + Add-Member -InputObject $script:mockServer -Name Query -MemberType ScriptMethod -Value { + param($sql) + $script:queryStatements += $sql + if ($sql -match "^CREATE LOGIN \[(?.+)\] FROM EXTERNAL PROVIDER$") { + $script:createdLogin.Name = $Matches.LoginName + $this.Logins[$Matches.LoginName] = $script:createdLogin + } + } -Force + + Mock Test-FunctionInterrupt { $false } -ModuleName dbatools + Mock Connect-DbaInstance { $script:mockServer } -ModuleName dbatools + Mock Add-TeppCacheItem { } -ModuleName dbatools + Mock Get-DbaLogin { + [PSCustomObject]@{ + Name = $Login + LoginType = "ExternalUser" + } + } -ModuleName dbatools + Mock Stop-Function { + throw $Message + } -ModuleName dbatools + Mock New-Object { + $script:pendingLogin + } -ModuleName dbatools + } + + It "Should use ALTER LOGIN for external provider defaults after T-SQL fallback" { + $null = New-DbaLogin -SqlInstance "sql2022" -Login "entra-user" -ExternalProvider -DefaultDatabase "tempdb" -Language "Deutsch" + + $script:queryStatements.Count | Should -Be 2 + $script:queryStatements[0] | Should -Be "CREATE LOGIN [entra-user] FROM EXTERNAL PROVIDER" + $script:queryStatements[0] | Should -Not -Match "WITH" + $script:queryStatements[1] | Should -Be "ALTER LOGIN [entra-user] WITH DEFAULT_DATABASE = [tempdb], DEFAULT_LANGUAGE = [Deutsch]" + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -284,7 +375,7 @@ Describe $CommandName -Tag IntegrationTests { } } - if ((Connect-DbaInstance -SqlInstance $TestConfig.InstanceMulti1).LoginMode -eq "Mixed") { + if ($TestConfig.InstanceMulti1 -and (Connect-DbaInstance -SqlInstance $TestConfig.InstanceMulti1).LoginMode -eq "Mixed") { Context "Connect with a new login" { It "Should login with newly created Sql Login, get instance name and kill the process" { $cred = New-Object System.Management.Automation.PSCredential ("tester", $securePassword) diff --git a/tests/Remove-DbaAgentJobSchedule.Tests.ps1 b/tests/Remove-DbaAgentJobSchedule.Tests.ps1 index 0b58c1a570d9..802927965bbf 100644 --- a/tests/Remove-DbaAgentJobSchedule.Tests.ps1 +++ b/tests/Remove-DbaAgentJobSchedule.Tests.ps1 @@ -1,10 +1,29 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Remove-DbaAgentJobSchedule", $PSDefaultParameterValues = $TestConfig.Defaults ) +class MockAgentServer { + [string]$Name + [string]$ComputerName + [string]$ServiceName + [string]$DomainInstanceName + [object]$JobServer + + MockAgentServer([string]$Name) { + $this.Name = $Name + $this.ComputerName = $Name + $this.ServiceName = "MSSQLSERVER" + $this.DomainInstanceName = $Name + } + + [string] ToString() { + return $this.Name + } +} + Describe $CommandName -Tag UnitTests { Context "Parameter validation" { It "Should have the expected parameters" { @@ -21,6 +40,130 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope "dbatools" { + BeforeAll { + function New-MockAgentSchedule { + param( + [string]$Name, + [int]$Id, + [switch]$ThrowOnDrop + ) + + $schedule = [PSCustomObject]@{ + Name = $Name + Id = $Id + ScheduleUid = [guid]::NewGuid() + DropCount = 0 + KeepSchedule = $null + ThrowOnDrop = $ThrowOnDrop + } + $schedule | Add-Member -MemberType ScriptMethod -Name Drop -Value { + param([bool]$keepSchedule) + + $this.DropCount++ + $this.KeepSchedule = $keepSchedule + + if ($this.ThrowOnDrop) { + throw "drop failed for $($this.Name)" + } + } -Force + + return $schedule + } + + function New-MockAgentJob { + param( + [string]$Name, + [object[]]$JobSchedules + ) + + return [PSCustomObject]@{ + Name = $Name + Parent = $null + JobSchedules = $JobSchedules + } + } + + function New-MockAgentServer { + param([object[]]$Job) + + $jobs = @{ } + foreach ($jobObject in $Job) { + $jobs[$jobObject.Name] = $jobObject + } + $jobs | Add-Member -MemberType ScriptProperty -Name Name -Value { $this.Keys } -Force + + $server = [MockAgentServer]::new("sql1") + $jobServer = [PSCustomObject]@{ + Parent = $server + Jobs = $jobs + } + $server.JobServer = $jobServer + + foreach ($jobObject in $Job) { + $jobObject.Parent = $jobServer + } + + return $server + } + } + + Context "When multiple schedules with the same name are attached to a job" { + BeforeAll { + $script:scheduleOne = New-MockAgentSchedule -Name "SharedSchedule" -Id 1 + $script:scheduleTwo = New-MockAgentSchedule -Name "SharedSchedule" -Id 2 + $job = New-MockAgentJob -Name "Job1" -JobSchedules @($script:scheduleOne, $script:scheduleTwo) + $script:mockServer = New-MockAgentServer -Job $job + + Mock Connect-DbaInstance { $script:mockServer } + } + + It "Should detach each matching schedule instead of failing on an array of job schedules" { + $splatDetach = @{ + SqlInstance = "sql1" + Job = "Job1" + Schedule = "SharedSchedule" + Confirm = $false + } + $result = Remove-DbaAgentJobSchedule @splatDetach + + $result.Count | Should -Be 2 + ($result | Where-Object IsDetached).Count | Should -Be 2 + ($result | Select-Object -ExpandProperty ScheduleId) | Should -Be @(1, 2) + $script:scheduleOne.DropCount | Should -Be 1 + $script:scheduleTwo.DropCount | Should -Be 1 + $script:scheduleOne.KeepSchedule | Should -Be $true + $script:scheduleTwo.KeepSchedule | Should -Be $true + } + } + + Context "When detaching a schedule fails" { + BeforeAll { + $script:failingSchedule = New-MockAgentSchedule -Name "BrokenSchedule" -Id 9 -ThrowOnDrop + $job = New-MockAgentJob -Name "Job1" -JobSchedules $script:failingSchedule + $script:mockServer = New-MockAgentServer -Job $job + + Mock Connect-DbaInstance { $script:mockServer } + Mock Stop-Function { } + } + + It "Should return the failed detach result instead of skipping output" { + $splatDetach = @{ + SqlInstance = "sql1" + Job = "Job1" + Schedule = "BrokenSchedule" + Confirm = $false + } + $result = Remove-DbaAgentJobSchedule @splatDetach + + $result | Should -Not -BeNullOrEmpty + $result.IsDetached | Should -Be $false + $result.Status | Should -Match "drop failed for BrokenSchedule" + $script:failingSchedule.DropCount | Should -Be 1 + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -117,4 +260,4 @@ Describe $CommandName -Tag IntegrationTests { $result.Job | Should -Be $jobName } } -} +} \ No newline at end of file diff --git a/tests/Remove-DbaDbTableData.Tests.ps1 b/tests/Remove-DbaDbTableData.Tests.ps1 index 736d075b0dd4..e54da0a90890 100644 --- a/tests/Remove-DbaDbTableData.Tests.ps1 +++ b/tests/Remove-DbaDbTableData.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Remove-DbaDbTableData", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -27,6 +27,101 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + function Write-Message { } + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + [Parameter(ValueFromRemainingArguments)] + $RemainingArguments + ) + + process { + $InputObject + } + } + } + + BeforeEach { + $script:executedSql = @() + $script:deleteRowCounts = @(10, 0) + $script:rowCountIndex = 0 + + $script:mockServer = [PSCustomObject]@{ + Name = "sql1" + ComputerName = "sql1" + DatabaseEngineType = [Microsoft.SqlServer.Management.Common.DatabaseEngineType]::Standalone + } + + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + Parent = $script:mockServer + RecoveryModel = "Simple" + } + + Add-Member -InputObject $script:mockDatabase -Name Query -MemberType ScriptMethod -Value { + param($sql) + + $script:executedSql += $sql + + if ($sql -like "SELECT COUNT(1) FROM msdb.dbo.log_shipping_monitor_primary*") { + return 0 + } + + if ($sql -eq "CHECKPOINT") { + return [PSCustomObject]@{ + RowCount = 0 + ErrorMessage = $null + } + } + + $rowCount = $script:deleteRowCounts[$script:rowCountIndex] + if ($script:rowCountIndex -lt ($script:deleteRowCounts.Count - 1)) { + $script:rowCountIndex += 1 + } + + return [PSCustomObject]@{ + RowCount = $rowCount + ErrorMessage = $null + } + } -Force + + Mock Test-FunctionInterrupt { $false } + Mock Get-DbaDatabase { $script:mockDatabase } + Mock Stop-Function { throw $Message } + } + + It "Preserves the empty schema segment in three-part table names" { + $result = Remove-DbaDbTableData -SqlInstance "sql1" -Database "db1" -Table "MyDb..Test" -BatchSize 10 -Confirm:$false + $deleteSql = $script:executedSql | Where-Object { $PSItem -like "*DELETE TOP (10) FROM*" } | Select-Object -First 1 + + $deleteSql | Should -Not -BeNullOrEmpty + $deleteSql.Contains("DELETE TOP (10) FROM [MyDb]..[Test];") | Should -Be $true + $result.TotalRowsDeleted | Should -Be 10 + $result.TotalIterations | Should -Be 1 + } + + It "Escapes closing brackets in bracketed table names" { + $result = Remove-DbaDbTableData -SqlInstance "sql1" -Database "db1" -Table "[dbo].[Test]]Name]" -BatchSize 10 -Confirm:$false + $deleteSql = $script:executedSql | Where-Object { $PSItem -like "*DELETE TOP (10) FROM*" } | Select-Object -First 1 + + $deleteSql | Should -Not -BeNullOrEmpty + $deleteSql.Contains("DELETE TOP (10) FROM [dbo].[Test]]Name];") | Should -Be $true + $result.TotalRowsDeleted | Should -Be 10 + $result.TotalIterations | Should -Be 1 + } + + It "Stops early when the table name cannot be parsed" { + { + Remove-DbaDbTableData -SqlInstance "sql1" -Database "db1" -Table "one.two.three.four" -Confirm:$false + } | Should -Throw "*could not be parsed as a valid table name*" + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -175,6 +270,46 @@ Describe $CommandName -Tag IntegrationTests { $result.Database | Should -Be $dbnameSimpleModel (Invoke-DbaQuery -SqlInstance $server -Database $dbnameSimpleModel -Query "SELECT COUNT(1) AS [RowCount] FROM dbo.Test").RowCount | Should -Be 0 } + + It "Removes Data for a specified database when -Table uses database..table format" { + $result = Remove-DbaDbTableData -SqlInstance $server -Database $dbnameSimpleModel -Table "$dbnameSimpleModel..Test" -BatchSize 10 + $result.TotalIterations | Should -Be 10 + $result.TotalRowsDeleted | Should -Be 100 + $result.LogBackups.Count | Should -Be 0 + $result.Timings.Count | Should -Be 10 + $result.Database | Should -Be $dbnameSimpleModel + (Invoke-DbaQuery -SqlInstance $server -Database $dbnameSimpleModel -Query "SELECT COUNT(1) AS [RowCount] FROM dbo.Test").RowCount | Should -Be 0 + } + } + + Context "Functionality with escaped table names" { + BeforeEach { + $sqlAddRowsToEscapedTable = " + IF OBJECT_ID(N'[dbo].[Test]]Name]', N'U') IS NOT NULL + DROP TABLE [dbo].[Test]]Name]; + + CREATE TABLE [dbo].[Test]]Name] (Id INTEGER); + + DECLARE + @loopCounter INTEGER = 0; + + WHILE @loopCounter < 25 + BEGIN + INSERT INTO [dbo].[Test]]Name] VALUES (@loopCounter); + SET @loopCounter = @loopCounter + 1; + END;" + $null = Invoke-DbaQuery -SqlInstance $server -Database $dbnameSimpleModel -Query $sqlAddRowsToEscapedTable + } + + It "Removes Data for a table name that contains a closing bracket" { + $result = Remove-DbaDbTableData -SqlInstance $server -Database $dbnameSimpleModel -Table "[dbo].[Test]]Name]" -BatchSize 10 + $result.TotalIterations | Should -Be 3 + $result.TotalRowsDeleted | Should -Be 25 + $result.LogBackups.Count | Should -Be 0 + $result.Timings.Count | Should -Be 3 + $result.Database | Should -Be $dbnameSimpleModel + (Invoke-DbaQuery -SqlInstance $server -Database $dbnameSimpleModel -Query "SELECT COUNT(1) AS [RowCount] FROM [dbo].[Test]]Name]").RowCount | Should -Be 0 + } } Context "Functionality with bulk_logged recovery model" { diff --git a/tests/Remove-DbaInstanceList.Tests.ps1 b/tests/Remove-DbaInstanceList.Tests.ps1 index ffd9965707ed..a9bf83f3e6f8 100644 --- a/tests/Remove-DbaInstanceList.Tests.ps1 +++ b/tests/Remove-DbaInstanceList.Tests.ps1 @@ -35,5 +35,10 @@ Describe $CommandName -Tag IntegrationTests { $result = Get-DbaInstanceList $result | Should -Not -Contain $instanceName.ToLowerInvariant() } + + It "instance no longer appears in the TEPP cache after removal" { + $cache = [Dataplat.Dbatools.TabExpansion.TabExpansionHost]::Cache["sqlinstance"] + $cache | Should -Not -Contain $instanceName.ToLowerInvariant() + } } } diff --git a/tests/Save-DbaKbUpdate.Tests.ps1 b/tests/Save-DbaKbUpdate.Tests.ps1 index 4f76635e978c..355825a35818 100644 --- a/tests/Save-DbaKbUpdate.Tests.ps1 +++ b/tests/Save-DbaKbUpdate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Save-DbaKbUpdate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -23,6 +23,24 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Implementation regression" { + It "passes ErrorAction Stop to Start-BitsTransfer so fallback errors are catchable" { + $commandText = (Get-Command $CommandName).ScriptBlock.ToString() + $bitsTransferCall = "Start-BitsTransfer -Source " + [char]36 + "link -Destination " + [char]36 + "file -ErrorAction Stop" + + $commandText | Should -Match ([regex]::Escape($bitsTransferCall)) + } + + It "checks UseWebRequest before selecting the BITS download path" { + $commandText = (Get-Command $CommandName).ScriptBlock.ToString() + $bitsTransferCondition = "if (-not " + [char]36 + "UseWebRequest -and (Get-Command Start-BitsTransfer -ErrorAction Ignore))" + $webRequestCall = "Invoke-TlsWebRequest -Uri " + [char]36 + "link -OutFile " + [char]36 + "file -ErrorAction Stop" + + $commandText | Should -Match ([regex]::Escape($bitsTransferCondition)) + ([regex]::Matches($commandText, [regex]::Escape($webRequestCall))).Count | Should -Be 2 + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Set-DbaDbCompression.Tests.ps1 b/tests/Set-DbaDbCompression.Tests.ps1 index 53938a948141..e187a47569e8 100644 --- a/tests/Set-DbaDbCompression.Tests.ps1 +++ b/tests/Set-DbaDbCompression.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Set-DbaDbCompression", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -27,6 +27,121 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + if (-not ("SetDbaDbCompressionTest.MockCollection[System.Object]" -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Collections; +using System.Collections.Generic; + +namespace SetDbaDbCompressionTest { + public class MockCollection : IEnumerable { + private Dictionary items = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void Add(string name, T item) { + items[name] = item; + } + + public T this[string name] { + get { + T value; + items.TryGetValue(name, out value); + return value; + } + } + + public IEnumerator GetEnumerator() { + return items.Values.GetEnumerator(); + } + } + + public class MockDatabase { + public string Name { get; set; } + public bool IsAccessible { get; set; } + public int IsSystemObject { get; set; } + public string Status { get; set; } + public string CompatibilityLevel { get; set; } + public object[] Tables { get; set; } + + public override string ToString() { + return Name; + } + } +} +"@ + } + + function Write-Message { } + + function New-MockCompressionTable { + param( + [string]$Schema, + [string]$Name + ) + + $table = [PSCustomObject]@{ + Name = $Name + Schema = $Schema + IsMemoryOptimized = $false + HasSparseColumn = $false + Indexes = @() + PhysicalPartitions = @( + [PSCustomObject]@{ + PartitionNumber = 1 + DataCompression = "NONE" + } + ) + OnlineHeapOperation = $false + HasHeapIndex = $true + } + $table | Add-Member -Force -MemberType ScriptMethod -Name Rebuild -Value { + $script:rebuiltSchemas += $this.Schema + } + + $table + } + } + + It "honors schema-qualified -Table input" { + $script:rebuiltSchemas = @() + $mockDatabase = New-Object "SetDbaDbCompressionTest.MockDatabase" + $mockDatabase.Name = "db1" + $mockDatabase.IsAccessible = $true + $mockDatabase.IsSystemObject = 0 + $mockDatabase.Status = "Normal" + $mockDatabase.CompatibilityLevel = "Version160" + $mockDatabase.Tables = @( + (New-MockCompressionTable -Schema "dbo" -Name "Customer"), + (New-MockCompressionTable -Schema "sales" -Name "Customer") + ) + + $mockDatabases = New-Object "SetDbaDbCompressionTest.MockCollection[System.Object]" + $mockDatabases.Add("db1", $mockDatabase) + + $mockServer = [PSCustomObject]@{ + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + EngineEdition = "Enterprise" + VersionMajor = 16 + isAzure = $false + Databases = $mockDatabases + } + + Mock Connect-DbaInstance { $mockServer } + Mock Stop-Function { throw $Message } + + $results = @(Set-DbaDbCompression -SqlInstance "sql1" -Database "db1" -Table "sales.Customer" -CompressionType Row) + + $results.Count | Should -Be 1 + $results[0].Schema | Should -Be "sales" + $script:rebuiltSchemas | Should -Be @("sales") + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -35,12 +150,29 @@ Describe $CommandName -Tag IntegrationTests { $PSDefaultParameterValues["*-Dba*:EnableException"] = $true $dbName = "dbatoolsci_test_$(Get-Random)" + $indexedViewName = "dbatoolsci_syscolview" + $indexedViewIndexName = "CL_dbatoolsci_syscolview" $server = Connect-DbaInstance -SqlInstance $TestConfig.InstanceSingle $null = $server.Query("Create Database [$dbName]") $null = $server.Query("select * into syscols from sys.all_columns select * into sysallparams from sys.all_parameters create clustered index CL_sysallparams on sysallparams (object_id) create nonclustered index NC_syscols on syscols (precision) include (collation_name)", $dbName) + $null = $server.Query("SET ANSI_NULLS ON; + SET QUOTED_IDENTIFIER ON; + EXEC sys.sp_executesql N'CREATE VIEW dbo.[$indexedViewName] + WITH SCHEMABINDING + AS + SELECT object_id, column_id + FROM dbo.syscols'", $dbName) + $null = $server.Query("SET ANSI_NULLS ON; + SET ANSI_PADDING ON; + SET ANSI_WARNINGS ON; + SET ARITHABORT ON; + SET CONCAT_NULL_YIELDS_NULL ON; + SET QUOTED_IDENTIFIER ON; + SET NUMERIC_ROUNDABORT OFF; + CREATE UNIQUE CLUSTERED INDEX [$indexedViewIndexName] ON dbo.[$indexedViewName] (object_id, column_id)", $dbName) # Get InputObject for testing $inputObject = Test-DbaDbCompression -SqlInstance $TestConfig.InstanceSingle -Database $dbName @@ -161,4 +293,20 @@ Describe $CommandName -Tag IntegrationTests { } } } + + Context "Command returns indexed view metadata when rebuilding to None" { + BeforeAll { + $null = Set-DbaDbCompression -SqlInstance $TestConfig.InstanceSingle -Database $dbName -CompressionType Page + $indexedViewResults = Set-DbaDbCompression -SqlInstance $TestConfig.InstanceSingle -Database $dbName -CompressionType None | + Where-Object IndexName -eq $indexedViewIndexName + } + + It "Should return the indexed view schema and name" { + $indexedViewResults | Should -Not -BeNullOrEmpty + foreach ($row in $indexedViewResults) { + $row.Schema | Should -Be "dbo" + $row.TableName | Should -Be $indexedViewName + } + } + } } \ No newline at end of file diff --git a/tests/Set-DbaDbIdentity.Tests.ps1 b/tests/Set-DbaDbIdentity.Tests.ps1 index 0a7f0369c4f6..33e106192f7d 100644 --- a/tests/Set-DbaDbIdentity.Tests.ps1 +++ b/tests/Set-DbaDbIdentity.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Set-DbaDbIdentity", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -21,6 +21,50 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + $script:lastQuery = $null + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + IsAccessible = $true + } + $script:mockServer = [PSCustomObject]@{ + Name = "sql1" + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + DomainInstanceName = "sql1" + Databases = @($script:mockDatabase) + } + + function Invoke-DbaQuery { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + $Query, + $Database, + [switch]$MessagesToOutput + ) + + process { + $script:lastQuery = $Query + "Checking identity information: current identity value '5'." + } + } + Mock Connect-DbaInstance { $script:mockServer } + } + + It "escapes closing brackets in normalized table names for reseed" { + $script:lastQuery = $null + + $result = Set-DbaDbIdentity -SqlInstance "sql1" -Database "db1" -Table "[dbo].[Bad]]Name]" -ReSeedValue 400 -Confirm:$false + + $script:lastQuery | Should -Be "DBCC CHECKIDENT('[dbo].[Bad]]Name]', RESEED, 400)" + $result.Cmd | Should -Be "DBCC CHECKIDENT('[dbo].[Bad]]Name]', RESEED, 400)" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Set-DbaDbMailAccount.Tests.ps1 b/tests/Set-DbaDbMailAccount.Tests.ps1 index 614d3bfb2038..51f8d72402b0 100644 --- a/tests/Set-DbaDbMailAccount.Tests.ps1 +++ b/tests/Set-DbaDbMailAccount.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Set-DbaDbMailAccount", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -30,6 +30,19 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Input validation" { + It "Should call Stop-Function when default credentials are combined with SMTP credentials" { + Mock Stop-Function { } + $securePassword = ConvertTo-SecureString "P@ssw0rd!" -AsPlainText -Force + + Set-DbaDbMailAccount -SqlInstance "sql1" -Account "alerts" -UseDefaultCredentials -UserName "alerts@contoso.com" -Password $securePassword | Should -BeNullOrEmpty + + Should -Invoke Stop-Function -Times 1 -Exactly + } + } + } } Describe $CommandName -Tag IntegrationTests { @@ -126,4 +139,4 @@ Describe $CommandName -Tag IntegrationTests { $mailServer.EnableSsl | Should -Be $false } } -} +} \ No newline at end of file diff --git a/tests/Set-DbaNetworkCertificate.Tests.ps1 b/tests/Set-DbaNetworkCertificate.Tests.ps1 index 570c4fef8c23..62e3b11c57f5 100644 --- a/tests/Set-DbaNetworkCertificate.Tests.ps1 +++ b/tests/Set-DbaNetworkCertificate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Set-DbaNetworkCertificate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -22,6 +22,62 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "RestartService" { + BeforeEach { + $script:serviceCredential = New-Object System.Management.Automation.PSCredential( + "sql1\svc-sql", + (ConvertTo-SecureString "Password123!" -AsPlainText -Force) + ) + $script:certificateThumbprint = "0123456789ABCDEF0123456789ABCDEF01234567" + + Mock Test-FunctionInterrupt { $false } + Mock Test-DbaNetworkCertificate { + [PSCustomObject]@{ + ComputerName = "sql1" + InstanceName = "MSSQLSERVER" + SqlInstance = "sql1" + ConfiguredCertificateThumbprint = $null + ConfiguredCertificateValid = $false + SuitableCertificateAvailable = $true + SuitableCertificateCount = 1 + SuitableCertificates = [PSCustomObject]@{ + Thumbprint = $script:certificateThumbprint + } + } + } + Mock Invoke-Command2 { + [PSCustomObject]@{ + Verbose = @() + Exception = $null + ServiceAccount = "sql1\svc-sql" + } + } + Mock Restart-DbaService { } + } + + It "passes Credential to Restart-DbaService when RestartService is used" { + $splatSetNetworkCertificate = @{ + SqlInstance = "sql1" + Credential = $script:serviceCredential + RestartService = $true + Confirm = $false + } + + $result = Set-DbaNetworkCertificate @splatSetNetworkCertificate + + $result.CertificateThumbprint | Should -Be $script:certificateThumbprint + Assert-MockCalled -CommandName Restart-DbaService -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { + $SqlInstance.FullName -eq "sql1" -and + $Credential -eq $script:serviceCredential -and + $Type -eq "Engine" -and + $Force -and + $EnableException + } + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Set-DbaPrivilege.Tests.ps1 b/tests/Set-DbaPrivilege.Tests.ps1 index 75e88925b75c..89ce2182c5b3 100644 --- a/tests/Set-DbaPrivilege.Tests.ps1 +++ b/tests/Set-DbaPrivilege.Tests.ps1 @@ -21,8 +21,73 @@ Describe $CommandName -Tag UnitTests { } } } + +InModuleScope dbatools { + Describe "Set-DbaPrivilege regressions" -Tag UnitTests { + BeforeAll { + function secedit { + param( + [Parameter(ValueFromRemainingArguments)] + [object[]]$ArgumentList + ) + } + } + + BeforeEach { + $script:policyFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "secpolByDbatools.cfg" + $script:capturedPolicyContent = $null + + Mock Test-ElevationRequirement { $true } + Mock Test-PSRemoting { $true } + Mock Invoke-Command2 { + param( + $ComputerName, + $Credential, + $ScriptBlock, + $ArgumentList + ) + + if ($ScriptBlock.ToString() -match "secedit /export /cfg") { + Set-Content -Path $script:policyFile -Value @( + "[Privilege Rights]" + "SeCreateGlobalPrivilege = " + ) + return + } + + if ($ArgumentList.Count -gt 0) { + & $ScriptBlock @ArgumentList + $script:capturedPolicyContent = Get-Content -Path $script:policyFile + return + } + + Remove-Item -Path $script:policyFile -Force -ErrorAction SilentlyContinue + } + } + + AfterEach { + Remove-Item -Path $script:policyFile -Force -ErrorAction SilentlyContinue + } + + It "adds CreateGlobalObjects when the privilege entry exists but is empty" { + $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + $expectedSid = ([System.Security.Principal.NTAccount]$user).Translate([System.Security.Principal.SecurityIdentifier]).Value + + $splatSetPrivilege = @{ + ComputerName = $env:COMPUTERNAME + Type = "CreateGlobalObjects" + User = $user + Confirm = $false + } + $null = Set-DbaPrivilege @splatSetPrivilege + + ($script:capturedPolicyContent | Where-Object { $PSItem -match "^SeCreateGlobalPrivilege" }) | + Should -Match "^SeCreateGlobalPrivilege = \*$([regex]::Escape($expectedSid))(,)?$" + } + } +} <# Integration test should appear below and are custom to the command you are writing. Read https://github.com/dataplat/dbatools/blob/development/contributing.md#tests for more guidence. -#> \ No newline at end of file +#> diff --git a/tests/Start-DbaDbEncryption.Tests.ps1 b/tests/Start-DbaDbEncryption.Tests.ps1 index bb4eb7934ac8..640d18b31abb 100644 --- a/tests/Start-DbaDbEncryption.Tests.ps1 +++ b/tests/Start-DbaDbEncryption.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Start-DbaDbEncryption", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -33,6 +33,35 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Parallel cleanup" { + It "disconnects thread-local connections even during WhatIf execution" { + $commandAst = (Get-Command $CommandName).ScriptBlock.Ast + $disconnectCommands = $commandAst.FindAll( { + param($Ast) + + $Ast -is [System.Management.Automation.Language.CommandAst] -and + $Ast.GetCommandName() -eq "Disconnect-DbaInstance" + }, $true) + + $disconnectCommands.Count | Should -Be 1 + + $expectedArgument = "-WhatIf:" + [char]36 + "false" + $disconnectCommands[0].Extent.Text | Should -Match ([regex]::Escape($expectedArgument)) + } + } + + Context "Parallel exclusions" { + It "uses the filtered database list when pre-creating encryption keys" { + $commandText = (Get-Command $CommandName).ScriptBlock.Ast.Extent.Text + $parallelBlockStart = $commandText.IndexOf("# Step 3: Create a database encryption key in the target database if needed") + $parallelBlockLength = [Math]::Min(500, $commandText.Length - $parallelBlockStart) + $parallelBlockText = $commandText.Substring($parallelBlockStart, $parallelBlockLength) + $expectedText = "foreach (" + [char]36 + "db in " + [char]36 + "databases)" + + $parallelBlockText | Should -Match ([regex]::Escape($expectedText)) + } + } } @@ -139,4 +168,5 @@ Describe $CommandName -Tag IntegrationTests { $results.DatabaseName | Should -Contain $parallelTestDatabases[0].Name } } + } \ No newline at end of file diff --git a/tests/Start-DbaMigration.Tests.ps1 b/tests/Start-DbaMigration.Tests.ps1 index 4d71b8868a08..794a1131c13b 100644 --- a/tests/Start-DbaMigration.Tests.ps1 +++ b/tests/Start-DbaMigration.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Start-DbaMigration", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -43,6 +43,412 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Dedicated admin connection handling" { + It "Stops before copying credentials when the dedicated admin connection cannot be opened" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Copy-DbaCredential", + "Disconnect-DbaInstance", + "Stop-Function", + "Test-FunctionInterrupt", + "Write-Message", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Write-ProgressHelper { } + function Disconnect-DbaInstance { } + function Stop-Function { + param( + $Message + ) + $script:stopMessages += $Message + } + function Copy-DbaCredential { $script:credentialCopied = $true } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + $script:connectCalls += "Dac" + return $null + } + + $script:connectCalls += "Normal" + [PSCustomObject]@{ + DomainInstanceName = "sql1" + } + } + + $script:connectCalls = @() + $script:stopMessages = @() + $script:credentialCopied = $false + $excludeForCredentialOnly = @( + "Databases", + "Logins", + "AgentServer", + "LinkedServers", + "SpConfigure", + "CentralManagementServer", + "DatabaseMail", + "SysDbUserObjects", + "SystemTriggers", + "BackupDevices", + "Audits", + "Endpoints", + "ExtendedEvents", + "PolicyManagement", + "ResourceGovernor", + "ServerAuditSpecifications", + "CustomErrors", + "ServerRoles", + "DataCollector", + "StartupProcedures", + "ExtendedStoredProcedures", + "AgentServerProperties", + "MasterCertificates", + "SsisCatalog" + ) + + $null = Start-DbaMigration -Source "sql1" -Destination "sql2" -Exclude $excludeForCredentialOnly + ($script:stopMessages -join ",") | Should -Be "Could not establish dedicated admin connection to sql1. Use -ExcludePassword to skip password migration." + ($script:connectCalls -join ",") | Should -Be "Dac" + $script:credentialCopied | Should -BeFalse + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + + It "Stops before copying credentials when the normal source connection cannot be opened" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Copy-DbaCredential", + "Disconnect-DbaInstance", + "Stop-Function", + "Test-FunctionInterrupt", + "Write-Message", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-FunctionInterrupt { $false } + function Write-Message { } + function Write-ProgressHelper { } + function Disconnect-DbaInstance { } + function Stop-Function { + param( + $Message + ) + $script:stopMessages += $Message + } + function Copy-DbaCredential { $script:credentialCopied = $true } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + $script:connectCalls += "Dac" + } else { + $script:connectCalls += "Normal" + } + + return $null + } + + $script:connectCalls = @() + $script:stopMessages = @() + $script:credentialCopied = $false + $excludeForCredentialOnly = @( + "Databases", + "Logins", + "AgentServer", + "LinkedServers", + "SpConfigure", + "CentralManagementServer", + "DatabaseMail", + "SysDbUserObjects", + "SystemTriggers", + "BackupDevices", + "Audits", + "Endpoints", + "ExtendedEvents", + "PolicyManagement", + "ResourceGovernor", + "ServerAuditSpecifications", + "CustomErrors", + "ServerRoles", + "DataCollector", + "StartupProcedures", + "ExtendedStoredProcedures", + "AgentServerProperties", + "MasterCertificates", + "SsisCatalog" + ) + + $null = Start-DbaMigration -Source "sql1" -Destination "sql2" -Exclude $excludeForCredentialOnly -ExcludePassword + ($script:stopMessages -join ",") | Should -Be "Could not connect to source instance sql1." + ($script:connectCalls -join ",") | Should -Be "Normal" + $script:credentialCopied | Should -BeFalse + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + } + + Context "SSIS catalog integration" { + It "Skips SSIS catalog migration when the source instance has no SSISDB catalog" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Copy-DbaSsisCatalog", + "Stop-Function", + "Test-FunctionInterrupt", + "Write-Message", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-FunctionInterrupt { $false } + function Write-ProgressHelper { } + function Stop-Function { + param( + $Message + ) + $script:stopMessages += $Message + } + function Write-Message { + param( + $Level, + $Message + ) + $script:messages += "${Level}:$Message" + } + function Copy-DbaSsisCatalog { $script:ssisCopied = $true } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + throw "Dedicated admin connection should not be requested." + } + + [PSCustomObject]@{ + DomainInstanceName = "sql1" + VersionMajor = 10 + Databases = @{ } + } + } + + $script:messages = @() + $script:ssisCopied = $false + $script:stopMessages = @() + $excludeForSsisOnly = @( + "Databases", + "Logins", + "AgentServer", + "Credentials", + "LinkedServers", + "SpConfigure", + "CentralManagementServer", + "DatabaseMail", + "SysDbUserObjects", + "SystemTriggers", + "BackupDevices", + "Audits", + "Endpoints", + "ExtendedEvents", + "PolicyManagement", + "ResourceGovernor", + "ServerAuditSpecifications", + "CustomErrors", + "ServerRoles", + "DataCollector", + "StartupProcedures", + "ExtendedStoredProcedures", + "AgentServerProperties", + "MasterCertificates" + ) + + $null = Start-DbaMigration -Source "sql1" -Destination "sql2" -Exclude $excludeForSsisOnly + $script:ssisCopied | Should -BeFalse + $script:stopMessages | Should -BeNullOrEmpty + @($script:messages | Where-Object { $PSItem -like "*Skipping SSIS catalog migration*" }).Count | Should -Be 1 + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + + It "Calls Copy-DbaSsisCatalog when the source instance has an SSISDB catalog" { + InModuleScope dbatools { + $functionNames = @( + "Connect-DbaInstance", + "Copy-DbaSsisCatalog", + "Stop-Function", + "Test-FunctionInterrupt", + "Write-Message", + "Write-ProgressHelper" + ) + $originalFunctions = @{ } + foreach ($functionName in $functionNames) { + if (Test-Path "Function:\$functionName") { + $originalFunctions[$functionName] = (Get-Item -Path "Function:\$functionName").ScriptBlock + } + } + + try { + function Test-FunctionInterrupt { $false } + function Write-ProgressHelper { } + function Stop-Function { + param( + $Message + ) + $script:stopMessages += $Message + } + function Write-Message { + param( + $Level, + $Message + ) + $script:messages += "${Level}:$Message" + } + function Copy-DbaSsisCatalog { + param( + $Source, + $Destination, + $DestinationSqlCredential, + [switch]$Force + ) + + $script:ssisCalls += [PSCustomObject]@{ + Source = $Source + Destination = $Destination + DestinationSqlCredential = $DestinationSqlCredential + Force = $Force.IsPresent + } + } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + throw "Dedicated admin connection should not be requested." + } + + [PSCustomObject]@{ + DomainInstanceName = "sql1" + VersionMajor = 15 + Databases = @{ + SSISDB = [PSCustomObject]@{ + Name = "SSISDB" + } + } + } + } + + $script:messages = @() + $script:ssisCalls = @() + $script:stopMessages = @() + $excludeForSsisOnly = @( + "Databases", + "Logins", + "AgentServer", + "Credentials", + "LinkedServers", + "SpConfigure", + "CentralManagementServer", + "DatabaseMail", + "SysDbUserObjects", + "SystemTriggers", + "BackupDevices", + "Audits", + "Endpoints", + "ExtendedEvents", + "PolicyManagement", + "ResourceGovernor", + "ServerAuditSpecifications", + "CustomErrors", + "ServerRoles", + "DataCollector", + "StartupProcedures", + "ExtendedStoredProcedures", + "AgentServerProperties", + "MasterCertificates" + ) + + $null = Start-DbaMigration -Source "sql1" -Destination "sql2" -Exclude $excludeForSsisOnly + $script:stopMessages | Should -BeNullOrEmpty + @($script:ssisCalls).Count | Should -Be 1 + $script:ssisCalls[0].Source.VersionMajor | Should -Be 15 + $script:ssisCalls[0].Destination | Should -Be "sql2" + @($script:messages | Where-Object { $PSItem -like "*Migrating SSIS catalog" }).Count | Should -Be 1 + } finally { + foreach ($functionName in $functionNames) { + if ($originalFunctions.ContainsKey($functionName)) { + Set-Item -Path "Function:\$functionName" -Value $originalFunctions[$functionName] + } else { + Remove-Item -Path "Function:\$functionName" -ErrorAction Ignore + } + } + } + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Stop-DbaDbEncryption.Tests.ps1 b/tests/Stop-DbaDbEncryption.Tests.ps1 index 47b03c3dab54..ffb64819e4f8 100644 --- a/tests/Stop-DbaDbEncryption.Tests.ps1 +++ b/tests/Stop-DbaDbEncryption.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Stop-DbaDbEncryption", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -19,6 +19,23 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Parallel cleanup" { + It "disconnects thread-local connections even during WhatIf execution" { + $commandAst = (Get-Command $CommandName).ScriptBlock.Ast + $disconnectCommands = $commandAst.FindAll( { + param($Ast) + + $Ast -is [System.Management.Automation.Language.CommandAst] -and + $Ast.GetCommandName() -eq "Disconnect-DbaInstance" + }, $true) + + $disconnectCommands.Count | Should -Be 1 + + $expectedArgument = "-WhatIf:" + [char]36 + "false" + $disconnectCommands[0].Extent.Text | Should -Match ([regex]::Escape($expectedArgument)) + } + } } diff --git a/tests/Sync-DbaAvailabilityGroup.Tests.ps1 b/tests/Sync-DbaAvailabilityGroup.Tests.ps1 index 565a2c4c6761..177e857c8651 100644 --- a/tests/Sync-DbaAvailabilityGroup.Tests.ps1 +++ b/tests/Sync-DbaAvailabilityGroup.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Sync-DbaAvailabilityGroup", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -31,4 +31,250 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } -} + + Context "Connection behavior" { + It "Should let Copy-DbaCredential manage dedicated admin connections" { + InModuleScope "dbatools" { + function Test-FunctionInterrupt { $false } + function Write-ProgressHelper { } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + $script:dacConnections += $SqlInstance.ToString() + } + + [PSCustomObject]@{ + Name = $SqlInstance.ToString() + DomainInstanceName = $SqlInstance.ToString() + } + } + function Copy-DbaCredential { + param( + $Source, + $Destination, + $Credential, + [switch]$ExcludePassword, + [switch]$Force + ) + + $script:copyCredentialCall = [PSCustomObject]@{ + Source = $Source + Destination = $Destination + Credential = $Credential + ExcludePassword = $ExcludePassword.IsPresent + } + } + + $script:dacConnections = @() + $script:copyCredentialCall = $null + + $exclude = @( + "AgentAlert", + "AgentCategory", + "AgentJob", + "AgentOperator", + "AgentProxy", + "AgentSchedule", + "CustomErrors", + "DatabaseMail", + "DatabaseOwner", + "LinkedServers", + "LoginPermissions", + "Logins", + "SpConfigure", + "SystemTriggers" + ) + $securePassword = ConvertTo-SecureString "Password1!" -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential("contoso\syncuser", $securePassword) + + $null = Sync-DbaAvailabilityGroup -Primary "sql1" -Secondary "sql2" -Credential $credential -Exclude $exclude + + $script:dacConnections | Should -BeNullOrEmpty + $script:copyCredentialCall.Credential.UserName | Should -Be "contoso\syncuser" + $script:copyCredentialCall.ExcludePassword | Should -BeFalse + } + } + + It "Should pass ExcludePassword to password-aware copy commands" { + InModuleScope "dbatools" { + function Test-FunctionInterrupt { $false } + function Write-ProgressHelper { } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + if ($DedicatedAdminConnection) { + $script:dacConnections += $SqlInstance.ToString() + } + + [PSCustomObject]@{ + Name = $SqlInstance.ToString() + DomainInstanceName = $SqlInstance.ToString() + } + } + function Copy-DbaCredential { + param( + $Source, + $Destination, + $Credential, + [switch]$ExcludePassword, + [switch]$Force + ) + + $script:copyCredentialCall = [PSCustomObject]@{ + Credential = $Credential + ExcludePassword = $ExcludePassword.IsPresent + } + } + function Copy-DbaDbMail { + param( + $Source, + $Destination, + $Credential, + [switch]$ExcludePassword, + [switch]$Force + ) + + $script:copyDbMailCall = [PSCustomObject]@{ + Credential = $Credential + ExcludePassword = $ExcludePassword.IsPresent + } + } + function Copy-DbaLinkedServer { + param( + $Source, + $Destination, + $Credential, + [switch]$ExcludePassword, + [switch]$Force + ) + + $script:copyLinkedServerCall = [PSCustomObject]@{ + Credential = $Credential + ExcludePassword = $ExcludePassword.IsPresent + } + } + + $script:dacConnections = @() + $script:copyCredentialCall = $null + $script:copyDbMailCall = $null + $script:copyLinkedServerCall = $null + + $exclude = @( + "AgentAlert", + "AgentCategory", + "AgentJob", + "AgentOperator", + "AgentProxy", + "AgentSchedule", + "CustomErrors", + "DatabaseOwner", + "LoginPermissions", + "Logins", + "SpConfigure", + "SystemTriggers" + ) + $securePassword = ConvertTo-SecureString "Password1!" -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential("contoso\syncuser", $securePassword) + + $null = Sync-DbaAvailabilityGroup -Primary "sql1" -Secondary "sql2" -Credential $credential -ExcludePassword -Exclude $exclude + + $script:dacConnections | Should -BeNullOrEmpty + $script:copyCredentialCall.Credential.UserName | Should -Be "contoso\syncuser" + $script:copyCredentialCall.ExcludePassword | Should -BeTrue + $script:copyDbMailCall.Credential.UserName | Should -Be "contoso\syncuser" + $script:copyDbMailCall.ExcludePassword | Should -BeTrue + $script:copyLinkedServerCall.Credential.UserName | Should -Be "contoso\syncuser" + $script:copyLinkedServerCall.ExcludePassword | Should -BeTrue + } + } + } + + Context "Agent job sync behavior" { + It "Should request only local jobs and keep local jobs in category 1" { + InModuleScope "dbatools" { + function Test-FunctionInterrupt { $false } + function Write-ProgressHelper { } + function Connect-DbaInstance { + param( + $SqlInstance, + $SqlCredential, + [switch]$DedicatedAdminConnection + ) + + [PSCustomObject]@{ + Name = $SqlInstance.ToString() + DomainInstanceName = $SqlInstance.ToString() + } + } + function Get-DbaAgentJob { + param( + $SqlInstance, + $Job, + $ExcludeJob, + $Type + ) + + $script:getAgentJobCall = [PSCustomObject]@{ + SqlInstance = $SqlInstance + Type = $Type + } + + [PSCustomObject]@{ + Name = "dbatoolsci_localjob" + JobType = "Local" + CategoryID = 1 + } + } + function Copy-DbaAgentJob { + param( + $Destination, + [switch]$Force, + [switch]$DisableOnDestination, + $InputObject + ) + + $script:copyAgentJobCall = [PSCustomObject]@{ + Destination = $Destination + InputObject = $InputObject + } + } + + $script:getAgentJobCall = $null + $script:copyAgentJobCall = $null + + $exclude = @( + "AgentAlert", + "AgentCategory", + "AgentOperator", + "AgentProxy", + "AgentSchedule", + "Credentials", + "CustomErrors", + "DatabaseMail", + "DatabaseOwner", + "LinkedServers", + "LoginPermissions", + "Logins", + "SpConfigure", + "SystemTriggers" + ) + + $null = Sync-DbaAvailabilityGroup -Primary "sql1" -Secondary "sql2" -Exclude $exclude + + $script:getAgentJobCall.SqlInstance.Name | Should -Be "sql1" + $script:getAgentJobCall.Type | Should -Be "Local" + $script:copyAgentJobCall.InputObject.Name | Should -Be "dbatoolsci_localjob" + $script:copyAgentJobCall.InputObject.JobType | Should -Be "Local" + } + } + } +} \ No newline at end of file diff --git a/tests/Test-DbaAgPolicyState.Tests.ps1 b/tests/Test-DbaAgPolicyState.Tests.ps1 new file mode 100644 index 000000000000..89151b0e53ff --- /dev/null +++ b/tests/Test-DbaAgPolicyState.Tests.ps1 @@ -0,0 +1,139 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Test-DbaAgPolicyState", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Parameter validation" { + It "Should have the expected parameters" { + $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } + $expectedParameters = $TestConfig.CommonParameters + $expectedParameters += @( + "SqlInstance", + "SqlCredential", + "AvailabilityGroup", + "Secondary", + "SecondarySqlCredential", + "InputObject", + "EnableException" + ) + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + } + } + + InModuleScope dbatools { + Context "Always On predefined policy coverage" { + BeforeAll { + function New-MockAvailabilityReplica { + param( + [string]$Name, + [string]$Role, + [string]$ConnectionState, + [string]$JoinState, + [string]$AvailabilityMode, + [string]$UniqueId + ) + + $replica = New-Object -TypeName Microsoft.SqlServer.Management.Smo.AvailabilityReplica + $replica.Name = $Name + $replica | Add-Member -Force -MemberType ScriptProperty -Name Role -Value { $this.psobject.Properties["MockRole"].Value } + $replica | Add-Member -Force -MemberType ScriptProperty -Name ConnectionState -Value { $this.psobject.Properties["MockConnectionState"].Value } + $replica | Add-Member -Force -MemberType ScriptProperty -Name JoinState -Value { $this.psobject.Properties["MockJoinState"].Value } + $replica | Add-Member -Force -MemberType ScriptProperty -Name AvailabilityMode -Value { $this.psobject.Properties["MockAvailabilityMode"].Value } + $replica | Add-Member -Force -MemberType ScriptProperty -Name UniqueId -Value { $this.psobject.Properties["MockUniqueId"].Value } + $replica | Add-Member -Force -NotePropertyName MockRole -NotePropertyValue $Role + $replica | Add-Member -Force -NotePropertyName MockConnectionState -NotePropertyValue $ConnectionState + $replica | Add-Member -Force -NotePropertyName MockJoinState -NotePropertyValue $JoinState + $replica | Add-Member -Force -NotePropertyName MockAvailabilityMode -NotePropertyValue $AvailabilityMode + $replica | Add-Member -Force -NotePropertyName MockUniqueId -NotePropertyValue $UniqueId + $replica + } + + function New-MockAvailabilityGroup { + $server = [PSCustomObject]@{ + ClusterQuorumState = "NormalQuorum" + } + + $primaryReplica = New-MockAvailabilityReplica -Name "PrimaryReplica" -Role "Primary" -ConnectionState "Connected" -JoinState "JoinedStandaloneInstance" -AvailabilityMode "SynchronousCommit" -UniqueId "primary-id" + $syncReplica = New-MockAvailabilityReplica -Name "SyncReplica" -Role "Secondary" -ConnectionState "Connected" -JoinState "JoinedStandaloneInstance" -AvailabilityMode "SynchronousCommit" -UniqueId "sync-id" + + $databaseReplicaStates = @( + [PSCustomObject]@{ + AvailabilityReplicaId = "primary-id" + AvailabilityReplicaServerName = "PrimaryReplica" + AvailabilityDatabaseName = "AgDb" + SynchronizationState = "Synchronized" + ReplicaAvailabilityMode = "SynchronousCommit" + IsSuspended = $false + IsJoined = $true + }, + [PSCustomObject]@{ + AvailabilityReplicaId = "sync-id" + AvailabilityReplicaServerName = "SyncReplica" + AvailabilityDatabaseName = "AgDb" + SynchronizationState = "Synchronizing" + ReplicaAvailabilityMode = "SynchronousCommit" + IsSuspended = $false + IsJoined = $true + } + ) + + $availabilityGroup = New-Object -TypeName Microsoft.SqlServer.Management.Smo.AvailabilityGroup + $availabilityGroup.Name = "AgOne" + $availabilityGroup | Add-Member -Force -MemberType ScriptMethod -Name Refresh -Value { } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name ComputerName -Value { "sqlhost" } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name InstanceName -Value { "MSSQLSERVER" } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name SqlInstance -Value { "sqlhost" } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name Parent -Value { $this.psobject.Properties["MockParent"].Value } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name AvailabilityReplicas -Value { $this.psobject.Properties["MockAvailabilityReplicas"].Value } + $availabilityGroup | Add-Member -Force -MemberType ScriptProperty -Name DatabaseReplicaStates -Value { $this.psobject.Properties["MockDatabaseReplicaStates"].Value } + $availabilityGroup | Add-Member -Force -NotePropertyName MockParent -NotePropertyValue $server + $availabilityGroup | Add-Member -Force -NotePropertyName MockAvailabilityReplicas -NotePropertyValue @($primaryReplica, $syncReplica) + $availabilityGroup | Add-Member -Force -NotePropertyName MockDatabaseReplicaStates -NotePropertyValue $databaseReplicaStates + $availabilityGroup + } + + $script:mockAvailabilityGroup = New-MockAvailabilityGroup + + Mock Get-DbaAvailabilityGroup { $script:mockAvailabilityGroup } + Mock New-Object { + [PSCustomObject]@{ + IsOnline = $true + IsAutoFailover = $true + NumberOfSynchronizedSecondaryReplicas = 1 + NumberOfDisconnectedReplicas = 0 + NumberOfNotSynchronizingReplicas = 0 + NumberOfReplicasWithUnhealthyRole = 0 + NumberOfNotSynchronizedReplicas = 0 + } + } -ParameterFilter { + $TypeName -eq "Microsoft.SqlServer.Management.Smo.AvailabilityGroupState" + } + } + + It "returns every documented policy with the expected categories" { + $results = Test-DbaAgPolicyState -SqlInstance "sqlhost" + + $results.Count | Should -Be 21 + ($results | Where-Object PolicyName -eq "Availability Replica Data Synchronization State").Count | Should -Be 2 + ($results | Where-Object PolicyName -eq "Availability Replica Role State" | Select-Object -ExpandProperty Category -Unique) | Should -Be "Critical" + ($results | Where-Object PolicyName -eq "Availability Replica Connection State" | Select-Object -ExpandProperty Category -Unique) | Should -Be "Critical" + ($results | Where-Object PolicyName -eq "Availability Database Data Synchronization State" | Select-Object -ExpandProperty Category -Unique) | Should -Be "Warning" + } + + It "marks synchronous replica synchronization lag as unhealthy at replica and database scope" { + $results = Test-DbaAgPolicyState -SqlInstance "sqlhost" + + $replicaPolicy = $results | Where-Object { $PSItem.PolicyName -eq "Availability Replica Data Synchronization State" -and $PSItem.Replica -eq "SyncReplica" } + $databasePolicy = $results | Where-Object { $PSItem.PolicyName -eq "Availability Database Data Synchronization State" -and $PSItem.Replica -eq "SyncReplica" -and $PSItem.Database -eq "AgDb" } + + $replicaPolicy.IsHealthy | Should -BeFalse + $replicaPolicy.Issue | Should -Be "Data synchronization state of some availability database is not healthy." + $databasePolicy.IsHealthy | Should -BeFalse + $databasePolicy.Issue | Should -Be "Data synchronization state of availability database is not healthy." + } + } + } +} \ No newline at end of file diff --git a/tests/Test-DbaBackupInformation.Tests.ps1 b/tests/Test-DbaBackupInformation.Tests.ps1 index bc5f0f4aafdb..037bbe07053a 100644 --- a/tests/Test-DbaBackupInformation.Tests.ps1 +++ b/tests/Test-DbaBackupInformation.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaBackupInformation", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -120,6 +120,56 @@ Describe $CommandName -Tag IntegrationTests { ($null -ne $WarnVar) | Should -Be $True } } + Context "Input without IsVerified member" { + It "Should add IsVerified when validation fails" { + $BackupHistory = Import-Clixml $PSScriptRoot\..\tests\ObjectDefinitions\BackupRestore\RawInput\CleanFormatDbaInformation.xml + $BackupHistory = $BackupHistory | Format-DbaBackupInformation + $BackupHistory | ForEach-Object { + $PSItem.PSObject.Properties.Remove("IsVerified") + } + Mock Connect-DbaInstance -MockWith { + $obj = [PSCustomObject]@{ + Name = "BASEName" + NetName = "BASENetName" + ComputerName = "BASEComputerName" + InstanceName = "BASEInstanceName" + DomainInstanceName = "BASEDomainInstanceName" + InstallDataDirectory = "BASEInstallDataDirectory" + ErrorLogPath = "BASEErrorLog_{0}_{1}_{2}_Path" -f "'", '"', "]" + ServiceName = "BASEServiceName" + VersionMajor = 9 + ConnectionContext = New-Object PSObject + } + Add-Member -InputObject $obj.ConnectionContext -Name ConnectionString -MemberType NoteProperty -Value "put=an=equal=in=it" + Add-Member -InputObject $obj -Name Query -MemberType ScriptMethod -Value { + param($query) + if ($query -eq "SELECT DB_NAME(database_id) AS Name, physical_name AS PhysicalName FROM sys.master_files") { + return @( + @{ "Name" = "master" + "PhysicalName" = "C:\temp\master.mdf" + } + ) + } + } + $obj.PSObject.TypeNames.Clear() + $obj.PSObject.TypeNames.Add("Microsoft.SqlServer.Management.Smo.Server") + return $obj + } + Mock Get-DbaDatabase { $null } + Mock New-DbaDirectory { $true } + Mock Test-DbaPath { [PSCustomObject]@{ + FilePath = "does\not\exists" + FileExists = $false + } + } + Mock New-DbaDirectory { $True } + $output = $BackupHistory | Test-DbaBackupInformation -SqlInstance NotExist -WarningVariable warnvar -WarningAction SilentlyContinue + ($output | Where-Object { "IsVerified" -notin $PSItem.PSObject.Properties.Name }).Count | Should -Be 0 + $output.IsVerified | Should -Not -Contain $true + $output.IsVerified | Should -Not -Contain $null + ($null -ne $WarnVar) | Should -Be $True + } + } Context "Multiple source dbs for restore is bad" { It "Should return fail as 2 origin dbs" { $BackupHistory = Import-Clixml $PSScriptRoot\..\tests\ObjectDefinitions\BackupRestore\RawInput\CleanFormatDbaInformation.xml diff --git a/tests/Test-DbaCmConnection.Tests.ps1 b/tests/Test-DbaCmConnection.Tests.ps1 index e7a9dcf1ba18..bb3d626a3497 100644 --- a/tests/Test-DbaCmConnection.Tests.ps1 +++ b/tests/Test-DbaCmConnection.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaCmConnection", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -22,6 +22,38 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + Context "Timeout option initialization" { + It "Initializes CIM session options when they are missing" { + Mock Get-WmiObject { + [PSCustomObject]@{ + Name = "mocked" + } + } + + Mock New-DbaCimSessionOptionWithTimeout { + if ($Protocol -eq "Default") { + New-CimSessionOption -Protocol Default + } else { + New-CimSessionOption -Protocol Dcom + } + } + + $connection = New-Object -TypeName Dataplat.Dbatools.Connection.ManagementConnection -ArgumentList "localhost" + $inputObject = New-Object -TypeName Dataplat.Dbatools.Parameter.DbaCmConnectionParameter -ArgumentList $connection + + $result = Test-DbaCmConnection -ComputerName $inputObject -Type Wmi + + $result.CimWinRMOptions | Should -Not -BeNullOrEmpty + $result.CimDCOMOptions | Should -Not -BeNullOrEmpty + Assert-MockCalled New-DbaCimSessionOptionWithTimeout -Exactly 1 -Scope It -ParameterFilter { $Protocol -eq "Default" } + Assert-MockCalled New-DbaCimSessionOptionWithTimeout -Exactly 1 -Scope It -ParameterFilter { $Protocol -eq "Dcom" } + } + } + } +} + Describe $CommandName -Tag IntegrationTests { It "returns some valid info" { $results = Test-DbaCmConnection -Type Wmi diff --git a/tests/Test-DbaDbCompression.Tests.ps1 b/tests/Test-DbaDbCompression.Tests.ps1 index a86a622f6266..4b949465f6cf 100644 --- a/tests/Test-DbaDbCompression.Tests.ps1 +++ b/tests/Test-DbaDbCompression.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaDbCompression", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -25,6 +25,55 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Table name normalization" { + BeforeAll { + $script:lastQuery = $null + $script:mockDatabase = [PSCustomObject]@{ + Name = "db1" + IsAccessible = $true + IsSystemObject = 0 + CompatibilityLevel = "Version160" + Status = "Normal" + } + $script:mockServer = [DbaInstanceParameter]"sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name ComputerName -Value "sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name ServiceName -Value "MSSQLSERVER" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name DomainInstanceName -Value "sql1" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name ConnectionContext -Value ([PSCustomObject]@{ + StatementTimeout = 30 + }) + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name EngineEdition -Value "EnterpriseOrDeveloper" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name VersionString -Value "16.0.1000.0" + $script:mockServer | Add-Member -Force -MemberType NoteProperty -Name Databases -Value @($script:mockDatabase) + $script:mockServer | Add-Member -Force -MemberType ScriptMethod -Name Query -Value { + param($Sql, $DatabaseName) + $script:lastQuery = $Sql + @() + } + + Mock Connect-DbaInstance { $script:mockServer } + Mock Get-DbaBuild { + [PSCustomObject]@{ + Build = [PSCustomObject]@{ + Major = 16 + } + } + } + } + + It "honors schema-qualified -Table input" { + $script:lastQuery = $null + + $null = Test-DbaDbCompression -SqlInstance "sql1" -Database "db1" -Table "db1.sales.Customer" + $normalizedQuery = $script:lastQuery -replace "\s+", " " + + $normalizedQuery | Should -Match "t\.name = N'Customer'\s+AND\s+s\.name = N'sales'\s+AND\s+DB_NAME\(\) = N'db1'" + $normalizedQuery | Should -Not -Match "t\.name IN" + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Test-DbaInstantFileInitialization.Tests.ps1 b/tests/Test-DbaInstantFileInitialization.Tests.ps1 index 2cd056b1e248..4d24bf84632e 100644 --- a/tests/Test-DbaInstantFileInitialization.Tests.ps1 +++ b/tests/Test-DbaInstantFileInitialization.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaInstantFileInitialization", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -20,6 +20,81 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + Context "IFI best practice detection" { + BeforeAll { + $script:mockServices = @() + $script:mockPrivileges = @() + + function Write-Message { } + function Select-DefaultView { + param( + [Parameter(ValueFromPipeline)] + $InputObject, + [Parameter(ValueFromRemainingArguments)] + $RemainingArguments + ) + + process { + $InputObject + } + } + function Stop-Function { + param( + $Message, + $ErrorRecord, + $Target, + [switch]$Continue + ) + + throw "$Message :: $($ErrorRecord.Exception.Message)" + } + function Get-DbaService { + param( + $ComputerName, + $Credential, + $Type, + [switch]$EnableException + ) + + $script:mockServices + } + function Get-DbaPrivilege { + param( + $ComputerName, + $Credential, + [switch]$EnableException + ) + + $script:mockPrivileges + } + } + + It "Treats a matching virtual service StartName as best practice" { + $script:mockServices = [PSCustomObject]@{ + ComputerName = "sql1" + InstanceName = "MSSQLSERVER" + ServiceName = "MSSQLSERVER" + StartName = "NT SERVICE\MSSQLSERVER" + } + + $script:mockPrivileges = [PSCustomObject]@{ + User = "NT SERVICE\MSSQLSERVER" + InstantFileInitialization = $true + } + + $result = Test-DbaInstantFileInitialization -ComputerName "sql1" + + $result.ServiceNameIFI | Should -BeTrue + $result.StartNameIFI | Should -BeTrue + $result.IsEnabled | Should -BeTrue + $result.IsBestPractice | Should -BeTrue + } + } + } +} + Describe $CommandName -Tag IntegrationTests { Context "Gets IFI status" { BeforeAll { @@ -40,4 +115,4 @@ Describe $CommandName -Tag IntegrationTests { $results[0].IsBestPractice | Should -BeOfType [bool] } } -} +} \ No newline at end of file diff --git a/tests/Test-DbaLastBackup.Tests.ps1 b/tests/Test-DbaLastBackup.Tests.ps1 index fd689a513db1..68ae9780de69 100644 --- a/tests/Test-DbaLastBackup.Tests.ps1 +++ b/tests/Test-DbaLastBackup.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaLastBackup", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -45,6 +45,254 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + InModuleScope dbatools { + Context "Path backup discovery" { + BeforeEach { + $script:mockFiles = @() + $script:mockHeaders = @() + + $script:mockDatabases = @{ } + Add-Member -InputObject $script:mockDatabases -Name Refresh -MemberType ScriptMethod -Value { } -Force + + $script:mockDestinationServer = [DbaInstanceParameter]"dest" + Add-Member -InputObject $script:mockDestinationServer -Name Databases -MemberType NoteProperty -Value $script:mockDatabases -Force + Add-Member -InputObject $script:mockDestinationServer -Name Name -MemberType NoteProperty -Value "dest" -Force + Add-Member -InputObject $script:mockDestinationServer -Name ServiceAccount -MemberType NoteProperty -Value "NT SERVICE\MSSQLSERVER" -Force + + Mock Connect-DbaInstance { $script:mockDestinationServer } + Mock Get-XpDirTreeRestoreFile { $script:mockFiles } + Mock Read-DbaBackupHeader { $script:mockHeaders } + Mock Get-SqlDefaultPaths { + param($SqlInstance, $FileType) + + switch ($FileType) { + "mdf" { "C:\sql\data" } + "ldf" { "C:\sql\log" } + } + } + Mock Restore-DbaDatabase { + [PSCustomObject]@{ + RestoreComplete = $true + } + } + Mock Stop-Function { + throw $Message + } + } + + It "Should honor wildcard database filters when using -Path" { + $script:mockFiles = @( + "C:\backups\dbAlpha-full.bak", + "C:\backups\dbBeta-full.bak", + "C:\backups\other-full.bak" + ) + $script:mockHeaders = @( + [PSCustomObject]@{ + BackupSetGUID = [guid]"11111111-1111-1111-1111-111111111111" + BackupTypeDescription = "Database" + MachineName = "source1" + ServiceName = "MSSQLSERVER" + ServerName = "source1" + DatabaseName = "dbAlpha" + UserName = "sa" + BackupStartDate = [datetime]"2026-03-19T12:00:00" + BackupFinishDate = [datetime]"2026-03-19T12:01:00" + BackupPath = "C:\backups\dbAlpha-full.bak" + FileList = [PSCustomObject]@{ + Type = "D" + LogicalName = "dbAlpha" + PhysicalName = "C:\sql\data\dbAlpha.mdf" + Size = 1024 + } + BackupSize = [PSCustomObject]@{ Byte = 1048576 } + CompressedBackupSize = [PSCustomObject]@{ Byte = 524288 } + Position = 1 + FirstLSN = 100 + DatabaseBackupLSN = 100 + CheckpointLSN = 100 + LastLsn = 200 + SoftwareVersionMajor = 16 + RecoveryModel = "Full" + IsCopyOnly = $false + }, + [PSCustomObject]@{ + BackupSetGUID = [guid]"22222222-2222-2222-2222-222222222222" + BackupTypeDescription = "Database" + MachineName = "source1" + ServiceName = "MSSQLSERVER" + ServerName = "source1" + DatabaseName = "dbBeta" + UserName = "sa" + BackupStartDate = [datetime]"2026-03-19T12:05:00" + BackupFinishDate = [datetime]"2026-03-19T12:06:00" + BackupPath = "C:\backups\dbBeta-full.bak" + FileList = [PSCustomObject]@{ + Type = "D" + LogicalName = "dbBeta" + PhysicalName = "C:\sql\data\dbBeta.mdf" + Size = 1024 + } + BackupSize = [PSCustomObject]@{ Byte = 1048576 } + CompressedBackupSize = [PSCustomObject]@{ Byte = 524288 } + Position = 1 + FirstLSN = 300 + DatabaseBackupLSN = 300 + CheckpointLSN = 300 + LastLsn = 400 + SoftwareVersionMajor = 16 + RecoveryModel = "Full" + IsCopyOnly = $false + }, + [PSCustomObject]@{ + BackupSetGUID = [guid]"33333333-3333-3333-3333-333333333333" + BackupTypeDescription = "Database" + MachineName = "source1" + ServiceName = "MSSQLSERVER" + ServerName = "source1" + DatabaseName = "other" + UserName = "sa" + BackupStartDate = [datetime]"2026-03-19T12:10:00" + BackupFinishDate = [datetime]"2026-03-19T12:11:00" + BackupPath = "C:\backups\other-full.bak" + FileList = [PSCustomObject]@{ + Type = "D" + LogicalName = "other" + PhysicalName = "C:\sql\data\other.mdf" + Size = 1024 + } + BackupSize = [PSCustomObject]@{ Byte = 1048576 } + CompressedBackupSize = [PSCustomObject]@{ Byte = 524288 } + Position = 1 + FirstLSN = 500 + DatabaseBackupLSN = 500 + CheckpointLSN = 500 + LastLsn = 600 + SoftwareVersionMajor = 16 + RecoveryModel = "Full" + IsCopyOnly = $false + } + ) + + $results = @(Test-DbaLastBackup -Path "C:\backups" -Destination "dest" -Database "db*" -ExcludeDatabase "dbB*" -NoCheck -NoDrop) + + $results.Count | Should -Be 1 + $results[0].Database | Should -Be "dbAlpha" + $results[0].SourceServer | Should -Be "source1" + } + + It "Should keep identical database names from different sources separate when using -Path" { + $script:mockFiles = @( + "C:\backups\source1\SharedDb-full.bak", + "C:\backups\source2\SharedDb-full.bak" + ) + $script:mockHeaders = @( + [PSCustomObject]@{ + BackupSetGUID = [guid]"44444444-4444-4444-4444-444444444444" + BackupTypeDescription = "Database" + MachineName = "source1" + ServiceName = "MSSQLSERVER" + ServerName = "source1" + DatabaseName = "SharedDb" + UserName = "sa" + BackupStartDate = [datetime]"2026-03-19T12:00:00" + BackupFinishDate = [datetime]"2026-03-19T12:01:00" + BackupPath = "C:\backups\source1\SharedDb-full.bak" + FileList = [PSCustomObject]@{ + Type = "D" + LogicalName = "SharedDb" + PhysicalName = "C:\sql\data\SharedDb.mdf" + Size = 1024 + } + BackupSize = [PSCustomObject]@{ Byte = 1048576 } + CompressedBackupSize = [PSCustomObject]@{ Byte = 524288 } + Position = 1 + FirstLSN = 700 + DatabaseBackupLSN = 700 + CheckpointLSN = 700 + LastLsn = 800 + SoftwareVersionMajor = 16 + RecoveryModel = "Full" + IsCopyOnly = $false + }, + [PSCustomObject]@{ + BackupSetGUID = [guid]"55555555-5555-5555-5555-555555555555" + BackupTypeDescription = "Database" + MachineName = "source2" + ServiceName = "MSSQLSERVER" + ServerName = "source2" + DatabaseName = "SharedDb" + UserName = "sa" + BackupStartDate = [datetime]"2026-03-19T12:05:00" + BackupFinishDate = [datetime]"2026-03-19T12:06:00" + BackupPath = "C:\backups\source2\SharedDb-full.bak" + FileList = [PSCustomObject]@{ + Type = "D" + LogicalName = "SharedDb" + PhysicalName = "C:\sql\data\SharedDb.mdf" + Size = 1024 + } + BackupSize = [PSCustomObject]@{ Byte = 1048576 } + CompressedBackupSize = [PSCustomObject]@{ Byte = 524288 } + Position = 1 + FirstLSN = 900 + DatabaseBackupLSN = 900 + CheckpointLSN = 900 + LastLsn = 1000 + SoftwareVersionMajor = 16 + RecoveryModel = "Full" + IsCopyOnly = $false + } + ) + + $results = @(Test-DbaLastBackup -Path "C:\backups" -Destination "dest" -NoCheck -NoDrop | Sort-Object SourceServer) + + $results.Count | Should -Be 2 + $results[0].Database | Should -Be "SharedDb" + $results[0].SourceServer | Should -Be "source1" + $results[1].Database | Should -Be "SharedDb" + $results[1].SourceServer | Should -Be "source2" + } + } + + Context "Start-DbccCheck compatibility" { + BeforeEach { + $script:lastQuery = $null + $script:mockServer = [DbaInstanceParameter]"dest" + Add-Member -InputObject $script:mockServer -Name Name -MemberType NoteProperty -Value "dest" -Force + Add-Member -InputObject $script:mockServer -Name ConnectionContext -MemberType NoteProperty -Value ([PSCustomObject]@{ + StatementTimeout = 30 + }) -Force + Add-Member -InputObject $script:mockServer -Name Databases -MemberType NoteProperty -Value @{ } -Force + Add-Member -InputObject $script:mockServer -Name Query -MemberType ScriptMethod -Value { + param($query) + $script:lastQuery = $query + } -Force + } + + It "Should keep returning a status string by default" { + $result = Start-DbccCheck -Server $script:mockServer -DbName "Db]Name" -MaxDop 4 + + $result | Should -Be "Success" + $script:lastQuery | Should -Be "DBCC CHECKDB ([Db]]Name]) WITH MAXDOP = 4" + } + + It "Should return status and messages when detailed output is requested" { + Mock Invoke-DbaQuery { + "message 1", "message 2" + } + + $result = Start-DbccCheck -Server $script:mockServer -DbName "Db]Name" -DetailedOutput + + $result.Status | Should -Be "Success" + $result.Output | Should -HaveCount 2 + $result.Output[0] | Should -Be "message 1" + $result.Output[1] | Should -Be "message 2" + $script:lastQuery | Should -BeNullOrEmpty + Should -Invoke Invoke-DbaQuery -Times 1 -Exactly + } + } + } } Describe $CommandName -Tag IntegrationTests { diff --git a/tests/Test-DbaLsnChain.Tests.ps1 b/tests/Test-DbaLsnChain.Tests.ps1 index f7f7cb4fd069..5ed53aebc5ea 100644 --- a/tests/Test-DbaLsnChain.Tests.ps1 +++ b/tests/Test-DbaLsnChain.Tests.ps1 @@ -1,21 +1,37 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaLsnChain", $PSDefaultParameterValues = $TestConfig.Defaults ) Describe $CommandName -Tag UnitTests { - Context "Parameter validation" { - It "Should have the expected parameters" { - $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } - $expectedParameters = $TestConfig.CommonParameters - $expectedParameters += @( - "FilteredRestoreFiles", - "Continue", - "EnableException" - ) - Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + InModuleScope dbatools { + Context "Parameter validation" { + It "Should have the expected parameters" { + $hasParameters = (Get-Command "Test-DbaLsnChain").Parameters.Values.Name | Where-Object { + $PSItem -notin @( + "Verbose", + "Debug", + "ErrorAction", + "WarningAction", + "InformationAction", + "ProgressAction", + "ErrorVariable", + "WarningVariable", + "InformationVariable", + "OutVariable", + "OutBuffer", + "PipelineVariable" + ) + } + $expectedParameters = @( + "FilteredRestoreFiles", + "Continue", + "EnableException" + ) + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + } } } } @@ -115,5 +131,27 @@ Describe $CommandName -Tag IntegrationTests { $output | Should -BeExactly $true } } + + Context "History imported from file with deserialized LSN values" { + BeforeAll { + $historyFromFile = Import-Clixml $PSScriptRoot\..\tests\ObjectDefinitions\BackupRestore\RawInput\CleanFormatDbaInformation.xml | + Format-DbaBackupInformation + $fullBackup = $historyFromFile | Where-Object Type -eq "Database" | Select-Object -First 1 + $transactionLogs = $historyFromFile | Where-Object Type -eq "Transaction Log" + $scrambledHistory = @( + $fullBackup + $transactionLogs[2] + $transactionLogs[0] + $transactionLogs[1] + $transactionLogs[4] + $transactionLogs[3] + ) + } + + It "Should sort deserialized LSN values numerically" { + $output = Test-DbaLsnChain -FilteredRestoreFiles $scrambledHistory -WarningAction SilentlyContinue + $output | Should -BeExactly $true + } + } } } \ No newline at end of file diff --git a/tests/Test-DbaNetworkCertificate.Tests.ps1 b/tests/Test-DbaNetworkCertificate.Tests.ps1 index 4307d683390e..166e792e5724 100644 --- a/tests/Test-DbaNetworkCertificate.Tests.ps1 +++ b/tests/Test-DbaNetworkCertificate.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaNetworkCertificate", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -20,6 +20,52 @@ Describe $CommandName -Tag UnitTests { Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + + Context "Configured certificate validity" { + It "Should treat a configured certificate that is not valid yet as invalid" { + $futureThumbprint = "0123456789ABCDEF0123456789ABCDEF01234567" + + Mock Get-DbaNetworkConfiguration { + [PSCustomObject]@{ + ComputerName = "sql1" + InstanceName = "MSSQLSERVER" + SqlInstance = "sql1" + Certificate = [PSCustomObject]@{ + Thumbprint = "0123456789ABCDEF0123456789ABCDEF01234567" + Generated = (Get-Date).AddDays(1) + Expires = (Get-Date).AddDays(30) + } + SuitableCertificate = @() + } + } -ModuleName dbatools + + $results = Test-DbaNetworkCertificate -SqlInstance "sql1" + + $results.ConfiguredCertificateValid | Should -Be $false + $results.ConfiguredCertificateThumbprint | Should -Be $futureThumbprint + } + + It "Should treat a configured certificate with missing validity dates as invalid" { + Mock Get-DbaNetworkConfiguration { + [PSCustomObject]@{ + ComputerName = "sql1" + InstanceName = "MSSQLSERVER" + SqlInstance = "sql1" + Certificate = [PSCustomObject]@{ + Thumbprint = "89ABCDEF0123456789ABCDEF0123456789ABCDEF" + Generated = $null + Expires = (Get-Date).AddDays(30) + } + SuitableCertificate = @() + } + } -ModuleName dbatools + + $results = Test-DbaNetworkCertificate -SqlInstance "sql1" + + $results.ConfiguredCertificateValid | Should -Be $false + $results.ConfiguredCertificateDaysValid | Should -BeGreaterThan 0 + } + } } Describe $CommandName -Tag IntegrationTests { @@ -98,4 +144,4 @@ Describe $CommandName -Tag IntegrationTests { ($results.PsObject.Properties.Name | Sort-Object) | Should -BeExactly ($expectedProps | Sort-Object) } } -} +} \ No newline at end of file diff --git a/tests/Test-DbaPath.Tests.ps1 b/tests/Test-DbaPath.Tests.ps1 index 73fda43ad087..df155b28c144 100644 --- a/tests/Test-DbaPath.Tests.ps1 +++ b/tests/Test-DbaPath.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Test-DbaPath", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -21,6 +21,54 @@ Describe $CommandName -Tag UnitTests { } } +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + BeforeAll { + function New-MockTestDbaPathServer { + $connectionContext = [PSCustomObject]@{ + } + Add-Member -InputObject $connectionContext -Name ExecuteWithResults -MemberType ScriptMethod -Value { + param($Query) + throw "xp_fileexist failed" + } -Force + + [PSCustomObject]@{ + Name = "sql1" + ServiceName = "MSSQLSERVER" + ComputerName = "sql1" + ConnectionContext = $connectionContext + } + } + } + + Context "xp_fileexist execution failures" { + BeforeAll { + Mock Connect-DbaInstance { + New-MockTestDbaPathServer + } + } + + It "Returns false for a single path by default" { + $result = Test-DbaPath -SqlInstance "sql1" -Path "C:\temp\file1.bak" + + $result | Should -Be $false + } + + It "Returns false objects for array input by default" { + $results = Test-DbaPath -SqlInstance "sql1" -Path @("C:\temp\file1.bak", "C:\temp\file2.bak") + + ($results | Measure-Object).Count | Should -Be 2 + ($results | Where-Object FilePath -eq "C:\temp\file1.bak").FileExists | Should -Be $false + ($results | Where-Object FilePath -eq "C:\temp\file2.bak").IsContainer | Should -Be $false + } + + It "Honors EnableException when xp_fileexist execution fails" { + { Test-DbaPath -SqlInstance "sql1" -Path "C:\temp\file1.bak" -EnableException } | Should -Throw + } + } + } +} + Describe $CommandName -Tag IntegrationTests { BeforeAll { $trueTest = (Get-DbaDbFile -SqlInstance $TestConfig.InstanceMulti1 -Database master)[0].PhysicalName diff --git a/tests/Update-DbaInstance.Tests.ps1 b/tests/Update-DbaInstance.Tests.ps1 index fe44c084f36a..7e6df2bf218b 100644 --- a/tests/Update-DbaInstance.Tests.ps1 +++ b/tests/Update-DbaInstance.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } param( - $ModuleName = "dbatools", + $ModuleName = "dbatools", $CommandName = "Update-DbaInstance", $PSDefaultParameterValues = $TestConfig.Defaults ) @@ -61,13 +61,13 @@ Describe -skip $CommandName -Tag UnitTests { InstanceName = "LAB" Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "11.0.5058" - "NameLevel" = "2012" - "SPLevel" = "SP2" - "CULevel" = $null - "KBLevel" = "2958429" - "BuildLevel" = [version]'11.0.5058' - "MatchType" = "Exact" + "Build" = "11.0.5058" + "NameLevel" = "2012" + "SPLevel" = "SP2" + "CULevel" = $null + "KBLevel" = "2958429" + "BuildLevel" = [version]'11.0.5058' + "MatchType" = "Exact" } } } @@ -125,35 +125,35 @@ Describe -skip $CommandName -Tag UnitTests { @( [PSCustomObject]@{ InstanceName = 'LAB0'; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "14.0.3038" - "NameLevel" = "2017" - "SPLevel" = "RTM" - "CULevel" = 'CU11' - "KBLevel" = "4462262" - "BuildLevel" = [version]'14.0.3038' - "MatchType" = "Exact" + "Build" = "14.0.3038" + "NameLevel" = "2017" + "SPLevel" = "RTM" + "CULevel" = 'CU11' + "KBLevel" = "4462262" + "BuildLevel" = [version]'14.0.3038' + "MatchType" = "Exact" } } [PSCustomObject]@{ InstanceName = "LAB"; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "11.0.5058" - "NameLevel" = "2012" - "SPLevel" = "SP2" - "CULevel" = $null - "KBLevel" = "2958429" - "BuildLevel" = [version]'11.0.5058' - "MatchType" = "Exact" + "Build" = "11.0.5058" + "NameLevel" = "2012" + "SPLevel" = "SP2" + "CULevel" = $null + "KBLevel" = "2958429" + "BuildLevel" = [version]'11.0.5058' + "MatchType" = "Exact" } } [PSCustomObject]@{ InstanceName = 'LAB2'; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "10.0.5770" - "NameLevel" = "2008" - "SPLevel" = "SP3" - "CULevel" = "CU3" - "KBLevel" = "2648098" - "BuildLevel" = [version]'10.0.5770' - "MatchType" = "Exact" + "Build" = "10.0.5770" + "NameLevel" = "2008" + "SPLevel" = "SP3" + "CULevel" = "CU3" + "KBLevel" = "2648098" + "BuildLevel" = [version]'10.0.5770' + "MatchType" = "Exact" } } ) @@ -289,24 +289,24 @@ Describe -skip $CommandName -Tag UnitTests { @( [PSCustomObject]@{ InstanceName = "LAB"; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "13.0.4435" - "NameLevel" = "2016" - "SPLevel" = "SP1" - "CULevel" = "CU3" - "KBLevel" = "4019916" - "BuildLevel" = [version]'13.0.4435' - "MatchType" = "Exact" + "Build" = "13.0.4435" + "NameLevel" = "2016" + "SPLevel" = "SP1" + "CULevel" = "CU3" + "KBLevel" = "4019916" + "BuildLevel" = [version]'13.0.4435' + "MatchType" = "Exact" } } [PSCustomObject]@{ InstanceName = 'LAB2'; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "10.0.4279" - "NameLevel" = "2008" - "SPLevel" = "SP2" - "CULevel" = "CU3" - "KBLevel" = "2498535" - "BuildLevel" = [version]'10.0.4279' - "MatchType" = "Exact" + "Build" = "10.0.4279" + "NameLevel" = "2008" + "SPLevel" = "SP2" + "CULevel" = "CU3" + "KBLevel" = "2498535" + "BuildLevel" = [version]'10.0.4279' + "MatchType" = "Exact" } } ) @@ -413,24 +413,24 @@ Describe -skip $CommandName -Tag UnitTests { @( [PSCustomObject]@{ InstanceName = "LAB"; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "13.0.4435" - "NameLevel" = "2016" - "SPLevel" = "SP1" - "CULevel" = "CU3" - "KBLevel" = "4019916" - "BuildLevel" = [version]'13.0.4435' - "MatchType" = "Exact" + "Build" = "13.0.4435" + "NameLevel" = "2016" + "SPLevel" = "SP1" + "CULevel" = "CU3" + "KBLevel" = "4019916" + "BuildLevel" = [version]'13.0.4435' + "MatchType" = "Exact" } } [PSCustomObject]@{ InstanceName = 'LAB2'; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "10.0.4279" - "NameLevel" = "2008" - "SPLevel" = "SP2" - "CULevel" = "CU3" - "KBLevel" = "2498535" - "BuildLevel" = [version]'10.0.4279' - "MatchType" = "Exact" + "Build" = "10.0.4279" + "NameLevel" = "2008" + "SPLevel" = "SP2" + "CULevel" = "CU3" + "KBLevel" = "2498535" + "BuildLevel" = [version]'10.0.4279' + "MatchType" = "Exact" } } ) @@ -495,13 +495,13 @@ Describe -skip $CommandName -Tag UnitTests { InstanceName = "LAB" Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "11.0.5058" - "NameLevel" = "2012" - "SPLevel" = "SP2" - "CULevel" = $null - "KBLevel" = "2958429" - "BuildLevel" = [version]'11.0.5058' - "MatchType" = "Exact" + "Build" = "11.0.5058" + "NameLevel" = "2012" + "SPLevel" = "SP2" + "CULevel" = $null + "KBLevel" = "2958429" + "BuildLevel" = [version]'11.0.5058' + "MatchType" = "Exact" } Resume = $true } @@ -540,13 +540,13 @@ Describe -skip $CommandName -Tag UnitTests { InstanceName = "LAB" Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "11.0.5058" - "NameLevel" = "2012" - "SPLevel" = "SP2" - "CULevel" = $null - "KBLevel" = "2958429" - "BuildLevel" = [version]'11.0.5058' - "MatchType" = "Exact" + "Build" = "11.0.5058" + "NameLevel" = "2012" + "SPLevel" = "SP2" + "CULevel" = $null + "KBLevel" = "2958429" + "BuildLevel" = [version]'11.0.5058' + "MatchType" = "Exact" } } } @@ -778,13 +778,13 @@ Describe -skip $CommandName -Tag UnitTests { Mock -CommandName Get-SQLInstanceComponent -ModuleName dbatools -MockWith { [PSCustomObject]@{ InstanceName = "LAB"; Version = [PSCustomObject]@{ "SqlInstance" = $null - "Build" = "10.0.4279" - "NameLevel" = "2008" - "SPLevel" = "SP2" - "CULevel" = "CU3" - "KBLevel" = "2498535" - "BuildLevel" = [version]'10.0.4279' - "MatchType" = "Exact" + "Build" = "10.0.4279" + "NameLevel" = "2008" + "SPLevel" = "SP2" + "CULevel" = "CU3" + "KBLevel" = "2498535" + "BuildLevel" = [version]'10.0.4279' + "MatchType" = "Exact" } } } @@ -800,7 +800,7 @@ Describe -skip $CommandName -Tag UnitTests { It "fails when a reboot is pending" { #override default mock Mock -CommandName Test-PendingReboot -MockWith { $true } -ModuleName dbatools - { Update-DbaInstance -Version 2008SP3CU7 -EnableException } | Should -Throw 'Reboot the computer before proceeding' + { Update-DbaInstance -Version 2008SP3CU7 -Path $exeDir -EnableException } | Should -Throw "Reboot the computer before proceeding" #revert default mock Mock -CommandName Test-PendingReboot -MockWith { $false } -ModuleName dbatools } @@ -817,11 +817,11 @@ Describe -skip $CommandName -Tag UnitTests { { Update-DbaInstance -Version 2008SP3CU7 -Path $exeDir -EnableException } | Should -Throw 'Could not find installer for the SQL2008 update KB' } It "fails when SP level is lower than required" { - { Update-DbaInstance -Type CumulativeUpdate -EnableException } | Should -Throw 'Current SP version SQL2008SP2 is not the latest available' + { Update-DbaInstance -Type CumulativeUpdate -Path $exeDir -EnableException } | Should -Throw "Current SP version SQL2008SP2 is not the latest available" } It "fails when repository is not available" { - { Update-DbaInstance -Version 2008SP3CU7 -Path .\NonExistingFolder -EnableException } | Should -Throw 'Cannot find path' - { Update-DbaInstance -Version 2008SP3CU7 -EnableException } | Should -Throw 'Path to SQL Server updates folder is not set' + { Update-DbaInstance -Version 2008SP3CU7 -Path .\NonExistingFolder -EnableException } | Should -Throw "Cannot find path" + { Update-DbaInstance -Version 2008SP3CU7 -EnableException } | Should -Throw "Path is required" } It "fails when update execution has failed" { #Mock Get-Item and Get-ChildItem with a dummy file @@ -879,4 +879,121 @@ Describe -Skip "$CommandName Integration Tests" -Tag IntegrationTests { } } } +} + +Describe "Update-DbaInstance authentication regression" -Tag UnitTests { + BeforeAll { + $credentialPassword = "pwd" | ConvertTo-SecureString -AsPlainText -Force + $testCredential = New-Object PSCredential("usr", $credentialPassword) + + Mock -CommandName Invoke-Program -MockWith { [PSCustomObject]@{ Successful = $true; ExitCode = [uint32[]]3010 } } -ModuleName dbatools + Mock -CommandName Test-ElevationRequirement -MockWith { $null } -ModuleName dbatools + Mock -CommandName Restart-Computer -MockWith { $null } -ModuleName dbatools + Mock -CommandName Register-RemoteSessionConfiguration -ModuleName dbatools -MockWith { + [PSCustomObject]@{ "Name" = "dbatoolsInstallSqlServerUpdate"; Successful = $true; Status = "Dummy" } + } + Mock -CommandName Unregister-RemoteSessionConfiguration -ModuleName dbatools -MockWith { + [PSCustomObject]@{ "Name" = "dbatoolsInstallSqlServerUpdate"; Successful = $true; Status = "Dummy" } + } + Mock -CommandName Get-DbaDiskSpace -MockWith { [PSCustomObject]@{ Name = "C:\"; Free = 1 } } -ModuleName dbatools + Mock -CommandName Get-SQLInstanceComponent -ModuleName dbatools -MockWith { + [PSCustomObject]@{ + InstanceName = "LAB" + Version = [PSCustomObject]@{ + "SqlInstance" = $null + "Build" = "11.0.5058" + "NameLevel" = "2012" + "SPLevel" = "SP2" + "CULevel" = $null + "KBLevel" = "2958429" + "BuildLevel" = [version]"11.0.5058" + "MatchType" = "Exact" + } + } + } + Mock -CommandName Invoke-Command2 -ModuleName dbatools -MockWith { $true } + Mock -CommandName Test-PendingReboot -MockWith { $false } -ModuleName dbatools + Mock -CommandName Get-ChildItem -ModuleName dbatools -MockWith { + [PSCustomObject]@{ + FullName = "c:\mocked\filename.exe" + } + } + Mock -CommandName Get-Item -ModuleName dbatools -MockWith { "c:\mocked" } + Mock -CommandName Find-SqlInstanceUpdate -ModuleName dbatools -MockWith { + [PSCustomObject]@{ + FullName = "c:\mocked\path" + } + } + Mock -CommandName Invoke-DbaAdvancedUpdate -ModuleName dbatools -MockWith { } + Mock -CommandName Resolve-DbaNetworkName -ModuleName dbatools -MockWith { + [PSCustomObject]@{ + FullComputerName = "mocked" + } + } + Mock -CommandName Initialize-CredSSP -ModuleName dbatools -MockWith { } + Mock -CommandName Get-DbaCmObject -ModuleName dbatools -MockWith { [PSCustomObject]@{ SystemType = "x64" } } + } + + It "passes Authentication to remote preparation helpers" { + $null = Update-DbaInstance -ComputerName "mocked" -Credential $testCredential -Authentication Credssp -Version "2012SP3" -Path "mocked" -EnableException + + Assert-MockCalled -CommandName Get-SQLInstanceComponent -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { $Authentication -eq "Credssp" } + Assert-MockCalled -CommandName Test-PendingReboot -Exactly 1 -Scope It -ModuleName dbatools -ParameterFilter { $Authentication -eq "Credssp" } + } +} + +Describe "Update-DbaInstance path validation regression" -Tag UnitTests { + It "fails early when Path contains only whitespace" { + Mock -CommandName Get-SQLInstanceComponent -ModuleName dbatools -MockWith { throw "Get-SQLInstanceComponent should not be called" } + + { Update-DbaInstance -Version "2012SP3" -Path " " -EnableException } | Should -Throw "*Path is required*" + + Assert-MockCalled -CommandName Get-SQLInstanceComponent -Exactly 0 -Scope It -ModuleName dbatools + } +} + +InModuleScope dbatools { + Describe "Update-DbaInstance helper regressions" -Tag UnitTests { + It "passes Authentication to Invoke-Command2 when gathering instance components" { + $credentialPassword = "pwd" | ConvertTo-SecureString -AsPlainText -Force + $testCredential = New-Object PSCredential("usr", $credentialPassword) + + Mock -CommandName Invoke-Command2 -MockWith { + [PSCustomObject]@{ + ComputerName = "MOCKED" + InstanceName = "LAB" + InstanceType = "Database Engine" + Version = "11.0.5058" + Log = @() + } + } + Mock -CommandName Get-DbaBuild -MockWith { + [PSCustomObject]@{ + Build = "11.0.5058" + NameLevel = "2012" + BuildLevel = [version]"11.0.5050" + MatchType = "Exact" + } + } + + $result = Get-SQLInstanceComponent -ComputerName "mocked" -Credential $testCredential -Authentication Credssp + + $result | Should -Not -BeNullOrEmpty + Assert-MockCalled -CommandName Invoke-Command2 -Exactly 1 -Scope It -ParameterFilter { $Authentication -eq "Credssp" } + } + + It "passes Authentication to Invoke-Command2 without using Get-DbaCmObject for pending reboot checks" { + $credentialPassword = "pwd" | ConvertTo-SecureString -AsPlainText -Force + $testCredential = New-Object PSCredential("usr", $credentialPassword) + + Mock -CommandName Invoke-Command2 -MockWith { $null } + Mock -CommandName Get-DbaCmObject -MockWith { throw "Get-DbaCmObject should not be called" } + + $result = Test-PendingReboot -ComputerName "mocked" -Credential $testCredential -Authentication Credssp + + $result | Should -BeFalse + Assert-MockCalled -CommandName Invoke-Command2 -Exactly 3 -Scope It -ParameterFilter { $Authentication -eq "Credssp" } + Assert-MockCalled -CommandName Get-DbaCmObject -Exactly 0 -Scope It + } + } } \ No newline at end of file diff --git a/tests/Update-ServiceStatus.Tests.ps1 b/tests/Update-ServiceStatus.Tests.ps1 new file mode 100644 index 000000000000..a437833f6f37 --- /dev/null +++ b/tests/Update-ServiceStatus.Tests.ps1 @@ -0,0 +1,141 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Update-ServiceStatus", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + InModuleScope dbatools { + BeforeAll { + $password = ConvertTo-SecureString "pw" -AsPlainText -Force + $script:credential = New-Object PSCredential("sqladmin", $password) + $script:mockCimSession = [PSCustomObject]@{ + ComputerName = "sql1" + } + } + + BeforeEach { + $script:service = [PSCustomObject]@{ + PSComputerName = "sql1" + ComputerName = "sql1" + ServiceName = "MSSQLSERVER" + InstanceName = "MSSQLSERVER" + ServiceType = "Engine" + ServicePriority = 1 + State = "Stopped" + } + $script:service.PSObject.TypeNames.Insert(0, "dbatools.DbaSqlService") + + $script:newCimSessionCalls = @() + $script:removedCimSessions = @() + + function Write-Message { + param( + $Message, + $Level, + $Target + ) + } + function Select-DefaultView { + param( + $Property + ) + + process { + $_ + } + } + function Get-DbaCmObject { + param( + $ComputerName, + $Namespace, + $Query, + $Credential + ) + + [PSCustomObject]@{ + Name = "MSSQLSERVER" + State = "Stopped" + StartMode = "Manual" + } + } + function New-CimSession { + param( + $ComputerName, + $Credential, + $SessionOption, + $ErrorAction + ) + + $script:newCimSessionCalls += [PSCustomObject]@{ + ComputerName = $ComputerName + Credential = $Credential + SessionOption = $SessionOption + } + $script:mockCimSession + } + function Get-CimInstance { + param( + $CimSession, + $Namespace, + $Query, + $InputObject + ) + + if ($Query -like "SELECT State FROM Win32_Service*") { + [PSCustomObject]@{ + State = "Running" + } + } else { + [PSCustomObject]@{ + Name = "MSSQLSERVER" + State = "Stopped" + StartMode = "Manual" + } + } + } + function Invoke-CimMethod { + param( + $InputObject, + $MethodName + ) + + [PSCustomObject]@{ + State = "Running" + ReturnValue = 0 + } + } + function Remove-CimSession { + param( + $CimSession, + $ErrorAction + ) + + $script:removedCimSessions += $CimSession + } + function Invoke-Parallel { + param( + $ScriptBlock, + $Throttle, + [switch]$ImportVariables + ) + + process { + $_ | ForEach-Object $ScriptBlock + } + } + } + + It "uses the supplied credential for worker CIM sessions and cleans them up" { + $null = Update-ServiceStatus -InputObject $script:service -Action "start" -Credential $script:credential + + $script:newCimSessionCalls.Count | Should -Be 1 + $script:newCimSessionCalls[0].ComputerName | Should -Be "sql1" + $script:newCimSessionCalls[0].Credential | Should -Be $script:credential + $script:newCimSessionCalls[0].SessionOption | Should -Not -BeNullOrEmpty + $script:removedCimSessions.Count | Should -Be 1 + $script:removedCimSessions[0] | Should -Be $script:mockCimSession + } + } +} \ No newline at end of file diff --git a/tests/appveyor.common.Tests.ps1 b/tests/appveyor.common.Tests.ps1 new file mode 100644 index 000000000000..123766cb1bef --- /dev/null +++ b/tests/appveyor.common.Tests.ps1 @@ -0,0 +1,26 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "appveyor.common", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + BeforeAll { + . "$PSScriptRoot\appveyor.common.ps1" + } + + Context "Get-FunctionNameFromTestFile" { + It "returns the command name for standard tests" { + $testPath = Join-Path $PSScriptRoot "Get-DbaBuild.Tests.ps1" + + Get-FunctionNameFromTestFile $testPath | Should -Be "Get-DbaBuild" + } + + It "returns the base command name for suffixed tests" { + $testPath = Join-Path $PSScriptRoot "Get-DbaBuild.one.Tests.ps1" + + Get-FunctionNameFromTestFile $testPath | Should -Be "Get-DbaBuild" + } + } +} \ No newline at end of file diff --git a/tests/appveyor.common.ps1 b/tests/appveyor.common.ps1 index 54adaab1c22d..be54fbe8071d 100644 --- a/tests/appveyor.common.ps1 +++ b/tests/appveyor.common.ps1 @@ -81,11 +81,11 @@ function Get-TestsForBuildScenario { } try { # Get the list of changed files in this PR compared to the base branch - $targetBranch = if ($env:APPVEYOR_REPO_BRANCH) { "origin/$env:APPVEYOR_REPO_BRANCH" } else { 'origin/development' } + $targetBranch = if ($env:APPVEYOR_REPO_BRANCH) { "origin/$env:APPVEYOR_REPO_BRANCH" } else { "origin/development" } $ChangedFiles = git diff --name-only "$targetBranch...HEAD" 2>$null if (-not($Silent)) { - Write-Host -ForegroundColor DarkGreen '...Changed files are: ' + Write-Host -ForegroundColor DarkGreen "...Changed files are: " foreach ($cmd in $ChangedFiles) { Write-Host -ForegroundColor DarkGreen "... - $cmd" } @@ -99,17 +99,17 @@ function Get-TestsForBuildScenario { foreach ($file in $ChangedFiles) { # Check for changes to public commands - if ($file -like 'public/*.ps1') { - $commandName = Split-Path $file -Leaf | ForEach-Object { $_ -replace '\.ps1$', '' } + if ($file -like "public/*.ps1") { + $commandName = Split-Path $file -Leaf | ForEach-Object { $_ -replace "\.ps1$", "" } $changedCommands += $commandName } # Check for changes to private functions - elseif ($file -like 'private/functions/*.ps1') { - $functionName = Split-Path $file -Leaf | ForEach-Object { $_ -replace '\.ps1$', '' } + elseif ($file -like "private/functions/*.ps1") { + $functionName = Split-Path $file -Leaf | ForEach-Object { $_ -replace "\.ps1$", "" } $changedCommands += $functionName } # Check for direct changes to test files - elseif ($file -like 'tests/*.Tests.ps1') { + elseif ($file -like "tests/*.Tests.ps1") { $testName = Split-Path $file -Leaf $changedTests += $testName } @@ -121,7 +121,7 @@ function Get-TestsForBuildScenario { if ($changedCommands.Count -gt 0) { # Find test files matching the changed commands foreach ($cmd in $changedCommands) { - $matchingTests = $AllTests | Where-Object { ($_.Name -replace '\.Tests\.ps1$', '') -eq $cmd } + $matchingTests = $AllTests | Where-Object { ($_.Name -replace "\.Tests\.ps1$", "") -eq $cmd } $testsForChangedFiles += $matchingTests } } @@ -205,7 +205,7 @@ function Get-TestsForBuildScenario { } else { # No direct matches - fall back to dbatools.Tests.ps1 only if (-not($Silent)) { - Write-Host -ForegroundColor DarkGreen 'Commit message: No direct test matches, falling back to dbatools.Tests.ps1' + Write-Host -ForegroundColor DarkGreen "Commit message: No direct test matches, falling back to dbatools.Tests.ps1" } $testsThatDependOn += Get-Item "$ModuleBase\tests\dbatools.Tests.ps1" } @@ -217,7 +217,7 @@ function Get-TestsForBuildScenario { Write-Host -ForegroundColor DarkGreen "Commit message: Extended to $($AllTests.Count) for all the dependencies" } if ($AllTests.Count -eq 0) { - throw 'something went wrong, nothing to test' + throw "something went wrong, nothing to test" } } @@ -225,12 +225,12 @@ function Get-TestsForBuildScenario { if ($env:SCENARIO) { # if so, do we have a group with tests to run ? if ($env:SCENARIO -in $TestsRunGroups.Keys) { - $AllScenarioTests = Get-TestsForScenario -scenario $env:SCENARIO -AllTest $AllTests -Silent:$Silent + $AllScenarioTests = Get-TestsForScenario -scenario $env:SCENARIO -AllTests $AllTests -Silent:$Silent } else { $AllTestsToExclude = @() $validScenarios = $TestsRunGroups.Keys | Where-Object { $_ -notin @('disabled', 'appveyor_disabled') } foreach ($k in $validScenarios) { - $AllTestsToExclude += Get-TestsForScenario -scenario $k -AllTest $AllTests + $AllTestsToExclude += Get-TestsForScenario -scenario $k -AllTests $AllTests } $AllScenarioTests = $AllTests | Where-Object { $_ -notin $AllTestsToExclude } } @@ -255,7 +255,7 @@ function Get-TestsForBuildScenario { } } if ($AllTests.Count -eq 0 -and $AllScenarioTests.Count -eq 0) { - throw 'something went wrong, nothing to test' + throw "something went wrong, nothing to test" } return $AllScenarioTests } @@ -284,7 +284,7 @@ function Get-TestIndications { $testpaths = @() foreach ($f in $funcs) { # exclude always used functions ?! - if ($f -in ('Connect-DbaInstance', 'Select-DefaultView', 'Stop-Function', 'Write-Message')) { continue } + if ($f -in ("Connect-DbaInstance", "Select-DefaultView", "Stop-Function", "Write-Message")) { continue } # can I find a correspondence to a physical file (again, on the convenience of having Get-DbaFoo.ps1 actually defining Get-DbaFoo)? $res = $allfiles | Where-Object { $_.Name -like "$($f).*Tests.ps1" } if ($res.Count -gt 0) { @@ -307,7 +307,7 @@ function Get-AllTestsIndications($Path, $ModuleBase) { $cmdDef = Get-PreparedCommandDefinitions -ExportedCommands $exportedCommands # get all test files once and for all, so we don't have to do it every time we look for a dependency - $allfiles = Get-ChildItem -File -Path "$ModuleBase\tests" -Filter '*.ps1' + $allfiles = Get-ChildItem -File -Path "$ModuleBase\tests" -Filter "*.ps1" $evaluated = Get-TestIndications -Path $baseTestFile -ModuleBase $ModuleBase -ExportedCommands $exportedCommands -AllFiles $allfiles -cmdDef $cmdDef $testIndicationsResultCache[$baseTestFile] = $evaluated @@ -351,12 +351,12 @@ function Get-AllTestsIndications($Path, $ModuleBase) { } # add dbatools.Tests.ps1 always - $returnValue = @{} + $returnValue = @{ } $returnValue["$ModuleBase\tests\dbatools.Tests.ps1"] = 1 foreach ($k in $testIndicationsResultCache.Keys) { $returnValue[$k] = 1 foreach ($dep in $testIndicationsResultCache[$k]) { - $returnValue[$k] = 1 + $returnValue[$dep] = 1 } } @@ -371,17 +371,17 @@ function Get-PreparedCommandDefinitions { param($ExportedCommands) # prepares in one sweep the "source code" of all the commands # so it can be searched for dependencies within - $CBHRex = [regex]'(?smi)<#(.*)#>' - $cmdDef = @{} + $CBHRex = [regex]"(?smi)<#(.*)#>" + $cmdDef = @{ } foreach ($f in $ExportedCommands) { $rawSource = $f.Definition $CBH = $CBHRex.match($rawSource).Value # This fails very hard sometimes if ($rawSource -and $CBH) { - $runningCode = $rawSource.Replace($CBH, '') + $runningCode = $rawSource.Replace($CBH, "") $cmdDef[$f.Name] = $runningCode } else { - $cmdDef[$f.Name] = '' + $cmdDef[$f.Name] = "" } } return $cmdDef @@ -389,7 +389,10 @@ function Get-PreparedCommandDefinitions { function Get-FunctionNameFromTestFile($testFilePath) { $leaf = Split-Path $testFilePath -Leaf - return $leaf.Replace('.Tests.ps1', '') + if ($leaf -match "^([^.]+)(.+)?.Tests.ps1$") { + return $Matches[1] + } + return $leaf.Replace(".Tests.ps1", "") } function Show-DependencyTree { @@ -397,14 +400,14 @@ function Show-DependencyTree { param ( [string]$Node, [object[]]$Data, - [string]$Prefix = '', + [string]$Prefix = "", [bool]$IsLast = $true, [System.Collections.Generic.HashSet[string]]$Visited = - (New-Object 'System.Collections.Generic.HashSet[string]') + (New-Object "System.Collections.Generic.HashSet[string]") ) if ($Prefix) { - Write-Host ('{0}{1}{2}' -f $Prefix, ($(if ($IsLast) { '\-- ' } else { '|- ' })), $Node) + Write-Host ("{0}{1}{2}" -f $Prefix, ($(if ($IsLast) { "\-- " } else { "|- " })), $Node) } else { Write-Host $Node } @@ -415,25 +418,27 @@ function Show-DependencyTree { $children = @( $Data | - Where-Object { - $_.Source -eq $Node -and $_.DependsOn -ne $_.Source - } | - Select-Object -ExpandProperty DependsOn -Unique | - Sort-Object + Where-Object { + $_.Source -eq $Node -and $_.DependsOn -ne $_.Source + } | + Select-Object -ExpandProperty DependsOn -Unique | + Sort-Object ) if ($children.Count -eq 0) { return } - $nextPrefix = $Prefix + ($(if ($IsLast) { ' ' } else { '| ' })) + $nextPrefix = $Prefix + ($(if ($IsLast) { " " } else { "| " })) for ($i = 0; $i -lt $children.Count; $i++) { - Show-DependencyTree ` - -Node $children[$i] ` - -Data $Data ` - -Prefix $nextPrefix ` - -IsLast ($i -eq $children.Count - 1) ` - -Visited $Visited + $splatDependencyTree = @{ + Node = $children[$i] + Data = $Data + Prefix = $nextPrefix + IsLast = ($i -eq $children.Count - 1) + Visited = $Visited + } + Show-DependencyTree @splatDependencyTree } -} +} \ No newline at end of file