|
| 1 | +$ErrorActionPreference = 'Stop' |
| 2 | +$root = Join-Path $PSScriptRoot ('.pr-validation-tests-' + [Guid]::NewGuid()) |
| 3 | +$count = 0 |
| 4 | +New-Item -ItemType Directory $root | Out-Null |
| 5 | + |
| 6 | +function Assert($Condition, $Message) { |
| 7 | + if (!$Condition) { throw $Message } |
| 8 | +} |
| 9 | + |
| 10 | +function Assert-Snapshot($Request) { |
| 11 | + Assert ($Request.resources.repositories.self.refName -ceq 'refs/pull/42/head') 'Wrong YAML ref' |
| 12 | + Assert ($Request.resources.repositories.self.version -ceq ('a' * 40)) 'Wrong YAML revision' |
| 13 | + Assert ($Request.templateParameters.prNumber -is [string] -and $Request.templateParameters.prNumber -ceq '42') 'PR number must be a string' |
| 14 | + Assert ($Request.templateParameters.headSha -ceq ('a' * 40)) 'Wrong head snapshot' |
| 15 | + Assert ($Request.templateParameters.baseSha -ceq ('b' * 40)) 'Wrong base snapshot' |
| 16 | +} |
| 17 | + |
| 18 | +function Invoke-WebRequest { |
| 19 | + [CmdletBinding()] |
| 20 | + param($Uri, $Method, $Headers, $UserAgent, $ContentType, $MaximumRedirection, [switch]$SkipHttpErrorCheck, $Body) |
| 21 | + $calls.Add($PSBoundParameters) |
| 22 | + Assert ($responses.ContainsKey($Uri)) "Unexpected URL: $Uri" |
| 23 | + Assert ($Headers.Authorization -ceq "Bearer $expectedToken") 'Wrong bearer token' |
| 24 | + Assert ($Headers.Accept -ceq 'application/json' -and $ContentType -ceq 'application/json') 'Wrong JSON headers' |
| 25 | + Assert ($UserAgent -ceq 'fsharp-pr-validation') 'Missing user agent' |
| 26 | + Assert ($MaximumRedirection -ceq 0 -and $SkipHttpErrorCheck -and $PSBoundParameters.ErrorAction -eq 'Stop') 'HTTP handling is not fail-closed' |
| 27 | + $response = $responses[$Uri] |
| 28 | + Assert ($Method -ceq $response.Method) 'Wrong HTTP method' |
| 29 | + if ($response.Failure) { throw 'Network failed (test-token)' } |
| 30 | + @{ |
| 31 | + StatusCode = $response.Status |
| 32 | + Content = if ($response.ContainsKey('Raw')) { $response.Raw } else { ConvertTo-Json -InputObject $response.Body -Depth 10 -Compress } |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +function Test-Case($Name, [scriptblock]$Change = {}, $ExpectedError = '', $Mode = 'Prepare', $CallCount = -1) { |
| 37 | + $event = @{ |
| 38 | + comment = @{ body = "/dart $('a' * 40)"; user = @{ login = 'maintainer'; type = 'User' } } |
| 39 | + issue = @{ number = 42; pull_request = @{} } |
| 40 | + } |
| 41 | + $pr = @{ |
| 42 | + state = 'open' |
| 43 | + head = @{ sha = 'a' * 40; repo = @{ full_name = 'contributor/fsharp' } } |
| 44 | + base = @{ sha = 'b' * 40; ref = 'main'; repo = @{ full_name = 'dotnet/fsharp' } } |
| 45 | + } |
| 46 | + $permission = @{ permission = 'write' } |
| 47 | + $request = @{ |
| 48 | + resources = @{ repositories = @{ self = @{ refName = 'refs/pull/42/head'; version = 'a' * 40 } } } |
| 49 | + templateParameters = @{ prNumber = '42'; headSha = 'a' * 40; baseSha = 'b' * 40 } |
| 50 | + } |
| 51 | + $permissionUri = 'https://api.github.com/repos/dotnet/fsharp/collaborators/maintainer/permission' |
| 52 | + $pullUri = 'https://api.github.com/repos/dotnet/fsharp/pulls/42' |
| 53 | + $memberUri = 'https://api.github.com/orgs/microsoft/members/maintainer' |
| 54 | + $queueUri = 'https://dev.azure.com/devdiv/DevDiv/_apis/pipelines/123/runs?api-version=7.1' |
| 55 | + $reportUri = 'https://api.github.com/repos/dotnet/fsharp/issues/42/comments' |
| 56 | + $responses = @{ |
| 57 | + $permissionUri = @{ Method = 'GET'; Status = 200; Body = $permission } |
| 58 | + $pullUri = @{ Method = 'GET'; Status = 200; Body = $pr } |
| 59 | + $memberUri = @{ Method = 'GET'; Status = 204 } |
| 60 | + $queueUri = @{ Method = 'POST'; Status = 200; Body = @{ id = 456 } } |
| 61 | + $reportUri = @{ Method = 'POST'; Status = 201; Body = @{ id = 789 } } |
| 62 | + } |
| 63 | + $calls = [Collections.Generic.List[object]]::new() |
| 64 | + $env:GITHUB_EVENT_PATH = Join-Path $root 'event.json' |
| 65 | + $env:GITHUB_OUTPUT = Join-Path $root 'output' |
| 66 | + $env:GITHUB_REPOSITORY = 'dotnet/fsharp'; $env:GITHUB_ACTOR = 'rerunning-admin' |
| 67 | + $env:GITHUB_SERVER_URL = 'https://github.com'; $env:GITHUB_RUN_ID = '987' |
| 68 | + $env:GITHUB_TOKEN = if ($Mode -eq 'Membership') { 'membership-token' } else { 'github-token' } |
| 69 | + $env:PIPELINE_ID = '123'; $env:AZDO_TOKEN = 'test-token' |
| 70 | + $env:REQUEST_BODY = ConvertTo-Json -InputObject $request -Depth 10 -Compress |
| 71 | + $env:RUN_URL = ''; $env:HEAD_SHA = 'a' * 40; $env:BASE_SHA = 'b' * 40 |
| 72 | + $expectedToken = if ($Mode -eq 'Queue') { $env:AZDO_TOKEN } else { $env:GITHUB_TOKEN } |
| 73 | + . $Change |
| 74 | + ConvertTo-Json -InputObject $event -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH -Encoding utf8 |
| 75 | + Remove-Item -LiteralPath $env:GITHUB_OUTPUT -ErrorAction SilentlyContinue |
| 76 | + $failure = $null |
| 77 | + try { $stdout = @(& "$PSScriptRoot\pr-validation.ps1" -Mode $Mode) } |
| 78 | + catch { $failure = "$_" } |
| 79 | + if ($ExpectedError) { |
| 80 | + Assert ($failure -and $failure -match $ExpectedError) "$Name failed for wrong reason: $failure" |
| 81 | + Assert (!(Test-Path -LiteralPath $env:GITHUB_OUTPUT)) "$Name wrote outputs on failure" |
| 82 | + Assert ($failure -notmatch 'test-token|github-token|membership-token') 'Leaked token' |
| 83 | + } |
| 84 | + else { |
| 85 | + Assert (!$failure) "$Name failed: $failure" |
| 86 | + Assert ($stdout.Count -eq 0) "$Name leaked stdout" |
| 87 | + switch ($Mode) { |
| 88 | + 'Prepare' { |
| 89 | + $lines = @(Get-Content -LiteralPath $env:GITHUB_OUTPUT) |
| 90 | + Assert ($lines.Count -eq 3 -and $lines[1] -ceq "head=$('a' * 40)" -and $lines[2] -ceq "base=$('b' * 40)") 'Wrong snapshot outputs' |
| 91 | + Assert-Snapshot (ConvertFrom-Json $lines[0].Substring(5)) |
| 92 | + Assert ($calls[0].Uri -ceq $permissionUri -and $calls[1].Uri -ceq $pullUri) 'Wrong requester or PR' |
| 93 | + } |
| 94 | + 'Queue' { |
| 95 | + Assert-Snapshot (ConvertFrom-Json $calls[0].Body) |
| 96 | + Assert ((Get-Content -LiteralPath $env:GITHUB_OUTPUT -Raw).Trim() -ceq 'url=https://dev.azure.com/devdiv/DevDiv/_build/results?buildId=456') 'Wrong run link' |
| 97 | + } |
| 98 | + 'Report' { |
| 99 | + $body = (ConvertFrom-Json $calls[0].Body).body |
| 100 | + $expected = if ($env:RUN_URL) { '[F# Apex run](https://example.invalid/run) requested by @maintainer.' } |
| 101 | + else { 'F# Apex request failed. [Workflow details](https://github.com/dotnet/fsharp/actions/runs/987).' } |
| 102 | + Assert ($body.StartsWith($expected)) 'Wrong PR feedback' |
| 103 | + Assert ($body.Contains('`/dart <sha>`') -and $body.Contains('`/pr-val <sha>`')) 'Missing retry guidance' |
| 104 | + if ($env:RUN_URL) { Assert ($body.Contains("Reviewed PR head and pipeline YAML: ``$env:HEAD_SHA``") -and $body.Contains("Base: ``$env:BASE_SHA``")) 'Missing snapshot identifiers' } |
| 105 | + } |
| 106 | + } |
| 107 | + if ($CallCount -lt 0) { $CallCount = if ($Mode -eq 'Prepare') { 2 } else { 1 } } |
| 108 | + } |
| 109 | + if ($CallCount -ge 0) { Assert ($calls.Count -eq $CallCount) "$Name made unexpected HTTP calls" } |
| 110 | + $script:count++ |
| 111 | + Write-Host "PASS: $Name" |
| 112 | +} |
| 113 | + |
| 114 | +try { |
| 115 | + foreach ($command in "/dart $('a' * 40)", "/pr-val $('a' * 40)", " `n/pr-val $('a' * 40) `n") { |
| 116 | + foreach ($authorRepo in 'dotnet/fsharp', 'contributor/fsharp') { |
| 117 | + Test-Case "$command from $authorRepo" { $event.comment.body = $command; $pr.head.repo.full_name = $authorRepo } |
| 118 | + } |
| 119 | + } |
| 120 | + foreach ($command in '/dart', '/pr-val', '/dart abc1234', 'please /dart', '`/dart`', "/dart`n/pr-val", '/dart-extra', '/DART', "/dart`nextra", "/dart $('A' * 40)", "/dart $('a' * 39)", "/dart $('a' * 41)", "/dart $('g' * 40)", "/dart $('a' * 40) extra", "/dart`n$('a' * 40)", "/dart $('a' * 40)", '', $null, 42) { |
| 121 | + Test-Case "reject command '$command'" { $event.comment.body = $command } 'standalone' -CallCount 0 |
| 122 | + } |
| 123 | + foreach ($alias in 'dart', 'pr-val') { |
| 124 | + Test-Case "$alias rejects base SHA" { $event.comment.body = "/$alias $('b' * 40)" } 'current PR head' -CallCount 2 |
| 125 | + Test-Case "$alias rejects stale reviewed SHA" { $event.comment.body = "/$alias $('c' * 40)" } 'current PR head' -CallCount 2 |
| 126 | + Test-Case "$alias rejects updated PR head" { $event.comment.body = "/$alias $('a' * 40)"; $pr.head.sha = 'c' * 40 } 'current PR head' -CallCount 2 |
| 127 | + } |
| 128 | + foreach ($access in 'write', 'maintain', 'admin') { Test-Case "permission $access" { $permission.permission = $access } } |
| 129 | + foreach ($access in 'read', 'triage', 'WRITE', '', $null, @('write')) { Test-Case "reject permission '$access'" { $permission.permission = $access } 'write access' -CallCount 1 } |
| 130 | + Test-Case 'bot' { $event.comment.user.type = 'Bot' } 'user comments' -CallCount 0 |
| 131 | + Test-Case 'array user type' { $event.comment.user.type = @('User') } 'user comments' -CallCount 0 |
| 132 | + Test-Case 'issue' { $event.issue.Remove('pull_request') } 'dotnet/fsharp PRs' -CallCount 0 |
| 133 | + Test-Case 'false PR marker' { $event.issue.pull_request = $false } 'dotnet/fsharp PRs' -CallCount 0 |
| 134 | + Test-Case 'array event' { $event = @($event) } 'dotnet/fsharp PRs' -CallCount 0 |
| 135 | + Test-Case 'array permission response' { $responses[$permissionUri].Body = @($permission) } 'write access' |
| 136 | + Test-Case 'array PR response' { $responses[$pullUri].Body = @($pr) } 'open PRs' |
| 137 | + foreach ($repo in 'other/fsharp', 'dotnet/other', 'DotNet/fsharp') { |
| 138 | + Test-Case "wrong repo $repo" { $env:GITHUB_REPOSITORY = $repo } 'dotnet/fsharp PRs' -CallCount 0 |
| 139 | + } |
| 140 | + foreach ($id in '1; command', '42', $null, 0, -1, 1.5, $true, 9007199254740992) { |
| 141 | + Test-Case "bad PR number '$id'" { $event.issue.number = $id } 'PR number' -CallCount 0 |
| 142 | + } |
| 143 | + Test-Case 'closed PR' { $pr.state = 'closed' } 'open PRs' |
| 144 | + Test-Case 'uppercase state' { $pr.state = 'OPEN' } 'open PRs' |
| 145 | + Test-Case 'array state' { $pr.state = @('open') } 'open PRs' |
| 146 | + Test-Case 'array target' { $pr.base.repo.full_name = @('dotnet/fsharp') } 'main' |
| 147 | + Test-Case 'array branch' { $pr.base.ref = @('main') } 'main' |
| 148 | + foreach ($branch in 'release/test', 'Main') { Test-Case "non-main $branch" { $pr.base.ref = $branch } 'main' } |
| 149 | + Test-Case 'foreign target' { $pr.base.repo.full_name = 'other/fsharp' } 'main' |
| 150 | + foreach ($side in 'head', 'base') { |
| 151 | + foreach ($sha in 'abcdef0', $null, ('A' * 40), (('a' * 40) + "`n"), 123, @('a' * 40)) { |
| 152 | + Test-Case "bad $side SHA '$sha'" { $pr[$side].sha = $sha } 'full head/base' |
| 153 | + } |
| 154 | + } |
| 155 | + Test-Case 'membership original commenter' -Mode Membership |
| 156 | + foreach ($status in 200, 201, 301, 302, 307, 308, 401, 403, 404, 429, 500) { |
| 157 | + Test-Case "membership HTTP $status" { $responses[$memberUri].Status = $status } "HTTP $status" Membership |
| 158 | + } |
| 159 | + foreach ($endpoint in 'permissionUri', 'pullUri', 'memberUri', 'queueUri', 'reportUri') { |
| 160 | + $mode = switch ($endpoint) { memberUri { 'Membership' }; queueUri { 'Queue' }; reportUri { 'Report' }; default { 'Prepare' } } |
| 161 | + Test-Case "$endpoint network failure" { $responses[(Get-Variable $endpoint -ValueOnly)].Failure = $true } 'HTTP request failed' $mode |
| 162 | + Test-Case "$endpoint API failure" { $responses[(Get-Variable $endpoint -ValueOnly)].Status = 500 } 'HTTP 500' $mode |
| 163 | + } |
| 164 | + Test-Case 'queue snapshot' -Mode Queue |
| 165 | + Test-Case 'queue created' { $responses[$queueUri].Status = 201 } -Mode Queue |
| 166 | + foreach ($json in '{"id":456.0}', '{"id":4.56e2}') { |
| 167 | + Test-Case "integral JSON number $json" { $responses[$queueUri].Raw = $json } -Mode Queue |
| 168 | + } |
| 169 | + foreach ($pipeline in '', '0', '123/path', "123`n", ' 123', '+123') { |
| 170 | + Test-Case "invalid pipeline '$pipeline'" { $env:PIPELINE_ID = $pipeline } 'Configure FSHARP_APEX_PIPELINE_ID' Queue 0 |
| 171 | + } |
| 172 | + foreach ($token in '', ' ') { Test-Case 'missing queue token' { $env:AZDO_TOKEN = $token } 'access token' Queue 0 } |
| 173 | + Test-Case 'invalid request JSON' { $env:REQUEST_BODY = '{' } 'JSON' Queue 0 |
| 174 | + foreach ($status in 301, 302, 303, 307, 308, 401, 403, 429, 500) { |
| 175 | + Test-Case "queue HTTP $status" { $responses[$queueUri].Status = $status } "HTTP $status" Queue |
| 176 | + } |
| 177 | + foreach ($id in $null, 0, -1, 1.5, '456', $true, 9007199254740992, @(456)) { |
| 178 | + Test-Case "invalid run ID '$id'" { $responses[$queueUri].Body.id = $id } 'valid run ID' Queue |
| 179 | + } |
| 180 | + foreach ($json in '{}', 'null', '[{"id":456}]', '{"id":[456]}', '{"id":{}}', '{"ID":456}') { |
| 181 | + Test-Case "invalid run $json" { $responses[$queueUri].Raw = $json } 'valid run ID' Queue |
| 182 | + } |
| 183 | + Test-Case 'invalid response JSON' { $responses[$queueUri].Raw = '<html>' } 'JSON' Queue |
| 184 | + Test-Case 'report success' { $env:RUN_URL = 'https://example.invalid/run' } -Mode Report |
| 185 | + Test-Case 'report failure' -Mode Report |
| 186 | + Write-Host "$count tests passed." |
| 187 | +} |
| 188 | +finally { Remove-Item -LiteralPath $root -Recurse -Force } |
0 commit comments