_TESTS/PSLocalBase.Hardening-v2.Tests.ps1
|
# Regression checks for the 2.0.0 hardening release. Each check fails on 1.0.x (03ccf2f). $ErrorActionPreference = 'Stop' Import-Module (Join-Path (Split-Path $PSScriptRoot -Parent) 'PSLocalBase.psd1') -Force $script:passed = 0; $script:failed = 0 function Check([string]$Name, [scriptblock]$Body) { $db = New-LocalBase try { & $Body $db; $script:passed++; Write-Host "[PASS] $Name" } catch { $script:failed++; Write-Host "[FAIL] ${Name}: $($_.Exception.Message)" } finally { $db.close() } } function Assert-Throws([scriptblock]$Body, [string]$Because) { $threw = $false try { & $Body } catch { $threw = $true } if (-not $threw) { throw "Expected an exception: $Because" } } function New-TempPath([string]$Name) { $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("pslb-h2-" + [Guid]::NewGuid().ToString('N')) [void][System.IO.Directory]::CreateDirectory($dir) return (Join-Path $dir $Name) } function Remove-TempPath([string]$Path) { $dir = Split-Path $Path -Parent if (Test-Path $dir) { Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue } } # --- Data-loss risks ------------------------------------------------------- Check 'H1 filtered set(array) is rejected and keeps every document' { param($db) foreach ($n in 1..3) { $db.collection('c').doc("k$n").set(@{ n = $n }) } foreach ($query in @($db.collection('c').where('n', '=', 1), $db.collection('c').orderBy('n'), $db.collection('c').limit(1), $db.collection('c').skip(1))) { Assert-Throws { $query.set(@(@{ id = 'z'; n = 9 })) } 'bulk set on a filtered/paginated query' Assert-Throws { $query.replaceAll(@(@{ id = 'z'; n = 9 })) } 'replaceAll on a filtered/paginated query' } if ($db.collection('c').count() -ne 3) { throw 'Rejected bulk write still deleted documents' } $db.collection('c').replaceAll(@(@{ id = 'a'; n = 1 }, @{ id = 'b'; n = 2 })) if (($db.collection('c').orderBy('n').get().n -join ',') -ne '1,2') { throw 'replaceAll on the unfiltered collection failed' } $db.collection('c').set(@(@{ id = 'only'; n = 5 })) if ($db.collection('c').count() -ne 1) { throw 'Unfiltered set(array) alias changed' } Assert-Throws { $db.collection('c').doc('only').replaceAll(@(@{ n = 1 })) } 'replaceAll after doc()' } Check 'H2 writes leave the query they were called on unchanged' { param($db) foreach ($n in 1..4) { $db.collection('c').doc("k$n").set(@{ n = $n }) } $doc = $db.collection('c').doc('k1') $doc.set(@{ n = 10 }) $after = $doc.get() if ($after -is [System.Array] -or $after.n -ne 10) { throw 'doc().set() reset the target key' } $doc.update(@{ extra = 1 }) if ($doc.get().extra -ne 1) { throw 'doc().update() reset the target key' } $q = $db.collection('c').where('n', '<=', 3) [void]$q.add(@{ n = 99 }) [void]$q.upsert(@{ id = 'u1'; n = 100 }) if ($q.count() -ne 2) { throw "add()/upsert() cleared the filter: count=$($q.count())" } $doc.delete() if ($db.collection('c').count() -ne 5) { throw 'delete() after set() removed more than its document' } $c = $db.collection('dropme') [void]$c.add(@{ v = 1 }) $c.drop() [void]$c.add(@{ v = 2 }) if ($c.count() -ne 1) { throw 'Collection reference unusable after drop()' } } Check 'H3 collectionGroup cannot be combined with union' { param($db) $db.collection('logs_1').doc('a').set(@{ n = 1 }) $db.collection('north').doc('b').set(@{ n = 2 }) $group = $db.collectionGroup('logs_*') $plain = $db.collection('north') Assert-Throws { $group.union($plain) } 'group.union' Assert-Throws { $plain.union($group) } 'plain.union(group)' Assert-Throws { $group.unionAll($plain) } 'group.unionAll' Assert-Throws { $plain.unionAll($group) } 'plain.unionAll(group)' } # --- Query semantics ------------------------------------------------------- Check 'M1 in/not-in treat null list members as IS NULL' { param($db) $db.collection('c').doc('a').set(@{ s = 'admin' }) $db.collection('c').doc('b').set(@{ s = 'user' }) $db.collection('c').doc('c').set(@{ s = $null }) $db.collection('c').doc('d').set(@{ other = 1 }) $keys = { param($q) (@($q.get(@{ keys = $true })).key | Sort-Object) -join ',' } if ((& $keys $db.collection('c').where('s', 'not-in', @('user', $null))) -ne 'a') { throw 'not-in with null dropped every row' } if ((& $keys $db.collection('c').where('s', 'not-in', @($null))) -ne 'a,b') { throw 'not-in @($null) must keep only non-null values' } if ((& $keys $db.collection('c').where('s', 'in', @($null))) -ne 'c,d') { throw 'in @($null) must match null and missing values' } if ((& $keys $db.collection('c').where('s', 'in', @('admin', $null))) -ne 'a,c,d') { throw 'in with null must combine both matches' } if ((& $keys $db.collection('c').where('s', 'not-in', @('user'))) -ne 'a') { throw 'not-in without null changed' } if ((& $keys $db.collection('c').where('s', 'not-in', @())) -ne 'a,b') { throw 'empty not-in must keep only present values' } if ((& $keys $db.collection('c').where('s', 'in', @())) -ne '') { throw 'empty in must match nothing' } } Check 'M2 aggregates use JSON numbers only and keep value types' { param($db) $values = @(5, 100, '10', 'abc', $true, 2.5) for ($i = 0; $i -lt $values.Count; $i++) { $db.collection('c').doc("k$i").set(@{ v = $values[$i] }) } $avg = $db.collection('c').avg('v') if ([Math]::Abs($avg - (107.5 / 3)) -gt 1e-9) { throw "avg used non-numeric values: $avg" } if ($db.collection('c').sum('v') -ne 107.5) { throw 'sum used non-numeric values' } $max = $db.collection('c').max('v') if ($max -ne 100 -or $max -isnot [long]) { throw "max returned $max [$($max.GetType().Name)]" } $min = $db.collection('c').min('v') if ($min -ne 2.5 -or $min -isnot [double]) { throw "min returned $min" } $db.collection('t').doc('a').set(@{ v = '007' }) $db.collection('t').doc('b').set(@{ v = '1,000' }) $textMin = $db.collection('t').min('v') if ($textMin -isnot [string] -or $textMin -cne '007') { throw "text min converted to $textMin" } $distinct = @($db.collection('c').where('v', 'type', 'integer').distinct('v')) if ($distinct.Count -ne 2 -or $distinct[0] -isnot [long]) { throw 'distinct() returned strings for numbers' } } Check 'M3 dates are stored as ISO-8601 UTC and filter correctly in both editions' { param($db) $when = [datetime]::new(2026, 1, 2, 3, 4, 5, [System.DateTimeKind]::Utc) $db.collection('c').doc('a').set(@{ d = $when; wrapped = (Get-Date -Date $when) }) $db.collection('c').doc('b').set(@{ d = $when.AddYears(-10) }) $raw = $db.query("SELECT doc FROM c WHERE key = 'a';")[0].doc if ($raw -notmatch '"d":"2026-01-02T03:04:05\.0000000Z"' -or $raw -notmatch '"wrapped":"2026-01-02T03:04:05\.0000000Z"') { throw "Unexpected date encoding: $raw" } if (@($db.collection('c').where('d', '>=', [datetime]'2030-01-01').get()).Count -ne 0) { throw 'Future date filter matched' } if (@($db.collection('c').where('d', '>=', $when.AddDays(-1)).get()).Count -ne 1) { throw 'Date range filter missed' } if (@($db.collection('c').where('d', 'in', @($when)).get()).Count -ne 1) { throw 'Date in-list did not match' } if (@($db.query('SELECT key FROM c WHERE json_extract(doc, ''$.d'') = ?;', @($when))).Count -ne 1) { throw 'Bound date parameter not normalized' } $db.collection('c').doc('o').set(@{ iso = '2026-01-01T10:00:00.1234567+02:00' }) $db.collection('c').doc('o').update(@{ other = 2 }) if ($db.collection('c').doc('o').get().iso -cne '2026-01-01T10:00:00.1234567+02:00') { throw 'update() rewrote an untouched date string' } } Check 'M4 beginTransaction takes the write lock immediately' { param($db) $path = New-TempPath 'tx.db' $first = $null; $second = $null try { $first = New-LocalBase -Path $path -Options @{ busyTimeout = 100 } $second = New-LocalBase -Path $path -Options @{ busyTimeout = 100 } $first.collection('c').doc('a').set(@{ n = 1 }) $first.beginTransaction() Assert-Throws { $second.beginTransaction() } 'second writer must be blocked while the first holds an immediate transaction' $first.rollback() $second.beginTransaction('deferred') $second.rollback() Assert-Throws { $first.beginTransaction('bogus') } 'unknown transaction mode' } finally { if ($first) { $first.close() } if ($second) { $second.close() } Remove-TempPath $path } } Check 'M5 query helpers reject or honour previously ignored input' { param($db) $db.collection('c').doc('a').set(@{ tags = @(1, 2); flag = $true; ref = 'x'; n = [uint64]2 }) $db.collection('c').doc('b').set(@{ tags = @(1); flag = 'yes' }) Assert-Throws { $db.collection('c').whereLength('tags', 'bogus', 2) } 'unknown whereLength operator' if (@($db.collection('c').whereLength('tags', '==', 2).get()).Count -ne 1) { throw 'whereLength == failed' } if ($null -ne $db.collection('c').limit(0).first()) { throw 'first() ignored limit(0)' } if ($db.collection('c').skip(5).exists()) { throw 'exists() ignored skip()' } if ($db.collection('c').doc('a').limit(0).exists()) { throw 'exists() ignored limit(0) on a key lookup' } if (@($db.collection('c').whereType('flag', 'boolean').get()).Count -ne 1) { throw 'whereType boolean failed' } if (@($db.collection('c').whereType('flag', 'true').get()).Count -ne 1) { throw 'whereType true failed' } $populated = $db.collection('c').doc('a').populate('ref', 'missing_collection', 'refDoc').get() if ($null -eq $populated -or $null -ne $populated.refDoc) { throw 'populate() on a missing collection must resolve to null' } Assert-Throws { $db.collection('c').doc(@{ email = 'a@b' }) } 'criteria without key/id/_key' if (@($db.collection('c').where('n', '=', [uint64]2).get()).Count -ne 1) { throw 'uint64 parameter bound as text' } } Check 'M7 a single filter value of 0 or $false is still bound' { param($db) $db.collection('c').doc('zero').set(@{ n = 0; s = '0' }) $db.collection('c').doc('one').set(@{ n = 1; s = '1' }) $db.collection('logs_1').doc('zero').set(@{ n = 0 }) foreach ($q in @($db.collection('c').where('n', '=', 0), $db.collection('c').where('n', 'in', @(0)), $db.collection('c').where('s', '=', '0'))) { if (@($q.get()).Count -ne 1 -or $q.count() -ne 1 -or -not $q.exists()) { throw 'Filter value 0 lost its parameter' } if ($q.sum('n') -ne 0 -or @($q.distinct('n')).Count -ne 1) { throw 'Aggregate with filter value 0 failed' } } if (@($db.collectionGroup('logs_*').where('n', '=', 0).get()).Count -ne 1) { throw 'collectionGroup filter value 0 failed' } } Check 'M6 collectionGroup resolves tables at query time and exposes _collection to filters' { param($db) $db.collection('logs_1').doc('a').set(@{ n = 1 }) $db.collection('logs_2').doc('b').set(@{ n = 2 }) $group = $db.collectionGroup('logs_*') $db.collection('logs_2').drop() $db.collection('logs_3').doc('c').set(@{ n = 3 }) $keys = (@($group.get(@{ keys = $true })).key | Sort-Object) -join ',' if ($keys -ne 'a,c' -or $group.count() -ne 2) { throw "Stale group tables: $keys" } if (@($group.where('_collection', '=', 'logs_3').get()).Count -ne 1) { throw '_collection filter never matched' } if (-not $group.where('_collection', '=', 'logs_1').exists()) { throw 'exists() disagrees with get() for _collection' } } # --- Security and robustness ----------------------------------------------- Check 'S1 children of a closed connection never reach a reused handle' { param($db) $root = New-LocalBase $stale = $root.collection('c') $root.close() Assert-Throws { $root.Init(':memory:', @{}) } 'reopening a closed instance' $others = @(for ($i = 0; $i -lt 10; $i++) { New-LocalBase }) try { Assert-Throws { [void]$stale.add(@{ v = 'from-stale-child' }) } 'stale child write' foreach ($other in $others) { if ($other.collectionExists('c')) { throw 'Stale child wrote into an unrelated connection' } } } finally { foreach ($other in $others) { $other.close() } } } Check 'S2 untrusted schema names and stored code are contained' { param($db) [void]$db.query('CREATE TABLE "a]b" (key TEXT, doc TEXT);') [void]$db.query('CREATE TABLE "users]);" (x TEXT);') [void]$db.query('CREATE TABLE users (key TEXT PRIMARY KEY, doc TEXT);') $names = @($db.getCollections()) if ($names -contains 'a]b' -or $names -contains 'users]);' -or -not ($names -contains 'users')) { throw "Unexpected collections: $($names -join ',')" } [void]$db.query("CREATE TRIGGER t AFTER INSERT ON users BEGIN SELECT pslocalbase_like(NEW.doc, '*'); END;") Assert-Throws { [void]$db.collection('users').add(@{ v = 1 }) } 'stored trigger invoking the native matcher' Assert-Throws { [void]$db.collection("evil`n") } 'collection name with trailing newline' if ($db.collectionExists("evil`n")) { throw 'Newline-suffixed table was created' } } Check 'S3 oversized or deeply nested documents fail without leaking content' { param($db) $deep = ('{"a":' * 150) + '"apiKey-SECRET"' + ('}' * 150) [void]$db.query('CREATE TABLE c (key TEXT PRIMARY KEY, doc TEXT NOT NULL CHECK (json_valid(doc)));') [void]$db.query('INSERT INTO c (key, doc) VALUES (?, ?);', @('deep', $deep)) $db.collection('c').doc('ok').set(@{ v = 1 }) $message = '' try { [void]$db.collection('c').get() } catch { $message = $_.Exception.Message } if ($message -notmatch 'deep' -or $message -match 'SECRET') { throw "Unexpected error: $message" } if ($db.collection('c').doc('ok').get().v -ne 1) { throw 'A healthy document became unreadable' } $small = New-LocalBase -Path ':memory:' -Options @{ maxDocumentBytes = 64 } try { $small.collection('c').doc('big').set(@{ v = ('x' * 200) }) Assert-Throws { [void]$small.collection('c').doc('big').get() } 'maxDocumentBytes' } finally { $small.close() } $caseSensitive = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) $caseSensitive['a'] = 1 $caseSensitive['A'] = 2 Assert-Throws { $db.collection('c').doc('dup').set($caseSensitive) } 'case-duplicated property names' if ($db.collection('c').doc('dup').exists()) { throw 'Unreadable document was written' } Assert-Throws { $db.collection('c').doc('empty').set(@{ '' = 1 }) } 'empty property name' } Check 'S4 query() runs one statement and errors hide SQL unless debugging' { param($db) $db.collection('c').doc('a').set(@{ v = 1 }) Assert-Throws { [void]$db.query('SELECT 1; DROP TABLE c;') } 'second statement' if (-not $db.collectionExists('c')) { throw 'Trailing statement executed' } if (@($db.query('SELECT 1 AS one; -- trailing comment')).Count -ne 1) { throw 'Trailing comment rejected' } $message = '' try { [void]$db.query("SELECT 'S3cr3t!' FROM missing_table;") } catch { $message = $_.Exception.Message } if ($message -match 'S3cr3t') { throw 'SQL literal leaked into the error message' } $db.config['debug'] = $true try { $message = '' try { [void]$db.query("SELECT 'visible' FROM missing_table;") } catch { $message = $_.Exception.Message } if ($message -notmatch 'visible') { throw 'Debug mode should include the SQL text' } } finally { $db.config['debug'] = $false } } Check 'S5 deleteDatabase uses the real database path and removes the journal' { param($db) $path = New-TempPath 'del.db' $victim = Join-Path (Split-Path $path -Parent) 'victim.txt' try { Set-Content -Path $victim -Value 'keep' $file = New-LocalBase -Path $path -Options @{ wal = 'false' } if ($file.JournalMode -ne 'delete') { throw "wal='false' kept journal mode $($file.JournalMode)" } $file.collection('c').doc('a').set(@{ v = 1 }) Set-Content -Path "$path-journal" -Value 'stale' $file.FilePath = $victim $file.deleteDatabase() if (-not (Test-Path $victim)) { throw 'deleteDatabase followed a modified FilePath' } if ((Test-Path $path) -or (Test-Path "$path-journal")) { throw 'Database or journal file left behind' } } finally { Remove-TempPath $path } } Check 'S6 reads never create tables and options are validated strictly' { param($db) [void]$db.collection('ghost').doc('k').get() [void]$db.collection('ghost').doc('k').exists() if ($db.collectionExists('ghost')) { throw 'A read created a table' } Assert-Throws { New-LocalBase -Path ':memory:' -Options @{ depth = 150 } } 'depth above 100' Assert-Throws { New-LocalBase -Path ':memory:' -Options @{ wal = 'maybe' } } 'non-boolean wal' $strict = New-LocalBase -Path ':memory:' -Options @{ debug = 'no' } try { if ($strict.config['debug'] -ne $false) { throw 'debug string not converted' } } finally { $strict.close() } } Check 'S7 module import leaves no helper variables in the session' { param($db) foreach ($name in @('csharpCode', 'isWin')) { if (Get-Variable -Name $name -Scope Global -ErrorAction SilentlyContinue) { throw "Variable `$$name leaked into the global scope" } } } Write-Host "Hardening v2: passed=$script:passed; failed=$script:failed" if ($script:failed -gt 0) { exit 1 } |