Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,10 @@ jobs:
sleep 1
done

BODY=$(curl -s -X POST http://localhost:5000/api/target \
-H 'Content-Type: application/json' -d '{"a":1}')
test "$BODY" = "true"

CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:5000/api/target \
-H 'Content-Type: application/json' -d 'nope')
test "$CODE" = "400"

# The 1 MB request body limit. TestServer does not enforce MaxRequestBodySize, so this
# is the only place the limit can be verified end to end.
python3 -c "open('big.json','w').write('{\"a\":\"' + 'x' * 1100000 + '\"}')"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:5000/api/target \
-H 'Content-Type: application/json' --data-binary @big.json)
test "$CODE" = "413"
# The same script that ships inside every release zip. Running it here means the
# shipped verification tool cannot rot unnoticed, and it covers the request body
# limit, which TestServer does not enforce and unit tests therefore cannot reach.
bash scripts/smoke-test.sh http://localhost:5000

docker logs tas
docker rm -f tas
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ jobs:

[[ "$RID" == win-* ]] || chmod +x "out/$RID/TargetApiSimulator"

# Shipped alongside the binary so anyone can confirm a download actually works.
cp scripts/smoke-test.ps1 scripts/smoke-test.sh "out/$RID/"
chmod +x "out/$RID/smoke-test.sh"

mkdir -p dist
( cd "out/$RID" && zip -r "$GITHUB_WORKSPACE/dist/$NAME.zip" . )
( cd dist && sha256sum "$NAME.zip" > "$NAME.zip.sha256" )
Expand Down Expand Up @@ -226,6 +230,8 @@ jobs:
set -euo pipefail
NAME="TargetApiSimulator-$VERSION-portable"
rm -f out/portable/web.config out/portable/TargetApiSimulator.staticwebassets.endpoints.json
cp scripts/smoke-test.ps1 scripts/smoke-test.sh out/portable/
chmod +x out/portable/smoke-test.sh
mkdir -p dist
( cd out/portable && zip -r "$GITHUB_WORKSPACE/dist/$NAME.zip" . )
( cd dist && sha256sum "$NAME.zip" > "$NAME.zip.sha256" )
Expand Down
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.0.4] - 2026-07-31

### Added

- `smoke-test.ps1` and `smoke-test.sh` ship inside every release zip. Start the simulator, run
one of them, and it exercises the whole documented contract — 25 checks — printing PASS or
FAIL for each and exiting non-zero if anything is wrong, so it doubles as a CI gate. Exits
with 2, and says so, when the simulator is not reachable.
- README: a browser recipe. `/healthz` and `/version` open directly; for the POST endpoint,
open `/healthz` first and use `fetch` from the console — being on the simulator's own origin
is what avoids CORS, since no CORS policy is configured.

### Changed

- The container smoke test in CI now runs `scripts/smoke-test.sh` instead of a handful of inline
curl calls, so the script that ships to users is exercised on every pull request and cannot
rot unnoticed.

### Fixed

- `/version` reported the commit sha twice — `1.0.3+<sha>.<sha>` — because the release pipeline
appends it to `InformationalVersion` and the SDK appended it again.
`IncludeSourceRevisionInInformationalVersion` is now off.

## [1.0.3] - 2026-07-31

### Changed
Expand Down Expand Up @@ -138,7 +162,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Every pull request runs `dotnet list package --vulnerable --include-transitive` and fails on a
hit. CLI output is forced to English so the check cannot silently pass on a localised runner.

[Unreleased]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.3...HEAD
[Unreleased]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.4...HEAD
[1.0.4]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.3...v1.0.4
[1.0.3]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/Mysttic/TargetApiSimulator/compare/v1.0.0...v1.0.1
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,62 @@ and assert that what you send is well-formed JSON.

A ready-to-run request collection is in [TargetApiSimulator.http](./TargetApiSimulator.http).

## Check that it works

Every release zip contains **`smoke-test.ps1`** and **`smoke-test.sh`**. Start the simulator, run one of
them, and it exercises the whole documented contract — 25 checks — printing PASS or FAIL for each and
exiting non-zero if anything is wrong, so it also works as a CI gate.

```bash
./TargetApiSimulator --urls http://localhost:5000 # in one terminal
./smoke-test.sh # in another
```

```powershell
.\TargetApiSimulator.exe --urls http://localhost:5000
.\smoke-test.ps1
```

```
Service endpoints
PASS GET /healthz returns 200
PASS GET /healthz body
...
Contract details
PASS Response declares JSON content type
PASS Nesting deeper than 64 -> 400
PASS Body over 1 MB -> 413

All 25 checks passed.
```

Pass a different address as the first argument (`./smoke-test.sh http://localhost:8080`, or
`-BaseUrl` in PowerShell). If the simulator is not running the script says so and exits with 2.

### From the browser

`http://localhost:5000/healthz` and `http://localhost:5000/version` open directly — they are plain GETs.

The validation endpoint only accepts POST, so a URL alone will not reach it. **First open
`http://localhost:5000/healthz`**, then press F12 and paste this into the console. Being on the
simulator's own origin is what makes it work without CORS:

```js
const check = async (body) => {
const r = await fetch('/api/target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
console.log(r.status, await r.text());
};

await check('{"key":"value"}'); // 200 true
await check('not json at all'); // 400 {"ErrorMessage": "This is not JSON"}
```

Whatever you send shows up in the simulator's console at the same moment.

## See it running

Send a payload, get a verdict:
Expand Down
2 changes: 1 addition & 1 deletion VERSION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Version

1.0.3
1.0.4

<!--
This file is the single source of truth for the released version.
Expand Down
160 changes: 160 additions & 0 deletions scripts/smoke-test.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<#
.SYNOPSIS
Checks a running TargetApiSimulator against its documented contract.

.DESCRIPTION
Start the simulator first, then point this script at it. Every check prints PASS or FAIL and
the script exits with 1 if anything failed, so it can be used as a CI gate.

.EXAMPLE
.\TargetApiSimulator.exe --urls http://localhost:5000
.\smoke-test.ps1

.EXAMPLE
.\smoke-test.ps1 -BaseUrl http://localhost:8080
#>
[CmdletBinding()]
param(
[string]$BaseUrl = 'http://localhost:5000'
)

$ErrorActionPreference = 'Stop'
$BaseUrl = $BaseUrl.TrimEnd('/')

Add-Type -AssemblyName System.Net.Http | Out-Null
$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromSeconds(30)

$script:Passed = 0
$script:Failed = 0

function Send-Request {
param(
[string]$Method,
[string]$Path,
[string]$Body,
[string]$ContentType = 'application/json'
)

$request = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::new($Method), "$BaseUrl$Path")

if ($PSBoundParameters.ContainsKey('Body')) {
$request.Content = [System.Net.Http.StringContent]::new(
$Body, [System.Text.Encoding]::UTF8, $ContentType)
}

$response = $client.SendAsync($request).GetAwaiter().GetResult()

[pscustomobject]@{
Status = [int]$response.StatusCode
Body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
ContentType = if ($response.Content.Headers.ContentType) {
$response.Content.Headers.ContentType.ToString()
} else { $null }
Allow = ($response.Content.Headers.Allow -join ',')
}
}

function Assert-That {
param([string]$Name, [string]$Expected, [string]$Actual)

if ($Expected -eq $Actual) {
Write-Host (' PASS {0}' -f $Name) -ForegroundColor Green
$script:Passed++
}
else {
Write-Host (' FAIL {0}' -f $Name) -ForegroundColor Red
Write-Host (' expected: {0}' -f $Expected) -ForegroundColor DarkGray
Write-Host (' actual: {0}' -f $Actual) -ForegroundColor DarkGray
$script:Failed++
}
}

Write-Host ''
Write-Host "TargetApiSimulator smoke test -> $BaseUrl" -ForegroundColor Cyan
Write-Host ''

try {
$null = Send-Request -Method GET -Path '/healthz'
}
catch {
Write-Host "Cannot reach $BaseUrl - is the simulator running?" -ForegroundColor Red
Write-Host "Start it with: .\TargetApiSimulator.exe --urls $BaseUrl" -ForegroundColor DarkGray
exit 2
}

Write-Host 'Service endpoints'
$r = Send-Request -Method GET -Path '/healthz'
Assert-That 'GET /healthz returns 200' '200' $r.Status
Assert-That 'GET /healthz body' '{"status":"ok"}' $r.Body

$r = Send-Request -Method GET -Path '/version'
Assert-That 'GET /version returns 200' '200' $r.Status
Assert-That 'GET /version reports a version' 'True' ([string]($r.Body -match '"version":"[^"]+"'))
Write-Host (' version: {0}' -f $r.Body) -ForegroundColor DarkGray

Write-Host ''
Write-Host 'Valid payloads'
foreach ($payload in '{"key":"value"}', '[1,2,3]', '{}', '[]', '123', '"str"', 'true', 'null') {
$r = Send-Request -Method POST -Path '/api/target' -Body $payload
Assert-That ('POST {0} -> 200 true' -f $payload) '200 true' ('{0} {1}' -f $r.Status, $r.Body)
}

Write-Host ''
Write-Host 'Rejected payloads'
foreach ($payload in 'not json at all', '{"a":}', '{"a":1,}', '{"a":1} // c', '{', '') {
$label = if ($payload -eq '') { '(empty body)' } else { $payload }
$r = Send-Request -Method POST -Path '/api/target' -Body $payload
Assert-That ('POST {0} -> 400' -f $label) `
'400 {"ErrorMessage": "This is not JSON"}' ('{0} {1}' -f $r.Status, $r.Body)
}

Write-Host ''
Write-Host 'Contract details'
$r = Send-Request -Method POST -Path '/api/target' -Body '{"a":1}'
Assert-That 'Response declares JSON content type' 'application/json; charset=utf-8' $r.ContentType

$deep = ('[' * 65) + (']' * 65)
$r = Send-Request -Method POST -Path '/api/target' -Body $deep
Assert-That 'Nesting deeper than 64 -> 400' '400' $r.Status

$r = Send-Request -Method POST -Path '/api/target' -Body '{"a":1}' -ContentType 'text/plain'
Assert-That 'Content-Type is ignored -> 200' '200' $r.Status

$r = Send-Request -Method GET -Path '/api/target'
Assert-That 'GET /api/target -> 405' '405' $r.Status
Assert-That '405 advertises Allow: POST' 'POST' $r.Allow

$r = Send-Request -Method POST -Path '/does-not-exist' -Body '{}'
Assert-That 'Unknown path -> 404' '404' $r.Status

$big = '{"a":"' + ('x' * 1100000) + '"}'
$r = Send-Request -Method POST -Path '/api/target' -Body $big
Assert-That 'Body over 1 MB -> 413' '413' $r.Status

Write-Host ''
Write-Host 'Failure injection (optional feature)'
$request = [System.Net.Http.HttpRequestMessage]::new(
[System.Net.Http.HttpMethod]::new('POST'), "$BaseUrl/api/target")
$request.Content = [System.Net.Http.StringContent]::new(
'{"a":1}', [System.Text.Encoding]::UTF8, 'application/json')
$request.Headers.Add('X-Sim-Status', '503')
$response = $client.SendAsync($request).GetAwaiter().GetResult()

if ([int]$response.StatusCode -eq 503) {
Write-Host ' INFO X-Sim-* control headers are enabled; forced 503 works' -ForegroundColor Cyan
}
else {
Write-Host ' INFO X-Sim-* control headers are off (the shipped default)' -ForegroundColor DarkGray
Write-Host ' Enable with: Simulator__EnableControlHeaders=true' -ForegroundColor DarkGray
}

Write-Host ''
if ($script:Failed -eq 0) {
Write-Host ('All {0} checks passed.' -f $script:Passed) -ForegroundColor Green
exit 0
}

Write-Host ('{0} passed, {1} FAILED.' -f $script:Passed, $script:Failed) -ForegroundColor Red
exit 1
Loading