From 54181dbdaa9ba158102094d0a9e38923ac1483e6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:00:12 +0200 Subject: [PATCH 1/7] Remove the API key credential machinery and make settings session-scoped Drop the Context vault, the stored contexts, the authenticated legal-source command, and the API key injection so the module targets Lovdata's open, key-free surface. Settings now live in memory for the session only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/classes/public/LovdataConfig.ps1 | 13 +- src/classes/public/LovdataContext.ps1 | 39 ---- src/classes/public/LovdataLegalSource.ps1 | 29 --- .../private/API/Invoke-LovdataAPI.ps1 | 50 ++--- .../Config/Initialize-LovdataConfig.ps1 | 79 ------- .../Context/Resolve-LovdataContext.ps1 | 70 ------ src/functions/public/Auth/Auth.md | 18 -- .../public/Auth/Connect-LovdataAccount.ps1 | 127 ----------- .../public/Auth/Disconnect-LovdataAccount.ps1 | 86 -------- .../public/Auth/Get-LovdataContext.ps1 | 90 -------- .../public/Auth/Switch-LovdataContext.ps1 | 67 ------ src/functions/public/Config/Config.md | 9 +- .../public/Config/Get-LovdataConfig.ps1 | 22 +- .../public/Config/Set-LovdataConfig.ps1 | 30 ++- .../LegalSources/Get-LovdataLegalSource.ps1 | 89 -------- .../public/LegalSources/LegalSources.md | 9 - src/variables/private/Lovdata.ps1 | 11 +- tests/Auth.Tests.ps1 | 199 ------------------ tests/LegalSources.Tests.ps1 | 92 -------- 19 files changed, 57 insertions(+), 1072 deletions(-) delete mode 100644 src/classes/public/LovdataContext.ps1 delete mode 100644 src/classes/public/LovdataLegalSource.ps1 delete mode 100644 src/functions/private/Config/Initialize-LovdataConfig.ps1 delete mode 100644 src/functions/private/Context/Resolve-LovdataContext.ps1 delete mode 100644 src/functions/public/Auth/Auth.md delete mode 100644 src/functions/public/Auth/Connect-LovdataAccount.ps1 delete mode 100644 src/functions/public/Auth/Disconnect-LovdataAccount.ps1 delete mode 100644 src/functions/public/Auth/Get-LovdataContext.ps1 delete mode 100644 src/functions/public/Auth/Switch-LovdataContext.ps1 delete mode 100644 src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 delete mode 100644 src/functions/public/LegalSources/LegalSources.md delete mode 100644 tests/Auth.Tests.ps1 delete mode 100644 tests/LegalSources.Tests.ps1 diff --git a/src/classes/public/LovdataConfig.ps1 b/src/classes/public/LovdataConfig.ps1 index fa864aa..de2d7b1 100644 --- a/src/classes/public/LovdataConfig.ps1 +++ b/src/classes/public/LovdataConfig.ps1 @@ -1,15 +1,8 @@ -# Module-wide settings for the Lovdata module, stored in a module-scoped context so they are shared -# by every user context in the vault. +# Module-wide settings for the Lovdata module, kept in memory for the current session. class LovdataConfig { - # The ID of the context that holds this configuration. - [string] $ID - - # The base URI new contexts connect to, for example 'https://api.lovdata.no'. + # The base URI the module sends requests to, for example 'https://api.lovdata.no'. [string] $ApiBaseUri - # The name of the context used by commands that are not given one explicitly. - [string] $DefaultContext - LovdataConfig() {} LovdataConfig([hashtable] $Properties) { @@ -28,6 +21,6 @@ class LovdataConfig { } [string] ToString() { - return $this.ID + return $this.ApiBaseUri } } diff --git a/src/classes/public/LovdataContext.ps1 b/src/classes/public/LovdataContext.ps1 deleted file mode 100644 index c1e7810..0000000 --- a/src/classes/public/LovdataContext.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -# A stored Lovdata connection: the API key plus everything needed to reach the API with it. -# The key is kept as a SecureString so it is never held in memory or written to disk in clear text. -class LovdataContext { - # The name the context is stored and selected by. - [string] $ID - - # The base URI this context connects to, for example 'https://api.lovdata.no'. - [string] $ApiBaseUri - - # The authentication scheme used against the API. Lovdata accepts an API key in the 'X-API-Key' header. - [string] $AuthType - - # The Lovdata API key. - [securestring] $ApiKey - - # When the API key was stored, so a stale context can be recognised. - [System.Nullable[datetime]] $ConnectedAt - - LovdataContext() {} - - LovdataContext([hashtable] $Properties) { - foreach ($name in $Properties.Keys) { - $this.$name = $Properties[$name] - } - } - - LovdataContext([pscustomobject] $Object) { - $known = [LovdataContext].GetProperties().Name - foreach ($property in $Object.PSObject.Properties) { - if ($known -contains $property.Name) { - $this.($property.Name) = $property.Value - } - } - } - - [string] ToString() { - return $this.ID - } -} diff --git a/src/classes/public/LovdataLegalSource.ps1 b/src/classes/public/LovdataLegalSource.ps1 deleted file mode 100644 index 1c2ad66..0000000 --- a/src/classes/public/LovdataLegalSource.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -# A legal source (base) in Lovdata's databases, such as Norwegian acts or central regulations. -class LovdataLegalSource { - # The identifier used by the API to address the source, for example 'lov'. - [string] $ID - - # The human readable name of the source, as Lovdata describes it. - [string] $Description - - LovdataLegalSource() {} - - LovdataLegalSource([hashtable] $Properties) { - foreach ($name in $Properties.Keys) { - $this.$name = $Properties[$name] - } - } - - LovdataLegalSource([pscustomobject] $Object) { - $known = [LovdataLegalSource].GetProperties().Name - foreach ($property in $Object.PSObject.Properties) { - if ($known -contains $property.Name) { - $this.($property.Name) = $property.Value - } - } - } - - [string] ToString() { - return $this.ID - } -} diff --git a/src/functions/private/API/Invoke-LovdataAPI.ps1 b/src/functions/private/API/Invoke-LovdataAPI.ps1 index 3454253..9de3089 100644 --- a/src/functions/private/API/Invoke-LovdataAPI.ps1 +++ b/src/functions/private/API/Invoke-LovdataAPI.ps1 @@ -1,24 +1,27 @@ function Invoke-LovdataAPI { <# .SYNOPSIS - Send an authenticated request to the Lovdata API. + Send a request to the Lovdata API. .DESCRIPTION - Owns every HTTP call the module makes. Builds the request URI from the context's base URI and the - endpoint, injects the API key as the 'X-API-Key' header Lovdata expects, and returns the response - body deserialized from JSON when the API sends JSON. Failures are translated into terminating - errors that carry the API's own problem description, with dedicated guidance for a rejected key - and for an exhausted rate limit. + Owns every JSON HTTP call the module makes. Builds the request URI from the module's configured + base URI and the endpoint, and returns the response body deserialized from JSON when the API + sends JSON, or the raw text otherwise. Failures are translated into terminating errors that carry + the API's own problem description, with dedicated guidance for an exhausted rate limit. + + The open Lovdata endpoints this release covers need no credential, so no authentication header is + sent. The service still rate limits unauthenticated callers, so the remaining budget it reports is + written to the verbose stream. .EXAMPLE - Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context + Invoke-LovdataAPI -Endpoint '/v1/publicData/list' - Sends an authenticated GET request and returns the deserialized response. + Sends a GET request and returns the deserialized response. .EXAMPLE - Invoke-LovdataAPI -Endpoint '/v1/search' -Query @{ q = 'arbeidsmiljo' } -Context $context + Invoke-LovdataAPI -Endpoint '/version' - Sends an authenticated GET request with a URL-encoded query string. + Sends a GET request to an endpoint that answers with plain text and returns it unchanged. .INPUTS None @@ -36,7 +39,7 @@ function Invoke-LovdataAPI { [OutputType([object])] [CmdletBinding()] param( - # The API endpoint to call, relative to the context's base URI, for example '/v1/legalSource/list'. + # The API endpoint to call, relative to the configured base URI, for example '/v1/publicData/list'. [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Endpoint, @@ -54,15 +57,11 @@ function Invoke-LovdataAPI { # The request payload, serialized as JSON. [Parameter()] [AllowNull()] - [object] $Body, - - # The resolved context holding the API key and base URI to use. - [Parameter(Mandatory)] - [ValidateNotNull()] - [LovdataContext] $Context + [object] $Body ) - $uri = '{0}/{1}' -f $Context.ApiBaseUri.TrimEnd('/'), $Endpoint.TrimStart('/') + $baseUri = (Get-LovdataConfig).ApiBaseUri + $uri = '{0}/{1}' -f $baseUri.TrimEnd('/'), $Endpoint.TrimStart('/') $queryString = @( $Query.GetEnumerator() | @@ -79,10 +78,7 @@ function Invoke-LovdataAPI { $params = @{ Method = $Method Uri = $uri - Headers = @{ - 'X-API-Key' = ConvertFrom-SecureString -SecureString $Context.ApiKey -AsPlainText - 'Accept' = 'application/json' - } + Headers = @{ 'Accept' = 'application/json' } SkipHttpErrorCheck = $true ErrorAction = 'Stop' } @@ -92,13 +88,13 @@ function Invoke-LovdataAPI { $params['Body'] = $Body | ConvertTo-Json -Depth 100 } - Write-Verbose "Sending [$Method] request to [$uri] using context [$($Context.ID)]." + Write-Verbose "Sending [$Method] request to [$uri]." $response = Invoke-WebRequest @params $statusCode = [int]$response.StatusCode $remaining = Get-LovdataResponseHeader -Headers $response.Headers -Name 'X-RateLimit-Remaining' if ($remaining) { - Write-Verbose "Lovdata rate limit remaining for this key: [$remaining]." + Write-Verbose "Lovdata rate limit remaining: [$remaining]." } $content = [string]$response.Content @@ -131,10 +127,6 @@ function Invoke-LovdataAPI { } $message = switch ($statusCode) { - 401 { - "The Lovdata API rejected the API key in context [$($Context.ID)] (401 Unauthorized). " + - "Confirm the key is current and that the Lovdata user holds the 'api' role, then reconnect with 'Connect-LovdataAccount'." - } 429 { $reset = Get-LovdataResponseHeader -Headers $response.Headers -Name 'X-RateLimit-Reset' $resetText = if ($null -ne ($reset -as [long])) { @@ -142,7 +134,7 @@ function Invoke-LovdataAPI { } else { '' } - "The Lovdata API rate limit for context [$($Context.ID)] is exhausted (429 Too Many Requests).$resetText" + "The Lovdata API rate limit is exhausted (429 Too Many Requests).$resetText" } default { "The Lovdata API request to [$uri] failed with status [$statusCode]." diff --git a/src/functions/private/Config/Initialize-LovdataConfig.ps1 b/src/functions/private/Config/Initialize-LovdataConfig.ps1 deleted file mode 100644 index 50068a6..0000000 --- a/src/functions/private/Config/Initialize-LovdataConfig.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Initialize-LovdataConfig { - <# - .SYNOPSIS - Load the Lovdata module configuration into memory. - - .DESCRIPTION - Reads the module-scoped configuration context from the Lovdata vault and caches it for the rest - of the session. The context is created from the module defaults the first time it is needed, and - settings that were added to the module after the context was stored are backfilled from those - defaults so an upgraded module never reads a half-populated configuration. - - .EXAMPLE - Initialize-LovdataConfig - - Loads the configuration, creating it from the module defaults when it does not exist yet. - - .EXAMPLE - Initialize-LovdataConfig -Force - - Discards the cached configuration and reads it from the vault again. - - .INPUTS - None - - .OUTPUTS - None - - .NOTES - The cached configuration lives in the module scope, so it is shared by every command in the session. - - .LINK - https://psmodule.io/Context/ - #> - [OutputType([void])] - [CmdletBinding()] - param( - # Reload the configuration from the vault instead of using the cached copy. - [Parameter()] - [switch] $Force - ) - - if (-not $Force -and $null -ne $script:Lovdata.Config) { - Write-Debug 'The Lovdata configuration is already loaded.' - return - } - - $vault = $script:Lovdata.ContextVault - $defaults = $script:Lovdata.DefaultConfig - $stored = Get-Context -ID $defaults.ID -Vault $vault - - if ($null -eq $stored) { - Write-Debug "Creating the Lovdata configuration context [$($defaults.ID)] in vault [$vault]." - $created = Set-Context -ID $defaults.ID -Context $defaults -Vault $vault -PassThru - $script:Lovdata.Config = [LovdataConfig]::new([pscustomobject]$created) - return - } - - $config = [LovdataConfig]::new([pscustomobject]$stored) - $config.ID = $defaults.ID - - # Settings introduced after the context was stored come back as $null. Fall back to the module - # defaults so the rest of the module never has to guard against a missing setting. - $backfilled = $false - foreach ($name in [LovdataConfig].GetProperties().Name) { - if ($null -eq $config.$name) { - $config.$name = $defaults.$name - $backfilled = $true - } - } - - if ($backfilled) { - Write-Debug 'Backfilling the stored Lovdata configuration with module defaults.' - $null = Set-Context -ID $config.ID -Context $config -Vault $vault - } - - $script:Lovdata.Config = $config -} diff --git a/src/functions/private/Context/Resolve-LovdataContext.ps1 b/src/functions/private/Context/Resolve-LovdataContext.ps1 deleted file mode 100644 index f5e651d..0000000 --- a/src/functions/private/Context/Resolve-LovdataContext.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function Resolve-LovdataContext { - <# - .SYNOPSIS - Turn a context reference into a usable Lovdata context. - - .DESCRIPTION - Accepts what a public command received on its Context parameter, which can be a context object, - a context name, or nothing at all, and returns the stored context it refers to. Nothing means the - default context. The result is checked for the API key and base URI the transport needs, so every - caller fails with the same actionable message instead of a raw HTTP error later on. - - .EXAMPLE - Resolve-LovdataContext -Context $null - - Returns the default context stored in the Lovdata vault. - - .EXAMPLE - Resolve-LovdataContext -Context 'production' - - Returns the context stored under the name 'production'. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - This helper never picks a context on its own beyond falling back to the configured default. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - #> - [OutputType([LovdataContext])] - [CmdletBinding()] - param( - # The context reference passed to the calling command. A LovdataContext, a context name, or $null. - [Parameter(Mandatory)] - [AllowNull()] - [object] $Context - ) - - $resolved = if ($Context -is [LovdataContext]) { - $Context - } elseif ($Context -is [string]) { - if ([string]::IsNullOrWhiteSpace($Context)) { - Get-LovdataContext - } else { - Get-LovdataContext -Context $Context - } - } elseif ($null -ne $Context) { - [LovdataContext]::new([pscustomobject]$Context) - } else { - Get-LovdataContext - } - - if ($null -eq $resolved) { - throw "No Lovdata context was found. Run 'Connect-LovdataAccount' to store an API key." - } - - if ($null -eq $resolved.ApiKey -or $resolved.ApiKey.Length -eq 0) { - throw "The Lovdata context [$($resolved.ID)] has no API key. Run 'Connect-LovdataAccount' to store one." - } - - if ([string]::IsNullOrWhiteSpace($resolved.ApiBaseUri)) { - throw "The Lovdata context [$($resolved.ID)] has no API base URI. Reconnect it with 'Connect-LovdataAccount'." - } - - $resolved -} diff --git a/src/functions/public/Auth/Auth.md b/src/functions/public/Auth/Auth.md deleted file mode 100644 index fdd28c7..0000000 --- a/src/functions/public/Auth/Auth.md +++ /dev/null @@ -1,18 +0,0 @@ -# Authentication - -Lovdata authenticates with an API key sent in the `X-API-Key` header. Keys are issued by Lovdata to -users holding the `api` role — contact [api@lovdata.no](mailto:api@lovdata.no) to request one. - -`Connect-LovdataAccount` stores a key in an encrypted context in the `PSModule.Lovdata` vault, handled -by the [Context](https://psmodule.io/Context/) module. Every other command reads the key from there, so -a key is entered once and never appears in a script. - -Several keys can be stored side by side under different names — one per account or environment. One of -them is the default: `Connect-LovdataAccount` makes the first stored context the default, and -`Switch-LovdataContext` moves it. Any single command can still target another connection through its own -`-Context` parameter. `Get-LovdataContext` shows what is stored and `Disconnect-LovdataAccount` removes it. - -Removing a context deletes the local copy of the key; it does not revoke the key with Lovdata. Revoke a -leaked key with Lovdata as well. - -Settings that are not tied to a single key are managed with the [Config](../Config/Config.md) commands. diff --git a/src/functions/public/Auth/Connect-LovdataAccount.ps1 b/src/functions/public/Auth/Connect-LovdataAccount.ps1 deleted file mode 100644 index d1d4b38..0000000 --- a/src/functions/public/Auth/Connect-LovdataAccount.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Connect-LovdataAccount { - <# - .SYNOPSIS - Store a Lovdata API key so later commands can use it. - - .DESCRIPTION - Saves a Lovdata API key in an encrypted context and remembers which API it belongs to. Every - command that talks to Lovdata picks the key up from there, so scripts never have to carry it. - Storing more than one context keeps several accounts available at the same time; the first one - stored becomes the default, and Default moves the default to another one. - - Lovdata issues API keys to users holding the `api` role. The key is sent as the `X-API-Key` - header on every request. - - .EXAMPLE - Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) - - Stores the key under the default context name. - - .EXAMPLE - Connect-LovdataAccount -ApiKey $key -Context 'production' -Default - - Stores the key as 'production' and makes it the context commands use by default. - - .EXAMPLE - Connect-LovdataAccount -ApiKey $key -ApiBaseUri $baseUri -PassThru - - Stores the key against an explicit API base URI, for a deployment other than the default one, - and returns the stored context. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - The key is encrypted at rest by the Context module and is never written to the output stream. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Disconnect-LovdataAccount/ - - .LINK - https://api.lovdata.no/swagger/index.html - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'A plain string key is accepted for convenience and converted before it is stored.' - )] - [OutputType([LovdataContext])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The Lovdata API key, as a SecureString or as a string. - [Parameter(Mandatory, Position = 0)] - [ValidateScript( - { - ($_ -is [securestring] -and $_.Length -gt 0) -or - ($_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_)) - }, - ErrorMessage = 'The API key must be a non-empty string or a non-empty SecureString.' - )] - [Alias('Key')] - [object] $ApiKey, - - # The name to store the connection under. - [Parameter()] - [ValidateNotNullOrEmpty()] - [Alias('Name')] - [string] $Context = 'default', - - # The base URI of the Lovdata API this key belongs to. Defaults to the module setting. - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] $ApiBaseUri, - - # Make this the context commands use by default, replacing any existing default. - [Parameter()] - [switch] $Default, - - # Return the stored context. - [Parameter()] - [switch] $PassThru - ) - - Initialize-LovdataConfig - - if ($Context -eq $script:Lovdata.Config.ID) { - throw "The context name [$($script:Lovdata.Config.ID)] is reserved for the Lovdata module settings. Choose another name." - } - - if (-not $PSBoundParameters.ContainsKey('ApiBaseUri')) { - $ApiBaseUri = $script:Lovdata.Config.ApiBaseUri - } - - if (-not $PSCmdlet.ShouldProcess("Lovdata context [$Context]", 'Store the API key')) { - return - } - - $secureApiKey = if ($ApiKey -is [securestring]) { - $ApiKey - } else { - ConvertTo-SecureString -String $ApiKey -AsPlainText -Force - } - - $contextObject = [LovdataContext]@{ - ID = $Context - ApiBaseUri = $ApiBaseUri - AuthType = 'APIKey' - ApiKey = $secureApiKey - ConnectedAt = Get-Date - } - - $null = Set-Context -ID $Context -Context $contextObject -Vault $script:Lovdata.ContextVault - - if ($Default -or [string]::IsNullOrWhiteSpace($script:Lovdata.Config.DefaultContext)) { - Set-LovdataConfig -Name DefaultContext -Value $Context -Confirm:$false - } - - if ($PassThru) { - Get-LovdataContext -Context $Context - } -} diff --git a/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 b/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 deleted file mode 100644 index 2ac356e..0000000 --- a/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Disconnect-LovdataAccount { - <# - .SYNOPSIS - Remove a stored Lovdata API key. - - .DESCRIPTION - Deletes a stored connection so the API key it holds is no longer on disk. Without arguments it - removes the context commands use by default. When the removed context was the default, the - default is cleared, so a later command asks for a new connection rather than silently using - another account. - - .EXAMPLE - Disconnect-LovdataAccount - - Removes the context commands use by default. - - .EXAMPLE - Disconnect-LovdataAccount -Context 'production' - - Removes the context stored under the name 'production'. - - .EXAMPLE - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount - - Removes every stored Lovdata connection. - - .INPUTS - LovdataContext - - .INPUTS - System.String - - .OUTPUTS - None - - .NOTES - Removing a context does not revoke the key with Lovdata. Revoke a leaked key with Lovdata as well. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Disconnect-LovdataAccount/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - #> - [OutputType([void])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The context to remove, as a name or a context object. Defaults to the context commands use. - [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [Alias('ID', 'Name')] - [object] $Context - ) - - begin { - Initialize-LovdataConfig - } - - process { - $name = if ($Context -is [LovdataContext]) { - $Context.ID - } elseif ($Context -is [string] -and -not [string]::IsNullOrWhiteSpace($Context)) { - $Context - } else { - $script:Lovdata.Config.DefaultContext - } - - if ([string]::IsNullOrWhiteSpace($name)) { - throw "No default Lovdata context is configured. Name the context to remove with -Context." - } - - if ($name -eq $script:Lovdata.Config.ID) { - throw "The context name [$($script:Lovdata.Config.ID)] is reserved for the Lovdata module settings and cannot be removed." - } - - if ($PSCmdlet.ShouldProcess("Lovdata context [$name]", 'Remove the stored API key')) { - Remove-Context -ID $name -Vault $script:Lovdata.ContextVault - - if ($script:Lovdata.Config.DefaultContext -eq $name) { - Write-Verbose "Clearing [$name] as the default Lovdata context." - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - } - } -} diff --git a/src/functions/public/Auth/Get-LovdataContext.ps1 b/src/functions/public/Auth/Get-LovdataContext.ps1 deleted file mode 100644 index efb1a5c..0000000 --- a/src/functions/public/Auth/Get-LovdataContext.ps1 +++ /dev/null @@ -1,90 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Get-LovdataContext { - <# - .SYNOPSIS - Get a stored Lovdata context. - - .DESCRIPTION - Returns the connections stored in the Lovdata vault. Without arguments it returns the context - commands use by default; with a name it returns that one; with ListAvailable it returns every - stored connection. The API key is part of the returned object but is held as a SecureString, so - it is never displayed. - - .EXAMPLE - Get-LovdataContext - - Returns the context commands use when none is given. - - .EXAMPLE - Get-LovdataContext -Context 'production' - - Returns the context stored under the name 'production'. - - .EXAMPLE - Get-LovdataContext -ListAvailable - - Returns every stored Lovdata connection. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - The module's own settings context is excluded, so only real connections are returned. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - #> - [OutputType([LovdataContext])] - [CmdletBinding(DefaultParameterSetName = 'As the default context')] - param( - # The name of the stored context to return. - [Parameter(Mandatory, Position = 0, ParameterSetName = 'By context name')] - [ValidateNotNullOrEmpty()] - [Alias('Name')] - [string] $Context, - - # Return every stored context instead of a single one. - [Parameter(Mandatory, ParameterSetName = 'As a list of every context')] - [switch] $ListAvailable - ) - - Initialize-LovdataConfig - $configID = $script:Lovdata.Config.ID - - $id = switch ($PSCmdlet.ParameterSetName) { - 'By context name' { - if ($Context -eq $configID) { - throw "The context name [$configID] is reserved for the Lovdata module settings." - } - $Context - } - 'As a list of every context' { - '*' - } - default { - if ([string]::IsNullOrWhiteSpace($script:Lovdata.Config.DefaultContext)) { - throw "No default Lovdata context is configured. Run 'Connect-LovdataAccount' to store an API key." - } - $script:Lovdata.Config.DefaultContext - } - } - - $contexts = @( - Get-Context -ID $id -Vault $script:Lovdata.ContextVault | - Where-Object { $_.ID -ne $configID } | - ForEach-Object { [LovdataContext]::new([pscustomobject]$_) } - ) - - if (-not $ListAvailable -and $contexts.Count -eq 0) { - throw "The Lovdata context [$id] was not found. Run 'Connect-LovdataAccount' to store it." - } - - $contexts | Sort-Object -Property ID -} diff --git a/src/functions/public/Auth/Switch-LovdataContext.ps1 b/src/functions/public/Auth/Switch-LovdataContext.ps1 deleted file mode 100644 index 24fe50d..0000000 --- a/src/functions/public/Auth/Switch-LovdataContext.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -function Switch-LovdataContext { - <# - .SYNOPSIS - Choose which stored Lovdata connection commands use by default. - - .DESCRIPTION - Points the module at another stored connection, so commands that are not given a context use it - from then on. The context has to exist; switching to a name that was never stored fails rather - than leaving the module pointing at nothing. - - .EXAMPLE - Switch-LovdataContext -Context 'production' - - Makes 'production' the connection commands use by default. - - .EXAMPLE - Switch-LovdataContext -Context 'test' -PassThru - - Switches to 'test' and returns the context that is now in use. - - .INPUTS - LovdataContext - - .INPUTS - System.String - - .OUTPUTS - LovdataContext - - .NOTES - A single command can still target another connection with its own Context parameter. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Switch-LovdataContext/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - #> - [OutputType([LovdataContext])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The context to use by default, as a name or a context object. - [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [ValidateNotNull()] - [Alias('ID', 'Name')] - [object] $Context, - - # Return the context that is now in use. - [Parameter()] - [switch] $PassThru - ) - - process { - $name = if ($Context -is [LovdataContext]) { $Context.ID } else { [string]$Context } - - # Resolving first means a typo fails here instead of on the next API call. - $target = Get-LovdataContext -Context $name - - if ($PSCmdlet.ShouldProcess("Lovdata context [$name]", 'Use as the default context')) { - Set-LovdataConfig -Name DefaultContext -Value $name -Confirm:$false - - if ($PassThru) { - $target - } - } - } -} diff --git a/src/functions/public/Config/Config.md b/src/functions/public/Config/Config.md index d89313d..2445495 100644 --- a/src/functions/public/Config/Config.md +++ b/src/functions/public/Config/Config.md @@ -1,8 +1,5 @@ # Configuration -Module-wide Lovdata settings: the API base URI new connections use, and the stored context that -commands fall back to when none is given. The settings live in their own context in the -`PSModule.Lovdata` vault, separate from any stored API key, so changing them never touches a -credential. - -Per-account credentials are managed with the [Auth](../Auth/Auth.md) commands. +Module-wide Lovdata settings for the current session, principally the API base URI every command sends +requests to. The settings are held in memory only and are never written to disk, because this module +stores no secret: everything the first release covers works with no account and no `API` key. diff --git a/src/functions/public/Config/Get-LovdataConfig.ps1 b/src/functions/public/Config/Get-LovdataConfig.ps1 index 0075179..a02608a 100644 --- a/src/functions/public/Config/Get-LovdataConfig.ps1 +++ b/src/functions/public/Config/Get-LovdataConfig.ps1 @@ -4,9 +4,10 @@ function Get-LovdataConfig { Get the module-wide Lovdata settings. .DESCRIPTION - Returns the settings that apply to the module as a whole rather than to a single stored API key: - the API base URI new connections use, and the name of the context commands fall back to when - none is given. The settings are created from the module defaults the first time they are read. + Returns the settings that apply to the module as a whole for the current session, principally the + API base URI every command sends requests to. The settings are held in memory only: they are + seeded from the module defaults the first time they are read and are not persisted, so a new + session starts from the defaults again. .EXAMPLE Get-LovdataConfig @@ -14,9 +15,9 @@ function Get-LovdataConfig { Returns the module-wide settings. .EXAMPLE - (Get-LovdataConfig).DefaultContext + (Get-LovdataConfig).ApiBaseUri - Returns the name of the context used by commands that are not given one. + Returns the API base URI the module sends requests to. .INPUTS None @@ -25,18 +26,23 @@ function Get-LovdataConfig { LovdataConfig .NOTES - Settings are stored in their own context and are never mixed with stored API keys. + The settings are session-scoped and never written to disk, because this module stores no secret. .LINK https://psmodule.io/Lovdata/Functions/Config/Get-LovdataConfig/ .LINK - https://psmodule.io/Context/ + https://psmodule.io/Lovdata/Functions/Config/Set-LovdataConfig/ #> [OutputType([LovdataConfig])] [CmdletBinding()] param() - Initialize-LovdataConfig + if ($null -eq $script:Lovdata.Config) { + $script:Lovdata.Config = [LovdataConfig]@{ + ApiBaseUri = $script:Lovdata.DefaultConfig.ApiBaseUri + } + } + $script:Lovdata.Config } diff --git a/src/functions/public/Config/Set-LovdataConfig.ps1 b/src/functions/public/Config/Set-LovdataConfig.ps1 index 40473eb..77bd3bd 100644 --- a/src/functions/public/Config/Set-LovdataConfig.ps1 +++ b/src/functions/public/Config/Set-LovdataConfig.ps1 @@ -1,24 +1,22 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - function Set-LovdataConfig { <# .SYNOPSIS Change a module-wide Lovdata setting. .DESCRIPTION - Updates one of the settings that apply to the module as a whole and stores it so it survives the - session. Use it to point the module at a different API base URI, or to choose which stored - context commands fall back to when none is given. + Updates one of the settings that apply to the module as a whole for the current session. Use it to + point the module at a different API base URI, for example a test deployment. The change lives in + memory only and is not persisted, so a new session starts from the module defaults again. .EXAMPLE - Set-LovdataConfig -Name ApiBaseUri -Value $baseUri + Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' - Points new connections at the given API base URI. + Points the module at the given API base URI. .EXAMPLE - Set-LovdataConfig -Name DefaultContext -Value 'production' -PassThru + Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.example.test' -PassThru - Makes 'production' the context commands use by default and returns the updated settings. + Points the module at the given API base URI and returns the updated settings. .INPUTS None @@ -27,8 +25,7 @@ function Set-LovdataConfig { LovdataConfig .NOTES - Changing ApiBaseUri does not move contexts that are already stored; those keep the base URI they - were connected with. + The change is session-scoped and never written to disk, because this module stores no secret. .LINK https://psmodule.io/Lovdata/Functions/Config/Set-LovdataConfig/ @@ -41,10 +38,10 @@ function Set-LovdataConfig { param( # The setting to change. [Parameter(Mandatory, Position = 0)] - [ValidateSet('ApiBaseUri', 'DefaultContext')] + [ValidateSet('ApiBaseUri')] [string] $Name, - # The new value of the setting. DefaultContext accepts an empty string to clear the default. + # The new value of the setting. [Parameter(Mandatory, Position = 1)] [AllowEmptyString()] [string] $Value, @@ -54,18 +51,17 @@ function Set-LovdataConfig { [switch] $PassThru ) - Initialize-LovdataConfig + $config = Get-LovdataConfig if ($Name -eq 'ApiBaseUri' -and [string]::IsNullOrWhiteSpace($Value)) { throw 'ApiBaseUri cannot be empty. Provide the base URI of the Lovdata API, for example https://api.lovdata.no.' } if ($PSCmdlet.ShouldProcess("Lovdata setting [$Name]", "Set to [$Value]")) { - $script:Lovdata.Config.$Name = $Value - $null = Set-Context -ID $script:Lovdata.Config.ID -Context $script:Lovdata.Config -Vault $script:Lovdata.ContextVault + $config.$Name = $Value if ($PassThru) { - $script:Lovdata.Config + $config } } } diff --git a/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 b/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 deleted file mode 100644 index 3f10673..0000000 --- a/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -function Get-LovdataLegalSource { - <# - .SYNOPSIS - Get the legal sources available in Lovdata. - - .DESCRIPTION - Returns the legal sources that the connected account can reach. A legal source is one of the bases - Lovdata organises its documents into, such as acts and central regulations. Each source has the - identifier other commands use to address it and the description Lovdata gives it. Note that - Lovdata does not make every source in its databases available through the API. - - .EXAMPLE - Get-LovdataLegalSource - - Returns every legal source the connected account can reach. - - .EXAMPLE - Get-LovdataLegalSource -ID 'lov*' - - Returns the legal sources whose identifier starts with 'lov'. - - .EXAMPLE - Get-LovdataLegalSource -Context 'production' - - Returns the legal sources reachable with the API key stored as 'production'. - - .INPUTS - None - - .OUTPUTS - LovdataLegalSource - - .NOTES - Which sources are returned depends on the account, so the result can differ between contexts. - - .LINK - https://psmodule.io/Lovdata/Functions/LegalSources/Get-LovdataLegalSource/ - - .LINK - https://api.lovdata.no/swagger/index.html - #> - [OutputType([LovdataLegalSource])] - [CmdletBinding()] - param( - # The identifier of the legal sources to return. Supports wildcards. - [Parameter(Position = 0)] - [SupportsWildcards()] - [ValidateNotNullOrEmpty()] - [string] $ID = '*', - - # The connection to use, as a name or a context object. Defaults to the context commands use. - [Parameter()] - [object] $Context - ) - - $resolvedContext = Resolve-LovdataContext -Context $Context - $response = Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Method Get -Context $resolvedContext - - # The API documents the entries as free-form objects, so accept the field names it is known to use. - $sources = foreach ($item in @($response)) { - if ($null -eq $item) { - continue - } - - $properties = $item.PSObject.Properties - $sourceID = '' - foreach ($name in 'id', 'base', 'name') { - if ($properties[$name]) { - $sourceID = [string]$properties[$name].Value - break - } - } - - $description = '' - foreach ($name in 'description', 'title') { - if ($properties[$name]) { - $description = [string]$properties[$name].Value - break - } - } - - [LovdataLegalSource]@{ - ID = $sourceID - Description = $description - } - } - - $sources | Where-Object { $_.ID -like $ID } | Sort-Object -Property ID -} diff --git a/src/functions/public/LegalSources/LegalSources.md b/src/functions/public/LegalSources/LegalSources.md deleted file mode 100644 index a42a15a..0000000 --- a/src/functions/public/LegalSources/LegalSources.md +++ /dev/null @@ -1,9 +0,0 @@ -# Legal sources - -Lovdata organises its documents into legal sources, also called bases: Norwegian acts (`lover`), -central regulations (`sentrale forskrifter`), and the other collections the foundation maintains. A -source identifier is what other commands use to address a collection, so listing the sources is -usually the first call in a script. - -Which sources come back depends on the account behind the API key — Lovdata does not expose every -source in its databases through the API, and access differs between accounts. diff --git a/src/variables/private/Lovdata.ps1 b/src/variables/private/Lovdata.ps1 index cfb71aa..0a2d131 100644 --- a/src/variables/private/Lovdata.ps1 +++ b/src/variables/private/Lovdata.ps1 @@ -1,14 +1,9 @@ $script:Lovdata = [pscustomobject]@{ - # The Context vault every Lovdata context is stored in. - ContextVault = 'PSModule.Lovdata' - - # The module-scoped settings used until the user changes them. + # The module-scoped settings used until the user changes them in this session. DefaultConfig = [LovdataConfig]@{ - ID = 'Module' - ApiBaseUri = 'https://api.lovdata.no' - DefaultContext = '' + ApiBaseUri = 'https://api.lovdata.no' } - # The configuration loaded from the vault, cached for the lifetime of the session. + # The in-memory settings for the current session. Seeded from DefaultConfig on first use. Config = $null } diff --git a/tests/Auth.Tests.ps1 b/tests/Auth.Tests.ps1 deleted file mode 100644 index 7ff92ac..0000000 --- a/tests/Auth.Tests.ps1 +++ /dev/null @@ -1,199 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'The fixed test key never leaves the throwaway test vault.' -)] -[CmdletBinding()] -param() - -Describe 'Auth' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue - } - - AfterEach { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - - Context 'Connect-LovdataAccount' { - It 'stores the API key and makes the first connection the default' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'first' -PassThru -Confirm:$false - - $context | Should -BeOfType [LovdataContext] - $context.ID | Should -Be 'first' - $context.AuthType | Should -Be 'APIKey' - $context.ApiBaseUri | Should -Be 'https://api.lovdata.no' - (Get-LovdataConfig).DefaultContext | Should -Be 'first' - } - - It 'keeps the API key as a secure string' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'secure' -PassThru -Confirm:$false - - $context.ApiKey | Should -BeOfType [securestring] - ($context | Out-String) | Should -Not -Match 'test-key' - } - - It 'round-trips a secure string key through the store' { - $secureKey = ConvertTo-SecureString -String 'secure-key' -AsPlainText -Force - - $context = Connect-LovdataAccount -ApiKey $secureKey -Context 'roundtrip' -PassThru -Confirm:$false - - ConvertFrom-SecureString -SecureString $context.ApiKey -AsPlainText | Should -Be 'secure-key' - } - - It 'leaves an existing default alone unless Default is used' { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'first' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'second' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'first' - - Connect-LovdataAccount -ApiKey 'test-key' -Context 'second' -Default -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'second' - } - - It 'stores the API base URI it was given' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'custom' -ApiBaseUri 'https://api.example.test' -PassThru -Confirm:$false - - $context.ApiBaseUri | Should -Be 'https://api.example.test' - } - - It 'stores nothing when WhatIf is used' { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'whatif' -WhatIf - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 0 - } - - It 'refuses the name reserved for the module settings' { - { Connect-LovdataAccount -ApiKey 'test-key' -Context 'Module' -Confirm:$false } | - Should -Throw '*reserved for the Lovdata module settings*' - } - - It 'rejects an empty API key' { - { Connect-LovdataAccount -ApiKey ' ' -Context 'empty' -Confirm:$false } | Should -Throw - { Connect-LovdataAccount -ApiKey ([securestring]::new()) -Context 'empty' -Confirm:$false } | Should -Throw - } - } - - Context 'Get-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'returns the default connection when none is named' { - (Get-LovdataContext).ID | Should -Be 'alpha' - } - - It 'returns a connection by name' { - (Get-LovdataContext -Context 'beta').ID | Should -Be 'beta' - } - - It 'lists every connection without the module settings' { - $contexts = @(Get-LovdataContext -ListAvailable) - - $contexts.ID | Should -Be @('alpha', 'beta') - $contexts.ID | Should -Not -Contain 'Module' - } - - It 'reports a name that was never stored' { - { Get-LovdataContext -Context 'missing' } | Should -Throw '*was not found*' - } - - It 'refuses to return the module settings as a connection' { - { Get-LovdataContext -Context 'Module' } | Should -Throw '*reserved for the Lovdata module settings*' - } - - It 'asks for a connection when no default is configured' { - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - - { Get-LovdataContext } | Should -Throw '*No default Lovdata context is configured*' - } - } - - Context 'Switch-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'changes which connection commands use by default' { - Switch-LovdataContext -Context 'beta' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'beta' - (Get-LovdataContext).ID | Should -Be 'beta' - } - - It 'returns the connection now in use when PassThru is used' { - $context = Switch-LovdataContext -Context 'beta' -PassThru -Confirm:$false - - $context | Should -BeOfType [LovdataContext] - $context.ID | Should -Be 'beta' - } - - It 'refuses a connection that was never stored' { - { Switch-LovdataContext -Context 'missing' -Confirm:$false } | Should -Throw '*was not found*' - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - - It 'changes nothing when WhatIf is used' { - Switch-LovdataContext -Context 'beta' -WhatIf - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - } - - Context 'Disconnect-LovdataAccount' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'removes a named connection and leaves the others' { - Disconnect-LovdataAccount -Context 'beta' -Confirm:$false - - @(Get-LovdataContext -ListAvailable).ID | Should -Be @('alpha') - } - - It 'removes the default connection and clears the default' { - Disconnect-LovdataAccount -Confirm:$false - - @(Get-LovdataContext -ListAvailable).ID | Should -Be @('beta') - (Get-LovdataConfig).DefaultContext | Should -BeNullOrEmpty - } - - It 'keeps the default when another connection is removed' { - Disconnect-LovdataAccount -Context 'beta' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - - It 'removes every connection it is piped' { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 0 - } - - It 'removes nothing when WhatIf is used' { - Disconnect-LovdataAccount -Context 'beta' -WhatIf - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 2 - } - - It 'refuses to remove the module settings' { - { Disconnect-LovdataAccount -Context 'Module' -Confirm:$false } | - Should -Throw '*reserved for the Lovdata module settings*' - } - } -} diff --git a/tests/LegalSources.Tests.ps1 b/tests/LegalSources.Tests.ps1 deleted file mode 100644 index f3cd507..0000000 --- a/tests/LegalSources.Tests.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Parameters are read by Pester mock parameter filters.' -)] -[CmdletBinding()] -param() - -Describe 'LegalSources' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - Connect-LovdataAccount -ApiKey 'test-key' -Context 'test' -Confirm:$false - } - - AfterAll { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue - } - - Context 'Get-LovdataLegalSource' { - BeforeEach { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { - @( - [pscustomobject]@{ id = 'sf'; description = 'Sentrale forskrifter' } - [pscustomobject]@{ id = 'lov'; description = 'Lover' } - [pscustomobject]@{ id = 'ltavd1'; description = 'Lovtidend avdeling I' } - ) - } - } - - It 'asks the API for the legal source list' { - Get-LovdataLegalSource - - Should -Invoke -ModuleName Lovdata -CommandName Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { - $Endpoint -eq '/v1/legalSource/list' -and $Method -eq 'Get' - } - } - - It 'returns every source as a typed, sorted object' { - $sources = @(Get-LovdataLegalSource) - - $sources | Should -HaveCount 3 - $sources[0] | Should -BeOfType [LovdataLegalSource] - $sources.ID | Should -Be @('lov', 'ltavd1', 'sf') - ($sources | Where-Object ID -EQ 'lov').Description | Should -Be 'Lover' - } - - It 'filters the sources by identifier with wildcards' { - @(Get-LovdataLegalSource -ID 'lo*').ID | Should -Be @('lov') - @(Get-LovdataLegalSource -ID 'l*').ID | Should -Be @('lov', 'ltavd1') - @(Get-LovdataLegalSource -ID 'sf').ID | Should -Be @('sf') - } - - It 'returns nothing when no source matches' { - @(Get-LovdataLegalSource -ID 'nothing-matches-this') | Should -HaveCount 0 - } - - It 'uses the connection it is given' { - Connect-LovdataAccount -ApiKey 'other-key' -Context 'other' -Confirm:$false - - Get-LovdataLegalSource -Context 'other' - - Should -Invoke -ModuleName Lovdata -CommandName Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { - $Context.ID -eq 'other' - } - - Disconnect-LovdataAccount -Context 'other' -Confirm:$false - } - - It 'accepts the field names the API is known to use' { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { - @([pscustomobject]@{ base = 'nl'; title = 'Norsk Lovtidend' }) - } - - $source = Get-LovdataLegalSource - - $source.ID | Should -Be 'nl' - $source.Description | Should -Be 'Norsk Lovtidend' - } - - It 'returns nothing when the API returns nothing' { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { $null } - - @(Get-LovdataLegalSource) | Should -HaveCount 0 - } - } -} From 93e7cd99af840d300e9de0bf0827552714a413b4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:02:43 +0200 Subject: [PATCH 2/7] Add the key-free public data and service commands Add Get-LovdataPublicDataset and Save-LovdataPublicDataset for Lovdata's free open data packages, and Test-LovdataConnection and Get-LovdataApiVersion for the open service endpoints. Introduce the LovdataPublicDataset and LovdataApiVersion classes and a streaming download helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/classes/public/LovdataApiVersion.ps1 | 32 +++++++ src/classes/public/LovdataPublicDataset.ps1 | 35 ++++++++ .../private/API/Invoke-LovdataDownload.ps1 | 55 ++++++++++++ .../PublicData/Get-LovdataPublicDataset.ps1 | 66 +++++++++++++++ src/functions/public/PublicData/PublicData.md | 12 +++ .../PublicData/Save-LovdataPublicDataset.ps1 | 83 +++++++++++++++++++ .../public/Service/Get-LovdataApiVersion.ps1 | 47 +++++++++++ src/functions/public/Service/Service.md | 5 ++ .../public/Service/Test-LovdataConnection.ps1 | 49 +++++++++++ 9 files changed, 384 insertions(+) create mode 100644 src/classes/public/LovdataApiVersion.ps1 create mode 100644 src/classes/public/LovdataPublicDataset.ps1 create mode 100644 src/functions/private/API/Invoke-LovdataDownload.ps1 create mode 100644 src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 create mode 100644 src/functions/public/PublicData/PublicData.md create mode 100644 src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 create mode 100644 src/functions/public/Service/Get-LovdataApiVersion.ps1 create mode 100644 src/functions/public/Service/Service.md create mode 100644 src/functions/public/Service/Test-LovdataConnection.ps1 diff --git a/src/classes/public/LovdataApiVersion.ps1 b/src/classes/public/LovdataApiVersion.ps1 new file mode 100644 index 0000000..ed60fc4 --- /dev/null +++ b/src/classes/public/LovdataApiVersion.ps1 @@ -0,0 +1,32 @@ +# The version of the deployed Lovdata API service, as reported by the '/version' endpoint. +class LovdataApiVersion { + # The name of the deployment, for example 'lovdata-api'. + [string] $Name + + # The build timestamp of the deployment, for example '2026-07-31-1613'. + [string] $Timestamp + + # The source revision the deployment was built from. + [string] $Revision + + LovdataApiVersion() {} + + LovdataApiVersion([hashtable] $Properties) { + foreach ($name in $Properties.Keys) { + $this.$name = $Properties[$name] + } + } + + LovdataApiVersion([pscustomobject] $Object) { + $known = [LovdataApiVersion].GetProperties().Name + foreach ($property in $Object.PSObject.Properties) { + if ($known -contains $property.Name) { + $this.($property.Name) = $property.Value + } + } + } + + [string] ToString() { + return '{0} {1}' -f $this.Name, $this.Timestamp + } +} diff --git a/src/classes/public/LovdataPublicDataset.ps1 b/src/classes/public/LovdataPublicDataset.ps1 new file mode 100644 index 0000000..3307fdb --- /dev/null +++ b/src/classes/public/LovdataPublicDataset.ps1 @@ -0,0 +1,35 @@ +# A public data package Lovdata publishes as free open data, such as the current acts or regulations. +class LovdataPublicDataset { + # The name of the package file, for example 'gjeldende-lover.tar.bz2'. + [string] $FileName + + # The description Lovdata gives the package. + [string] $Description + + # The size of the package in bytes. + [long] $SizeBytes + + # When the package was last updated. + [datetime] $LastModified + + LovdataPublicDataset() {} + + LovdataPublicDataset([hashtable] $Properties) { + foreach ($name in $Properties.Keys) { + $this.$name = $Properties[$name] + } + } + + LovdataPublicDataset([pscustomobject] $Object) { + $known = [LovdataPublicDataset].GetProperties().Name + foreach ($property in $Object.PSObject.Properties) { + if ($known -contains $property.Name) { + $this.($property.Name) = $property.Value + } + } + } + + [string] ToString() { + return $this.FileName + } +} diff --git a/src/functions/private/API/Invoke-LovdataDownload.ps1 b/src/functions/private/API/Invoke-LovdataDownload.ps1 new file mode 100644 index 0000000..40fb60a --- /dev/null +++ b/src/functions/private/API/Invoke-LovdataDownload.ps1 @@ -0,0 +1,55 @@ +function Invoke-LovdataDownload { + <# + .SYNOPSIS + Stream a Lovdata download endpoint to a file on disk. + + .DESCRIPTION + Owns the binary download path the module uses, kept separate from the JSON transport in + Invoke-LovdataAPI because it streams the response straight to disk rather than buffering it in + memory. The Lovdata public data packages are tens of megabytes, so the body is written with + Invoke-WebRequest -OutFile and the transfer is reported on the progress stream. The endpoint is + open, so no authentication header is sent. + + .EXAMPLE + Invoke-LovdataDownload -Endpoint '/v1/publicData/get/gjeldende-lover.tar.bz2' -OutFile 'C:\data\gjeldende-lover.tar.bz2' + + Streams the package to the given file. + + .INPUTS + None + + .OUTPUTS + None + + .NOTES + Invoke-WebRequest raises a terminating error on an HTTP failure, which the caller is expected to surface. + + .LINK + https://api.lovdata.no/swagger/index.html + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The download endpoint to call, relative to the configured base URI. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Endpoint, + + # The full path of the file to stream the response into. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $OutFile + ) + + $baseUri = (Get-LovdataConfig).ApiBaseUri + $uri = '{0}/{1}' -f $baseUri.TrimEnd('/'), $Endpoint.TrimStart('/') + + $activity = "Downloading [$([System.IO.Path]::GetFileName($OutFile))] from Lovdata" + Write-Progress -Activity $activity -Status 'Transferring' + try { + Write-Verbose "Streaming [$uri] to [$OutFile]." + Invoke-WebRequest -Uri $uri -OutFile $OutFile -ErrorAction Stop + } finally { + Write-Progress -Activity $activity -Completed + } +} diff --git a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 new file mode 100644 index 0000000..3a89461 --- /dev/null +++ b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 @@ -0,0 +1,66 @@ +function Get-LovdataPublicDataset { + <# + .SYNOPSIS + Get the free open data packages Lovdata publishes. + + .DESCRIPTION + Returns the public data packages Lovdata publishes as free open data under the Norwegian Licence + for Open Government Data (NLOD) 2.0, such as the current acts and central regulations. Each entry + carries the filename Save-LovdataPublicDataset downloads, plus the size and last-modified date so + a caller can decide what to fetch. No account and no API key are needed. + + Content retrieved through this command remains subject to Lovdata's terms; credit Lovdata as the + source when redistributing it. + + .EXAMPLE + Get-LovdataPublicDataset + + Returns every public data package Lovdata publishes. + + .EXAMPLE + Get-LovdataPublicDataset -FileName 'gjeldende-*' + + Returns the packages whose filename starts with 'gjeldende-'. + + .INPUTS + None + + .OUTPUTS + LovdataPublicDataset + + .NOTES + The packages are published under NLOD 2.0. See https://data.norge.no/nlod/en/2.0 for the licence. + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Get-LovdataPublicDataset/ + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Save-LovdataPublicDataset/ + #> + [OutputType([LovdataPublicDataset])] + [CmdletBinding()] + param( + # The filename of the packages to return. Supports wildcards. + [Parameter(Position = 0)] + [SupportsWildcards()] + [ValidateNotNullOrEmpty()] + [string] $FileName = '*' + ) + + $response = Invoke-LovdataAPI -Endpoint '/v1/publicData/list' + + $datasets = foreach ($item in @($response)) { + if ($null -eq $item) { + continue + } + + [LovdataPublicDataset]@{ + FileName = [string]$item.filename + Description = [string]$item.description + SizeBytes = [long]$item.sizeBytes + LastModified = [datetime]$item.lastModified + } + } + + $datasets | Where-Object { $_.FileName -like $FileName } | Sort-Object -Property FileName +} diff --git a/src/functions/public/PublicData/PublicData.md b/src/functions/public/PublicData/PublicData.md new file mode 100644 index 0000000..11ca33a --- /dev/null +++ b/src/functions/public/PublicData/PublicData.md @@ -0,0 +1,12 @@ +# Public data + +Lovdata publishes the current Norwegian acts (`gjeldende-lover.tar.bz2`) and central regulations +(`gjeldende-sentrale-forskrifter.tar.bz2`) as free open data. These commands list the packages and +download one to disk, with no account and no `API` key. + +## Licence and attribution + +The packages are published under the +[Norwegian Licence for Open Government Data (NLOD) 2.0](https://data.norge.no/nlod/en/2.0). Content +retrieved through these commands remains subject to Lovdata's terms; credit Lovdata as the source when +you redistribute it. This module is not affiliated with or endorsed by Lovdata. diff --git a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 new file mode 100644 index 0000000..7639f92 --- /dev/null +++ b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 @@ -0,0 +1,83 @@ +function Save-LovdataPublicDataset { + <# + .SYNOPSIS + Download a Lovdata public data package to disk. + + .DESCRIPTION + Downloads one of the free open data packages Lovdata publishes to a directory on disk. The + package is streamed straight to the file and the transfer is reported on the progress stream, + because the packages are large. No account and no API key are needed. An existing file is not + overwritten unless Force is given. The resulting file is returned so it can be piped on, for + example to an extraction step. + + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. + Content retrieved through this command remains subject to Lovdata's terms; credit Lovdata as the + source when redistributing it. + + .EXAMPLE + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' + + Downloads the current acts package into the current directory. + + .EXAMPLE + Get-LovdataPublicDataset -FileName 'gjeldende-*' | Save-LovdataPublicDataset -Path 'C:\lovdata' -Force + + Downloads every matching package into the given directory, overwriting any existing files. + + .INPUTS + LovdataPublicDataset + + .OUTPUTS + System.IO.FileInfo + + .NOTES + The packages are published under NLOD 2.0. See https://data.norge.no/nlod/en/2.0 for the licence. + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Save-LovdataPublicDataset/ + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Get-LovdataPublicDataset/ + #> + [OutputType([System.IO.FileInfo])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The filename of the package to download, as reported by Get-LovdataPublicDataset. + [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [string] $FileName, + + # The directory to download the package into. Defaults to the current directory. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Path = '.', + + # Overwrite an existing file instead of refusing. + [Parameter()] + [switch] $Force + ) + + process { + $directory = Resolve-Path -Path $Path -ErrorAction SilentlyContinue + if ($null -eq $directory) { + throw "The download directory [$Path] does not exist. Create it first, or pass an existing directory to -Path." + } + if (-not (Test-Path -Path $directory -PathType Container)) { + throw "The download path [$Path] is not a directory. Pass a directory to -Path." + } + + $target = Join-Path -Path $directory -ChildPath $FileName + + if ((Test-Path -Path $target -PathType Leaf) -and -not $Force) { + throw "A file already exists at [$target]. Use -Force to overwrite it." + } + + if (-not $PSCmdlet.ShouldProcess($target, "Download Lovdata package [$FileName]")) { + return + } + + Invoke-LovdataDownload -Endpoint "/v1/publicData/get/$FileName" -OutFile $target + + Get-Item -Path $target + } +} diff --git a/src/functions/public/Service/Get-LovdataApiVersion.ps1 b/src/functions/public/Service/Get-LovdataApiVersion.ps1 new file mode 100644 index 0000000..7c5e1aa --- /dev/null +++ b/src/functions/public/Service/Get-LovdataApiVersion.ps1 @@ -0,0 +1,47 @@ +function Get-LovdataApiVersion { + <# + .SYNOPSIS + Get the version of the deployed Lovdata API service. + + .DESCRIPTION + Returns the deployment name, build timestamp, and source revision the Lovdata API reports. The + endpoint is open, so no account and no API key are needed. Use it to record which build a script + ran against, or as a first-run diagnostic alongside Test-LovdataConnection. + + .EXAMPLE + Get-LovdataApiVersion + + Returns the deployed API version. + + .EXAMPLE + (Get-LovdataApiVersion).Revision + + Returns the source revision the deployed API was built from. + + .INPUTS + None + + .OUTPUTS + LovdataApiVersion + + .NOTES + The build timestamp is returned as Lovdata reports it, for example '2026-07-31-1613'. + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Get-LovdataApiVersion/ + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Test-LovdataConnection/ + #> + [OutputType([LovdataApiVersion])] + [CmdletBinding()] + param() + + $response = Invoke-LovdataAPI -Endpoint '/version' + + [LovdataApiVersion]@{ + Name = [string]$response.name + Timestamp = [string]$response.timestamp + Revision = [string]$response.revision + } +} diff --git a/src/functions/public/Service/Service.md b/src/functions/public/Service/Service.md new file mode 100644 index 0000000..a669c9d --- /dev/null +++ b/src/functions/public/Service/Service.md @@ -0,0 +1,5 @@ +# Service + +Operational checks that answer two questions before a script starts pulling data: is the Lovdata API +reachable, and which build is deployed. Both endpoints are open, so these commands work with no account +and no `API` key, which makes them usable as a first-run diagnostic. diff --git a/src/functions/public/Service/Test-LovdataConnection.ps1 b/src/functions/public/Service/Test-LovdataConnection.ps1 new file mode 100644 index 0000000..0321844 --- /dev/null +++ b/src/functions/public/Service/Test-LovdataConnection.ps1 @@ -0,0 +1,49 @@ +function Test-LovdataConnection { + <# + .SYNOPSIS + Test whether the Lovdata API is reachable. + + .DESCRIPTION + Sends a request to the Lovdata service ping endpoint and returns whether it answered. The endpoint + is open, so no account and no API key are needed. The command never throws when the service is + unreachable; it writes a warning and returns false, so it is safe to use directly in a + conditional as a first-run diagnostic. + + .EXAMPLE + Test-LovdataConnection + + Returns $true when the Lovdata API answered, or $false when it did not. + + .EXAMPLE + if (Test-LovdataConnection) { Get-LovdataPublicDataset } + + Only lists the public data packages when the service is reachable. + + .INPUTS + None + + .OUTPUTS + System.Boolean + + .NOTES + A failure to reach the service is reported as $false with a warning rather than a terminating error. + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Test-LovdataConnection/ + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Get-LovdataApiVersion/ + #> + [OutputType([bool])] + [CmdletBinding()] + param() + + try { + $null = Invoke-LovdataAPI -Endpoint '/ping' + $true + } catch { + $reason = $_.Exception.Message + Write-Warning "The Lovdata API could not be reached: $reason" + $false + } +} From 7d7d1b0f383ece6cfaa10c869707ca50c8df3d19 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:07:29 +0200 Subject: [PATCH 3/7] Rewrite the test suite for the key-free surface Cover Config, PublicData, and Service commands plus the internal transport and download helpers with mocks at the module boundary, so the suite runs offline with no network and no key. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Config.Tests.ps1 | 23 ++-- tests/Lovdata.Internals.Tests.ps1 | 188 +++++++----------------------- tests/Lovdata.TestSetup.ps1 | 19 +-- tests/PublicData.Tests.ps1 | 121 +++++++++++++++++++ tests/Service.Tests.ps1 | 48 ++++++++ 5 files changed, 222 insertions(+), 177 deletions(-) create mode 100644 tests/PublicData.Tests.ps1 create mode 100644 tests/Service.Tests.ps1 diff --git a/tests/Config.Tests.ps1 b/tests/Config.Tests.ps1 index 62d230a..ac852c4 100644 --- a/tests/Config.Tests.ps1 +++ b/tests/Config.Tests.ps1 @@ -8,12 +8,8 @@ param() Describe 'Config' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') } Context 'Get-LovdataConfig' { @@ -21,26 +17,21 @@ Describe 'Config' { $config = Get-LovdataConfig $config | Should -BeOfType [LovdataConfig] - $config.ID | Should -Be 'Module' $config.ApiBaseUri | Should -Be 'https://api.lovdata.no' - $config.DefaultContext | Should -BeNullOrEmpty } It 'returns the same settings on a later read' { - (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.lovdata.no' + $first = Get-LovdataConfig + $first.ApiBaseUri = 'https://api.example.test' + + (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.example.test' } } Context 'Set-LovdataConfig' { - AfterEach { - Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' -Confirm:$false - } - - It 'stores a changed setting so it survives a reload' { + It 'changes the API base URI for the session' { Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.example.test' -Confirm:$false - InModuleScope -ModuleName Lovdata { Initialize-LovdataConfig -Force } - (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.example.test' } diff --git a/tests/Lovdata.Internals.Tests.ps1 b/tests/Lovdata.Internals.Tests.ps1 index 0c65d3a..6bedc11 100644 --- a/tests/Lovdata.Internals.Tests.ps1 +++ b/tests/Lovdata.Internals.Tests.ps1 @@ -8,68 +8,55 @@ 'PSReviewUnusedParameter', '', Justification = 'Parameters are read by Pester mock parameter filters.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'The fixed test key never leaves the mocked request boundary.' -)] [CmdletBinding()] param() Describe 'Lovdata internals' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + InModuleScope -ModuleName Lovdata { + $script:Lovdata.Config = [LovdataConfig]@{ ApiBaseUri = 'https://api.example.test' } + } } Context 'Types' { It 'exports the module types as type accelerators' { [LovdataConfig] | Should -Not -BeNullOrEmpty - [LovdataContext] | Should -Not -BeNullOrEmpty - [LovdataLegalSource] | Should -Not -BeNullOrEmpty + [LovdataPublicDataset] | Should -Not -BeNullOrEmpty + [LovdataApiVersion] | Should -Not -BeNullOrEmpty } - It 'builds a context from an object and ignores fields it does not know' { - $context = [LovdataContext]::new([pscustomobject]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - AuthType = 'APIKey' + It 'builds a dataset from an object and ignores fields it does not know' { + $dataset = [LovdataPublicDataset]::new([pscustomobject]@{ + FileName = 'gjeldende-lover.tar.bz2' + SizeBytes = 10 Unsupported = 'ignored' }) - $context.ID | Should -Be 'demo' - $context.ApiBaseUri | Should -Be 'https://api.example.test' - "$context" | Should -Be 'demo' + $dataset.FileName | Should -Be 'gjeldende-lover.tar.bz2' + $dataset.SizeBytes | Should -Be 10 + "$dataset" | Should -Be 'gjeldende-lover.tar.bz2' } } Context 'Invoke-LovdataAPI' { - It 'sends the API key as the X-API-Key header and deserializes the response' { + It 'builds the request URI from the configured base URI and deserializes the response' { InModuleScope -ModuleName Lovdata { Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200 - Content = '[{"id":"lov","description":"Lover"}]' + Content = '[{"filename":"gjeldende-lover.tar.bz2"}]' Headers = @{ 'X-RateLimit-Remaining' = @('199') } } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - AuthType = 'APIKey' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - $result = Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context + $result = Invoke-LovdataAPI -Endpoint '/v1/publicData/list' - $result.id | Should -Be 'lov' + $result.filename | Should -Be 'gjeldende-lover.tar.bz2' Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Method -eq 'Get' -and - $Uri -eq 'https://api.example.test/v1/legalSource/list' -and - $Headers['X-API-Key'] -eq 'secret-key' + $Uri -eq 'https://api.example.test/v1/publicData/list' -and + -not $Headers.ContainsKey('X-API-Key') } } } @@ -80,13 +67,7 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 200; Content = '{}'; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test/' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - Invoke-LovdataAPI -Endpoint 'v1/search' -Query @{ q = 'lov test'; limit = 10; offset = $null } -Context $context + Invoke-LovdataAPI -Endpoint 'v1/search' -Query @{ q = 'lov test'; limit = 10; offset = $null } Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.test/v1/search?limit=10&q=lov%20test' @@ -100,34 +81,7 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 200; Content = 'Pong'; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - Invoke-LovdataAPI -Endpoint '/ping' -Context $context | Should -Be 'Pong' - } - } - - It 'explains a rejected API key' { - InModuleScope -ModuleName Lovdata { - Mock Invoke-WebRequest { - [pscustomobject]@{ - StatusCode = 401 - Content = '{"status":401,"message":"Unauthorized","detail":"Missing api role"}' - Headers = @{} - } - } - - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context } | - Should -Throw '*rejected the API key in context*demo*Missing api role*' + Invoke-LovdataAPI -Endpoint '/ping' | Should -Be 'Pong' } } @@ -141,14 +95,8 @@ Describe 'Lovdata internals' { } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/search' -Context $context } | - Should -Throw '*rate limit for context*demo*is exhausted*The limit resets at*' + { Invoke-LovdataAPI -Endpoint '/v1/search' } | + Should -Throw '*rate limit is exhausted*The limit resets at*' } } @@ -158,96 +106,40 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 500; Content = ''; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/search' -Context $context } | + { Invoke-LovdataAPI -Endpoint '/v1/search' } | Should -Throw '*https://api.example.test/v1/search*failed with status*500*' } } } - Context 'Get-LovdataResponseHeader' { - It 'reads a header regardless of how it is cased' { + Context 'Invoke-LovdataDownload' { + It 'streams the configured download URI to the given file' { InModuleScope -ModuleName Lovdata { - $headers = @{ 'X-RateLimit-Remaining' = @('42') } + Mock Invoke-WebRequest {} - Get-LovdataResponseHeader -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '42' - } - } + Invoke-LovdataDownload -Endpoint '/v1/publicData/get/gjeldende-lover.tar.bz2' -OutFile 'TestDrive:\out.bz2' - It 'returns nothing for a header the response does not carry' { - InModuleScope -ModuleName Lovdata { - Get-LovdataResponseHeader -Headers @{} -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty - Get-LovdataResponseHeader -Headers $null -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty + Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.test/v1/publicData/get/gjeldende-lover.tar.bz2' -and + $OutFile -eq 'TestDrive:\out.bz2' + } } } } - Context 'Resolve-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - AfterEach { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - - It 'falls back to the default connection' { - InModuleScope -ModuleName Lovdata { - (Resolve-LovdataContext -Context $null).ID | Should -Be 'alpha' - (Resolve-LovdataContext -Context '').ID | Should -Be 'alpha' - } - } - - It 'resolves a connection by name' { - InModuleScope -ModuleName Lovdata { - (Resolve-LovdataContext -Context 'beta').ID | Should -Be 'beta' - } - } - - It 'passes an already resolved connection straight through' { - InModuleScope -ModuleName Lovdata { - $context = Get-LovdataContext -Context 'beta' - - (Resolve-LovdataContext -Context $context).ID | Should -Be 'beta' - } - } - - It 'asks for a key when the connection has none' { - InModuleScope -ModuleName Lovdata { - $context = [LovdataContext]@{ ID = 'keyless'; ApiBaseUri = 'https://api.example.test' } - - { Resolve-LovdataContext -Context $context } | Should -Throw '*has no API key*' - } - } - - It 'asks for a base URI when the connection has none' { + Context 'Get-LovdataResponseHeader' { + It 'reads a header regardless of how it is cased' { InModuleScope -ModuleName Lovdata { - $context = [LovdataContext]@{ - ID = 'uriless' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } + $headers = @{ 'X-RateLimit-Remaining' = @('42') } - { Resolve-LovdataContext -Context $context } | Should -Throw '*has no API base URI*' + Get-LovdataResponseHeader -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '42' } } - } - Context 'Initialize-LovdataConfig' { - It 'backfills settings that a stored configuration predates' { + It 'returns nothing for a header the response does not carry' { InModuleScope -ModuleName Lovdata { - $null = Set-Context -ID 'Module' -Context ([pscustomobject]@{ ID = 'Module' }) -Vault $script:Lovdata.ContextVault - - Initialize-LovdataConfig -Force - - $script:Lovdata.Config.ApiBaseUri | Should -Be 'https://api.lovdata.no' - (Get-Context -ID 'Module' -Vault $script:Lovdata.ContextVault).ApiBaseUri | Should -Be 'https://api.lovdata.no' + Get-LovdataResponseHeader -Headers @{} -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty + Get-LovdataResponseHeader -Headers $null -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty } } } diff --git a/tests/Lovdata.TestSetup.ps1 b/tests/Lovdata.TestSetup.ps1 index 78a081b..61d691e 100644 --- a/tests/Lovdata.TestSetup.ps1 +++ b/tests/Lovdata.TestSetup.ps1 @@ -5,29 +5,22 @@ Shared setup for the Lovdata test suites. .DESCRIPTION - Points the imported Lovdata module at a throwaway Context vault so tests exercise the real store - without touching the vault a developer or runner already has, and returns the vault name so the - calling suite can remove it again. + Resets the imported Lovdata module's in-memory settings back to the module defaults so each suite + starts from a known state. The module keeps no vault and no secret, so there is nothing to clean up + afterwards. .EXAMPLE - $vault = . "$PSScriptRoot/Lovdata.TestSetup.ps1" + . "$PSScriptRoot/Lovdata.TestSetup.ps1" .INPUTS None .OUTPUTS - System.String + None #> [CmdletBinding()] param() -$testVault = "PSModule.Lovdata.Tests.$([guid]::NewGuid().Guid)" - -InModuleScope -ModuleName Lovdata -Parameters @{ Vault = $testVault } -ScriptBlock { - param($Vault) - - $script:Lovdata.ContextVault = $Vault +InModuleScope -ModuleName Lovdata { $script:Lovdata.Config = $null } - -$testVault diff --git a/tests/PublicData.Tests.ps1 b/tests/PublicData.Tests.ps1 new file mode 100644 index 0000000..51e863b --- /dev/null +++ b/tests/PublicData.Tests.ps1 @@ -0,0 +1,121 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Parameters are read by Pester mock parameter filters.' +)] +[CmdletBinding()] +param() + +Describe 'PublicData' { + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + } + + Context 'Get-LovdataPublicDataset' { + It 'returns the packages as typed objects with no API key stored' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + @( + [pscustomobject]@{ + filename = 'gjeldende-lover.tar.bz2' + description = 'Gjeldende lover, ajourfort med endringer' + sizeBytes = '5842472' + lastModified = '2026-08-01T01:31:00Z' + } + ) + } + + $result = Get-LovdataPublicDataset + + $result | Should -BeOfType [LovdataPublicDataset] + $result.FileName | Should -Be 'gjeldende-lover.tar.bz2' + $result.SizeBytes | Should -Be 5842472 + $result.SizeBytes | Should -BeOfType [long] + $result.LastModified | Should -BeOfType [datetime] + Should -Invoke -ModuleName Lovdata Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/v1/publicData/list' + } + } + + It 'filters the packages by filename wildcard' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + $modified = '2026-08-01T01:31:00Z' + @( + [pscustomobject]@{ filename = 'gjeldende-lover.tar.bz2'; sizeBytes = '1'; lastModified = $modified } + [pscustomobject]@{ filename = 'gjeldende-sentrale-forskrifter.tar.bz2'; sizeBytes = '2'; lastModified = $modified } + [pscustomobject]@{ filename = 'historiske-lover.tar.bz2'; sizeBytes = '3'; lastModified = $modified } + ) + } + + $result = Get-LovdataPublicDataset -FileName 'gjeldende-*' + + $result.FileName | Should -Be @('gjeldende-lover.tar.bz2', 'gjeldende-sentrale-forskrifter.tar.bz2') + } + } + + Context 'Save-LovdataPublicDataset' { + BeforeEach { + $script:downloadDir = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().Guid) + $null = New-Item -Path $script:downloadDir -ItemType Directory + } + + It 'downloads a package into the target directory and returns the file' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'data' + } + + $result = Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir + + $result | Should -BeOfType [System.IO.FileInfo] + $result.Name | Should -Be 'gjeldende-lover.tar.bz2' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/v1/publicData/get/gjeldende-lover.tar.bz2' -and + $OutFile -eq (Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2') + } + } + + It 'refuses to overwrite an existing file without Force' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + $existing = Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2' + Set-Content -Path $existing -Value 'old' + + { Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir } | + Should -Throw '*already exists*' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + + It 'overwrites an existing file when Force is given' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'new' + } + $existing = Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2' + Set-Content -Path $existing -Value 'old' + + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir -Force + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly + } + + It 'downloads nothing when WhatIf is used' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir -WhatIf + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + + It 'accepts a dataset from the pipeline by property name' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'data' + } + $dataset = [LovdataPublicDataset]@{ FileName = 'gjeldende-lover.tar.bz2' } + + $result = $dataset | Save-LovdataPublicDataset -Path $script:downloadDir + + $result.Name | Should -Be 'gjeldende-lover.tar.bz2' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly + } + } +} diff --git a/tests/Service.Tests.ps1 b/tests/Service.Tests.ps1 new file mode 100644 index 0000000..c429503 --- /dev/null +++ b/tests/Service.Tests.ps1 @@ -0,0 +1,48 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' +)] +[CmdletBinding()] +param() + +Describe 'Service' { + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + } + + Context 'Test-LovdataConnection' { + It 'returns true when the service answers' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { 'Pong' } + + Test-LovdataConnection | Should -BeTrue + } + + It 'returns false and warns instead of throwing when the service is unreachable' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { throw 'connection refused' } + + $result = Test-LovdataConnection -WarningAction SilentlyContinue + + $result | Should -BeFalse + } + } + + Context 'Get-LovdataApiVersion' { + It 'maps the reported version onto a typed object' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + [pscustomobject]@{ name = 'lovdata-api'; timestamp = '2026-07-31-1613'; revision = '3806f92' } + } + + $version = Get-LovdataApiVersion + + $version | Should -BeOfType [LovdataApiVersion] + $version.Name | Should -Be 'lovdata-api' + $version.Timestamp | Should -Be '2026-07-31-1613' + $version.Revision | Should -Be '3806f92' + Should -Invoke -ModuleName Lovdata Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/version' + } + } + } +} From 9d40672da1cca8b3bc1c394b6c425a913586498e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:10:47 +0200 Subject: [PATCH 4/7] Rewrite the README, examples, and security notes for the open surface Lead the README with the key-free story and Install-PSResource, replace the context-based examples with open-data and service scenarios, and update SECURITY.md to state that the module stores no secret. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 49 +++++++++++-------- SECURITY.md | 8 +-- examples/Get-LegalSources.ps1 | 22 --------- examples/Get-OpenDataPackages.ps1 | 30 ++++++++++++ examples/Test-ServiceAndListData.ps1 | 22 +++++++++ examples/Use-MultipleContexts.ps1 | 30 ------------ .../PublicData/Get-LovdataPublicDataset.ps1 | 2 +- .../PublicData/Save-LovdataPublicDataset.ps1 | 2 +- 8 files changed, 87 insertions(+), 78 deletions(-) delete mode 100644 examples/Get-LegalSources.ps1 create mode 100644 examples/Get-OpenDataPackages.ps1 create mode 100644 examples/Test-ServiceAndListData.ps1 delete mode 100644 examples/Use-MultipleContexts.ps1 diff --git a/README.md b/README.md index 895c5e3..20c0119 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # Lovdata A PowerShell module for working with Norwegian legal data from [Lovdata](https://lovdata.no), the foundation that maintains the -authoritative, continuously updated body of Norwegian law. The module wraps the [Lovdata API](https://api.lovdata.no/swagger/index.html) -so laws (`lover`) and regulations (`forskrifter`) can be listed, inspected, and pulled into scripts as PowerShell objects instead of -scraped HTML. +authoritative, continuously updated body of Norwegian law. + +**No account needed.** This release wraps the open, key-free part of the [Lovdata API](https://api.lovdata.no/swagger/index.html): +the current acts (`lover`) and central regulations (`forskrifter`) published as free open data under +[NLOD 2.0](https://data.norge.no/nlod/no/2.0), plus the service endpoints that report whether the API is up and which build is +deployed. Install the module and pull the full corpus of Norwegian acts in two commands, with nothing to configure and no +credential to obtain. ## Prerequisites - PowerShell 7 or later on Windows, Linux, or macOS. -- An API key from Lovdata for the authenticated endpoints. Lovdata issues keys to users with the `api` role in their user base; - contact [api@lovdata.no](mailto:api@lovdata.no) to request one. The key is sent as the `X-API-Key` request header on every call. -- No key is needed for Lovdata's free public datasets, which are published under - [NLOD 2.0](https://data.norge.no/nlod/no/2.0). See [Lovdata's API information page](https://lovdata.no/info/api) for the background. +- No account, no API key, no configuration. -The API also rate limits each key. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers, and -the module surfaces the remaining budget on verbose output so long-running scripts can pace themselves. +The API rate limits every caller. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers, +and the module surfaces the remaining budget on verbose output so long-running scripts can pace themselves. ## Installation @@ -27,35 +28,35 @@ Import-Module -Name Lovdata ## Capabilities -Store the API key once. It is encrypted at rest by the [Context](https://psmodule.io/Context/) module and reused by every -subsequent command, so scripts never carry the key themselves. +Check that the service is up and see which build is deployed: ```powershell -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) +Test-LovdataConnection +Get-LovdataApiVersion ``` -List the legal sources the account can reach, then narrow to the ones of interest: +List the free open data packages Lovdata publishes, with the size and last-modified date of each: ```powershell -Get-LovdataLegalSource -Get-LovdataLegalSource -ID 'lov*' +Get-LovdataPublicDataset +Get-LovdataPublicDataset -FileName 'gjeldende-*' ``` -Keep several keys side by side — one per environment or customer — and switch between them without reconnecting: +Download the current acts and central regulations to a folder, with progress and without silently overwriting existing files: ```powershell -Connect-LovdataAccount -ApiKey $productionKey -Context 'production' -Get-LovdataContext -ListAvailable -Switch-LovdataContext -Context 'production' +Get-LovdataPublicDataset -FileName 'gjeldende-*' | Save-LovdataPublicDataset -Path './lovdata' ``` -Module-wide defaults, such as the API base URI, live in their own context and can be inspected or changed: +Point the module at a different API base URI for the session, for example a test deployment: ```powershell Get-LovdataConfig Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' ``` +See the [examples](examples) folder for complete scripts, including downloading and unpacking the full corpus. + ## Attribution Lovdata publishes current laws and central regulations as open data under the @@ -63,6 +64,12 @@ Lovdata publishes current laws and central regulations as open data under the remains subject to Lovdata's terms; credit Lovdata as the source when redistributing it. This module is not affiliated with or endorsed by Lovdata. +## The paid surface + +Everything Lovdata offers behind an API key -- search, document retrieval, structured rules, vocabularies, reference resolution -- +is not covered by this release. That authenticated surface, together with the credential store it needs, is tracked in +[PSModule/Lovdata#15](https://github.com/PSModule/Lovdata/issues/15). + ## Documentation Documentation is published at [psmodule.io/Lovdata](https://psmodule.io/Lovdata/). @@ -71,5 +78,5 @@ Use PowerShell help and command discovery for module details: ```powershell Get-Command -Module Lovdata -Get-Help -Name Get-LovdataLegalSource -Examples +Get-Help -Name Get-LovdataPublicDataset -Examples ``` diff --git a/SECURITY.md b/SECURITY.md index 56b5fb1..7b18f23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,9 +19,11 @@ triaged as quickly as possible. ## Credentials handled by this module -This module stores a Lovdata API key using the [`Context`](https://github.com/PSModule/Context) module, which encrypts secrets -at rest. Keys are never written to the repository, module source, or command output. If a key is exposed, revoke it with -Lovdata and remove the stored context with `Disconnect-LovdataAccount`. +This release handles no credentials. Everything it covers is Lovdata's open, key-free surface, so the module stores no API key +and no other secret, on disk or in memory. There is nothing for this module to leak. + +The authenticated Lovdata surface, and the encrypted credential store it will need, are tracked separately in +[PSModule/Lovdata#15](https://github.com/PSModule/Lovdata/issues/15); this section will be revisited when that lands. Problems with the Lovdata service itself, rather than with this module, belong with Lovdata's own tech support at [api@lovdata.no](mailto:api@lovdata.no). diff --git a/examples/Get-LegalSources.ps1 b/examples/Get-LegalSources.ps1 deleted file mode 100644 index f7e3e80..0000000 --- a/examples/Get-LegalSources.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -<# - .SYNOPSIS - Connect to the Lovdata API and list the available legal sources. - - .DESCRIPTION - Stores a Lovdata API key in an encrypted context, lists the legal sources the account can reach, - and narrows the result to Norwegian acts. The stored context is reused by every later command in - the session, so the key is entered once. -#> - -Import-Module -Name Lovdata - -# The key is read interactively so it never ends up in the script or the shell history. -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) - -# Every legal source the account has access to. -Get-LovdataLegalSource - -# Only the sources whose identifier starts with 'lov'. -Get-LovdataLegalSource -ID 'lov*' - -Disconnect-LovdataAccount diff --git a/examples/Get-OpenDataPackages.ps1 b/examples/Get-OpenDataPackages.ps1 new file mode 100644 index 0000000..cc0106a --- /dev/null +++ b/examples/Get-OpenDataPackages.ps1 @@ -0,0 +1,30 @@ +<# + .SYNOPSIS + Download the current Norwegian acts and regulations and unpack them locally. + + .DESCRIPTION + Lists Lovdata's free open data packages, downloads the current acts and central regulations into a + local folder, and unpacks the .tar.bz2 archives with the tar tool that ships with PowerShell 7. + No account and no API key are needed. The unpacked content is published under NLOD 2.0; credit + Lovdata as the source when you redistribute it. +#> + +Import-Module -Name Lovdata + +$destination = Join-Path -Path (Get-Location) -ChildPath 'lovdata' +$null = New-Item -Path $destination -ItemType Directory -Force + +# See what Lovdata publishes, with the size and last-modified date of each package. +Get-LovdataPublicDataset | Format-Table -Property FileName, SizeBytes, LastModified + +# Download the current acts and central regulations, overwriting any earlier copies. +$packages = Get-LovdataPublicDataset -FileName 'gjeldende-*' | + Save-LovdataPublicDataset -Path $destination -Force + +# Unpack each downloaded archive next to itself. +foreach ($package in $packages) { + $target = Join-Path -Path $destination -ChildPath $package.BaseName + $null = New-Item -Path $target -ItemType Directory -Force + tar -xjf $package.FullName -C $target + "Unpacked [$($package.Name)] into [$target]." +} diff --git a/examples/Test-ServiceAndListData.ps1 b/examples/Test-ServiceAndListData.ps1 new file mode 100644 index 0000000..d6fd71a --- /dev/null +++ b/examples/Test-ServiceAndListData.ps1 @@ -0,0 +1,22 @@ +<# + .SYNOPSIS + Check that the Lovdata API is reachable before running a data job. + + .DESCRIPTION + Uses the open service endpoints to confirm the Lovdata API is up and to record which build is + deployed, then lists the available open data packages. All of this works with no account and no + API key, so it is a safe first step in an unattended script. +#> + +Import-Module -Name Lovdata + +if (-not (Test-LovdataConnection)) { + Write-Warning 'The Lovdata API is not reachable right now. Try again later.' + return +} + +$version = Get-LovdataApiVersion +"Connected to Lovdata API [$($version.Name)] build [$($version.Timestamp)]." + +# List the open data packages the service currently offers. +Get-LovdataPublicDataset | Format-Table -Property FileName, SizeBytes, LastModified diff --git a/examples/Use-MultipleContexts.ps1 b/examples/Use-MultipleContexts.ps1 deleted file mode 100644 index c58fcad..0000000 --- a/examples/Use-MultipleContexts.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -<# - .SYNOPSIS - Work with several Lovdata API keys side by side. - - .DESCRIPTION - Stores two named Lovdata contexts, inspects them, switches the default between them, and adjusts - a module-wide setting. Useful when the same script has to run against more than one Lovdata - account, for example a test account and a production account. -#> - -Import-Module -Name Lovdata - -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Test API key' -AsSecureString) -Context 'test' -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Production API key' -AsSecureString) -Context 'production' - -# Inspect what is stored. The API key itself is never returned in clear text. -Get-LovdataContext -ListAvailable - -# Commands without an explicit -Context use the default one. -Switch-LovdataContext -Context 'production' -Get-LovdataLegalSource - -# A single command can target another context without changing the default. -Get-LovdataLegalSource -Context 'test' - -# Module-wide defaults live in their own context. -Get-LovdataConfig -Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' - -Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount diff --git a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 index 3a89461..aa2277d 100644 --- a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 +++ b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 @@ -29,7 +29,7 @@ function Get-LovdataPublicDataset { LovdataPublicDataset .NOTES - The packages are published under NLOD 2.0. See https://data.norge.no/nlod/en/2.0 for the licence. + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. .LINK https://psmodule.io/Lovdata/Functions/PublicData/Get-LovdataPublicDataset/ diff --git a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 index 7639f92..b1c9638 100644 --- a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 +++ b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 @@ -31,7 +31,7 @@ function Save-LovdataPublicDataset { System.IO.FileInfo .NOTES - The packages are published under NLOD 2.0. See https://data.norge.no/nlod/en/2.0 for the licence. + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. .LINK https://psmodule.io/Lovdata/Functions/PublicData/Save-LovdataPublicDataset/ From a0f7dae457a88a91eea9b6f55006a946d8c5e90d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:17:58 +0200 Subject: [PATCH 5/7] Avoid bare URLs in Set-LovdataConfig examples so the generated docs lint clean PlatyPS renders example command lines without a code fence, so a literal URL in an example trips markdownlint MD034. Use a variable instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/functions/public/Config/Set-LovdataConfig.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/functions/public/Config/Set-LovdataConfig.ps1 b/src/functions/public/Config/Set-LovdataConfig.ps1 index 77bd3bd..0115a5f 100644 --- a/src/functions/public/Config/Set-LovdataConfig.ps1 +++ b/src/functions/public/Config/Set-LovdataConfig.ps1 @@ -9,14 +9,14 @@ function Set-LovdataConfig { memory only and is not persisted, so a new session starts from the module defaults again. .EXAMPLE - Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' + Set-LovdataConfig -Name ApiBaseUri -Value $baseUri - Points the module at the given API base URI. + Points the module at the API base URI held in $baseUri. .EXAMPLE - Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.example.test' -PassThru + Set-LovdataConfig -Name ApiBaseUri -Value $baseUri -PassThru - Points the module at the given API base URI and returns the updated settings. + Points the module at the API base URI held in $baseUri and returns the updated settings. .INPUTS None From 64ec4c0712daa6a9e827d451f222715fa2917efa Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:28:47 +0200 Subject: [PATCH 6/7] Parse public dataset LastModified as UTC instead of local time The API reports lastModified as an ISO-8601 Z string, but a bare [datetime] cast converts it to the runner's local time, so the same package can report a different calendar day depending on the machine's timezone. Parse with InvariantCulture and RoundtripKind so the value stays UTC and machine-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../public/PublicData/Get-LovdataPublicDataset.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 index aa2277d..bc619ed 100644 --- a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 +++ b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 @@ -58,7 +58,11 @@ function Get-LovdataPublicDataset { FileName = [string]$item.filename Description = [string]$item.description SizeBytes = [long]$item.sizeBytes - LastModified = [datetime]$item.lastModified + LastModified = [datetime]::Parse( + [string]$item.lastModified, + [cultureinfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind + ) } } From 152b4a78f1165b76a229bac44b6267859b9926f0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 15:28:56 +0200 Subject: [PATCH 7/7] Constrain Save-LovdataPublicDataset FileName to a bare filename FileName flowed unchecked into Join-Path and the download endpoint, so a value like '../../evil.txt' wrote outside -Path, bypassed the overwrite check against the un-normalised path, and escaped the URL segment. Validate it as a bare filename at the parameter, which closes the path and the URL in one place. Also resolve -Path with -LiteralPath and use its ProviderPath so a directory name containing wildcard characters resolves correctly, and drop the module's own static progress bar since Invoke-WebRequest -OutFile already renders one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/API/Invoke-LovdataDownload.ps1 | 10 ++-------- .../PublicData/Save-LovdataPublicDataset.ps1 | 14 +++++++++----- tests/PublicData.Tests.ps1 | 10 ++++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/functions/private/API/Invoke-LovdataDownload.ps1 b/src/functions/private/API/Invoke-LovdataDownload.ps1 index 40fb60a..6f455bf 100644 --- a/src/functions/private/API/Invoke-LovdataDownload.ps1 +++ b/src/functions/private/API/Invoke-LovdataDownload.ps1 @@ -44,12 +44,6 @@ function Invoke-LovdataDownload { $baseUri = (Get-LovdataConfig).ApiBaseUri $uri = '{0}/{1}' -f $baseUri.TrimEnd('/'), $Endpoint.TrimStart('/') - $activity = "Downloading [$([System.IO.Path]::GetFileName($OutFile))] from Lovdata" - Write-Progress -Activity $activity -Status 'Transferring' - try { - Write-Verbose "Streaming [$uri] to [$OutFile]." - Invoke-WebRequest -Uri $uri -OutFile $OutFile -ErrorAction Stop - } finally { - Write-Progress -Activity $activity -Completed - } + Write-Verbose "Streaming [$uri] to [$OutFile]." + Invoke-WebRequest -Uri $uri -OutFile $OutFile -ErrorAction Stop } diff --git a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 index b1c9638..95d4724 100644 --- a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 +++ b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 @@ -45,6 +45,10 @@ function Save-LovdataPublicDataset { # The filename of the package to download, as reported by Get-LovdataPublicDataset. [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] + [ValidateScript( + { $_ -eq [System.IO.Path]::GetFileName($_) -and $_ -notin '.', '..' }, + ErrorMessage = "FileName must be a bare package filename with no path separators, for example 'gjeldende-lover.tar.bz2'." + )] [string] $FileName, # The directory to download the package into. Defaults to the current directory. @@ -58,17 +62,17 @@ function Save-LovdataPublicDataset { ) process { - $directory = Resolve-Path -Path $Path -ErrorAction SilentlyContinue + $directory = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue if ($null -eq $directory) { throw "The download directory [$Path] does not exist. Create it first, or pass an existing directory to -Path." } - if (-not (Test-Path -Path $directory -PathType Container)) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { throw "The download path [$Path] is not a directory. Pass a directory to -Path." } - $target = Join-Path -Path $directory -ChildPath $FileName + $target = Join-Path -Path $directory.ProviderPath -ChildPath $FileName - if ((Test-Path -Path $target -PathType Leaf) -and -not $Force) { + if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not $Force) { throw "A file already exists at [$target]. Use -Force to overwrite it." } @@ -78,6 +82,6 @@ function Save-LovdataPublicDataset { Invoke-LovdataDownload -Endpoint "/v1/publicData/get/$FileName" -OutFile $target - Get-Item -Path $target + Get-Item -LiteralPath $target } } diff --git a/tests/PublicData.Tests.ps1 b/tests/PublicData.Tests.ps1 index 51e863b..d9e7e1b 100644 --- a/tests/PublicData.Tests.ps1 +++ b/tests/PublicData.Tests.ps1 @@ -36,6 +36,8 @@ Describe 'PublicData' { $result.SizeBytes | Should -Be 5842472 $result.SizeBytes | Should -BeOfType [long] $result.LastModified | Should -BeOfType [datetime] + $result.LastModified.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result.LastModified | Should -Be ([datetime]::new(2026, 8, 1, 1, 31, 0, [System.DateTimeKind]::Utc)) Should -Invoke -ModuleName Lovdata Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { $Endpoint -eq '/v1/publicData/list' } @@ -106,6 +108,14 @@ Describe 'PublicData' { Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly } + It 'rejects a FileName that contains a path and never downloads' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + + { Save-LovdataPublicDataset -FileName '../escape.txt' -Path $script:downloadDir } | + Should -Throw '*bare package filename*' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + It 'accepts a dataset from the pipeline by property name' { Mock -ModuleName Lovdata Invoke-LovdataDownload { Set-Content -Path $OutFile -Value 'data'