feat: Implement API client and token models with hashing and JWT authentication

- Added ApiClientModel and ApiTokenModel for managing API clients and tokens.
- Introduced ConfigurationDefinitionModel and ConfigurationValueModel for configuration management.
- Created CredentialSecretModel for storing credential secrets.
- Developed DeploymentArtifactModel and DeploymentBatchModel for deployment management.
- Enhanced DeploymentTargetModel and DeploymentTemplateSelectionModel to support template revisions.
- Added TemplateRevisionModel and TemplateVersionModel for versioning templates.
- Implemented ApiClientSecretHasher for secure secret hashing.
- Created ApiTokenService for generating and validating JWT tokens.
- Updated QueueJobService to handle deployment requests with artifacts.
- Configured authentication settings in appsettings.json for JWT and Negotiate authentication.
This commit is contained in:
Torsten Brendgen
2026-07-09 14:44:38 +02:00
parent 1aa2adac08
commit dc93ea0813
72 changed files with 32906 additions and 516 deletions

310
.tests/Api.Tests.ps1 Normal file
View File

@@ -0,0 +1,310 @@
param(
[string]$ApiBaseUrl = 'http://localhost:5286/api',
[string]$DeploymentBatchId = '80000000-0000-0000-0000-000000000101',
[int]$ApiTimeoutSec = 30,
[bool]$UseDefaultCredentials = $true,
[System.Management.Automation.PSCredential]$Credential
)
$ErrorActionPreference = 'Stop'
function Invoke-TestMergeApiJson {
param(
[ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
[string]$Method,
[string]$Path
)
$uri = ('{0}/{1}' -f $ApiBaseUrl.TrimEnd('/'), $Path.TrimStart('/'))
$parameters = @{
Method = $Method
Uri = $uri
TimeoutSec = $ApiTimeoutSec
ErrorAction = 'Stop'
}
if ($Credential) {
$parameters.Credential = $Credential
}
elseif ($UseDefaultCredentials) {
$parameters.UseDefaultCredentials = $true
}
try {
Invoke-RestMethod @parameters
}
catch {
$responseBody = '<empty response body>'
if ($_.Exception.Response) {
try {
$stream = $_.Exception.Response.GetResponseStream()
if ($stream) {
$reader = [System.IO.StreamReader]::new($stream)
$text = $reader.ReadToEnd()
if (-not [string]::IsNullOrWhiteSpace($text)) {
$responseBody = $text
}
}
}
catch {
$responseBody = '<could not read response body>'
}
}
throw "API request failed. Method=[$Method], Uri=[$uri], Response=[$responseBody]. $($_.Exception.Message)"
}
}
function ConvertFrom-TestJson {
param([string]$Json)
$Json | ConvertFrom-Json
}
function Get-TestDefinition {
param(
[object[]]$Definitions,
[string]$RevisionId,
[string]$Kind,
[string]$Name
)
@($Definitions | Where-Object {
[string]$_.templateRevisionId -eq $RevisionId -and
$_.kind -eq $Kind -and
$_.name -eq $Name
})[0]
}
function Get-TestFirst {
param([object[]]$Items)
@($Items | Select-Object -First 1)[0]
}
function Get-TestSelectionByRole {
param(
[object]$Composition,
[string]$Role
)
Get-TestFirst -Items @($Composition.templateSelections | Where-Object { $_.templateRole -eq $Role })
}
function Get-TestRevisionDefinitions {
param([object]$Selection)
$templateId = [string]$Selection.templateVersion.templateId
$versionId = [string]$Selection.templateVersionId
$revisionId = [string]$Selection.templateRevisionId
@(Invoke-TestMergeApiJson -Method GET -Path "Template/$templateId/Versions/$versionId/Revisions/$revisionId/Definitions")
}
function Assert-TestApiIsReachable {
try {
Invoke-TestMergeApiJson -Method GET -Path "deployment-batches/$DeploymentBatchId/composition" | Out-Null
}
catch {
if ($_.Exception.Message -match '401|Nicht autorisiert|Unauthorized') {
throw @"
API authentication failed for [$ApiBaseUrl].
Start the API with Windows authentication enabled and run the test from a session that can authenticate to it.
You can also pass explicit credentials:
Invoke-Pester -Script @{ Path = '.Net\Microsoft.SelfService.Portal.Core.API\.tests\Api.Tests.ps1'; Parameters = @{ Credential = (Get-Credential) } }
$($_.Exception.Message)
"@
}
throw
}
}
Describe ' API DemoData composition' {
BeforeAll {
Assert-TestApiIsReachable
$script:expectedTemplateAliases = @(
'Environment-Test'
'Domain-Contoso'
'Service-SharePoint-Contoso'
'Stage-Install'
)
$script:expectedTemplateRoles = @(
'Environment'
'Domain'
'Service'
'Stage'
)
$script:expectedTemplateRevisionIds = @(
'72000000-0000-0000-0000-000000000101'
'72000000-0000-0000-0000-000000000102'
'72000000-0000-0000-0000-000000000103'
'72000000-0000-0000-0000-000000000104'
)
$script:apiComposition = Invoke-TestMergeApiJson -Method GET -Path "deployment-batches/$DeploymentBatchId/composition"
$script:environmentSelection = Get-TestSelectionByRole -Composition $script:apiComposition -Role 'Environment'
$script:domainSelection = Get-TestSelectionByRole -Composition $script:apiComposition -Role 'Domain'
$script:sharePointSelection = Get-TestSelectionByRole -Composition $script:apiComposition -Role 'Service'
$script:stageSelection = Get-TestSelectionByRole -Composition $script:apiComposition -Role 'Stage'
$script:environmentRevisionId = [string]$script:environmentSelection.templateRevisionId
$script:domainRevisionId = [string]$script:domainSelection.templateRevisionId
$script:sharePointRevisionId = [string]$script:sharePointSelection.templateRevisionId
$script:environmentDefinitions = Get-TestRevisionDefinitions -Selection $script:environmentSelection
$script:domainDefinitions = Get-TestRevisionDefinitions -Selection $script:domainSelection
$script:sharePointDefinitions = Get-TestRevisionDefinitions -Selection $script:sharePointSelection
$script:stageDefinitions = Get-TestRevisionDefinitions -Selection $script:stageSelection
$script:deploymentArtifacts = @(Invoke-TestMergeApiJson -Method GET -Path "deployment-artifacts?deploymentGroupId=$DeploymentBatchId")
$script:credentialSecrets = @(Invoke-TestMergeApiJson -Method GET -Path 'credential-secrets')
$script:configurationDefinitions = @(Invoke-TestMergeApiJson -Method GET -Path 'configuration-definitions')
$script:parameterDefinitions = @(Invoke-TestMergeApiJson -Method GET -Path 'configuration-definitions?kind=Parameter')
$script:variableDefinitions = @(Invoke-TestMergeApiJson -Method GET -Path 'configuration-definitions?kind=Variable')
$script:configurationValues = @(Invoke-TestMergeApiJson -Method GET -Path 'configuration-values')
}
It 'loads the seeded deployment composition from the API' {
$script:apiComposition.deploymentBatchId | Should Be $DeploymentBatchId
@($script:apiComposition.templateSelections).Count | Should Be 4
@($script:apiComposition.targetAssignments).Count | Should Be 3
}
It 'keeps the template selection order from the seeded deployment' {
$aliases = @($script:apiComposition.templateSelections | Sort-Object sortOrder | ForEach-Object { $_.alias })
$roles = @($script:apiComposition.templateSelections | Sort-Object sortOrder | ForEach-Object { $_.templateRole })
($aliases -join '|') | Should Be ($script:expectedTemplateAliases -join '|')
($roles -join '|') | Should Be ($script:expectedTemplateRoles -join '|')
}
It 'pins every template selection to an immutable template revision' {
$revisionIds = @($script:apiComposition.templateSelections | Sort-Object sortOrder | ForEach-Object { [string]$_.templateRevisionId })
($revisionIds -join '|') | Should Be ($script:expectedTemplateRevisionIds -join '|')
@($revisionIds | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count | Should Be 0
}
It 'loads each pinned template revision and its definitions from the API' {
foreach ($selection in @($script:apiComposition.templateSelections | Sort-Object sortOrder)) {
$templateId = [string]$selection.templateVersion.templateId
$versionId = [string]$selection.templateVersionId
$revisionId = [string]$selection.templateRevisionId
$revision = Invoke-TestMergeApiJson -Method GET -Path "Template/$templateId/Versions/$versionId/Revisions/$revisionId"
$definitions = @(Invoke-TestMergeApiJson -Method GET -Path "Template/$templateId/Versions/$versionId/Revisions/$revisionId/Definitions")
$revision.id | Should Be $revisionId
$revision.templateVersionId | Should Be $versionId
$revision.revisionNumber | Should Be 1
$revision.isCurrent | Should Be $true
$revision.isPublished | Should Be $true
if ($selection.templateRole -ne 'Stage') {
@($definitions).Count | Should BeGreaterThan 0
}
}
}
It 'exposes deployment artifact and credential store API surfaces' {
@($script:deploymentArtifacts).Count | Should Be 1
$script:deploymentArtifacts[0].deploymentGroupId | Should Be $DeploymentBatchId
$script:deploymentArtifacts[0].artifactType | Should Be 'ResolvedConfigurationData'
$script:deploymentArtifacts[0].isStale | Should Be $false
$secretNames = @($script:credentialSecrets | ForEach-Object { $_.name } | Sort-Object)
$secretNames -contains 'Windows/SharePoint/SetupAccount' | Should Be $true
$secretNames -contains 'Windows/SharePoint/FarmAccount' | Should Be $true
$secretNames -contains 'Windows/SharePoint/FarmPassphrase' | Should Be $true
$secretNames -contains 'Windows/SharePoint/DefaultServiceAccount' | Should Be $true
$secretNames -contains 'Windows/SharePoint/SearchAccount' | Should Be $true
}
It 'returns resolved deployment artifact values without reading local PSD1 files' {
$deploymentJson = ConvertFrom-TestJson -Json $script:deploymentArtifacts[0].deploymentJson
$deploymentJson.variables.DatabasePrefix | Should Be 'SharePoint_Contoso_Test'
$deploymentJson.variables.ServiceDbPrefix | Should Be 'SharePoint_Contoso_Test_Services'
$deploymentJson.variables.ConfigDbName | Should Be 'SharePoint_Contoso_Test_Farm_Config'
$deploymentJson.resources.NonNodeData.LocalConfigurationManager.ConfigurationMode | Should Be 'ApplyOnly'
@($deploymentJson.resources.AllNodes).Count | Should Be 3
$deploymentJson.resources.AllNodes[0].NodeName | Should Be 'CLD-SHP-01'
$deploymentJson.resources.AllNodes[0].RunCentralAdministration | Should Be $true
}
It 'seeds parameter and variable definitions in the Configuration Definition API' {
@($script:configurationDefinitions).Count | Should BeGreaterThan 0
@($script:parameterDefinitions).Count | Should BeGreaterThan 0
@($script:variableDefinitions).Count | Should BeGreaterThan 0
$landscape = Get-TestDefinition -Definitions $script:environmentDefinitions -RevisionId $script:environmentRevisionId -Kind 'Parameter' -Name 'Landscape'
$domainFqdn = Get-TestDefinition -Definitions $script:domainDefinitions -RevisionId $script:domainRevisionId -Kind 'Parameter' -Name 'DomainFQDN'
$databasePrefix = Get-TestDefinition -Definitions $script:sharePointDefinitions -RevisionId $script:sharePointRevisionId -Kind 'Parameter' -Name 'DatabasePrefix'
$configDbName = Get-TestDefinition -Definitions $script:environmentDefinitions -RevisionId $script:environmentRevisionId -Kind 'Variable' -Name 'ConfigDbName'
$landscape.id | Should Not BeNullOrEmpty
$domainFqdn.id | Should Not BeNullOrEmpty
$databasePrefix.id | Should Not BeNullOrEmpty
$configDbName.id | Should Not BeNullOrEmpty
(ConvertFrom-TestJson -Json $landscape.propertiesJson).AllowedValues -contains 'Test' | Should Be $true
(ConvertFrom-TestJson -Json $databasePrefix.propertiesJson).DefaultValue | Should Be 'SharePoint'
(ConvertFrom-TestJson -Json $configDbName.propertiesJson).Expression | Should Match 'joinNotEmpty'
}
It 'exposes configuration definitions through the standalone API and keeps revision definitions typed' {
$environmentDefinitions = @(Invoke-TestMergeApiJson -Method GET -Path "configuration-definitions?templateRevisionId=$($script:environmentRevisionId)")
$environmentVariables = @($script:environmentDefinitions | Where-Object { $_.kind -eq 'Variable' })
@($environmentDefinitions).Count | Should BeGreaterThan 0
@($environmentVariables).Count | Should BeGreaterThan 0
@($environmentVariables | Where-Object { $_.kind -ne 'Variable' }).Count | Should Be 0
$environmentDefinitionNames = @($script:environmentDefinitions | ForEach-Object { $_.name })
$environmentVariableNames = @($environmentVariables | ForEach-Object { $_.name })
$environmentDefinitionNames -contains 'Landscape' | Should Be $true
$environmentVariableNames -contains 'ConfigDbName' | Should Be $true
}
It 'seeds configuration values for parameters and variables' {
@($script:configurationValues).Count | Should BeGreaterThan 0
$landscape = Get-TestDefinition -Definitions $script:environmentDefinitions -RevisionId $script:environmentRevisionId -Kind 'Parameter' -Name 'Landscape'
$domainFqdn = Get-TestDefinition -Definitions $script:domainDefinitions -RevisionId $script:domainRevisionId -Kind 'Parameter' -Name 'DomainFQDN'
$configDbName = Get-TestDefinition -Definitions $script:environmentDefinitions -RevisionId $script:environmentRevisionId -Kind 'Variable' -Name 'ConfigDbName'
$landscapeValues = @(Invoke-TestMergeApiJson -Method GET -Path "configuration-definitions/$($landscape.id)/values")
$domainFqdnValues = @(Invoke-TestMergeApiJson -Method GET -Path "configuration-values?definitionId=$($domainFqdn.id)")
$configDbNameValues = @(Invoke-TestMergeApiJson -Method GET -Path "configuration-values?definitionId=$($configDbName.id)")
@($landscapeValues).Count | Should Be 1
@($domainFqdnValues).Count | Should Be 1
@($configDbNameValues).Count | Should Be 1
(ConvertFrom-TestJson -Json $landscapeValues[0].valueJson).value | Should Be 'Test'
(ConvertFrom-TestJson -Json $domainFqdnValues[0].valueJson).value | Should Be 'contoso.local'
(ConvertFrom-TestJson -Json $configDbNameValues[0].valueJson).expression | Should Match 'joinNotEmpty'
$landscapeValues[0].scopeType | Should Be 'TemplateRevision'
[string]$landscapeValues[0].scopeId | Should Be $script:environmentRevisionId
$configDbNameValues[0].valueSourceType | Should Be 'Expression'
}
It 'marks sensitive parameter values as secret references' {
$setupCredential = Get-TestDefinition -Definitions $script:sharePointDefinitions -RevisionId $script:sharePointRevisionId -Kind 'Parameter' -Name 'SetupCredential'
$setupCredentialValues = @(Invoke-TestMergeApiJson -Method GET -Path "configuration-values?definitionId=$($setupCredential.id)")
$setupValue = ConvertFrom-TestJson -Json $setupCredentialValues[0].valueJson
@($setupCredentialValues).Count | Should Be 1
$setupCredentialValues[0].valueSourceType | Should Be 'SecretReference'
$setupValue.value.Provider | Should Be 'SecretManagement'
$setupValue.value.Name | Should Be 'Windows/SharePoint/SetupAccount'
}
}