From c6fd43162673a4d68c92943640b1fb184f3e5327 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 15 Sep 2026 22:18:24 +0300 Subject: [PATCH] feat: add local acceptance test workflow --- .env.example | 11 ++++++ .github/workflows/ci.yml | 9 +++-- .github/workflows/weekly-load.yml | 6 ++- AGENTS.md | 8 +++- README.md | 44 +++++++++++++++------ SERVICE.md | 4 +- Taskfile.yml | 42 ++++++++++++++++++++ scripts/check.ps1 | 34 +++++++++++----- scripts/run-calendar.ps1 | 66 ++++++++++++++++++++++++------- scripts/run-chaos.ps1 | 8 ++-- scripts/run-load.ps1 | 6 +-- tests/calendar-event.js | 17 ++++---- 12 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 Taskfile.yml diff --git a/.env.example b/.env.example index 83949ea..c740763 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,15 @@ K6_USERS=5 K6_TIME=20s K6_P95_MS=300 K6_ERROR_RATE=0.01 +KEYCLOAK_URL=http://localhost:8081 +CHANNEL_URL=http://host.docker.internal:18084 +ACTION_URL=http://host.docker.internal:18081 +CALENDAR_TEST_URL=http://host.docker.internal:18082 +OIDC_REALM=portable-agent +OIDC_CLIENT_ID=portable-agent-local + +# Keep local credentials and test API keys in ignored .env, not in Git. +# TEST_USERNAME=local-user +# TEST_PASSWORD=change-me +# CALENDAR_TEST_API_KEY=change-me diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f3526..d0b605c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,13 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 + - uses: go-task/setup-task@v2 + with: + version: 3.53.1 - name: Проверить конфигурацию - shell: pwsh - run: ./scripts/check.ps1 + run: task verify - name: Запустить k6 smoke - shell: pwsh - run: ./scripts/run-load.ps1 + run: task test:smoke security: uses: portable-agent/.github/.github/workflows/reusable-security.yml@main diff --git a/.github/workflows/weekly-load.yml b/.github/workflows/weekly-load.yml index 1c518a1..b10dac0 100644 --- a/.github/workflows/weekly-load.yml +++ b/.github/workflows/weekly-load.yml @@ -11,10 +11,12 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 + - uses: go-task/setup-task@v2 + with: + version: 3.53.1 - name: Запустить расширенный локальный тест - shell: pwsh env: K6_USERS: "20" K6_TIME: 2m - run: ./scripts/run-load.ps1 + run: task test:smoke diff --git a/AGENTS.md b/AGENTS.md index cdadc77..82dbf2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,10 @@ ## Команды ```powershell -pwsh ./scripts/check.ps1 -pwsh ./scripts/run-load.ps1 +task verify +task test:smoke +task test:e2e ``` + +`test:e2e` запускается против уже поднятого `deploy`-окружения. Не переноси сюда управление жизненным +циклом сервисов: `test-lab` владеет сценариями и результатами, `deploy` владеет стендом. diff --git a/README.md b/README.md index c6777f1..33ccfab 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,13 @@ ```powershell Copy-Item .env.example .env -pwsh ./scripts/check.ps1 -pwsh ./scripts/run-load.ps1 +task verify +task test:smoke ``` +Все повседневные команды собраны в `Taskfile.yml`. Выполни `task --list`, чтобы увидеть их. На +Windows Task сам использует Windows PowerShell, поэтому отдельная установка `pwsh` не нужна. + По умолчанию Compose поднимает только локальный fake-service. Для внешней среды явно передай `TARGET_URL`; production URL скрипты отклоняют. Пороги k6 хранятся рядом со сценарием. @@ -21,18 +24,35 @@ pwsh ./scripts/run-load.ps1 действие ждёт подтверждения, после подтверждения завершается и создаёт ровно одно событие. Повтор с тем же `requestKey` не создаёт дубль. -До появления Temporal worker и `fake-calendar` этот тест намеренно красный. После запуска полного -локального стенда получи JWT тестового пользователя и выполни: +Сначала подними полный локальный стенд в репозитории `deploy`. Затем заполни в локальном `.env` +учётные данные только тестового пользователя и ключ проверочного API: + +```dotenv +TEST_USERNAME=local-user +TEST_PASSWORD=<пароль локального пользователя> +CALENDAR_TEST_API_KEY=<локальный ключ Calendar MCP> +``` + +После этого запусти: ```powershell -$env:ACTION_TOKEN = "" -$env:CALENDAR_TEST_API_KEY = "<тот же локальный секрет, что у Calendar MCP>" -pwsh ./scripts/run-calendar.ps1 +task test:e2e ``` -Скрипт принимает только локальные HTTP-адреса. Проверочный API `fake-calendar` доступен только в -тестовом режиме и требует отдельный `X-Test-Key`; секрет не хранится в Git. +Runner сам получает короткоживущий JWT у локального Keycloak. В CI вместо тестового логина и пароля +можно передать готовый `ACTION_TOKEN`. Скрипт принимает только локальные HTTP-адреса. Проверочный API +`fake-calendar` доступен только в тестовом режиме и требует отдельный `X-Test-Key`; секреты не +хранятся в Git. + +Путь начинается с публичной границы `Channel Gateway`, затем проходит через Agent Runtime, Action +Service, Temporal и Calendar MCP. Один JWT передаётся по этому пути; каждый защищённый сервис +самостоятельно проверяет подпись, issuer, срок и свой audience. Идентификаторы пользователя и tenant +не передаются в JSON запроса: сервисы получают их из проверенных claims `sub` и `tenant_id`. + +## Границы тестов -Один JWT передаётся в Agent Runtime и Action Service. Оба сервиса независимо проверяют подпись, -issuer, срок и свой audience. Идентификаторы пользователя и tenant не передаются в JSON запроса: -сервисы получают их из проверенных claims `sub` и `tenant_id`. +- unit-, component- и integration-тесты принадлежат репозиторию конкретного сервиса; +- здесь остаются только критические black-box пути пользователя, нагрузочные проверки и безопасные + resilience-сценарии; +- `test:e2e` не поднимает сервисы и не управляет их жизненным циклом: за окружение отвечает `deploy`; +- production-адреса runner отклоняет до запуска теста. diff --git a/SERVICE.md b/SERVICE.md index ca1b9f2..dc3160f 100644 --- a/SERVICE.md +++ b/SERVICE.md @@ -6,5 +6,7 @@ | Бизнес-код | отсутствует | | Основной инструмент | k6; contract-first сценарии | | Chaos | отдельный ручной запуск, выключен по умолчанию | -| Первый acceptance-путь | Agent Runtime 2.1 → Action Service → Temporal → Calendar MCP | +| Первый acceptance-путь | Channel Gateway → Agent Runtime → Action Service → Temporal → Calendar MCP | +| Локальный запуск | `task test:e2e` против уже поднятого окружения | +| Граница ответственности | Не поднимает сервисы и не содержит их component-тесты | diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..358dc25 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,42 @@ +version: "3" + +silent: true + +vars: + POWERSHELL: '{{if eq OS "windows"}}powershell.exe{{else}}pwsh{{end}}' + POWERSHELL_ARGS: '{{if eq OS "windows"}}-NoProfile -ExecutionPolicy Bypass{{else}}-NoProfile{{end}}' + +tasks: + default: + desc: Показать доступные команды + cmds: + - task --list + + doctor: + desc: Проверить Docker и Task + cmds: + - task --version + - docker version --format "Docker Engine {{`{{.Server.Version}}`}}" + - docker compose version + + verify: + desc: Проверить структуру и конфигурацию лаборатории + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/check.ps1' + + test:smoke: + desc: Выполнить быстрый тест локального fake-service + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-load.ps1' + + test:e2e: + desc: Проверить создание встречи через полный локальный срез + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-calendar.ps1' + + test:load: + desc: Выполнить настраиваемый нагрузочный тест + cmds: + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-load.ps1 -TargetUrl "{{.TARGET_URL}}"' + requires: + vars: [TARGET_URL] diff --git a/scripts/check.ps1 b/scripts/check.ps1 index f8a496f..203e7e1 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,20 +1,36 @@ $ErrorActionPreference = "Stop" -foreach ($file in @(".env.example", "compose.yaml", "tests/smoke.js", "tests/calendar-event.js", "scripts/run-calendar.ps1", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) { - if (-not (Test-Path $file)) { throw "Нет обязательного файла: $file" } +foreach ($file in @(".env.example", "compose.yaml", "Taskfile.yml", "tests/smoke.js", "tests/calendar-event.js", "scripts/run-calendar.ps1", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) { + if (-not (Test-Path $file)) { throw "Required file is missing: $file" } } -if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker не найден." } +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is not installed." } & docker compose --env-file .env.example config --quiet -if ($LASTEXITCODE -ne 0) { throw "Compose config содержит ошибку." } +if ($LASTEXITCODE -ne 0) { throw "Compose config is invalid." } $script = Get-Content tests/smoke.js -Raw foreach ($required in @("TARGET_URL", "thresholds", "http_req_failed", "http_req_duration")) { - if ($script -notmatch $required) { throw "В k6-тесте нет $required." } + if ($script -notmatch $required) { throw "k6 test does not contain $required." } } $calendarScript = Get-Content tests/calendar-event.js -Raw -foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "availableConnectors", "requiresApproval", "Authorization")) { - if ($calendarScript -notmatch [regex]::Escape($required)) { throw "В calendar acceptance-тесте нет $required." } +foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "CHANNEL_URL", "/api/v1/messages", "requiresApproval", "Authorization")) { + if ($calendarScript -notmatch [regex]::Escape($required)) { throw "Calendar acceptance test does not contain $required." } } foreach ($oldName in @("utterance", "tenant_id", "actor_id", "available_connectors", "requires_approval")) { - if ($calendarScript -match [regex]::Escape($oldName)) { throw "В calendar acceptance-тесте осталось старое поле $oldName." } + if ($calendarScript -match [regex]::Escape($oldName)) { throw "Calendar acceptance test still contains old field $oldName." } } -Write-Host "Быстрые проверки test-lab прошли." +if ($calendarScript -match "/api/v1/proposals") { throw "Acceptance-test must start through Channel Gateway." } + +$runner = Get-Content scripts/run-calendar.ps1 -Raw +foreach ($required in @("ACTION_TOKEN", "KEYCLOAK_URL", "OIDC_REALM", "OIDC_CLIENT_ID", "TEST_USERNAME", "TEST_PASSWORD", "protocol/openid-connect/token")) { + if ($runner -notmatch [regex]::Escape($required)) { throw "Calendar runner does not support $required." } +} + +$taskfile = Get-Content Taskfile.yml -Raw +foreach ($required in @("verify:", "test:smoke:", "test:e2e:", "test:load:")) { + if ($taskfile -notmatch [regex]::Escape($required)) { throw "Taskfile does not contain $required." } +} + +$ci = Get-Content .github/workflows/ci.yml -Raw +foreach ($required in @("go-task/setup-task", "task verify", "task test:smoke")) { + if ($ci -notmatch [regex]::Escape($required)) { throw "CI does not use $required." } +} +Write-Host "Test Lab checks passed." diff --git a/scripts/run-calendar.ps1 b/scripts/run-calendar.ps1 index 00f6d05..2da75ac 100644 --- a/scripts/run-calendar.ps1 +++ b/scripts/run-calendar.ps1 @@ -1,29 +1,67 @@ param( - [string]$AgentUrl = "http://host.docker.internal:18080", - [string]$ActionUrl = "http://host.docker.internal:18081", - [string]$CalendarTestUrl = "http://host.docker.internal:18082" + [string]$ChannelUrl = "", + [string]$ActionUrl = "", + [string]$CalendarTestUrl = "", + [string]$KeycloakUrl = "" ) $ErrorActionPreference = "Stop" -foreach ($url in @($AgentUrl, $ActionUrl, $CalendarTestUrl)) { +function Get-Setting([string]$Name, [switch]$Secret) { + $value = [Environment]::GetEnvironmentVariable($Name) + if (-not $value -and (Test-Path -LiteralPath ".env")) { + $line = Get-Content -LiteralPath ".env" | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1 + if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) } + } + if (-not $value -and -not $Secret -and (Test-Path -LiteralPath ".env.example")) { + $line = Get-Content -LiteralPath ".env.example" | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1 + if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) } + } + if (-not $value) { throw "Set $Name in the environment or local .env file." } + return $value +} + +function Assert-LocalUrl([string]$Url) { $uri = [Uri]$url if ($uri.Scheme -ne "http" -or $uri.Host -notin @("localhost", "127.0.0.1", "host.docker.internal")) { - throw "Calendar acceptance-тест разрешён только для локальных HTTP-адресов." + throw "Calendar acceptance test allows local HTTP URLs only." } } -if (-not $env:ACTION_TOKEN) { throw "Укажи ACTION_TOKEN с JWT тестового пользователя." } -if (-not $env:CALENDAR_TEST_API_KEY) { throw "Укажи CALENDAR_TEST_API_KEY локального Calendar MCP." } -$parts = $env:ACTION_TOKEN.Split('.') -if ($parts.Count -ne 3) { throw "ACTION_TOKEN не похож на JWT." } + +$ChannelUrl = if ($ChannelUrl) { $ChannelUrl } else { Get-Setting "CHANNEL_URL" } +$ActionUrl = if ($ActionUrl) { $ActionUrl } else { Get-Setting "ACTION_URL" } +$CalendarTestUrl = if ($CalendarTestUrl) { $CalendarTestUrl } else { Get-Setting "CALENDAR_TEST_URL" } +$KeycloakUrl = if ($KeycloakUrl) { $KeycloakUrl } else { Get-Setting "KEYCLOAK_URL" } + +foreach ($url in @($ChannelUrl, $ActionUrl, $CalendarTestUrl, $KeycloakUrl)) { + Assert-LocalUrl $url +} + +$token = [Environment]::GetEnvironmentVariable("ACTION_TOKEN") +if (-not $token) { + $tokenResponse = Invoke-RestMethod -Method Post ` + -Uri "$KeycloakUrl/realms/$(Get-Setting 'OIDC_REALM')/protocol/openid-connect/token" ` + -Body @{ + client_id = Get-Setting "OIDC_CLIENT_ID" + username = Get-Setting "TEST_USERNAME" -Secret + password = Get-Setting "TEST_PASSWORD" -Secret + grant_type = "password" + } + $token = $tokenResponse.access_token +} + +$parts = $token.Split('.') +if ($parts.Count -ne 3) { throw "ACTION_TOKEN is not a JWT." } +$calendarTestKey = Get-Setting "CALENDAR_TEST_API_KEY" -Secret +$k6Image = Get-Setting "K6_IMAGE" & docker run --rm ` --volume "${PWD}/tests:/tests:ro" ` - --env "AGENT_URL=$AgentUrl" ` + --env "CHANNEL_URL=$ChannelUrl" ` --env "ACTION_URL=$ActionUrl" ` --env "CALENDAR_TEST_URL=$CalendarTestUrl" ` - --env "CALENDAR_TEST_API_KEY=$env:CALENDAR_TEST_API_KEY" ` - --env "ACTION_TOKEN=$env:ACTION_TOKEN" ` - "grafana/k6:2.2.0" run /tests/calendar-event.js + --env "CALENDAR_TEST_API_KEY=$calendarTestKey" ` + --env "ACTION_TOKEN=$token" ` + $k6Image run /tests/calendar-event.js -if ($LASTEXITCODE -ne 0) { throw "Сценарий создания встречи не прошёл." } +if ($LASTEXITCODE -ne 0) { throw "Calendar acceptance scenario failed." } diff --git a/scripts/run-chaos.ps1 b/scripts/run-chaos.ps1 index 89eea77..44c274b 100644 --- a/scripts/run-chaos.ps1 +++ b/scripts/run-chaos.ps1 @@ -3,11 +3,11 @@ param( [switch]$AllowChaos ) $ErrorActionPreference = "Stop" -if (-not $AllowChaos) { throw "Нужен явный флаг -AllowChaos." } -if ($Namespace -match '(?i)prod|production') { throw "Chaos в production запрещён этим репозиторием." } +if (-not $AllowChaos) { throw "Explicit -AllowChaos flag is required." } +if ($Namespace -match '(?i)prod|production') { throw "Chaos in production is forbidden by this repository." } $label = & kubectl get namespace $Namespace -o 'jsonpath={.metadata.labels.portable-agent\.io/chaos-ready}' -if ($LASTEXITCODE -ne 0 -or $label -ne "true") { throw "У namespace нет метки portable-agent.io/chaos-ready=true." } +if ($LASTEXITCODE -ne 0 -or $label -ne "true") { throw "Namespace must have portable-agent.io/chaos-ready=true label." } $yaml = (Get-Content chaos/pod-delay.yaml -Raw).Replace("change-me", $Namespace) $yaml | & kubectl apply -f - -if ($LASTEXITCODE -ne 0) { throw "Chaos manifest не применился." } +if ($LASTEXITCODE -ne 0) { throw "Chaos manifest was not applied." } diff --git a/scripts/run-load.ps1 b/scripts/run-load.ps1 index 74fc3ab..3ed773c 100644 --- a/scripts/run-load.ps1 +++ b/scripts/run-load.ps1 @@ -3,14 +3,14 @@ $ErrorActionPreference = "Stop" $envFiles = @("--env-file", ".env.example") if (Test-Path .env) { $envFiles += @("--env-file", ".env") } if ($TargetUrl) { - if ($TargetUrl -match '(?i)prod|production') { throw "Запуск против production запрещён." } + if ($TargetUrl -match '(?i)prod|production') { throw "Running against production is forbidden." } $env:TARGET_URL = $TargetUrl } try { & docker compose @envFiles up -d --wait fake-service - if ($LASTEXITCODE -ne 0) { throw "Fake service не запустился." } + if ($LASTEXITCODE -ne 0) { throw "Fake service did not start." } & docker compose @envFiles --profile test run --rm k6 - if ($LASTEXITCODE -ne 0) { throw "k6-пороги не выполнены." } + if ($LASTEXITCODE -ne 0) { throw "k6 thresholds were not met." } } finally { & docker compose @envFiles --profile test down | Out-Null diff --git a/tests/calendar-event.js b/tests/calendar-event.js index 714e136..b518c25 100644 --- a/tests/calendar-event.js +++ b/tests/calendar-event.js @@ -1,15 +1,15 @@ import http from 'k6/http'; import { check, fail, sleep } from 'k6'; -const agentUrl = requiredUrl('AGENT_URL'); +const channelUrl = requiredUrl('CHANNEL_URL'); const actionUrl = requiredUrl('ACTION_URL'); const calendarTestUrl = requiredUrl('CALENDAR_TEST_URL'); const calendarTestKey = required('CALENDAR_TEST_API_KEY'); const userToken = required('ACTION_TOKEN'); const eventData = { title: 'Обсуждение проекта', - startAt: '2026-09-08T12:00:00+03:00', - endAt: '2026-09-08T12:30:00+03:00', + startAt: '2030-09-08T12:00:00+03:00', + endAt: '2030-09-08T12:30:00+03:00', timeZone: 'Europe/Moscow', }; @@ -69,23 +69,24 @@ export default function () { function createProposal() { const response = http.post( - `${agentUrl}/api/v1/proposals`, + `${channelUrl}/api/v1/messages`, JSON.stringify({ + requestKey: `proposal-${Date.now()}-${__VU}-${__ITER}`, text: `Создай встречу "${eventData.title}" с ${eventData.startAt} до ${eventData.endAt}`, context: { + locale: 'ru-RU', timeZone: eventData.timeZone, - availableConnectors: ['fake-calendar'], }, }), authHeaders(), ); - expectStatus(response, 200, 'agent-runtime did not create a proposal'); + expectStatus(response, 200, 'channel-gateway did not create a proposal'); const body = response.json(); if (!body.proposal || body.clarification) { - fail('agent-runtime returned no complete proposal'); + fail('channel-gateway returned no complete proposal'); } if (!body.proposal.requiresApproval) { - fail('agent-runtime proposal does not require approval'); + fail('channel-gateway proposal does not require approval'); } check(body.proposal.payload, { 'proposal keeps the exact event data': (payload) =>