@@ -1001,6 +1001,243 @@ Describe 'Connect-HaloAPI' {
10011001 }
10021002 }
10031003
1004+ It ' loads secrets from Key Vault using interactive Azure login when no identity is provided' {
1005+ Mock - CommandName ' Connect-AzAccount' - ModuleName ' HaloAPI' - MockWith {} - ParameterFilter {
1006+ -not $Identity
1007+ }
1008+
1009+ Mock - CommandName ' Get-AzKeyVaultSecret' - ModuleName ' HaloAPI' - MockWith {
1010+ switch ($Name ) {
1011+ ' halo_URL' { [pscustomobject ]@ { SecretValueText = ' https://interactive.example/' } }
1012+ ' halo_ClientID' { [pscustomobject ]@ { SecretValueText = ' interactive-client' } }
1013+ ' halo_ClientSecret' { [pscustomobject ]@ { SecretValueText = ' interactive-secret' } }
1014+ }
1015+ }
1016+
1017+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1018+ [pscustomobject ]@ { content = ' {"auth_url":"https://auth.example/oauth2","tenant_id":"tenantFromAuthInfo"}' }
1019+ } - ParameterFilter {
1020+ $Method -eq ' GET'
1021+ }
1022+
1023+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1024+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1025+ } - ParameterFilter {
1026+ $Method -eq ' POST'
1027+ }
1028+
1029+ $Result = Connect-HaloAPI - URL ' https://placeholder.example/' - ClientID ' unused' - ClientSecret ' unused' - Scopes ' all' - UseKeyVault $true - VaultName ' vault' - SecretName ' halo'
1030+
1031+ $Result | Should - BeTrue
1032+ Should - Invoke - CommandName ' Connect-AzAccount' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1033+ -not $Identity
1034+ }
1035+ }
1036+
1037+ It ' saves secrets to Key Vault using interactive Azure login when no identity is provided' {
1038+ Mock - CommandName ' Connect-AzAccount' - ModuleName ' HaloAPI' - MockWith {} - ParameterFilter {
1039+ -not $Identity
1040+ }
1041+
1042+ Mock - CommandName ' Set-AzKeyVaultSecret' - ModuleName ' HaloAPI' - MockWith {
1043+ [pscustomobject ]@ { Name = $Name }
1044+ }
1045+
1046+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1047+ [pscustomobject ]@ { content = ' {"auth_url":"https://auth.example/oauth2","tenant_id":"tenantFromAuthInfo"}' }
1048+ } - ParameterFilter {
1049+ $Method -eq ' GET'
1050+ }
1051+
1052+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1053+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1054+ } - ParameterFilter {
1055+ $Method -eq ' POST'
1056+ }
1057+
1058+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-save' - ClientSecret ' secret-save' - Scopes ' all' - SaveToKeyVault $true - VaultName ' vault' - SecretName ' halo'
1059+
1060+ $Result | Should - BeTrue
1061+ Should - Invoke - CommandName ' Connect-AzAccount' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1062+ -not $Identity
1063+ }
1064+ }
1065+
1066+ It ' falls back to auth/token without tenant when authinfo response is empty and no tenant is provided' {
1067+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1068+ [pscustomobject ]@ { content = $null }
1069+ } - ParameterFilter {
1070+ $Method -eq ' GET'
1071+ }
1072+
1073+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1074+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1075+ } - ParameterFilter {
1076+ $Method -eq ' POST'
1077+ }
1078+
1079+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-fallback' - ClientSecret ' secret-fallback' - Scopes ' all'
1080+
1081+ $Result | Should - BeTrue
1082+ Should - Invoke - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1083+ $Method -eq ' POST' -and $Uri -eq ' https://example.halo/auth/token'
1084+ }
1085+ }
1086+
1087+ It ' rethrows non-throttling HttpResponseException from auth info lookup' {
1088+ $Response = [System.Net.Http.HttpResponseMessage ]::new([System.Net.HttpStatusCode ]::BadRequest)
1089+ $HttpException = [Microsoft.PowerShell.Commands.HttpResponseException ]::new(' bad request' , $Response )
1090+
1091+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1092+ throw $HttpException
1093+ } - ParameterFilter {
1094+ $Method -eq ' GET'
1095+ }
1096+
1097+ {
1098+ Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-authinfo' - ClientSecret ' secret-authinfo' - Scopes ' all'
1099+ } | Should - Throw
1100+
1101+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 0 - Exactly - ParameterFilter {
1102+ $HasResponse
1103+ }
1104+ }
1105+
1106+ It ' routes unexpected auth info lookup errors through New-HaloError and then falls back to the default auth path' {
1107+ $script :AuthInfoErrorCallCount = 0
1108+
1109+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1110+ $script :AuthInfoErrorCallCount += 1
1111+ if ($script :AuthInfoErrorCallCount -eq 1 ) {
1112+ throw [System.Exception ]::new(' auth info failed' )
1113+ }
1114+
1115+ [pscustomobject ]@ { content = $null }
1116+ } - ParameterFilter {
1117+ $Method -eq ' GET'
1118+ }
1119+
1120+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1121+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1122+ } - ParameterFilter {
1123+ $Method -eq ' POST'
1124+ }
1125+
1126+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-authinfo' - ClientSecret ' secret-authinfo' - Scopes ' all'
1127+
1128+ $Result | Should - BeTrue
1129+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1130+ $HasResponse
1131+ }
1132+ }
1133+
1134+ It ' reports auth info retry exhaustion after repeated throttling before falling back to the default auth path' {
1135+ $Response = [System.Net.Http.HttpResponseMessage ]::new([System.Net.HttpStatusCode ]::TooManyRequests)
1136+ $HttpException = [Microsoft.PowerShell.Commands.HttpResponseException ]::new(' throttled' , $Response )
1137+
1138+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1139+ throw $HttpException
1140+ } - ParameterFilter {
1141+ $Method -eq ' GET'
1142+ }
1143+
1144+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1145+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1146+ } - ParameterFilter {
1147+ $Method -eq ' POST'
1148+ }
1149+
1150+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-authinfo' - ClientSecret ' secret-authinfo' - Scopes ' all'
1151+
1152+ $Result | Should - BeTrue
1153+ Should - Invoke - CommandName ' Start-Sleep' - ModuleName ' HaloAPI' - Times 10 - Exactly - ParameterFilter {
1154+ $Seconds -eq 5
1155+ }
1156+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1157+ $ModuleMessage -eq ' Retried auth info request 10 times, request unsuccessful.'
1158+ }
1159+ }
1160+
1161+ It ' rethrows non-throttling HttpResponseException from token request' {
1162+ $Response = [System.Net.Http.HttpResponseMessage ]::new([System.Net.HttpStatusCode ]::BadRequest)
1163+ $HttpException = [Microsoft.PowerShell.Commands.HttpResponseException ]::new(' bad request' , $Response )
1164+
1165+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1166+ [pscustomobject ]@ { content = ' {"auth_url":"https://auth.example/oauth2","tenant_id":"tenantFromAuthInfo"}' }
1167+ } - ParameterFilter {
1168+ $Method -eq ' GET'
1169+ }
1170+
1171+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1172+ throw $HttpException
1173+ } - ParameterFilter {
1174+ $Method -eq ' POST'
1175+ }
1176+
1177+ {
1178+ Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-token' - ClientSecret ' secret-token' - Scopes ' all'
1179+ } | Should - Throw
1180+
1181+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 0 - Exactly - ParameterFilter {
1182+ -not $HasResponse -and $ErrorRecord
1183+ }
1184+ }
1185+
1186+ It ' routes unexpected token request errors through New-HaloError before succeeding on retry' {
1187+ $script :TokenErrorCallCount = 0
1188+
1189+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1190+ [pscustomobject ]@ { content = ' {"auth_url":"https://auth.example/oauth2","tenant_id":"tenantFromAuthInfo"}' }
1191+ } - ParameterFilter {
1192+ $Method -eq ' GET'
1193+ }
1194+
1195+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1196+ $script :TokenErrorCallCount += 1
1197+ if ($script :TokenErrorCallCount -eq 1 ) {
1198+ throw [System.Exception ]::new(' token failed' )
1199+ }
1200+
1201+ [pscustomobject ]@ { Content = ' {"token_type":"Bearer","access_token":"abc","expires_in":3600,"refresh_token":"ref","id_token":"id"}' }
1202+ } - ParameterFilter {
1203+ $Method -eq ' POST'
1204+ }
1205+
1206+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-token' - ClientSecret ' secret-token' - Scopes ' all'
1207+
1208+ $Result | Should - BeTrue
1209+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1210+ -not $HasResponse -and $ErrorRecord
1211+ }
1212+ }
1213+
1214+ It ' reports auth retry exhaustion after repeated throttling and returns false' {
1215+ $Response = [System.Net.Http.HttpResponseMessage ]::new([System.Net.HttpStatusCode ]::TooManyRequests)
1216+ $HttpException = [Microsoft.PowerShell.Commands.HttpResponseException ]::new(' throttled' , $Response )
1217+
1218+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1219+ [pscustomobject ]@ { content = ' {"auth_url":"https://auth.example/oauth2","tenant_id":"tenantFromAuthInfo"}' }
1220+ } - ParameterFilter {
1221+ $Method -eq ' GET'
1222+ }
1223+
1224+ Mock - CommandName ' Invoke-WebRequest' - ModuleName ' HaloAPI' - MockWith {
1225+ throw $HttpException
1226+ } - ParameterFilter {
1227+ $Method -eq ' POST'
1228+ }
1229+
1230+ $Result = Connect-HaloAPI - URL ' https://example.halo/' - ClientID ' client-token' - ClientSecret ' secret-token' - Scopes ' all'
1231+
1232+ $Result | Should - BeFalse
1233+ Should - Invoke - CommandName ' Start-Sleep' - ModuleName ' HaloAPI' - Times 10 - Exactly - ParameterFilter {
1234+ $Seconds -eq 5
1235+ }
1236+ Should - Invoke - CommandName ' New-HaloError' - ModuleName ' HaloAPI' - Times 1 - Exactly - ParameterFilter {
1237+ $ModuleMessage -eq ' Retried auth request 10 times, request unsuccessful.'
1238+ }
1239+ }
1240+
10041241 It ' retries the auth info request after a throttled response and does not report a failure after success' {
10051242 $script :AuthInfoCallCount = 0
10061243 $Response = [System.Net.Http.HttpResponseMessage ]::new([System.Net.HttpStatusCode ]::TooManyRequests)
0 commit comments