diff --git a/.github/scripts/dotnet-install.asc b/.github/scripts/dotnet-install.asc new file mode 100644 index 0000000..a76cec7 --- /dev/null +++ b/.github/scripts/dotnet-install.asc @@ -0,0 +1,19 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BSN Pgp v1.1.0.0 + +mQENBGPkMDwBCAC4k+2aKmawupYX+NL91FC8mU0Pe8JYBxDuG6+ZbWLywpWvKHUv +WMMPKV8dQrV0q7MMqdbQthSylWLJkkfUgMhSMJ885+0CXS0Inv5yyMeMA8XvgJ2E +GUgrSs5fabwAdPniVjndLmxIaov7xtCPSZAPP0Mjr8c000aTyQIUNsOYPviDUykS +TMP4oQeBBenTLJsauH/IXAq8wbQyQyXjI6yVV13UmUSTe+uwpM+SN/s7B8GpkxOb ++vZ9DmSexeoUvlaAO2MAAux8pogyQdvMDDkP7aXOkEfc9N87B1dXOSWShLvRg04W +aDUfU+Q6EkQk1yGyZeWvCAMf5nVyxmwan5KTABEBAAG0OU1pY3Jvc29mdCBEZXZV +WFRlYW1QcmFndWUgPGRldnV4dGVhbXByYWd1ZUBtaWNyb3NvZnQuY29tPokBOAQT +AQgAIgUCY+QwPAIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQuc8aUfx9 +Os+aTggAq9wJY0/hS2+R18RBm3CajFqN9be/uZ0uxLfmu05zgAKm6E2rRlSwX12J +x0tcaJAT+Q+P7vVMeBf+B4yOLwOwe2z/MZaxYkckC6kEnnvzXNEhfKtRhapLClHD +7Lo3iinGNUB8Qa1lBX/t+1oAp50YW1pUeNEk7Ppk2daz3/BuoFwaP8fxwVJ9MEFB +NidXwDy+uGQR9KBCU9mo6XZK3bsFW2vByWr4AQMvixw3m5QCY8m+NXRX08J9LD6t +f0G5bcHEZj+t/RbWHt1iQKv/WB62CVdArSbPFYusd73kYtdLUdwELZILcIoPqV1c +AX0xKovPmQNk8ITCopbbCs3jCIVvmw== +=awWT +-----END PGP PUBLIC KEY BLOCK----- diff --git a/.github/scripts/publish-linux.sh b/.github/scripts/publish-linux.sh new file mode 100644 index 0000000..6a86189 --- /dev/null +++ b/.github/scripts/publish-linux.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Publica fiscalapi para linux-x64 con Native AOT dentro de AlmaLinux 8 (glibc 2.28). Un binario AOT de Linux solo corre +# en la glibc con la que se enlazó o en una más nueva (Microsoft Learn, "Native AOT deployment"): enlazarlo aquí lo deja +# correr en RHEL 8 y sus derivados, Debian 10+, Ubuntu 20.04+ y SLES 15, en lugar de exigir la glibc 2.35 de Ubuntu 22.04. +# +# Lo usan el job linux-x64 de release.yml y la verificación local, desde la raíz del repo: +# docker run --rm -v "$PWD:/src" -w /src almalinux:8@sha256: bash .github/scripts/publish-linux.sh +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Uso: publish-linux.sh " >&2 + exit 2 +fi + +version="$1" +output="$2" +# La glibc de AlmaLinux 8. Si el binario pide una más nueva, se rompió la promesa de compatibilidad y el release no sigue. +max_glibc="2.28" + +# Prerrequisitos de Native AOT en RHEL 8 (Microsoft Learn): clang y zlib-devel; binutils trae objcopy (StripSymbols) y +# objdump. libicu es del SDK, no del binario (InvariantGlobalization). gnupg2 verifica el script de instalación. +dnf install -y --setopt=install_weak_deps=False clang zlib-devel binutils libicu findutils gnupg2 > /dev/null + +# El SDK exacto de global.json, con el script oficial (https://dot.net/v1/dotnet-install.sh), que solo se ejecuta si su +# firma GPG es la de Microsoft. El script baja el SDK por HTTPS de builds.dotnet.microsoft.com. +export DOTNET_ROOT=/opt/dotnet +export PATH="$DOTNET_ROOT:$PATH" +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 +curl -fsSL --retry 3 https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh +curl -fsSL --retry 3 https://dot.net/v1/dotnet-install.sig -o /tmp/dotnet-install.sig +bash "$(dirname "${BASH_SOURCE[0]}")/verify-dotnet-install.sh" /tmp/dotnet-install.sh /tmp/dotnet-install.sig +bash /tmp/dotnet-install.sh --jsonfile global.json --install-dir "$DOTNET_ROOT" --no-path + +# TreatWarningsAsErrors (Directory.Build.props) convierte cualquier advertencia IL de trimming/AOT en un error. +dotnet publish src/FiscalApi.Cli -c Release -r linux-x64 -p:Version="$version" -o "$output" + +binary="$output/fiscalapi" +required_glibc=$(objdump -T "$binary" | grep -oE 'GLIBC_[0-9]+(\.[0-9]+)+' | sed 's/^GLIBC_//' | sort -uV | tail -n 1) +newest=$(printf '%s\n%s\n' "$required_glibc" "$max_glibc" | sort -V | tail -n 1) +if [ "$newest" != "$max_glibc" ]; then + echo "::error::fiscalapi pide glibc $required_glibc; el release promete correr desde glibc $max_glibc." >&2 + exit 1 +fi +echo "fiscalapi pide glibc $required_glibc o más nueva." + +# Humo en la distro más vieja que se soporta, sin el aviso de versión nueva (el contenedor no hereda CI=true). +actual=$(FISCALAPI_NO_UPDATE_CHECK=1 "$binary" --version) +if [ "$actual" != "$version" ]; then + echo "::error::fiscalapi --version devolvió '$actual' en AlmaLinux 8; se esperaba '$version'." >&2 + exit 1 +fi diff --git a/.github/scripts/verify-dotnet-install.sh b/.github/scripts/verify-dotnet-install.sh new file mode 100644 index 0000000..ce7595a --- /dev/null +++ b/.github/scripts/verify-dotnet-install.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Verifica dotnet-install.sh con su firma GPG antes de ejecutarlo (Microsoft Learn, "dotnet-install scripts reference", +# sección "Signature validation of dotnet-install.sh"). La llave pública va versionada junto a este script +# (dotnet-install.asc, descargada de https://dot.net/v1/dotnet-install.asc el 2026-10-13) y se exige la huella que publica +# Microsoft en esa página: +# 2B93 0AB1 228D 11D5 D7F6 B6AC B9CF 1A51 FC7D 3ACF Microsoft DevUXTeamPrague +# Si Microsoft rota la llave, la verificación falla y la llave nueva se actualiza a propósito, comprobando su huella. +# +# Uso: verify-dotnet-install.sh +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Uso: verify-dotnet-install.sh " >&2 + exit 2 +fi + +script="$1" +signature="$2" +expected_fingerprint="2B930AB1228D11D5D7F6B6ACB9CF1A51FC7D3ACF" +public_key="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/dotnet-install.asc" + +# Un anillo de llaves desechable: solo contiene la llave de Microsoft y no toca el del usuario. +GNUPGHOME=$(mktemp -d) +export GNUPGHOME +trap 'rm -rf "$GNUPGHOME"' EXIT +gpg --batch --quiet --import "$public_key" + +# gpg --verify sale con 0 aunque la llave no tenga confianza (lo advierte Microsoft Learn); lo que cuenta es la línea +# VALIDSIG del estado, que solo aparece con una firma buena y trae la huella de la llave que firmó. +if ! status=$(gpg --batch --status-fd 1 --verify "$signature" "$script" 2> /dev/null); then + echo "::error::La firma GPG de dotnet-install.sh no es válida." >&2 + exit 1 +fi +if ! grep -q "^\[GNUPG:\] VALIDSIG $expected_fingerprint " <<< "$status"; then + echo "::error::dotnet-install.sh no está firmado con la llave $expected_fingerprint." >&2 + exit 1 +fi +echo "dotnet-install.sh: firma GPG válida de la llave $expected_fingerprint." diff --git a/.github/scripts/with-test-keychain.sh b/.github/scripts/with-test-keychain.sh new file mode 100644 index 0000000..a53b4db --- /dev/null +++ b/.github/scripts/with-test-keychain.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Corre un comando con un llavero de prueba de macOS como llavero por omisión del usuario: desbloqueado, sin bloqueo por +# tiempo y desechable. Así CredentialStoreTests (SecItemAdd/SecItemCopyMatching/SecItemDelete sobre el llavero por omisión) +# no depende del estado del llavero de sesión del runner. Lo usa release.yml: +# bash .github/scripts/with-test-keychain.sh dotnet test FiscalApi.Cli.slnx --filter "FullyQualifiedName~CredentialStoreTests" +set -euo pipefail +# Compatible con el bash 3.2 de macOS: un arreglo vacío se expande con ${a[@]+"${a[@]}"} para no chocar con set -u. + +keychain="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/fiscalapi-cli-test.keychain-db" +password="llavero-de-prueba" +# `security` imprime las rutas entre comillas, una por línea; xargs las quita. Las de un runner no llevan espacios. +original_default=$( (security default-keychain -d user 2> /dev/null || true) | xargs) +read -r -a original_search_list <<< "$( (security list-keychains -d user 2> /dev/null || true) | xargs)" + +restore() { + if [ -n "$original_default" ]; then + security default-keychain -d user -s "$original_default" || true + fi + security list-keychains -d user -s ${original_search_list[@]+"${original_search_list[@]}"} || true + security delete-keychain "$keychain" || true +} +trap restore EXIT + +security create-keychain -p "$password" "$keychain" +# Sin -t ni -l: el llavero no se bloquea solo a mitad de las pruebas. +security set-keychain-settings "$keychain" +security unlock-keychain -p "$password" "$keychain" +security list-keychains -d user -s "$keychain" ${original_search_list[@]+"${original_search_list[@]}"} +security default-keychain -d user -s "$keychain" + +"$@" diff --git a/.github/scripts/with-test-secret-service.sh b/.github/scripts/with-test-secret-service.sh new file mode 100644 index 0000000..6530296 --- /dev/null +++ b/.github/scripts/with-test-secret-service.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Corre un comando con un Secret Service de prueba: gnome-keyring desbloqueado con una contraseña desechable, en +# la sesión de D-Bus que abre quien lo llama. Lo usan el CI y la verificación local en Docker: +# dbus-run-session -- bash .github/scripts/with-test-secret-service.sh dotnet test FiscalApi.Cli.slnx +set -euo pipefail + +printf '%s' "llavero-de-prueba" | gnome-keyring-daemon --unlock --components=secrets > /dev/null + +# gnome-keyring publica org.freedesktop.secrets después de volver: se espera a que el nombre exista en el bus, +# con tope, en lugar de dormir un tiempo fijo que en un runner lento no alcanza. +for _ in $(seq 1 50); do + if dbus-send --session --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus \ + org.freedesktop.DBus.ListNames | grep -q '"org.freedesktop.secrets"'; then + exec "$@" + fi + sleep 0.2 +done + +echo "::error::org.freedesktop.secrets no apareció en la sesión de D-Bus en 10 s." >&2 +exit 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..881d3f7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,67 @@ +name: CI-Pipeline + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read # Required for actions/checkout + +# Un push nuevo al mismo PR cancela la corrida anterior: solo importa el último commit. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + # Runner hospedado por GitHub: el CLI no depende de Docker ni del runner self-hosted de fapi-back. + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Sin el token en .git/config: ningún paso posterior lo necesita y así no queda al alcance de otro proceso. + persist-credentials: false + + # El SDK sale de global.json, el mismo que se usa en local. + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + # Prerrequisitos de Native AOT en Linux (el linker clang y zlib) y un Secret Service de prueba para las + # pruebas del almacén de credenciales (libsecret, gnome-keyring y D-Bus con dbus-send). + - name: Install Native AOT and Secret Service prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang zlib1g-dev libsecret-1-0 gnome-keyring dbus + + - name: Restore + run: dotnet restore FiscalApi.Cli.slnx + + - name: Build + run: dotnet build FiscalApi.Cli.slnx -c Release --no-restore + + # gnome-keyring en una sesión de D-Bus propia: así corren contra libsecret las pruebas de + # CredentialStoreTests, que sin FISCALAPI_CLI_SECRET_SERVICE_TESTS se omiten en Linux. + - name: Test + env: + FISCALAPI_CLI_SECRET_SERVICE_TESTS: "1" + run: >- + dbus-run-session -- bash .github/scripts/with-test-secret-service.sh + dotnet test FiscalApi.Cli.slnx -c Release --no-build + + # TreatWarningsAsErrors convierte cualquier advertencia IL de trimming/AOT en un error que rompe este paso. + - name: Publish Native AOT (linux-x64) + run: dotnet publish src/FiscalApi.Cli -r linux-x64 -c Release -o artifacts/linux-x64 + + - name: Smoke test + run: | + expected=$(grep -oPm1 "(?<=)[^<]+" src/FiscalApi.Cli/FiscalApi.Cli.csproj) + actual=$(./artifacts/linux-x64/fiscalapi --version) + if [ "$actual" != "$expected" ]; then + echo "::error::fiscalapi --version devolvió '$actual'; se esperaba '$expected'." + exit 1 + fi + ./artifacts/linux-x64/fiscalapi --help diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b0e6e64 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,294 @@ +name: Release + +# Compila fiscalapi con Native AOT para cada RID en un runner de su SO (AOT no compila entre sistemas operativos) y deja +# un release EN BORRADOR con los empaques y SHA256SUMS. Publicarlo lo decide una persona (U-03). +on: + push: + tags: + - "v*" + # Prueba del pipeline sin crear un tag: el borrador queda como prerelease si la versión lleva "-". + workflow_dispatch: + inputs: + version: + description: "Versión semántica sin la v (p. ej. 0.1.0-rc.1). El borrador se crea con el tag v sobre el commit elegido." + required: true + type: string + +permissions: + contents: read # Required for actions/checkout; solo el job release escribe. + +# Las corridas del mismo tag o rama van de una en una: la que llega queda pendiente hasta que termine la que está en curso, +# que nunca se cancela. Si llega otra mientras una espera, GitHub cancela la pendiente y deja la más nueva. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + version: + name: Versión + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + # La versión sale del tag (v1.2.3 → 1.2.3) o de la entrada del workflow_dispatch, y se valida como semver 2.0.0 + # antes de compilar: es -p:Version, el --version del binario y lo que compara el aviso de versión nueva. + - name: Resolve version + id: version + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$REF_TYPE" = "tag" ]; then + version="${REF_NAME#v}" + else + version="$INPUT_VERSION" + fi + # El valor sin validar no se imprime: podría traer texto que el runner interprete como comando (::…::). + identifier='(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)' + semver="^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-${identifier}(\.${identifier})*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$" + if ! [[ "$version" =~ $semver ]]; then + echo "::error::La versión del tag o de la entrada 'version' no es semántica (p. ej. v1.2.3 o 1.2.3-rc.1)." + exit 1 + fi + # workflow_dispatch sobre un tag: la versión del binario debe ser la del tag. + if [ "$REF_TYPE" = "tag" ] && [ -n "$INPUT_VERSION" ] && [ "$INPUT_VERSION" != "$version" ]; then + echo "::error::La entrada 'version' no coincide con el tag v$version; déjala igual a $version o corre sobre una rama." + exit 1 + fi + without_build="${version%%+*}" + if [[ "$without_build" == *-* ]]; then prerelease=true; else prerelease=false; fi + { + echo "version=$version" + echo "tag=v$version" + echo "prerelease=$prerelease" + } >> "$GITHUB_OUTPUT" + echo "Versión $version (prerelease: $prerelease)." + + # En workflow_dispatch el borrador apunta a este commit con --target, pero GitHub ignora target_commitish si el tag + # ya existe: el release quedaría con binarios de otro commit. Por eso un tag existente detiene la corrida. + - name: Ensure the tag does not exist yet + if: github.ref_type != 'tag' + env: + GH_TOKEN: ${{ github.token }} # contents: read del workflow. + TAG: v${{ steps.version.outputs.version }} + run: | + if output=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .ref 2>&1); then + echo "::error::El tag $TAG ya existe. Usa otra versión o empuja el tag para un release real." + exit 1 + fi + if [[ "$output" != *"HTTP 404"* ]]; then + echo "::error::No se pudo comprobar si el tag $TAG existe: $output" + exit 1 + fi + echo "El tag $TAG no existe: el borrador lo creará sobre $GITHUB_SHA al publicarse." + + build: + name: Build ${{ matrix.rid }} + needs: version + strategy: + fail-fast: true + matrix: + include: + - rid: win-x64 + os: windows-latest + binary: fiscalapi.exe + package: fiscalapi-win-x64.zip + # El runner solo aloja Docker: el binario se enlaza en AlmaLinux 8 (glibc 2.28), ver publish-linux.sh. + - rid: linux-x64 + os: ubuntu-latest + binary: fiscalapi + package: fiscalapi-linux-x64.tar.gz + - rid: osx-arm64 + os: macos-latest + binary: fiscalapi + package: fiscalapi-osx-arm64.tar.gz + # Runner Intel propio: el humo de --version corre nativo, sin depender de Rosetta en el runner arm64. + - rid: osx-x64 + os: macos-26-intel + binary: fiscalapi + package: fiscalapi-osx-x64.tar.gz + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + env: + VERSION: ${{ needs.version.outputs.version }} + RID: ${{ matrix.rid }} + PUBLISH_DIR: artifacts/${{ matrix.rid }} + BINARY: ${{ matrix.binary }} + PACKAGE: ${{ matrix.package }} + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + DOTNET_NOLOGO: "1" + # AlmaLinux 8.10 fijada por digest (docker inspect almalinux:8, 2026-10-13); se actualiza a propósito, no sola. + LINUX_BUILD_IMAGE: almalinux:8@sha256:8b469a3a78515e8a18ea8fc727e6a3679e1d0c5ba6f58d5b30be3d0d9b86cffe + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # Sin el token en .git/config: ningún paso posterior lo necesita y así no queda al alcance de otro proceso. + persist-credentials: false + + # En Linux el SDK se instala dentro del contenedor de compilación. + - name: Set up .NET + if: runner.os != 'Linux' + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + # Con VS 18 (2026) e ILCompiler 10.0.12, vcvarsall.bat llama a vswhere.exe sin ruta y el link falla (MSB3073) si su + # carpeta no está en PATH. windows-latest trae hoy VS 2022 17.14 con "Desktop development with C++"; agregarla + # no cambia nada ahí y protege la compilación cuando el runner pase a VS 2026. + - name: Add the Visual Studio Installer folder to PATH + if: runner.os == 'Windows' + shell: pwsh + run: | + $installer = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer' + $vswhere = Get-Command vswhere.exe -ErrorAction SilentlyContinue + Write-Host "vswhere.exe antes del cambio: $(if ($vswhere) { $vswhere.Source } else { 'no está en PATH' })" + Add-Content -Path $env:GITHUB_PATH -Value $installer + + # Ida y vuelta real en el almacén del SO (CLI-002): Credential Manager en Windows y el llavero en macOS, que el CI de + # los PR (Linux) no cubre. El release no sigue si falla, y se exige que todas hayan corrido y pasado (ninguna omitida). + # En macOS corre con un llavero de prueba desbloqueado como llavero por omisión. + - name: Credential store tests (Credential Manager / Keychain) + if: runner.os != 'Linux' + run: | + test_command=(dotnet test FiscalApi.Cli.slnx -c Release + --filter "FullyQualifiedName~CredentialStoreTests" + --logger "trx;LogFileName=credential-store.trx" --results-directory TestResults) + if [ "$RUNNER_OS" = "macOS" ]; then + bash .github/scripts/with-test-keychain.sh "${test_command[@]}" + else + "${test_command[@]}" + fi + counters=$(grep -o ']*>' TestResults/credential-store.trx) + total=$(sed -E 's/.* total="([0-9]+)".*/\1/' <<< "$counters") + passed=$(sed -E 's/.* passed="([0-9]+)".*/\1/' <<< "$counters") + echo "CredentialStoreTests: $passed de $total pasaron." + if [ "$total" -eq 0 ] || [ "$passed" -ne "$total" ]; then + echo "::error::CredentialStoreTests debe correr y pasar completa en $RID ($passed de $total)." + exit 1 + fi + + # TreatWarningsAsErrors (Directory.Build.props) convierte cualquier advertencia IL de trimming/AOT en un error. + - name: Publish Native AOT (${{ matrix.rid }}) + run: | + if [ "$RUNNER_OS" = "Linux" ]; then + docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src "$LINUX_BUILD_IMAGE" \ + bash .github/scripts/publish-linux.sh "$VERSION" "$PUBLISH_DIR" + else + dotnet publish src/FiscalApi.Cli -c Release -r "$RID" -p:Version="$VERSION" -o "$PUBLISH_DIR" + fi + + # El binario de cada RID responde en su SO. CI=true (lo define Actions) apaga el aviso de versión nueva. + - name: Smoke test + run: | + actual=$("./$PUBLISH_DIR/$BINARY" --version | tr -d '\r') + if [ "$actual" != "$VERSION" ]; then + echo "::error::$BINARY --version devolvió '$actual' en $RID; se esperaba '$VERSION'." + exit 1 + fi + "./$PUBLISH_DIR/$BINARY" --help > /dev/null + echo "$RID: fiscalapi $actual" + + # Solo el binario: los símbolos (.pdb, .dbg, .dSYM) se quedan fuera, y cada empaque debe traer exactamente una + # entrada, el binario. + - name: Package (zip) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist | Out-Null + Compress-Archive -Path "$env:PUBLISH_DIR/$env:BINARY" -DestinationPath "dist/$env:PACKAGE" + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path "dist/$env:PACKAGE")) + try { $entries = @($zip.Entries | ForEach-Object FullName) } finally { $zip.Dispose() } + if ($entries.Count -ne 1 -or $entries[0] -ne $env:BINARY) { + Write-Output "::error::$env:PACKAGE debe traer solo $env:BINARY; trae: $($entries -join ', ')." + exit 1 + } + + # COPYFILE_DISABLE: el tar de macOS no agrega los archivos ._* de atributos extendidos. + - name: Package (tar.gz) + if: runner.os != 'Windows' + run: | + mkdir -p dist + COPYFILE_DISABLE=1 tar -czf "dist/$PACKAGE" -C "$PUBLISH_DIR" "$BINARY" + tar -tzvf "dist/$PACKAGE" + entries=$(tar -tzf "dist/$PACKAGE") + if [ "$entries" != "$BINARY" ]; then + echo "::error::$PACKAGE debe traer solo $BINARY; trae: $(tr '\n' ' ' <<< "$entries")." + exit 1 + fi + + - name: Upload package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: package-${{ matrix.rid }} + path: dist/${{ matrix.package }} + if-no-files-found: error + retention-days: 7 + # Ya va comprimido. + compression-level: 0 + + release: + name: Draft release + needs: [version, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # Crear el release borrador y subir sus archivos. + env: + VERSION: ${{ needs.version.outputs.version }} + TAG: ${{ needs.version.outputs.tag }} + PRERELEASE: ${{ needs.version.outputs.prerelease }} + steps: + - name: Download packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: package-* + path: dist + merge-multiple: true + + # Exactamente los 4 empaques de D-24 y su SHA256SUMS, verificado antes de subirlo. + - name: Checksums + working-directory: dist + run: | + expected=(fiscalapi-win-x64.zip fiscalapi-linux-x64.tar.gz fiscalapi-osx-arm64.tar.gz fiscalapi-osx-x64.tar.gz) + for package in "${expected[@]}"; do + if [ ! -f "$package" ]; then + echo "::error::Falta $package." + exit 1 + fi + done + if [ "$(find . -maxdepth 1 -type f | wc -l)" -ne "${#expected[@]}" ]; then + echo "::error::dist trae archivos de más: $(ls)." + exit 1 + fi + sha256sum "${expected[@]}" > SHA256SUMS + sha256sum -c SHA256SUMS + cat SHA256SUMS + + # Un tag empujado ya existe (--verify-tag). En workflow_dispatch el tag se crea sobre este commit hasta que alguien + # publique el borrador. --generate-notes arma las notas desde los PR; se editan antes de publicar. + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + REF_TYPE: ${{ github.ref_type }} + run: | + args=(--draft --title "fiscalapi $VERSION" --generate-notes) + if [ "$REF_TYPE" = "tag" ]; then + args+=(--verify-tag) + else + args+=(--target "$GITHUB_SHA") + fi + if [ "$PRERELEASE" = "true" ]; then + args+=(--prerelease) + fi + gh release create "$TAG" "${args[@]}" dist/fiscalapi-* dist/SHA256SUMS + gh release view "$TAG" --json isDraft,isPrerelease,assets --jq '{isDraft, isPrerelease, assets: [.assets[].name]}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,482 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..92acbc5 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,12 @@ +// markdownlint-cli2 para README.md y docs/: el repo envuelve la prosa a 120 columnas, como el código (.editorconfig de +// fapi-back). Las tablas y los bloques de código no se cortan: una salida real del CLI o un comando se copian tal cual. +{ + "config": { + "MD013": { + "line_length": 120, + "tables": false, + "code_blocks": false + } + }, + "globs": ["README.md", "docs/*.md"] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e976240 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + + true + + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 + + true + + false + + true + + + + + true + + + diff --git a/FiscalApi.Cli.slnx b/FiscalApi.Cli.slnx new file mode 100644 index 0000000..0f86dcd --- /dev/null +++ b/FiscalApi.Cli.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md index 33a3b06..5eb1854 100644 --- a/README.md +++ b/README.md @@ -1 +1,104 @@ -# fapi-cli +# fiscalapi — CLI de FiscalAPI + +`fiscalapi` lleva los webhooks de FiscalAPI a tu terminal: recibe los eventos en tu equipo y los reenvía a tu servidor +local, dispara eventos de prueba y administra tus webhooks y eventos sin salir de la consola. + +Es un solo binario nativo para Windows, Linux y macOS: no necesitas instalar .NET. + +## Instalación + +Descarga el empaque de tu sistema y `SHA256SUMS` desde +[Releases](https://github.com/FiscalAPI/fiscalapi-cli/releases), y **verifica el SHA-256 antes de ejecutarlo**: + +| Sistema | Empaque | +|---|---| +| Windows x64 | `fiscalapi-win-x64.zip` | +| Linux x64 (glibc 2.28+) | `fiscalapi-linux-x64.tar.gz` | +| macOS Apple Silicon | `fiscalapi-osx-arm64.tar.gz` | +| macOS Intel | `fiscalapi-osx-x64.tar.gz` | + +Linux (en macOS, cambia el empaque y usa `shasum -a 256 -c -` en lugar de `sha256sum -c -`): + +```bash +base=https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download +curl -fsSLO "$base/fiscalapi-linux-x64.tar.gz" +curl -fsSLO "$base/SHA256SUMS" +grep ' fiscalapi-linux-x64.tar.gz$' SHA256SUMS | sha256sum -c - # debe decir: OK +tar -xzf fiscalapi-linux-x64.tar.gz +sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi +``` + +Windows (PowerShell): + +```powershell +$base = 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download' +Invoke-WebRequest "$base/fiscalapi-win-x64.zip" -OutFile fiscalapi-win-x64.zip +Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS +$expected = (Select-String -Path SHA256SUMS -Pattern ' fiscalapi-win-x64\.zip$').Line.Split(' ')[0] +$actual = (Get-FileHash fiscalapi-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "El SHA-256 no coincide: $actual" } else { 'SHA-256 OK' } +Expand-Archive fiscalapi-win-x64.zip -DestinationPath (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi') -Force +``` + +Cómo agregarlo al `PATH`, Gatekeeper y SmartScreen, actualizar, el aviso de versión nueva y desinstalar: +[docs/instalacion.md](docs/instalacion.md). + +## Inicio rápido + +```bash +# 1. Guarda tu API key y tu tenant key en el almacén de credenciales del sistema (ambiente de pruebas) +fiscalapi login + +# 2. Recibe los eventos de tu cuenta y reenvíalos a tu servidor local +fiscalapi listen --forward-to http://localhost:3000/webhooks +# Listo. Tu secreto de firma es whsec_… (escucha …) +# 2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] + +# 3. En otra terminal, dispara un evento de prueba hacia tu escucha +fiscalapi trigger invoice.canceled +``` + +Verifica la firma de cada entrega en tu servidor con el secreto `whsec_…` que muestra `listen` y el `Verify` del SDK de +tu lenguaje ([cómo](docs/listen.md#verificar-la-firma)). `fiscalapi trigger` solo funciona en el +ambiente de pruebas ([detalles](docs/trigger.md)). + +## Documentación + +| Tema | Contenido | +|---|---| +| [Instalación](docs/instalacion.md) | Binarios por sistema, verificación, `PATH`, actualización, aviso de versión nueva y desinstalación. | +| [Autenticación](docs/autenticacion.md) | `login`/`logout`, almacén de credenciales, variables de entorno, ambientes y CI. | +| [`fiscalapi listen`](docs/listen.md) | Recibir eventos en local: opciones, firma, reconexión y salida `--json`. | +| [Webhooks y eventos](docs/webhooks-y-eventos.md) | `webhooks list/retrieve/create/update/delete/rotate-secret` y `events list/retrieve/resend`. | +| [`fiscalapi trigger`](docs/trigger.md) | Eventos de prueba hacia tu escucha o un webhook: tipos, opciones y errores. | +| [Salida y códigos](docs/salida-y-codigos.md) | stdout/stderr, `--json`, `--verbose` y códigos de salida. | +| [Solución de problemas](docs/solucion-de-problemas.md) | Errores frecuentes con su causa y remedio. | + +`fiscalapi --help` y `fiscalapi --help` describen cada opción. La guía de webhooks de la API (forma del +evento, firma, reintentos) está en [docs.fiscalapi.com/webhooks](https://docs.fiscalapi.com/webhooks). + +## Desarrollo + +Requiere el SDK de .NET de `global.json`. Para el binario nativo, los prerrequisitos de +[Native AOT](https://learn.microsoft.com/dotnet/core/deploying/native-aot/): en Windows, Visual Studio con +"Desktop development with C++"; en Linux, `clang` y `zlib1g-dev`; en macOS, las Command Line Tools de Xcode. + +```bash +dotnet build FiscalApi.Cli.slnx -c Release +dotnet test FiscalApi.Cli.slnx -c Release +dotnet publish src/FiscalApi.Cli -c Release -r # win-x64, linux-x64, osx-arm64 u osx-x64 +``` + +Con Visual Studio 2026, si el publish de Windows falla con "'vswhere.exe' is not recognized", agrega +`C:\Program Files (x86)\Microsoft Visual Studio\Installer` al PATH. + +### Releases + +`.github/workflows/release.yml` compila cada binario en un runner de su sistema (AOT no compila entre sistemas): +Windows, Linux dentro de AlmaLinux 8 (para que el binario corra desde glibc 2.28), macOS Apple Silicon y macOS Intel. +Corre las pruebas del almacén de credenciales en Windows y macOS, prueba cada binario con `--version` y deja un +release **en borrador** con los 4 empaques y `SHA256SUMS`. Publicarlo es una decisión manual. + +- Release real: empuja un tag `v1.2.3`. +- Prueba sin tag: `gh workflow run release.yml -R FiscalAPI/fiscalapi-cli --ref -f version=0.1.0-rc.1`. Deja un + borrador marcado como prerelease; bórralo con `gh release delete v0.1.0-rc.1 -R FiscalAPI/fiscalapi-cli --yes`. diff --git a/docs/autenticacion.md b/docs/autenticacion.md new file mode 100644 index 0000000..30da0ac --- /dev/null +++ b/docs/autenticacion.md @@ -0,0 +1,163 @@ +# Autenticación y ambientes + +Cada comando que llama a la API manda tu **API key** (header `X-API-KEY`) y tu **tenant key** (`X-TENANT-KEY`). El CLI +las toma de las variables de entorno o, si no están, de las que guardó `fiscalapi login`. Ningún comando las acepta +como argumento: quedarían en el historial del shell y en la lista de procesos. + +## Ambientes + +| Opción | URL base | Uso | +|---|---|---| +| (ninguna) | `https://test.fiscalapi.com` | Pruebas. Es el ambiente por omisión. | +| `--live` | `https://live.fiscalapi.com` | Producción. | +| `--api-base ` | La que indiques | Otra URL base, p. ej. un back local. Gana sobre `--live`. | + +`--api-base` acepta solo URLs absolutas `http` o `https`. Como las llaves viajan en headers, `http` solo se permite hacia +tu propio equipo (`localhost`, `127.0.0.1` o `::1`): + +```text +$ fiscalapi webhooks list --api-base http://fiscalapi.example.com +Error: --api-base con http solo se permite para localhost, 127.0.0.1 o ::1; usa https para 'http://fiscalapi.example.com'. +Usa `fiscalapi --help` para ver los comandos y opciones. +``` + +Las credenciales se guardan **por ambiente**, una entrada por host (y puerto, si no es el de omisión): las de +`test.fiscalapi.com`, `live.fiscalapi.com` y `localhost:5001` son independientes. + +## `fiscalapi login` + +```text +$ fiscalapi login +API key: +Tenant key: +Listo. Credenciales guardadas para test.fiscalapi.com. +``` + +`login` pide la API key y la tenant key sin mostrar lo que escribes, las valida con `GET /api/v4/me/features` y las +guarda en el almacén de credenciales del sistema. Para producción, usa `fiscalapi login --live`. Los prompts y los +mensajes van a stderr. + +| Caso | Resultado | +|---|---| +| La API rechaza las llaves (401) | No guarda nada y sale con 1: `Error: Las credenciales no son válidas para test.fiscalapi.com.` | +| Otro error de la API | No guarda nada y muestra el error de la API (salida 1). | +| La persona de la API key no tiene `Webhooks.Manage` | Guarda las llaves y avisa: `Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403.` | +| Llave vacía, de más de 200 caracteres o con caracteres que no son ASCII visible | Error de uso (salida 2): `La API key está vacía, es demasiado larga o tiene caracteres no válidos.` | + +Los espacios alrededor de cada llave se ignoran. + +### Sin terminal: stdin + +Si stdin viene redirigido (un script), `login` no muestra prompts y lee **una llave por línea**: primero la API key y +luego la tenant key. + +```bash +printf '%s\n%s\n' "$API_KEY" "$TENANT_KEY" | fiscalapi login +``` + +`printf` es un comando interno del shell, así que las llaves no aparecen en la lista de procesos. Un archivo guardado +como "UTF-8 con BOM" también funciona. + +> **Terminales sin ConPTY.** En mintty (la terminal de Git Bash en Windows) y en `docker run -i` sin `-t`, la entrada +> no llega como terminal: `login` la trata como redirigida, sin prompt y **mostrando lo que escribes**. Usa Windows +> Terminal o PowerShell, `docker run -it`, o las variables de entorno. + +## `fiscalapi logout` + +Borra la entrada del ambiente. Termina con 0 aunque no hubiera nada guardado: + +```text +$ fiscalapi logout +Credenciales borradas para test.fiscalapi.com. +$ fiscalapi logout --live +No había credenciales guardadas para live.fiscalapi.com. +``` + +Si el almacén está bloqueado o falla, la entrada puede seguir ahí: `logout` lo dice y sale con 1. + +`logout` es local: no llama a la API ni revoca la API key. Para revocarla, usa el dashboard de FiscalAPI. + +## Dónde se guardan + +| Sistema | Almacén | Entrada | +|---|---|---| +| Windows | Administrador de credenciales (Credential Manager), "Credenciales de Windows" | Credencial genérica `fiscalapi-cli:` | +| macOS | Llavero de sesión ("Acceso a Llaveros") | Contraseña genérica con servicio `fiscalapi-cli`, cuenta `` y nombre `FiscalAPI CLI ()` | +| Linux | Secret Service por libsecret (GNOME Keyring o compatible) | Secreto `FiscalAPI CLI ()` con los atributos `service=fiscalapi-cli` y `host=` | + +No hay respaldo en archivo: si el sistema no tiene almacén, `login` no guarda nada en texto plano. + +## Variables de entorno + +| Variable | Valor | +|---|---| +| `FISCALAPI_API_KEY` | Tu API key. | +| `FISCALAPI_TENANT_KEY` | Tu tenant key. | + +Si las dos están definidas, ganan sobre lo que guardó `login` y valen para **cualquier** ambiente: con llaves de +pruebas, no agregues `--live` o la API responderá 401. Una variable vacía cuenta como no definida. + +Se definen **juntas**. Con una sola, el CLI no la completa con lo guardado y sale con 2: + +```text +Error: Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY juntas; solo está definida FISCALAPI_API_KEY. +``` + +Sin variables y sin nada guardado para el ambiente, también sale con 2: + +```text +Error: No hay credenciales para test.fiscalapi.com. Ejecuta `fiscalapi login` o define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +``` + +## Casos especiales + +### CI + +En un CI no hay almacén ni nadie que escriba: define las dos variables como secretos del CI y no uses `login`. + +```yaml +# GitHub Actions +env: + FISCALAPI_API_KEY: ${{ secrets.FISCALAPI_API_KEY }} + FISCALAPI_TENANT_KEY: ${{ secrets.FISCALAPI_TENANT_KEY }} +``` + +Usa `--json` para procesar la salida y revisa el código de salida ([Salida y códigos](salida-y-codigos.md)). Con `CI` +definida, el aviso de versión nueva no corre ([Instalación](instalacion.md#aviso-de-versión-nueva)). + +### WSL, servidores y contenedores + +En Linux sin Secret Service (WSL, un servidor sin sesión gráfica, un contenedor), `login` sale con 2 antes de pedir +las llaves: + +```text +Error: No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. + Detalle: No se encontró libsecret-1.so.0 (paquete libsecret-1-0 o libsecret). +``` + +El `Detalle` cambia según la causa: falta libsecret o no hay Secret Service en la sesión de D-Bus. Usa las variables +de entorno. Si quieres el almacén, instala libsecret (`libsecret-1-0` en Debian/Ubuntu, `libsecret` en RHEL/Fedora) y +un servicio como GNOME Keyring dentro de una sesión de D-Bus. + +### Almacén bloqueado + +Si el almacén existe pero está bloqueado o no respondió (un llavero bloqueado, GNOME Keyring sin desbloquear), el +comando sale con 1: + +```text +Error: El almacén de credenciales del sistema existe, pero está bloqueado o no respondió. + Detalle: … +``` + +Desbloquéalo (en macOS, abre "Acceso a Llaveros"; en Linux, inicia sesión en el escritorio o desbloquea el llavero) y +repite el comando, o usa las variables de entorno. + +### Entrada guardada ilegible + +Si la entrada guardada se dañó, el comando sale con 1: + +```text +Error: La entrada guardada para test.fiscalapi.com no se puede leer. Ejecuta `fiscalapi logout` y luego `fiscalapi login`. +``` + +Siguiente: [`fiscalapi listen`](listen.md). diff --git a/docs/instalacion.md b/docs/instalacion.md new file mode 100644 index 0000000..511afff --- /dev/null +++ b/docs/instalacion.md @@ -0,0 +1,195 @@ +# Instalación + +`fiscalapi` es un solo binario nativo (.NET Native AOT): no necesitas instalar .NET ni ninguna otra dependencia. + +## Sistemas y empaques + +Cada [release](https://github.com/FiscalAPI/fiscalapi-cli/releases) trae un empaque por sistema, con solo el binario, +y un archivo `SHA256SUMS` con el SHA-256 de cada empaque. + +| Sistema | Empaque | Requisito | +|---|---|---| +| Windows x64 | `fiscalapi-win-x64.zip` | Windows 10 u 11, o Windows Server 2012 o más nuevo | +| Linux x64 | `fiscalapi-linux-x64.tar.gz` | glibc 2.28 o más nueva: RHEL/AlmaLinux/Rocky 8+, Debian 10+, Ubuntu 20.04+. No corre en Alpine (musl). | +| macOS Apple Silicon | `fiscalapi-osx-arm64.tar.gz` | macOS 15 o más nuevo, Mac con chip Apple (M1 o posterior) | +| macOS Intel | `fiscalapi-osx-x64.tar.gz` | macOS 15 o más nuevo, Mac con procesador Intel | + +Los requisitos de Windows y macOS son los de .NET 10 +([sistemas soportados](https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md)). + +Los comandos de abajo descargan la **última versión publicada**. Para una versión fija, cambia +`releases/latest/download` por `releases/download/v1.2.3`. + +**Verifica siempre el SHA-256 antes de ejecutar el binario.** La verificación debe decir `OK`; si dice `FAILED`, borra +lo que descargaste y vuelve a bajarlo. + +## Linux + +```bash +base=https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download +curl -fsSLO "$base/fiscalapi-linux-x64.tar.gz" +curl -fsSLO "$base/SHA256SUMS" +grep ' fiscalapi-linux-x64.tar.gz$' SHA256SUMS | sha256sum -c - # debe decir: OK +tar -xzf fiscalapi-linux-x64.tar.gz +sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi +fiscalapi --version +``` + +Sin permisos de administrador, instálalo en tu usuario con `install -D -m 0755 fiscalapi ~/.local/bin/fiscalapi` y +verifica que `~/.local/bin` esté en tu `PATH`. + +## macOS + +Usa `fiscalapi-osx-arm64.tar.gz` en una Mac con Apple Silicon y `fiscalapi-osx-x64.tar.gz` en una Intel (`uname -m` +dice `arm64` o `x86_64`). + +```bash +package=fiscalapi-osx-arm64.tar.gz +base=https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download +curl -fsSLO "$base/$package" +curl -fsSLO "$base/SHA256SUMS" +grep " $package\$" SHA256SUMS | shasum -a 256 -c - # debe decir: OK +tar -xzf "$package" +sudo mkdir -p /usr/local/bin +sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi +fiscalapi --version +``` + +El binario no está firmado ni notarizado por Apple. Descargado con `curl` corre sin más; si lo bajaste con el +navegador, Gatekeeper lo bloquea la primera vez. Quítale la marca de cuarentena: + +```bash +xattr -d com.apple.quarantine /usr/local/bin/fiscalapi +``` + +## Windows (PowerShell) + +```powershell +$base = 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download' +Invoke-WebRequest "$base/fiscalapi-win-x64.zip" -OutFile fiscalapi-win-x64.zip +Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS +$expected = (Select-String -Path SHA256SUMS -Pattern ' fiscalapi-win-x64\.zip$').Line.Split(' ')[0] +$actual = (Get-FileHash fiscalapi-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "El SHA-256 no coincide: $actual" } else { 'SHA-256 OK' } + +$dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' +Expand-Archive fiscalapi-win-x64.zip -DestinationPath $dir -Force +``` + +### Agregar `fiscalapi` al PATH + +Este bloque agrega la carpeta al `Path` de tu usuario. Lo escribe en el registro como `REG_EXPAND_SZ`, sin expandir las +variables que ya tenga (`%USERPROFILE%`…), y no la duplica si ya está: + +```powershell +$dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' +$environment = Get-Item 'HKCU:\Environment' +$userPath = $environment.GetValue('Path', '', 'DoNotExpandEnvironmentNames') +if (($userPath -split ';') -notcontains $dir) { + $newPath = (@($userPath.TrimEnd(';'), $dir) | Where-Object { $_ }) -join ';' + Set-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath -Type ExpandString + # Avisa a Windows del cambio para que las terminales nuevas lo vean (no crea ninguna variable). + [Environment]::SetEnvironmentVariable('FISCALAPI_CLI_PATH_REFRESH', [NullString]::Value, 'User') +} +``` + +A mano: busca "Editar las variables de entorno de esta cuenta" en el menú Inicio, abre `Path`, elige **Nuevo** y +escribe `%LOCALAPPDATA%\Programs\fiscalapi`. + +Abre una terminal nueva y corre `fiscalapi --version`. + +El binario no lleva firma Authenticode. Si bajaste el zip con el navegador, SmartScreen puede advertir al abrirlo +("Windows protegió su PC"). Quita la marca de descarga: + +```powershell +Unblock-File (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi\fiscalapi.exe') +``` + +## Actualizar + +Repite la instalación de tu sistema: el binario nuevo reemplaza al anterior. Tus credenciales de `fiscalapi login` se +conservan, porque viven en el almacén del sistema y no junto al binario. + +### Aviso de versión nueva + +Mientras corre un comando, `fiscalapi` pregunta a GitHub por el último release, **como mucho una vez cada 24 horas** y +sin enviar tus credenciales. Si hay uno más nuevo, al terminar el comando escribe en stderr: + +```text + +Hay una versión nueva de fiscalapi: 0.2.0 (tienes 0.1.0). +Descárgala en https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0 +``` + +- La consulta tarda como mucho 2 s. Si GitHub no responde, falla o devuelve algo inesperado, no se muestra nada y el + comando termina con su mismo código de salida. +- Solo se anuncian releases publicados y estables, nunca un borrador ni una prerelease (`1.0.0-rc.1`). +- Tras Ctrl+C solo se muestra si la respuesta ya había llegado (por ejemplo, durante un `fiscalapi listen` largo). + +El aviso **no corre**: + +| Caso | Por qué | +|---|---| +| Con `--json` | La salida es para otra herramienta. | +| Con stderr redirigido (`2> log.txt`) | No ensucia el log de un script. | +| Con la variable `CI` definida, como en GitHub Actions y la mayoría de los CI | En un CI no hay quién lo lea. | +| Con `FISCALAPI_NO_UPDATE_CHECK=1` | Para apagarlo siempre. | + +`CI` y `FISCALAPI_NO_UPDATE_CHECK` cuentan como definidas con cualquier valor salvo vacío, `0` o `false`. Para apagar +el aviso de forma permanente: + +```bash +echo 'export FISCALAPI_NO_UPDATE_CHECK=1' >> ~/.bashrc # o ~/.zshrc +``` + +```powershell +[Environment]::SetEnvironmentVariable('FISCALAPI_NO_UPDATE_CHECK', '1', 'User') +``` + +La fecha de la última consulta, sin ningún secreto, se guarda en: + +| Sistema | Archivo | +|---|---| +| Windows | `%LOCALAPPDATA%\fiscalapi-cli\update-check.json` | +| macOS | `~/Library/Application Support/fiscalapi-cli/update-check.json` | +| Linux | `$XDG_STATE_HOME/fiscalapi-cli/update-check.json` (por omisión `~/.local/state/fiscalapi-cli/…`) | + +Borrarlo solo adelanta la siguiente consulta. Si la carpeta no se puede escribir, el CLI no consulta. + +## Desinstalar + +1. Borra las credenciales guardadas de cada ambiente que usaste, **antes** de borrar el binario: + + ```bash + fiscalapi logout + fiscalapi logout --live + fiscalapi logout --api-base http://localhost:5001 # si usaste otra URL base + ``` + +2. Borra el binario y la carpeta de estado: + + ```bash + # Linux + sudo rm /usr/local/bin/fiscalapi + rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/fiscalapi-cli" + # macOS + sudo rm /usr/local/bin/fiscalapi + rm -rf ~/Library/Application\ Support/fiscalapi-cli + ``` + + En Windows, quita también la carpeta del `Path` de tu usuario, sin tocar el resto: + + ```powershell + $dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' + $environment = Get-Item 'HKCU:\Environment' + $userPath = $environment.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $newPath = (($userPath -split ';') | Where-Object { $_ -and $_ -ne $dir }) -join ';' + if ($newPath -ne $userPath) { + Set-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath -Type ExpandString + [Environment]::SetEnvironmentVariable('FISCALAPI_CLI_PATH_REFRESH', [NullString]::Value, 'User') + } + Remove-Item $dir -Recurse -Force + Remove-Item (Join-Path $env:LOCALAPPDATA 'fiscalapi-cli') -Recurse -Force -ErrorAction SilentlyContinue + ``` + +Siguiente: [Autenticación](autenticacion.md). diff --git a/docs/listen.md b/docs/listen.md new file mode 100644 index 0000000..fa3026f --- /dev/null +++ b/docs/listen.md @@ -0,0 +1,222 @@ +# `fiscalapi listen` + +Recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu servidor local, sin exponerlo a internet. + +## Uso + +```text +fiscalapi listen [--forward-to ] [--events ] [--headers "K:V,K:V"] [--skip-verify] [--print-secret] +``` + +Requiere una API key con el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). + +## Opciones + +| Opción | Descripción | +|---|---| +| `--forward-to ` | URL absoluta `http` o `https` de tu servidor, p. ej. `http://localhost:3000/webhooks`. Sin ella, `listen` solo muestra los eventos. | +| `--events ` | Tipos separados por coma, p. ej. `invoice.canceled,invoice.cancellation_rejected`. Por omisión, `*` (todos). | +| `--headers "K:V,K:V"` | Headers extra en cada reenvío. El valor llega hasta la coma siguiente y puede llevar `:`. | +| `--skip-verify` | No verifica el certificado TLS de `--forward-to` (p. ej. uno autofirmado). **Nunca** afecta a la conexión con la API. | +| `--print-secret` | Imprime solo el secreto de firma en stdout y termina. | + +También acepta las opciones globales `--live`, `--api-base`, `--json` y `--verbose` +([Salida y códigos](salida-y-codigos.md)). + +Estas combinaciones son errores de uso (salida 2): + +| Caso | Error | +|---|---| +| `--headers` o `--skip-verify` sin `--forward-to` | `Error: --skip-verify solo aplica con --forward-to.` | +| `--headers` con un header firmado | `Error: --headers no puede cambiar webhook-id: lo firma FiscalAPI.` | +| `--print-secret` con `--forward-to`, `--headers` o `--skip-verify` | `Error: --print-secret no se combina con --forward-to.` | + +> **`--print-secret` abre una sesión**, igual que `listen`. Si ya corre un `fiscalapi listen` con tu misma persona, lo +> reemplaza y aquel termina con el [409](#conexión-y-sesiones). Úsalo antes de arrancar `listen`. + +## Ejemplos + +Reenvía los eventos a tu servidor local: + +```text +$ fiscalapi listen --forward-to http://localhost:3000/webhooks +Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a) +2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] +``` + +Cada línea trae la hora local, el tipo, el id del evento (el header `webhook-id`) y lo que respondió tu servidor. Para +disparar un evento de prueba, corre [`fiscalapi trigger`](trigger.md) en otra terminal. + +```bash +# Solo muestra las cancelaciones, sin reenviarlas +fiscalapi listen --events invoice.canceled,invoice.cancellation_rejected + +# Servidor con TLS autofirmado y un header de autorización propio +fiscalapi listen --forward-to https://localhost:5443/webhooks --skip-verify --headers "Authorization:Bearer token-local" + +# Guarda el secreto para configurar tu servidor +export FISCALAPI_WEBHOOK_SECRET=$(fiscalapi listen --print-secret) +``` + +## Cómo funciona + +1. `listen` abre **tu escucha de CLI**: un webhook sin URL, uno por persona, con su propio secreto de firma + (`POST /api/v4/webhooks/cli-listener`). El secreto no cambia entre reinicios: configúralo una vez. +2. Cada evento de tu cuenta de los tipos de `--events` genera una entrega para tu escucha. `listen` las pide por + long-polling (`GET /api/v4/webhooks/{id}/cli-deliveries`, hasta 25 s por petición). +3. Reenvía cada entrega a `--forward-to` como un `POST` con el cuerpo **exacto** que firmó FiscalAPI + (`Content-Type: application/json; charset=utf-8`), los headers firmados `webhook-id`, `webhook-timestamp` y + `webhook-signature`, y los de `--headers`. +4. Manda a la API un acuse con el resultado, así la entrega queda en el historial como la de un webhook con URL. + +Se reenvían hasta 16 entregas a la vez; las de un mismo evento conservan su orden. Las redirecciones no se siguen. + +**No hay reintentos.** Si tu servidor falla, la entrega queda con ese resultado; repítela con +[`fiscalapi events resend`](webhooks-y-eventos.md#reenviar-un-evento). + +## Resultado de cada reenvío + +| Final de la línea | Significado | +|---|---| +| `[200]` | Tu servidor respondió ese código. Cualquier código de 100 a 599 se muestra y se acusa tal cual. | +| `[error: Timeout]` | Tu servidor no respondió en 15 s. | +| `[error: ConnectionFailed]` | No se pudo conectar: nada escucha en ese puerto, DNS o conexión rechazada. | +| `[error: TlsFailed]` | Falló el TLS hacia `--forward-to`, p. ej. un certificado autofirmado sin `--skip-verify`. | +| `[error: InvalidResponse]` | La respuesta no es HTTP válido o se cortó. | +| (nada) | Sin `--forward-to`: el evento solo se mostró y se acusa como entregado. | + +Son los mismos nombres de error del historial de entregas de la API. + +## Verificar la firma + +Cada reenvío lleva la firma de FiscalAPI con el secreto de tu escucha (`whsec_…`), según +[Standard Webhooks](https://www.standardwebhooks.com/). Verifícala como la de un webhook con URL, con el **cuerpo +crudo** tal como llegó. Si lo deserializas y lo vuelves a serializar, cambian sus bytes y la firma no coincide. + +Los SDK oficiales traen la verificación: + +| SDK | Función | +|---|---| +| .NET (`Fiscalapi`) | `Fiscalapi.Webhooks.Webhook.Verify(headers, body, secret)` | +| Node.js (`fiscalapi`) | `Webhook.verify(headers, body, secret)` | +| Python (`fiscalapi`) | `fiscalapi.webhooks.verify(headers, body, secret)` | +| PHP | `Fiscalapi\Webhooks\Webhook::verify(...)` | +| Java | `com.fiscalapi.webhooks.Webhook.verify(headers, body, secret)` | + +Todas comparan la firma en tiempo constante, rechazan un `webhook-timestamp` con más de 5 minutos de diferencia y +devuelven el evento verificado. En ASP.NET Core: + +```csharp +[HttpPost("webhooks/fiscalapi")] +public async Task Receive() +{ + string body; + using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) + body = await reader.ReadToEndAsync(); + + WebhookEvent webhookEvent; + try + { + var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()); + webhookEvent = Webhook.Verify(headers, body, _webhookSecret); + } + catch (WebhookVerificationException) + { + return BadRequest(); // firma inválida, header faltante o timestamp fuera de tolerancia + } + + // Responde 2xx rápido y procesa después. + return Ok(); +} +``` + +Para probar tu verificación sin la API, usa el vector oficial de Standard Webhooks. Con la hora actual igual al +timestamp, la firma verifica; si cambias un carácter del cuerpo, de la firma o del secreto, falla. + +| Dato | Valor | +|---|---| +| Secreto | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` | +| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` | +| `webhook-timestamp` | `1614265330` | +| Cuerpo | `{"test": 2432232314}` | +| `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` | + +La forma del evento y la verificación a mano en cada lenguaje están en la +[guía de webhooks de FiscalAPI](https://docs.fiscalapi.com/webhooks). + +## Conexión y sesiones + +`listen` corre hasta que lo detienes. Si la primera apertura de la escucha falla (p. ej. un `--api-base` equivocado o +una API key sin permiso), termina de inmediato con el error. Después: + +| Situación | Qué hace `listen` | +|---|---| +| Red caída, 408, 429 o 5xx | Reintenta con espera creciente, de 1 s hasta 30 s con variación aleatoria, y lo avisa en stderr. | +| 410, sesión caducada | La API cierra la sesión tras 60 s sin long-polling (un corte largo, el equipo suspendido). `listen` abre una nueva y sigue. | +| 409, sesión reemplazada | Otro `listen` o un `--print-secret` con tu misma persona abrió sesión. Solo puede haber una: esta termina con 1. | +| Otro error de la API (401, 403, 404…) | Termina con 1 y el error de la API. | +| Ctrl+C | Acusa durante 2 s lo que ya tenía resultado y sale con **130**. Lo que estaba a medio reenviar queda sin acuse y la API lo cierra. | + +```text +Se perdió la conexión con FiscalAPI (No se pudo conectar con https://test.fiscalapi.com/: …); reintentando en 2 s. +Conexión con FiscalAPI restablecida. +La sesión de fiscalapi listen caducó; se abre una nueva. +``` + +Los eventos que ocurren sin sesión abierta **no** llegan después. Búscalos con `fiscalapi events list` y reenvíalos +con `fiscalapi events resend `. + +Una sesión reemplazada termina así: + +```text +Error: Sorry, it's not me, it's you. + Detalle: Otra sesión de fiscalapi listen reemplazó a esta. + Código HTTP: 409 + traceIdentifier (para soporte): … +``` + +Otros avisos en stderr: + +| Aviso | Qué pasó | +|---|---| +| `La entrega () venció antes de reenviarse; la API la cerró sin acuse.` | La entrega esperó su turno más de 2 minutos y la API ya la cerró: no se reenvía. | +| `No se registró el acuse de la entrega : …` | La API rechazó el acuse. Un 409 (la entrega ya no esperaba acuse) solo se muestra con `--verbose`. | +| `No se pudo reabrir la sesión de escucha (…); reintentando en s.` | Al reabrir tras un 410, la API respondió 409 porque otra apertura de tu persona estaba en curso. Se reintenta con espera creciente. | + +## Salida con `--json` + +Con `--json`, stdout lleva **un objeto JSON por entrega**, uno por línea (JSON Lines). Los avisos, los errores y la +línea `Listo. Tu secreto de firma es …` siguen en stderr como texto. + +```json +{"time":"2026-10-12T09:47:46.1234567-06:00","deliveryId":"a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d","eventId":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","type":"invoice.canceled","statusCode":200,"latencyMs":12,"event":{"id":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","type":"invoice.canceled","createdAt":"2026-10-12T15:47:46Z","personId":null,"data":{"relatedObject":{"id":"c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f","type":"invoice","url":"/api/v4/invoices/c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f"},"status":"X","previousStatus":"C"}}} +``` + +| Campo | Contenido | +|---|---| +| `time` | Cuándo terminó el reenvío, en hora local con su desfase. | +| `deliveryId` | Id de la entrega. | +| `eventId` | El header `webhook-id`, o `?` si no vino. | +| `type` | El tipo del evento, o `?` si el cuerpo no es un evento. | +| `statusCode` | Lo que respondió tu servidor. Solo con `--forward-to` y si respondió. | +| `latencyMs` | Lo que tardó el reenvío. Solo con `--forward-to`. | +| `error` | `Timeout`, `ConnectionFailed`, `TlsFailed` o `InvalidResponse`. Solo con `--forward-to` y si no hubo respuesta. | +| `event` | El evento tal como llegó. Se omite si el cuerpo no es JSON. | + +Con `--print-secret --json`, la única línea es `{"secret":"whsec_…"}`. + +```bash +# El tipo y el resultado de cada reenvío +fiscalapi listen --forward-to http://localhost:3000/webhooks --json | jq -r '"\(.type) \(.statusCode // .error)"' +``` + +## Docker y terminales sin ConPTY + +Dentro de un contenedor, `http://localhost:…` es el propio contenedor. Para llegar a un servidor del equipo anfitrión +usa su nombre de red (en Docker Desktop, `http://host.docker.internal:3000/webhooks`) y pasa las credenciales con +`-e FISCALAPI_API_KEY -e FISCALAPI_TENANT_KEY`. + +`listen` funciona en cualquier terminal. Solo `login` necesita una terminal real para ocultar las llaves +([Autenticación](autenticacion.md#sin-terminal-stdin)). + +Siguiente: [Webhooks y eventos](webhooks-y-eventos.md). diff --git a/docs/salida-y-codigos.md b/docs/salida-y-codigos.md new file mode 100644 index 0000000..d16fce1 --- /dev/null +++ b/docs/salida-y-codigos.md @@ -0,0 +1,99 @@ +# Salida y códigos de salida + +## stdout y stderr + +`fiscalapi` separa los datos de los mensajes, para que puedas redirigir o procesar los datos sin ruido: + +| Va a stdout | Va a stderr | +|---|---| +| Las tablas y los detalles (`webhooks list`, `events retrieve`…) | Los errores (`Error: …`) y sus detalles | +| La línea de cada entrega de `listen` | `Listo. Tu secreto de firma es …` de `listen` | +| `Secreto de firma: whsec_…` de `create` y `rotate-secret` | El aviso de guardar el secreto | +| El secreto de `listen --print-secret` | Los prompts y mensajes de `login` y `logout` | +| `Reenvío creado: …` de `events resend` y `Evento disparado: …` de `trigger` | La confirmación de `webhooks delete` y `Webhook … borrado.` | +| `--version` y `--help` (sin `--json`) | El detalle HTTP de `--verbose` y el aviso de versión nueva | + +```bash +fiscalapi webhooks list > webhooks.txt # la tabla al archivo, los errores en la terminal +fiscalapi events list 2> /dev/null | less # sin mensajes en el paginador +``` + +## `--json` + +Con `--json`, **stdout trae solo JSON** y todo lo demás va a stderr: + +| Comando | stdout con `--json` | +|---|---| +| `webhooks …`, `events list`, `events resend`, `trigger` | El `data` de la respuesta de la API, sin cambios. | +| `events retrieve` | El evento tal como se entregó (igual que sin `--json`). | +| `listen` | Un objeto JSON por entrega, uno por línea (JSON Lines). Ver [listen](listen.md#salida-con---json). | +| `listen --print-secret` | `{"secret":"whsec_…"}` | +| `login`, `logout` | Nada: sus mensajes van a stderr. | +| `--help`, `--version` | Nada: con `--json` se escriben en stderr. | + +Un error nunca escribe en stdout, ni siquiera con `--json`: si el código de salida no es 0, stdout está vacío o +incompleto. Los errores siguen siendo texto en stderr. + +```bash +if ! data=$(fiscalapi events list --type invoice.canceled --json); then + echo "falló con código $?" >&2 +fi +echo "$data" | jq '.totalCount' +``` + +## `--verbose` + +`--verbose` escribe en stderr cada petición HTTP a la API y su respuesta, con el tiempo que tardó. Este ejemplo es +contra una API falsa local; la tabla del resultado va después, en stdout: + +```text +$ fiscalapi events list --page-size 1 --verbose --api-base http://localhost:5099 +> GET http://localhost:5099/api/v4/events?pageSize=1 +> User-Agent: fiscalapi-cli/0.1.0 +< 200 OK (2037 ms) +< Server: BaseHTTP/0.6, Python/3.12.10 +< Date: Tue, 13 Oct 2026 23:54:21 GMT +< Content-Type: application/json; charset=utf-8 +< Content-Length: 679 +``` + +Un fallo de red aparece como `! GET … falló (ConnectionError) tras 12 ms: …`. + +`--verbose` **nunca** muestra, ni enmascarados: + +- los headers de credenciales: `X-API-KEY`, `X-TENANT-KEY`, `Authorization`, `Proxy-Authorization`, `Cookie` y + `Set-Cookie`; +- los cuerpos de las peticiones y respuestas, porque pueden traer secretos (como el `whsec_` de un webhook). + +Así puedes pegar la salida en un ticket de soporte. El detalle de `--verbose` es de las llamadas a la API, no de los +reenvíos de `listen` a tu servidor. + +## Códigos de salida + +| Código | Significado | Causas típicas | +|---|---|---| +| 0 | Éxito | El comando terminó bien. También `logout` sin nada guardado y `webhooks delete` cuando respondes "no". | +| 1 | Error de la API o de la red | 401 (credenciales), 403 (sin `Webhooks.Manage`), 404, 400 de validación, 5xx; la API no respondió; `listen` reemplazado por otra sesión (409); `trigger` en producción (403) o sin `listen` abierto (409); el almacén de credenciales está bloqueado; editar tu escucha de CLI. | +| 2 | Error de uso | Opción o argumento inválido; falta de credenciales; solo una de `FISCALAPI_API_KEY`/`FISCALAPI_TENANT_KEY`; no hay almacén de credenciales; `delete` sin `--yes` en un script. | +| 130 | Cancelado | Ctrl+C. | + +Un error de la API muestra el mensaje, el detalle, el código HTTP, el `traceIdentifier` (dáselo a soporte) y, cuando +la hay, una sugerencia: + +```text +$ fiscalapi webhooks list +Error: Sorry, it's not me, it's you. + Detalle: Invalid tenant key + Código HTTP: 401 + traceIdentifier (para soporte): 4C2E9A7B1D3F5E608B9A0C1D2E3F4A5B + Sugerencia: Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +``` + +Un 400 de validación agrega, debajo del detalle, una línea `- ` por cada regla que falló. Una respuesta que no +es JSON (por ejemplo, un 502 o 503 en HTML de un proxy) se muestra como `Error: Respuesta inesperada (503).` + +Los GET se reintentan solos, hasta 3 intentos en total con espera creciente (de 0.5 s hasta 10 s), ante una conexión +fallida o cortada, 408, 429 y 5xx. Se respeta `Retry-After`; si pide más de 10 s, no se reintenta. Nunca se reintentan +una API que no responde a tiempo, un error de DNS o de TLS, otro 4xx ni un POST, PUT o DELETE. + +Siguiente: [Solución de problemas](solucion-de-problemas.md). diff --git a/docs/solucion-de-problemas.md b/docs/solucion-de-problemas.md new file mode 100644 index 0000000..889135f --- /dev/null +++ b/docs/solucion-de-problemas.md @@ -0,0 +1,222 @@ +# Solución de problemas + +Cada caso trae el texto que muestra el CLI o la API, la causa y el remedio. Para más detalle de una llamada a la API, +repite el comando con `--verbose` ([Salida y códigos](salida-y-codigos.md#--verbose)); nunca muestra tus llaves. + +## Credenciales + +### 401 `Invalid tenant key` + +```text +Error: Sorry, it's not me, it's you. + Detalle: Invalid tenant key + Código HTTP: 401 + traceIdentifier (para soporte): … + Sugerencia: Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +``` + +**Causa:** la tenant key no existe en ese ambiente, o la API key y la tenant key son de ambientes distintos (las de +pruebas no sirven con `--live` y al revés). +**Remedio:** revisa la tenant key en el dashboard del ambiente correcto y vuelve a correr `fiscalapi login` (o +`fiscalapi login --live`). Si usas variables de entorno, recuerda que ganan sobre lo guardado. + +### 401 `Missing required http X-API-KEY.` + +**Causa:** la API key no es válida para ese ambiente (revocada, mal copiada o de otro ambiente). +**Remedio:** el mismo de arriba. `fiscalapi login` valida las llaves antes de guardarlas: +`Error: Las credenciales no son válidas para test.fiscalapi.com.` + +### 403 sin `Webhooks.Manage` + +```text +Error: Sorry, it's not me, it's you. + Detalle: No tienes permiso para administrar webhooks. + Código HTTP: 403 + traceIdentifier (para soporte): … +``` + +**Causa:** la persona de la API key no tiene el permiso `Webhooks.Manage`, que piden `listen`, `trigger`, `webhooks` y +`events`. `login` ya lo había advertido: +`Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403.` +**Remedio:** pide al owner de la cuenta que dé el permiso a esa persona, o usa una API key de una persona que lo tenga. + +### `No hay credenciales para …` + +```text +Error: No hay credenciales para test.fiscalapi.com. Ejecuta `fiscalapi login` o define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +``` + +**Causa:** no hay variables de entorno ni nada guardado para **ese** ambiente. Las credenciales se guardan por host: +haber hecho `login` en pruebas no sirve para `--live` ni para otro `--api-base`. +**Remedio:** `fiscalapi login` con las mismas opciones de ambiente que usa el comando. + +### `Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY juntas` + +**Causa:** solo una de las dos variables está definida. **Remedio:** define las dos o ninguna. + +### No hay almacén de credenciales + +```text +Error: No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. + Detalle: … +``` + +**Causa:** Linux sin Secret Service: WSL, un servidor sin sesión gráfica, un contenedor o falta libsecret. +**Remedio:** usa las variables de entorno, o instala libsecret y GNOME Keyring +([Autenticación](autenticacion.md#wsl-servidores-y-contenedores)). + +### Almacén bloqueado + +```text +Error: El almacén de credenciales del sistema existe, pero está bloqueado o no respondió. +``` + +**Causa:** el llavero de macOS o GNOME Keyring está bloqueado, o no respondió a tiempo. +**Remedio:** desbloquéalo y repite el comando, o usa las variables de entorno. + +### `login` muestra lo que escribo + +**Causa:** la terminal no tiene ConPTY (mintty de Git Bash) o es `docker run -i` sin `-t`; `login` trata la entrada +como redirigida, sin prompt y con eco. +**Remedio:** usa Windows Terminal o PowerShell, `docker run -it`, o las variables de entorno. + +## Opciones y URLs + +### `--api-base con http solo se permite para localhost…` + +```text +Error: --api-base con http solo se permite para localhost, 127.0.0.1 o ::1; usa https para 'http://fiscalapi.example.com'. +``` + +**Causa:** tus llaves viajan en headers; sin TLS solo se permite hablar con tu propio equipo. +**Remedio:** usa `https://`. Para un back local, `http://localhost:` sí está permitido. + +### Opciones que no se combinan + +Son errores de uso (salida 2) y dicen qué corregir, por ejemplo: + +```text +Error: --skip-verify solo aplica con --forward-to. +Error: --print-secret no se combina con --forward-to. +Error: --person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons. +Error: Para borrar sin confirmación interactiva usa --yes. +``` + +`fiscalapi --help` lista las opciones de cada comando. + +## `fiscalapi listen` + +### 409 `Otra sesión de fiscalapi listen reemplazó a esta.` + +**Causa:** abriste otro `fiscalapi listen` o un `fiscalapi listen --print-secret` con la misma persona (en otra +terminal, otra máquina o un script). Solo puede haber una sesión por persona; la anterior termina con código 1. +**Remedio:** cierra la otra y vuelve a abrir `listen`. Corre `--print-secret` antes de `listen`, no mientras corre. + +### El receptor no responde: `[error: Timeout]`, `[error: ConnectionFailed]` + +```text +2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [error: ConnectionFailed] +``` + +**Causa:** nada escucha en la URL de `--forward-to` (puerto equivocado, servidor apagado, ruta en otro host), o tu +servidor tardó más de 15 s (`Timeout`). Dentro de un contenedor, `localhost` es el propio contenedor. +**Remedio:** prueba la URL con `curl -X POST `; responde 2xx rápido y procesa después. Con un certificado +autofirmado verás `[error: TlsFailed]`: agrega `--skip-verify`. La entrega no se reintenta; pídela otra vez con +`fiscalapi events resend `. + +### La firma no verifica en mi servidor + +**Causa más común:** tu servidor verifica un cuerpo **re-serializado**. Si el framework deserializa el JSON (por ejemplo +`[FromBody]` en ASP.NET Core o `express.json()` en Express) y lo vuelves a serializar, cambian los espacios, el orden o +los escapes, y la firma ya no coincide. `listen` reenvía los bytes exactos que firmó FiscalAPI. +**Remedio:** lee el cuerpo crudo antes de deserializarlo y pásalo al `Verify` del SDK +([listen](listen.md#verificar-la-firma)). + +Otras causas: + +- **Otro secreto:** el de tu escucha de CLI no es el de tus webhooks con URL. Toma el de la línea + `Listo. Tu secreto de firma es whsec_…` o de `fiscalapi listen --print-secret`, completo y con su prefijo `whsec_`. +- **Reloj desfasado:** los SDK rechazan un `webhook-timestamp` con más de 5 minutos de diferencia. Sincroniza la hora + del equipo. +- **Rotaste el secreto:** durante 24 horas configura los dos secretos (los SDK aceptan una lista). +- **Headers alterados:** un proxy local que cambia `webhook-id`, `webhook-timestamp` o `webhook-signature`. `--headers` + no puede cambiarlos. + +### `La sesión de fiscalapi listen caducó; se abre una nueva.` + +**Causa:** pasaron más de 60 s sin long-polling (corte de red, suspensión del equipo). `listen` se recupera solo, pero +los eventos de ese intervalo no llegan. **Remedio:** búscalos con `fiscalapi events list --from ` y reenvíalos +con `fiscalapi events resend `. + +### `events resend` sin `--webhook` falla + +```text +Error: Sorry, it's not me, it's you. + Detalle: La escucha de CLI no está conectada; ejecuta fiscalapi listen. + Código HTTP: 409 + traceIdentifier (para soporte): … +``` + +**Causa:** sin `--webhook`, el reenvío va a tu escucha de CLI, y no hay un `listen` abierto. **Remedio:** abre +`fiscalapi listen` en otra terminal o usa `--webhook `. + +## `fiscalapi trigger` + +### 403 `fiscalapi trigger no está disponible en producción` + +```text +$ fiscalapi trigger invoice.canceled --live +Error: Sorry, it's not me, it's you. + Detalle: fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas. + Código HTTP: 403 + traceIdentifier (para soporte): … +``` + +**Causa:** los eventos de prueba solo existen en el ambiente de pruebas: en producción se confundirían con uno real. +**Remedio:** quita `--live` y usa tus credenciales de pruebas. + +### 409 `La escucha de CLI no está conectada; ejecuta fiscalapi listen.` + +**Causa:** sin `--webhook`, el evento va a tu escucha de CLI, y no hay un `fiscalapi listen` abierto con tu persona. +**Remedio:** abre `fiscalapi listen` en otra terminal y repite el `trigger`, o usa `--webhook `. + +### 400 `El tipo de evento '…' no existe.` + +**Causa:** un tipo mal escrito (`invoice.cancelled` en lugar de `invoice.canceled`) o `webhook.test`, que no se +dispara. **Remedio:** usa uno de los tipos de `fiscalapi trigger --help`. + +### El evento no llega a mi servidor + +**Causa:** `trigger` respondió `Evento disparado`, pero la entrega es de un solo intento: si tu servidor no respondió +2xx, queda fallida y no se reintenta. **Remedio:** revisa la línea de `listen` (`[500]`, `[error: …]`) o el log de +entregas del webhook en el dashboard, corrige y repite con `fiscalapi events resend [--webhook ]`, con +el mismo `--webhook` del `trigger` (sin él, el reenvío va a tu escucha de `listen`). + +## Instalación + +### macOS bloquea el binario (Gatekeeper) + +**Causa:** el binario no está firmado ni notarizado por Apple y lo bajaste con el navegador, que le pone la marca de +cuarentena. **Remedio:** `xattr -d com.apple.quarantine /usr/local/bin/fiscalapi`, o descárgalo con `curl`. + +### Windows advierte al abrirlo (SmartScreen) + +**Causa:** el binario no lleva firma Authenticode y el zip vino del navegador ("Windows protegió su PC"). +**Remedio:** verifica el SHA-256 ([Instalación](instalacion.md#windows-powershell)) y quita la marca de descarga con +`Unblock-File`. + +### `fiscalapi` no se encuentra después de instalar + +**Causa:** la carpeta del binario no está en el `PATH`, o la terminal se abrió antes del cambio. +**Remedio:** abre una terminal nueva; en Windows revisa +[Agregar `fiscalapi` al PATH](instalacion.md#agregar-fiscalapi-al-path). + +### Linux: `GLIBC_2.xx not found` + +**Causa:** la distro es más vieja que glibc 2.28 (o es Alpine, con musl). **Remedio:** usa RHEL/AlmaLinux/Rocky 8+, +Debian 10+ o Ubuntu 20.04+. + +## Soporte + +Si el problema sigue, abre un ticket con el comando, la salida completa con `--verbose` y el `traceIdentifier` del +error. Ninguna de las dos incluye tus llaves. diff --git a/docs/trigger.md b/docs/trigger.md new file mode 100644 index 0000000..d2c6801 --- /dev/null +++ b/docs/trigger.md @@ -0,0 +1,109 @@ +# `fiscalapi trigger` + +Dispara un evento de prueba y lo entrega a tu escucha de [`fiscalapi listen`](listen.md) o a un webhook. Sirve para +probar tu receptor sin timbrar, cancelar ni descargar nada real. Solo funciona en el ambiente de pruebas. + +## Uso + +```text +fiscalapi trigger [--related-object-id ] [--person-id ] [--webhook ] +``` + +Requiere una API key con el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). + +## Argumentos + +| Argumento | Descripción | +|---|---| +| `` | Tipo de evento: `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, `download_request.completed`, `download_request.abandoned` o `download_rule.suspended`. | + +`fiscalapi trigger --help` lista los tipos y, si usas +[`dotnet-suggest`](https://learn.microsoft.com/dotnet/standard/commandline/how-to-enable-tab-completion), el tabulador +los completa. Quien valida el tipo es la API, así que un tipo nuevo funciona sin actualizar el CLI. `webhook.test` no +se dispara: lo manda la prueba de un webhook. + +## Opciones + +| Opción | Descripción | +|---|---| +| `--related-object-id ` | Id del recurso del evento (`data.relatedObject.id`), hasta 36 caracteres. Por omisión, un id sintético cuyo `GET` responde 404. | +| `--person-id ` | Persona de tu cuenta dueña del recurso (`personId`). Por omisión, el evento va sin persona. | +| `--webhook ` | Webhook que recibe el evento. Por omisión, tu escucha de `fiscalapi listen`, que debe estar abierta. | + +También acepta las opciones globales `--live`, `--api-base`, `--json` y `--verbose` +([Salida y códigos](salida-y-codigos.md)). + +## Ejemplos + +Dispara un evento hacia tu escucha: + +```text +# Terminal 1 +$ fiscalapi listen --forward-to http://localhost:3000/webhooks +Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a) +2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] + +# Terminal 2 +$ fiscalapi trigger invoice.canceled +Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a. +``` + +Dispara un evento hacia un webhook con URL, sin `listen`: + +```text +$ fiscalapi trigger invoice.canceled --webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. +``` + +Usa un recurso y una persona reales de tu cuenta, para que tu servidor pueda consultarlos: + +```bash +fiscalapi trigger download_request.completed \ + --related-object-id 8a1f0c2e-5d6b-4f7a-9c3e-1b2d3e4f5a6b \ + --person-id 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f +``` + +Con `--json`, imprime el `data` de la respuesta sin cambios: + +```text +$ fiscalapi trigger invoice.canceled --json +{"eventId":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","webhookId":"9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a"} +``` + +## Cómo se entrega + +- El evento tiene la forma y la firma de uno real: el recurso, `status` y `previousStatus` de su tipo (por ejemplo, + `invoice.canceled` pasa de `C` a `X`). Tu servidor lo verifica con el mismo código. +- Llega **solo** al destino indicado, aunque no esté suscrito a ese tipo ni a esa persona (`--events` de `listen` o + los eventos del webhook). Tus demás webhooks no lo reciben. +- Es **un solo intento**: si tu servidor falla, no se reintenta. Repítelo con `trigger` o con + [`fiscalapi events resend [--webhook ]`](webhooks-y-eventos.md#reenviar-un-evento), con el mismo + `--webhook` del `trigger`: sin él, el reenvío va a tu escucha de `listen`. +- Queda en `fiscalapi events list` como cualquier otro evento. + +## Errores + +Salen con código 1 y el texto en stderr: + +```text +$ fiscalapi trigger invoice.canceled --live +Error: Sorry, it's not me, it's you. + Detalle: fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas. + Código HTTP: 403 + traceIdentifier (para soporte): 8F3A2C1D9B7E4F60A5C3D2E1F0B9A877 +``` + +| Código | Texto | Qué hacer | +|---|---|---| +| 409 | `La escucha de CLI no está conectada; ejecuta fiscalapi listen.` | Abre `fiscalapi listen` o usa `--webhook`. | +| 400 | `El tipo de evento 'invoice.cancelled' no existe.` | Usa un tipo de la lista. | +| 400 | `El webhook está deshabilitado: habilítalo antes de probarlo.` | `fiscalapi webhooks update --enabled`. | +| 400 | `La persona especificada no es válida o no pertenece a tu organización.` | Revisa `--person-id`. | +| 403 | `fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas.` | Quita `--live`. | +| 403 | `No tienes permiso para administrar webhooks.` | Usa una API key con el permiso `Webhooks.Manage` ([detalles](solucion-de-problemas.md#403-sin-webhooksmanage)). | +| 404 | `No such resource for the given Id: …` | Revisa `--webhook`: no existe, está dado de baja o no es tuyo. | +| 429 | `Se excedió el límite de solicitudes de la familia BusinessWrite (…). Reintenta en N segundos. …` | Demasiadas peticiones con la misma API key: espera los segundos que indica y repite. | + +Más casos en [Solución de problemas](solucion-de-problemas.md#fiscalapi-trigger). + +Siguiente: [Salida y códigos de salida](salida-y-codigos.md). diff --git a/docs/webhooks-y-eventos.md b/docs/webhooks-y-eventos.md new file mode 100644 index 0000000..3e584c6 --- /dev/null +++ b/docs/webhooks-y-eventos.md @@ -0,0 +1,259 @@ +# Webhooks y eventos + +`fiscalapi webhooks` administra los webhooks de tu cuenta y `fiscalapi events` consulta y reenvía los eventos de los +últimos 30 días. Requieren una API key con el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). + +## `fiscalapi webhooks` + +| Comando | Ruta | Qué hace | +|---|---|---| +| `webhooks list` | `GET /api/v4/webhooks` | Lista los webhooks, sin su secreto. | +| `webhooks retrieve ` | `GET /api/v4/webhooks/{id}` | Muestra un webhook, sin su secreto. | +| `webhooks create` | `POST /api/v4/webhooks` | Da de alta un webhook e imprime su secreto **una sola vez**. | +| `webhooks update ` | `GET` y `PUT /api/v4/webhooks/{id}` | Cambia URL, descripción, eventos, personas o estado. | +| `webhooks delete ` | `DELETE /api/v4/webhooks/{id}` | Da de baja un webhook y cierra sus entregas pendientes; su historial se conserva. | +| `webhooks rotate-secret ` | `POST /api/v4/webhooks/{id}/rotate-secret` | Genera un secreto nuevo y lo imprime una sola vez. | + +El `` de un webhook es un GUID: otro valor responde 404 con `Detalle: Not Found`, y un GUID que no es de tu cuenta, +`No such resource for the given Id: …`. + +### Listar webhooks + +```text +$ fiscalapi webhooks list +ID TIPO ESTADO URL EVENTOS DESCRIPCIÓN +6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d url enabled https://example.com/webhooks invoice.canceled,invoice.cancellation_requested Producción +9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a cliListener enabled - * - +Página 1 de 3 (5 en total). Siguiente: --page 2 +``` + +`TIPO` es `url` para un webhook con URL y `cliListener` para tu [escucha de `fiscalapi listen`](listen.md), que no +tiene URL. El pie indica la opción para pedir la página siguiente. + +### Paginación + +`webhooks list` y `events list` aceptan: + +| Opción | Descripción | +|---|---| +| `--page ` | Número de página, desde 1. Por omisión, 1. | +| `--page-size ` | Elementos por página, de 1 a 50. Por omisión, 10. | + +### Ver un webhook + +```text +$ fiscalapi webhooks retrieve 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Tipo url +Estado enabled +URL https://example.com/webhooks +Descripción Producción +Eventos invoice.canceled, invoice.cancellation_requested +Personas 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f, 7c3b2a1d-0e9f-4d8c-a7b6-5f4e3d2c1b0a +Creado 2026-10-13T09:30:00.000 +``` + +Solo aparecen los campos con valor. Según el caso también verás `Motivo` y `Deshabilitado` (webhook deshabilitado), +`Fallando desde`, `Persona de la escucha` y `Sesión vence` (escucha de CLI) y `Actualizado`. + +### Crear un webhook + +| Opción | Descripción | +|---|---| +| `--url ` | **Obligatoria.** URL `https` que recibe las entregas. | +| `--events ` | **Obligatoria.** Tipos separados por coma, o `*` para todos. | +| `--description ` | Descripción libre, hasta 256 caracteres. | +| `--person ` | Solo los eventos de esta persona, hasta 100. Se repite (`--person a --person b`) o va separado por coma. | +| `--all-persons` | Los eventos de todas las personas. Es el comportamiento sin `--person`. | + +```text +$ fiscalapi webhooks create --url https://example.com/webhooks --events '*' +ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Tipo url +Estado enabled +URL https://example.com/webhooks +Eventos * +Personas todas +Creado 2026-10-13T09:30:00.000 + +Secreto de firma: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw +Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI. +``` + +**El secreto se muestra una sola vez.** `list` y `retrieve` nunca lo muestran: guárdalo en ese momento en la +configuración segura de tu receptor. Si lo pierdes, genera otro con `rotate-secret`. La línea `Secreto de firma: …` va +a stdout y el aviso, a stderr. + +En bash y zsh, escribe `'*'` entre comillas para que el shell no lo expanda. + +Los tipos de evento son `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, +`download_request.completed`, `download_request.abandoned` y `download_rule.suspended`. La API valida la URL y los +tipos; si algo no es válido, responde 400 con una línea por cada regla que falló. + +### Filtrar por persona + +Un webhook recibe los eventos de todas las personas de tu cuenta o solo los de las que indiques: + +```bash +fiscalapi webhooks create --url https://example.com/webhooks --events invoice.canceled --person 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f,7c3b2a1d-0e9f-4d8c-a7b6-5f4e3d2c1b0a +fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --all-persons # quita el filtro +``` + +`--person` vacío es un error, para que una variable vacía (`--person "$PERSONAS"`) no amplíe el webhook a todas las +personas sin querer: + +```text +Error: --person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons. +``` + +`--person` y `--all-persons` no se combinan. + +### Actualizar un webhook + +`update` lee el webhook, cambia solo lo que indicas y lo guarda. Acepta las opciones de `create`, todas opcionales, y +además: + +| Opción | Descripción | +|---|---| +| `--enabled` | Habilita el webhook y limpia el motivo y la fecha de la deshabilitación. | +| `--disabled` | Deshabilita el webhook y cierra sus entregas pendientes. | + +```bash +fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --events invoice.canceled,invoice.cancellation_rejected +fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --disabled +``` + +Imprime el webhook actualizado. Estos casos fallan: + +| Caso | Salida | Error | +|---|---|---| +| Sin ninguna opción | 2 | `Error: Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled.` | +| `--enabled` con `--disabled` | 2 | `Error: --enabled y --disabled no se combinan.` | +| El id es tu escucha de CLI | 1 | `Error: Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger.` | + +### Borrar un webhook + +En una terminal, `delete` pide confirmación. Solo `s`, `si`, `sí`, `y` o `yes` confirman: + +```text +$ fiscalapi webhooks delete 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +¿Borrar el webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d? Sus entregas pendientes se cerrarán. [s/N] s +Webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d borrado. +``` + +Cualquier otra respuesta imprime `No se borró el webhook .` y sale con 0. En un script (stdin redirigido) o con +`--json` no hay a quién preguntar: agrega `--yes`, o sale con 2. + +```text +$ fiscalapi webhooks delete 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --json +Error: Para borrar sin confirmación interactiva usa --yes. +``` + +### Rotar el secreto + +```text +$ fiscalapi webhooks rotate-secret 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +… +Secreto de firma: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw +Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI. +El secreto anterior seguirá firmando durante 24 horas para que actualices tu receptor sin perder eventos. +``` + +Durante esas 24 horas cada entrega lleva las dos firmas: configura tu receptor con los dos secretos (los SDK aceptan +una lista) y quita el anterior al terminar. Firman como mucho dos secretos: si vuelves a rotar dentro de esas 24 horas, +el anterior sigue siendo el original y el intermedio deja de firmar. + +`rotate-secret` también funciona con el id de tu escucha de CLI. + +## `fiscalapi events` + +| Comando | Ruta | Qué hace | +|---|---|---| +| `events list` | `GET /api/v4/events` | Lista los eventos de los últimos 30 días, del más nuevo al más viejo. | +| `events retrieve ` | `GET /api/v4/events/{id}` | Imprime el evento tal como se entregó. | +| `events resend ` | `POST /api/v4/events/{id}/resend` | Reenvía un evento a un webhook o a tu escucha de `fiscalapi listen`. | + +### Listar eventos + +| Opción | Descripción | +|---|---| +| `--type ` | Solo los eventos de este tipo, p. ej. `invoice.canceled` o `webhook.test`. | +| `--related-object-id ` | Solo los eventos de este recurso (`data.relatedObject.id`), p. ej. el id de una factura. | +| `--from ` | Desde este instante, incluido. | +| `--to ` | Hasta este instante, incluido. Por omisión, ahora. | +| `--page`, `--page-size` | Ver [Paginación](#paginación). | + +```text +$ fiscalapi events list +ID TIPO CREADO (UTC) RECURSO PERSONA +0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f invoice.canceled 2026-10-12T15:47:46Z c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f +5e8a7b6c-3d2f-4a1e-9b0c-8d7e6f5a4b3c webhook.test 2026-10-12T15:40:00Z 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d - +Página 1 de 1 (2 en total). +``` + +```bash +# Las cancelaciones de una factura en octubre +fiscalapi events list --type invoice.canceled --related-object-id c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f --from 2026-10-01 --to 2026-10-31 +``` + +### Fechas + +`--from` y `--to` aceptan ISO 8601: + +| Escribes | Significa | +|---|---| +| `2026-10-01` | Con `--from`, el inicio del día en UTC. Con `--to`, **el día completo**, hasta las 23:59:59.9999999 UTC. | +| `2026-10-01T08:00` o `2026-10-01T08:00:00` | Ese instante en UTC. | +| `2026-10-01T08:00:00-06:00` | Ese instante en esa zona horaria. | + +Sin zona, la hora se toma como UTC. Otro formato es un error de uso (salida 2): + +```text +$ fiscalapi events list --from ayer +Error: --from debe ser una fecha ISO 8601 (p. ej. 2026-10-01 o 2026-10-01T08:00:00-06:00); se recibió 'ayer'. +``` + +### Ver un evento + +`events retrieve` imprime el evento exactamente como lo entrega la API, con o sin `--json`. Su `id` es el del header +`webhook-id` de sus entregas. + +```text +$ fiscalapi events retrieve 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f +{"id":"0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f","type":"invoice.canceled","createdAt":"2026-10-12T15:47:46Z","personId":"2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f","data":{"relatedObject":{"id":"c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f","type":"invoice","url":"/api/v4/invoices/c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f"},"status":"X","previousStatus":"C"}} +``` + +### Reenviar un evento + +```text +$ fiscalapi events resend 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f --webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Reenvío creado: entrega d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a al webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. +``` + +| Opción | Descripción | +|---|---| +| `--webhook ` | Webhook que recibe el reenvío. Por omisión, tu escucha de CLI, que debe tener un `fiscalapi listen` abierto. | + +Sin `--webhook` recibes en local un evento que ocurrió mientras `listen` no corría. Si no hay un `listen` abierto, la +API responde 409 (`La escucha de CLI no está conectada; ejecuta fiscalapi listen.`). + +El reenvío crea una entrega nueva del mismo evento, con el mismo `webhook-id` y el mismo cuerpo. Tu receptor debe +descartar los ids que ya procesó. + +## `--json` + +Con `--json`, cada comando imprime en stdout el `data` de la respuesta de la API, sin cambios: + +```bash +# Los ids de los webhooks habilitados +fiscalapi webhooks list --page-size 50 --json | jq -r '.items[] | select(.status == "enabled") | .id' + +# El secreto de un webhook nuevo, directo a una variable +secret=$(fiscalapi webhooks create --url https://example.com/webhooks --events '*' --json | jq -r .secret) +``` + +Una página trae `items`, `pageNumber`, `totalPages`, `totalCount`, `hasPreviousPage` y `hasNextPage`. El aviso de +guardar el secreto y los errores siguen en stderr como texto. Más en [Salida y códigos](salida-y-codigos.md). + +Siguiente: [`fiscalapi trigger`](trigger.md). diff --git a/global.json b/global.json new file mode 100644 index 0000000..163a6e1 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "latestFeature" + } +} diff --git a/src/FiscalApi.Cli/ApiEnvironment.cs b/src/FiscalApi.Cli/ApiEnvironment.cs new file mode 100644 index 0000000..b907d3d --- /dev/null +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -0,0 +1,33 @@ +namespace FiscalApi.Cli; + +/// +/// Ambiente de la API contra el que trabaja una invocación. Se resuelve una sola vez por invocación +/// y viaja como valor inmutable, sin estáticos mutables. +/// +public sealed record ApiEnvironment +{ + public static readonly Uri TestBaseAddress = new("https://test.fiscalapi.com/"); + public static readonly Uri LiveBaseAddress = new("https://live.fiscalapi.com/"); + + private ApiEnvironment(Uri baseAddress) => BaseAddress = baseAddress; + + /// Siempre termina en /, para que las rutas relativas conserven su prefijo. + public Uri BaseAddress { get; } + + /// + /// Host (y puerto, si no es el de omisión) de la API: identifica la entrada del almacén de credenciales y es + /// lo que ven los mensajes. Así test, live y cada back local guardan credenciales separadas. + /// + public string Host => BaseAddress.Authority; + + /// Precedencia: --api-base gana sobre --live, y este sobre test. + public static ApiEnvironment Resolve(Uri? apiBase, bool live) => + new(WithTrailingSlash(apiBase ?? (live ? LiveBaseAddress : TestBaseAddress))); + + // Sin la "/" final, Uri resuelve "api/v4/..." contra el padre y descarta el último segmento + // (https://host/fiscalapi + api/v4 = https://host/api/v4). + private static Uri WithTrailingSlash(Uri baseAddress) => + baseAddress.AbsolutePath.EndsWith('/') + ? baseAddress + : new UriBuilder(baseAddress) { Path = baseAddress.AbsolutePath + "/" }.Uri; +} diff --git a/src/FiscalApi.Cli/CliApplication.cs b/src/FiscalApi.Cli/CliApplication.cs new file mode 100644 index 0000000..edc990f --- /dev/null +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -0,0 +1,143 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Updates; + +namespace FiscalApi.Cli; + +/// +/// Comando raíz fiscalapi y el único punto que atrapa errores y los traduce a códigos de salida. +/// Los comandos llegan ya construidos por constructor: sin contenedor DI ni registro por , +/// que se resuelven por reflexión y chocan con Native AOT. +/// +public sealed class CliApplication +{ + private readonly RootCommand _rootCommand; + private readonly GlobalOptions _globalOptions; + private readonly CliConsole _console; + private readonly InvocationConfiguration _invocation; + private readonly InvocationConfiguration _jsonInvocation; + private readonly IUpdateCheck _updateCheck; + + public CliApplication(GlobalOptions globalOptions, IReadOnlyList commands, CliConsole console, IUpdateCheck updateCheck) + { + _globalOptions = globalOptions; + _console = console; + _updateCheck = updateCheck; + _rootCommand = new RootCommand("CLI de FiscalAPI: webhooks y eventos desde la terminal."); + globalOptions.AddTo(_rootCommand); + foreach (var command in commands) + { + _rootCommand.Subcommands.Add(command); + } + + // La versión sale de la misma fuente que el User-Agent y no del ensamblado de entrada del proceso. + var versionOption = _rootCommand.Options.OfType().Single(); + versionOption.Action = new PrintVersionAction(); + versionOption.Description = "Muestra la versión del CLI."; + _rootCommand.Options.OfType().Single().Description = "Muestra la ayuda y el uso."; + + // Sin subcomando, la ayuda: es lo que la persona necesita y no es un error. + _rootCommand.SetAction((parseResult, _) => Task.FromResult(new HelpAction().Invoke(parseResult))); + + _invocation = CreateInvocation(output: console.Out, console.Error); + // Con --json, el texto que genera System.CommandLine (la ayuda) va a stderr: stdout es solo para JSON. + // Los comandos escriben sus datos en CliConsole.Out, que no cambia. + _jsonInvocation = CreateInvocation(output: console.Error, console.Error); + } + + public async Task RunAsync(IReadOnlyList args, CancellationToken cancellationToken) + { + var parseResult = _rootCommand.Parse(args); + if (parseResult.Action is ParseErrorAction) + { + return await ReportUsageErrorsAsync(parseResult); + } + + var settings = _globalOptions.Read(parseResult); + // El aviso de versión nueva corre mientras corre el comando y se muestra al final, después de su salida. + var updateNotice = _updateCheck.Start(settings, cancellationToken); + var exitCode = await InvokeAsync(parseResult, settings, cancellationToken); + if (await NoticeToShowAsync(updateNotice, exitCode) is { } notice) + { + await notice.WriteToAsync(_console.Error); + } + + return exitCode; + } + + /// + /// Tras Ctrl+C la salida es inmediata: solo se muestra un aviso que ya llegó (fiscalapi listen siempre termina así), + /// sin esperar una consulta en curso. En los demás casos se espera, a lo más el tope de la consulta. + /// + private static async Task NoticeToShowAsync(Task updateNotice, int exitCode) + { + if (exitCode != ExitCodes.Cancelled) + { + return await updateNotice; + } + + return updateNotice.IsCompletedSuccessfully ? updateNotice.Result : null; + } + + private async Task InvokeAsync(ParseResult parseResult, GlobalSettings settings, CancellationToken cancellationToken) + { + try + { + var invocation = settings.Json ? _jsonInvocation : _invocation; + return await parseResult.InvokeAsync(invocation, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return ExitCodes.Cancelled; + } + catch (CliException exception) + { + exception.WriteTo(_console.Error); + return exception.ExitCode; + } + catch (Exception exception) + { + // Un defecto del CLI: se muestra completo para poder reportarlo, sin el volcado del runtime. + await _console.Error.WriteLineAsync($"Error inesperado del CLI: {exception}"); + return ExitCodes.Failure; + } + } + + /// + /// Los errores de parseo se escriben aquí y solo en stderr: la acción de System.CommandLine también escribe + /// en stdout (la ayuda y sugerencias), y con --json stdout debe quedar limpio. Sale con 2 y no con + /// el 1 que devuelve System.CommandLine, porque el contrato reserva el 1 para la API y la red. + /// + private async Task ReportUsageErrorsAsync(ParseResult parseResult) + { + foreach (var error in parseResult.Errors) + { + await _console.Error.WriteLineAsync($"Error: {error.Message}"); + } + + await _console.Error.WriteLineAsync("Usa `fiscalapi --help` para ver los comandos y opciones."); + return ExitCodes.Usage; + } + + private static InvocationConfiguration CreateInvocation(TextWriter output, TextWriter error) => new() + { + Output = output, + Error = error, + // Los errores los atrapa RunAsync, con los códigos de salida del contrato. + EnableDefaultExceptionHandler = false, + // Ctrl+C lo maneja Program y llega aquí como el token de RunAsync. + ProcessTerminationTimeout = null + }; + + // Asíncrona como el resto de las acciones: System.CommandLine pide no mezclar acciones sync y async. + private sealed class PrintVersionAction : AsynchronousCommandLineAction + { + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + await parseResult.InvocationConfiguration.Output.WriteLineAsync(CliVersion.Current.AsMemory(), cancellationToken); + return ExitCodes.Success; + } + } +} diff --git a/src/FiscalApi.Cli/CliConsole.cs b/src/FiscalApi.Cli/CliConsole.cs new file mode 100644 index 0000000..d74301b --- /dev/null +++ b/src/FiscalApi.Cli/CliConsole.cs @@ -0,0 +1,7 @@ +namespace FiscalApi.Cli; + +/// +/// Salida del CLI: los datos van a y los diagnósticos y errores a , +/// para que --json deje solo JSON en stdout. +/// +public sealed record CliConsole(TextWriter Out, TextWriter Error); diff --git a/src/FiscalApi.Cli/CliVersion.cs b/src/FiscalApi.Cli/CliVersion.cs new file mode 100644 index 0000000..cf79d97 --- /dev/null +++ b/src/FiscalApi.Cli/CliVersion.cs @@ -0,0 +1,14 @@ +using System.Reflection; + +namespace FiscalApi.Cli; + +public static class CliVersion +{ + /// + /// Sale de y no de Assembly.Location, + /// que viene vacío en un binario single-file o AOT. + /// + public static string Current { get; } = + typeof(CliVersion).Assembly.GetCustomAttribute()?.InformationalVersion + ?? throw new InvalidOperationException("El ensamblado no trae AssemblyInformationalVersion; el SDK lo genera desde ."); +} diff --git a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs new file mode 100644 index 0000000..586c561 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs @@ -0,0 +1,41 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Commands; + +/// +/// Lo que comparten los comandos que llaman a la API: las opciones globales, la fábrica +/// de clientes y la salida, más la única regla de salida de sus datos (). +/// +public sealed record ApiCommandContext(GlobalOptions GlobalOptions, FiscalApiClientFactory ApiClients, CliConsole Console) +{ + public GlobalSettings Settings(ParseResult parseResult) => GlobalOptions.Read(parseResult); + + public FiscalApiClient Client(GlobalSettings settings) => ApiClients.Create(settings, FiscalApiClientFactory.DefaultTimeout); + + /// + /// Con --json, el data de la respuesta exactamente como lo escribió la API (contrato común); sin él, la + /// vista de texto del recurso leído con . + /// + public void WriteData(GlobalSettings settings, JsonElement data, JsonTypeInfo type, Action writeText) + { + if (settings.Json) + { + WriteRaw(data); + return; + } + + writeText(Console.Out, Read(data, type)); + } + + /// El JSON del data sin re-serializarlo: los mismos caracteres que mandó la API. + public void WriteRaw(JsonElement data) => Console.Out.WriteLine(data.GetRawText()); + + /// + /// El modelo de texto del data. ya descarta el que falta o es null, así que + /// la lectura no devuelve null. + /// + public static T Read(JsonElement data, JsonTypeInfo type) => ApiData.Required(data).Deserialize(type)!; +} diff --git a/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs new file mode 100644 index 0000000..f4c7b41 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs @@ -0,0 +1,17 @@ +using System.CommandLine.Parsing; + +namespace FiscalApi.Cli.Commands; + +/// +/// Las opciones de lista (--events a,b, --person a --person b,c): cada valor se parte por comas y se recortan +/// los espacios. Lo que contiene la lista lo valida la API, con sus textos. +/// +public static class CommaSeparatedValues +{ + public static IReadOnlyList Parse(ArgumentResult result) => + [.. result.Tokens.SelectMany(token => Split(token.Value))]; + + /// Los valores no vacíos de , sin espacios alrededor. + public static string[] Split(string value) => + value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); +} diff --git a/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs new file mode 100644 index 0000000..2504902 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs @@ -0,0 +1,20 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Commands.Events; + +/// +/// fiscalapi events list|retrieve|resend: los eventos de webhook del tenant de los últimos 30 días sobre +/// /api/v4/events. Sin subcomando muestra la ayuda. +/// +public sealed class EventsCommand : Command +{ + public EventsCommand(GlobalOptions globalOptions, FiscalApiClientFactory apiClients, CliConsole console) + : base("events", "Consulta y reenvía los eventos de webhook de los últimos 30 días.") + { + var context = new ApiCommandContext(globalOptions, apiClients, console); + Subcommands.Add(new ListEventsCommand(context)); + Subcommands.Add(new RetrieveEventCommand(context)); + Subcommands.Add(new ResendEventCommand(context)); + } +} diff --git a/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs new file mode 100644 index 0000000..d3084c9 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs @@ -0,0 +1,120 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Events; + +/// +/// fiscalapi events list [--type] [--related-object-id] [--from] [--to] [--page] [--page-size]: +/// GET /api/v4/events. Los filtros los valida la API (tipo que existe, rango de 30 días); el CLI solo convierte las +/// fechas a UTC. +/// +public sealed class ListEventsCommand : Command +{ + // ISO 8601: solo la fecha, o fecha y hora con o sin segundos y fracción, con o sin zona (Z o ±hh:mm). + private const string DateOnlyFormat = "yyyy-MM-dd"; + + private static readonly string[] DateTimeFormats = + [ + "yyyy-MM-dd'T'HH:mm", + "yyyy-MM-dd'T'HH:mmK", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ssK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK" + ]; + + private readonly Option _type = new("--type") + { + Description = "Solo los eventos de este tipo (p. ej. invoice.canceled o webhook.test).", + HelpName = "tipo" + }; + + private readonly Option _relatedObjectId = new("--related-object-id") + { + Description = "Solo los eventos de este recurso (data.relatedObject.id).", + HelpName = "id" + }; + + private readonly Option _from = new("--from") + { + Description = "Desde este instante, incluido, en ISO 8601 (p. ej. 2026-10-01 o 2026-10-01T08:00:00-06:00); sin zona, UTC.", + HelpName = "fecha", + CustomParser = result => ParseInstant(result, dateIncludesWholeDay: false) + }; + + private readonly Option _to = new("--to") + { + Description = "Hasta este instante, incluido, en ISO 8601; sin zona, UTC. Con solo la fecha, incluye ese día completo " + + "(hasta las 23:59:59.9999999 UTC). Por omisión, ahora.", + HelpName = "fecha", + CustomParser = result => ParseInstant(result, dateIncludesWholeDay: true) + }; + + private readonly PageOptions _page = new(); + private readonly ApiCommandContext _context; + + public ListEventsCommand(ApiCommandContext context) + : base("list", "Lista los eventos de los últimos 30 días, del más nuevo al más viejo.") + { + _context = context; + Options.Add(_type); + Options.Add(_relatedObjectId); + Options.Add(_from); + Options.Add(_to); + _page.AddTo(this); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var query = new WebhookEventsQuery( + parseResult.GetValue(_type), + parseResult.GetValue(_relatedObjectId), + parseResult.GetValue(_from), + parseResult.GetValue(_to), + _page.Read(parseResult)); + + var data = await new EventsApi(_context.Client(settings)).ListAsync(query, cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.PagedListWebhookEventEnvelope, WebhookEventView.WriteList); + return ExitCodes.Success; + } + + /// + /// La API compara instantes UTC, ambos extremos incluidos: una fecha sin zona se toma como UTC y una con zona se + /// convierte. + /// + /// + /// Para --to: "hasta el 13" incluye todo el 13, así que solo la fecha es su último instante (un tick antes del + /// día siguiente; la API guarda createdAt en datetime2). + /// + private static DateTimeOffset? ParseInstant(ArgumentResult result, bool dateIncludesWholeDay) + { + var value = result.Tokens.Single().Value; + if (TryParseUtc(value, [DateOnlyFormat], out var date)) + { + return dateIncludesWholeDay ? date.AddDays(1).AddTicks(-1) : date; + } + + if (TryParseUtc(value, DateTimeFormats, out var instant)) + { + return instant; + } + + var option = ((OptionResult)result.Parent!).Option.Name; + result.AddError( + $"{option} debe ser una fecha ISO 8601 (p. ej. 2026-10-01 o 2026-10-01T08:00:00-06:00); se recibió '{value}'."); + return null; + } + + private static bool TryParseUtc(string value, string[] formats, out DateTimeOffset instant) => + DateTimeOffset.TryParseExact( + value, + formats, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out instant); +} diff --git a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs new file mode 100644 index 0000000..69cbb0d --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs @@ -0,0 +1,41 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Events; + +/// +/// fiscalapi events resend <eventId> [--webhook <id>]: POST /api/v4/events/{id}/resend. +/// Sin --webhook, la API lo entrega a la escucha de CLI de la persona: fiscalapi listen tiene +/// que estar abierto, o responde 409. +/// +public sealed class ResendEventCommand : Command +{ + private readonly Argument _eventId = new("eventId") { Description = "Id del evento que se reenvía." }; + + private readonly Option _webhook = new("--webhook") + { + Description = "Id del webhook que recibe el reenvío. Por omisión, tu escucha de fiscalapi listen.", + HelpName = "id" + }; + + private readonly ApiCommandContext _context; + + public ResendEventCommand(ApiCommandContext context) + : base("resend", "Reenvía un evento a un webhook o a tu escucha de fiscalapi listen.") + { + _context = context; + Arguments.Add(_eventId); + Options.Add(_webhook); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var data = await new EventsApi(_context.Client(settings)).ResendAsync( + parseResult.GetValue(_eventId)!, parseResult.GetValue(_webhook), cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.WebhookEventResend, WebhookEventView.WriteResend); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs b/src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs new file mode 100644 index 0000000..234cbfe --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Events; + +/// +/// fiscalapi events retrieve <id>: GET /api/v4/events/{id}. Imprime el sobre exactamente como lo entregó +/// la API, con o sin --json: es lo que se firmó y lo que el receptor recibió, y re-serializarlo cambiaría sus bytes. +/// +public sealed class RetrieveEventCommand : Command +{ + private readonly Argument _id = new("id") { Description = "Id del evento: el webhook-id con el que se entregó." }; + private readonly ApiCommandContext _context; + + public RetrieveEventCommand(ApiCommandContext context) + : base("retrieve", "Imprime el sobre de un evento tal como se entregó.") + { + _context = context; + Arguments.Add(_id); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + _context.WriteRaw(await new EventsApi(_context.Client(settings)).RetrieveAsync(parseResult.GetValue(_id)!, cancellationToken)); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/ListenCommand.cs b/src/FiscalApi.Cli/Commands/ListenCommand.cs new file mode 100644 index 0000000..647c14a --- /dev/null +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -0,0 +1,204 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Diagnostics; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Listen; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi listen: abre la escucha de CLI, hace long-polling y reenvía cada entrega a +/// --forward-to con el cuerpo y los headers firmados intactos. +/// +public sealed class ListenCommand : Command +{ + private readonly Option _forwardTo = new("--forward-to") + { + Description = "URL a la que se reenvía cada evento (p. ej. http://localhost:3000/webhooks).", + HelpName = "url", + CustomParser = ParseForwardTo + }; + + // Sin --events, todos los tipos: el comodín de fapi-back (WebhookEventTypes.Wildcard). + private readonly Option> _events = new("--events") + { + Description = "Tipos de evento separados por coma (p. ej. invoice.canceled,download_request.completed). Por omisión, todos.", + HelpName = "tipos", + Arity = ArgumentArity.ExactlyOne, + CustomParser = CommaSeparatedValues.Parse, + DefaultValueFactory = _ => ["*"] + }; + + private readonly Option>> _headers = new("--headers") + { + Description = "Headers extra para cada reenvío, como \"Nombre:Valor,Nombre:Valor\".", + HelpName = "headers", + Arity = ArgumentArity.ExactlyOne, + CustomParser = ParseHeaders, + DefaultValueFactory = _ => [] + }; + + private readonly Option _skipVerify = new("--skip-verify") + { + Description = "No verifica el certificado TLS de --forward-to (p. ej. uno autofirmado). Nunca afecta a la API." + }; + + private readonly Option _printSecret = new("--print-secret") + { + Description = "Imprime solo el secreto de firma de la escucha y termina. Abre una sesión: un listen en curso " + + "con la misma API key termina." + }; + + private readonly GlobalOptions _globalOptions; + private readonly FiscalApiClientFactory _apiClients; + private readonly CliConsole _console; + private readonly TimeProvider _timeProvider; + private readonly ListenTimings _timings; + + public ListenCommand( + GlobalOptions globalOptions, + FiscalApiClientFactory apiClients, + CliConsole console, + TimeProvider timeProvider, + ListenTimings timings) + : base("listen", "Recibe los eventos de webhooks en la terminal y los reenvía a tu servidor local.") + { + _globalOptions = globalOptions; + _apiClients = apiClients; + _console = console; + _timeProvider = timeProvider; + _timings = timings; + Options.Add(_forwardTo); + Options.Add(_events); + Options.Add(_headers); + Options.Add(_skipVerify); + Options.Add(_printSecret); + Validators.Add(RequireForwardToFor); + Validators.Add(RejectForwardingWithPrintSecret); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _globalOptions.Read(parseResult); + var api = new ListenerApi(_apiClients.Create(settings, _timings.ApiTimeout), _timings.PollWaitSeconds); + var enabledEvents = parseResult.GetValue(_events)!; + var output = ListenOutput.For(settings, _console, _timeProvider); + + if (parseResult.GetValue(_printSecret)) + { + // El secreto solo llega al abrir una sesión, y abrirla reemplaza la de otro listen en curso. + var session = await api.OpenAsync(enabledEvents, cancellationToken); + output.WriteSecret(session.Secret); + return ExitCodes.Success; + } + + using var forwarder = CreateForwarder(parseResult); + var target = forwarder ?? (IDeliveryTarget)DisplayOnlyTarget.Instance; + var processor = new DeliveryProcessor(target, api, output, _timings, _timeProvider); + await new ListenLoop(api, enabledEvents, processor, output, _timings, _timeProvider).RunAsync(cancellationToken); + throw new UnreachableException("El bucle de listen solo termina con una excepción."); + } + + private HttpForwardTarget? CreateForwarder(ParseResult parseResult) => + parseResult.GetValue(_forwardTo) is { } forwardTo + ? HttpForwardTarget.Create( + forwardTo, parseResult.GetValue(_headers)!, parseResult.GetValue(_skipVerify), _timings.ForwardTimeout, _timeProvider) + : null; + + // Sin destino no hay reenvío al que agregar headers ni TLS que relajar: la opción sobrante es un error de uso y no un + // silencio que haga creer a la persona que surtió efecto. + private void RequireForwardToFor(CommandResult result) + { + if (result.GetResult(_forwardTo) is not null) + { + return; + } + + foreach (var option in new Option[] { _headers, _skipVerify }) + { + if (result.GetResult(option) is { Implicit: false }) + { + result.AddError($"{option.Name} solo aplica con --forward-to."); + } + } + } + + // --print-secret abre la sesión, imprime el secreto y termina: nunca reenvía, así que las opciones del reenvío no + // tendrían efecto. + private void RejectForwardingWithPrintSecret(CommandResult result) + { + if (result.GetResult(_printSecret) is not { Implicit: false }) + { + return; + } + + foreach (var option in new Option[] { _forwardTo, _headers, _skipVerify }) + { + if (result.GetResult(option) is { Implicit: false }) + { + result.AddError($"--print-secret no se combina con {option.Name}."); + } + } + } + + // "Nombre:Valor,Nombre:Valor": el valor llega hasta la coma siguiente y puede llevar ':'. Cada header se prueba aquí + // con la misma validación de HttpClient que usará el reenvío, así un header inválido es un error de uso y no una + // falla en cada entrega. + private static IReadOnlyList> ParseHeaders(ArgumentResult result) + { + var headers = new List>(); + foreach (var entry in result.Tokens.Single().Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var separator = entry.IndexOf(':', StringComparison.Ordinal); + var name = separator > 0 ? entry[..separator].Trim() : ""; + var value = separator > 0 ? entry[(separator + 1)..].Trim() : ""; + if (name.Length == 0) + { + result.AddError($"--headers espera pares Nombre:Valor separados por coma; se recibió '{entry}'."); + } + else if (WebhookHeaderNames.IsSigned(name)) + { + result.AddError($"--headers no puede cambiar {name}: lo firma FiscalAPI."); + } + else if (!IsValidRequestHeader(name, value)) + { + result.AddError($"--headers trae un header que HTTP no admite: '{name}'."); + } + else + { + headers.Add(KeyValuePair.Create(name, value)); + } + } + + return headers; + } + + // Content-Type y los demás headers de contenido no van en los del request: los fija el reenvío. + private static bool IsValidRequestHeader(string name, string value) + { + using var probe = new HttpRequestMessage(); + try + { + probe.Headers.Add(name, value); + return true; + } + catch (Exception exception) when (exception is FormatException or InvalidOperationException) + { + return false; + } + } + + // Una URL relativa o de otro esquema terminaría en un error de red confuso en cada entrega. + private static Uri? ParseForwardTo(ArgumentResult result) + { + var value = result.Tokens.Single().Value; + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + { + return uri; + } + + result.AddError($"--forward-to debe ser una URL absoluta http o https; se recibió '{value}'."); + return null; + } +} diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs new file mode 100644 index 0000000..5d4c5db --- /dev/null +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -0,0 +1,86 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi login: pide la API key y la tenant key, las valida con +/// GET /api/v4/me/features y las guarda en el almacén del SO, en la entrada del host del ambiente. +/// No usa las variables de entorno: guarda lo que la persona acaba de dar. +/// +public sealed class LoginCommand : Command +{ + private const string FeaturesPath = "api/v4/me/features"; + + // El permiso que exigen listen, trigger y webhooks (Permissions.Webhooks.Manage de fapi-back). + private const string WebhooksManagePermission = "Webhooks.Manage"; + + private readonly GlobalOptions _globalOptions; + private readonly FiscalApiClientFactory _apiClients; + private readonly ICredentialStore _store; + private readonly ISecretReader _input; + private readonly CliConsole _console; + + public LoginCommand( + GlobalOptions globalOptions, + FiscalApiClientFactory apiClients, + ICredentialStore store, + ISecretReader input, + CliConsole console) + : base("login", "Valida la API key y la tenant key y las guarda en el almacén de credenciales del sistema.") + { + _globalOptions = globalOptions; + _apiClients = apiClients; + _store = store; + _input = input; + _console = console; + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _globalOptions.Read(parseResult); + var host = settings.Environment.Host; + + // Sin almacén no se piden las llaves ni se llama a la API: no se podrían guardar. + _store.EnsureAvailable(host); + + // Cada valor se valida en cuanto se lee: un valor inválido es un error de uso, no un 401, y una API key + // inválida se rechaza antes de pedir la tenant key. + var apiKey = ApiCredentials.ValidApiKey(await _input.ReadAsync("API key: ", cancellationToken)); + var credentials = ApiCredentials.Create(apiKey, await _input.ReadAsync("Tenant key: ", cancellationToken)); + + var features = await FetchFeaturesAsync(settings, credentials, cancellationToken); + _store.Save(host, credentials); + + // Los mensajes van a stderr: login no produce datos, y con --json stdout queda vacío. + await _console.Error.WriteLineAsync($"Listo. Credenciales guardadas para {host}."); + if (features?.Permissions.Contains(WebhooksManagePermission, StringComparer.Ordinal) != true) + { + await _console.Error.WriteLineAsync( + "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403."); + } + + return ExitCodes.Success; + } + + private async Task FetchFeaturesAsync( + GlobalSettings settings, ApiCredentials credentials, CancellationToken cancellationToken) + { + var client = _apiClients.Create(settings, credentials, FiscalApiClientFactory.DefaultTimeout); + try + { + return await client.GetAsync(FeaturesPath, FiscalApiJsonContext.Default.ApiResponsePersonFeatures, cancellationToken); + } + // fapi-back responde 401 tanto por la API key como por la tenant key. Otro error (p. ej. el 403 de un + // tenant inactivo) se muestra tal cual, con su mensaje y su traceIdentifier. + catch (ApiException exception) when (exception.StatusCode == HttpStatusCode.Unauthorized) + { + throw new InvalidCredentialsException(settings.Environment.Host, exception); + } + } +} diff --git a/src/FiscalApi.Cli/Commands/LogoutCommand.cs b/src/FiscalApi.Cli/Commands/LogoutCommand.cs new file mode 100644 index 0000000..f2e7b9b --- /dev/null +++ b/src/FiscalApi.Cli/Commands/LogoutCommand.cs @@ -0,0 +1,54 @@ +using System.CommandLine; +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi logout: borra la entrada del host del ambiente en el almacén del SO. Es local (no +/// llama a la API). Termina con éxito si borró o si no había nada guardado; si el almacén existe pero falla +/// (bloqueado, sin respuesta), lo dice y sale con 1. +/// +public sealed class LogoutCommand : Command +{ + private readonly GlobalOptions _globalOptions; + private readonly ICredentialStore _store; + private readonly CliConsole _console; + + public LogoutCommand(GlobalOptions globalOptions, ICredentialStore store, CliConsole console) + : base("logout", "Borra las credenciales guardadas para el ambiente.") + { + _globalOptions = globalOptions; + _store = store; + _console = console; + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var host = _globalOptions.Read(parseResult).Environment.Host; + + // Como login, escribe en stderr: no produce datos. + await _console.Error.WriteLineAsync(DeleteSavedCredentials(host) + ? $"Credenciales borradas para {host}." + : $"No había credenciales guardadas para {host}."); + return ExitCodes.Success; + } + + private bool DeleteSavedCredentials(string host) + { + try + { + return _store.Delete(host); + } + // Sin almacén en el SO, login nunca pudo guardar nada: no hay nada que borrar y no es un error. + catch (CredentialStoreUnavailableException) + { + return false; + } + // El almacén existe: la entrada puede seguir ahí, así que nunca se dice que no había nada. + catch (CredentialStoreException exception) + { + throw exception.During($"No se pudieron borrar las credenciales guardadas para {host}."); + } + } +} diff --git a/src/FiscalApi.Cli/Commands/PageOptions.cs b/src/FiscalApi.Cli/Commands/PageOptions.cs new file mode 100644 index 0000000..12fd9eb --- /dev/null +++ b/src/FiscalApi.Cli/Commands/PageOptions.cs @@ -0,0 +1,28 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Commands; + +/// --page y --page-size de los listados. Los rangos (1–50 por página) los valida la API. +public sealed class PageOptions +{ + private readonly Option _page = new("--page") + { + Description = "Número de página, desde 1 (por omisión 1).", + HelpName = "número" + }; + + private readonly Option _pageSize = new("--page-size") + { + Description = "Elementos por página, de 1 a 50 (por omisión 10).", + HelpName = "tamaño" + }; + + public void AddTo(Command command) + { + command.Options.Add(_page); + command.Options.Add(_pageSize); + } + + public PageRequest Read(ParseResult parseResult) => new(parseResult.GetValue(_page), parseResult.GetValue(_pageSize)); +} diff --git a/src/FiscalApi.Cli/Commands/TriggerCommand.cs b/src/FiscalApi.Cli/Commands/TriggerCommand.cs new file mode 100644 index 0000000..3b10378 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/TriggerCommand.cs @@ -0,0 +1,69 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi trigger <type> [--related-object-id <id>] [--person-id <id>] [--webhook <id>]: +/// POST /api/v4/events/trigger. La API genera un evento sintético del tipo y lo entrega a un solo +/// destino: sin --webhook, la escucha de CLI de la persona, así que fiscalapi listen tiene que estar +/// abierto o responde 409. El CLI no valida el tipo ni bloquea --live: esas reglas viven solo en la API, que +/// responde 400 o 403 con su texto. +/// +public sealed class TriggerCommand : Command +{ + // Con HelpName el uso dice ; sin él, System.CommandLine lo reemplaza por la lista entera de sugerencias. + private readonly Argument _type = new("type") + { + Description = $"Tipo de evento: {string.Join(", ", WebhookEventTypes.Subscribable)}. Quien lo valida es la API.", + HelpName = "type" + }; + + private readonly Option _relatedObjectId = new("--related-object-id") + { + Description = "Id del recurso del evento (data.relatedObject.id). Por omisión, un id sintético cuyo GET responde 404.", + HelpName = "id" + }; + + private readonly Option _personId = new("--person-id") + { + Description = "Persona del tenant dueña del recurso (personId del evento). Por omisión, el evento va sin persona.", + HelpName = "id" + }; + + private readonly Option _webhook = new("--webhook") + { + Description = "Id del webhook que recibe el evento. Por omisión, tu escucha de fiscalapi listen.", + HelpName = "id" + }; + + private readonly ApiCommandContext _context; + + public TriggerCommand(GlobalOptions globalOptions, FiscalApiClientFactory apiClients, CliConsole console) + : base("trigger", "Dispara un evento de prueba hacia tu escucha de fiscalapi listen o hacia un webhook (solo en pruebas).") + { + _context = new ApiCommandContext(globalOptions, apiClients, console); + // Sugerencias, no AcceptOnlyFromAmong: un tipo nuevo del servidor se puede disparar sin actualizar el CLI. + _type.CompletionSources.Add([.. WebhookEventTypes.Subscribable]); + Arguments.Add(_type); + Options.Add(_relatedObjectId); + Options.Add(_personId); + Options.Add(_webhook); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var request = new TriggerWebhookEventRequest( + parseResult.GetValue(_type)!, + parseResult.GetValue(_relatedObjectId), + parseResult.GetValue(_personId), + parseResult.GetValue(_webhook)); + + var data = await new EventsApi(_context.Client(settings)).TriggerAsync(request, cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.WebhookEventTrigger, WebhookEventView.WriteTrigger); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs new file mode 100644 index 0000000..b02ec5b --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs @@ -0,0 +1,38 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// fiscalapi webhooks create --url <url> --events <tipos> [--description <texto>] [--person <id>…]: +/// POST /api/v4/webhooks. Imprime el secreto de firma una sola vez, con el aviso de guardarlo. +/// +public sealed class CreateWebhookCommand : Command +{ + private readonly WebhookOptions _webhook = new(required: true); + private readonly ApiCommandContext _context; + + public CreateWebhookCommand(ApiCommandContext context) + : base("create", "Da de alta un webhook e imprime su secreto de firma una sola vez.") + { + _context = context; + _webhook.AddTo(this); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var request = new CreateWebhookRequest( + parseResult.GetValue(_webhook.Url)!, + WebhookOptions.GivenValue(parseResult, _webhook.Description), + parseResult.GetValue(_webhook.Events)!, + // Sin --person (o con --all-persons, que no se combina con él) no viaja: recibe los eventos de todas. + WebhookOptions.GivenValue(parseResult, _webhook.Persons)); + + var data = await new WebhooksApi(_context.Client(settings)).CreateAsync(request, cancellationToken); + WebhookWithSecretOutput.Write(_context, settings, data, rotated: false); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs new file mode 100644 index 0000000..8ac765f --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs @@ -0,0 +1,71 @@ +using System.CommandLine; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Input; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// fiscalapi webhooks delete <id> [--yes]: DELETE /api/v4/webhooks/{id}. Pide confirmación en la +/// terminal; sin una persona que responda (stdin redirigido) o con --json (un script), exige --yes. +/// +public sealed class DeleteWebhookCommand : Command +{ + private readonly Argument _id = new("id") { Description = "Id del webhook (o de tu escucha de CLI)." }; + + private readonly Option _yes = new("--yes") + { + Description = "Borra sin pedir confirmación (necesario en scripts y con --json)." + }; + + private readonly ApiCommandContext _context; + private readonly IConfirmationPrompt _confirmation; + + public DeleteWebhookCommand(ApiCommandContext context, IConfirmationPrompt confirmation) + : base("delete", "Da de baja un webhook y cierra sus entregas pendientes; su historial se conserva.") + { + _context = context; + _confirmation = confirmation; + Arguments.Add(_id); + Options.Add(_yes); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var id = parseResult.GetValue(_id)!; + if (!parseResult.GetValue(_yes)) + { + if (settings.Json || !_confirmation.IsInteractive) + { + throw new UsageException("Para borrar sin confirmación interactiva usa --yes."); + } + + var confirmed = await _confirmation.ConfirmAsync( + $"¿Borrar el webhook {id}? Sus entregas pendientes se cerrarán.", cancellationToken); + // En Windows, Ctrl+C con la pregunta en pantalla hace que ReadLine devuelva null (un "no") en lugar de + // cancelar la espera: sin esto saldría con 0 y no con 130. + cancellationToken.ThrowIfCancellationRequested(); + if (!confirmed) + { + // La persona decidió no borrar: no es un error. + await _context.Console.Error.WriteLineAsync($"No se borró el webhook {id}."); + return ExitCodes.Success; + } + } + + var data = await new WebhooksApi(_context.Client(settings)).DeleteAsync(id, cancellationToken); + if (settings.Json) + { + _context.WriteRaw(data); + } + else + { + // Como logout: el resultado es un aviso para la persona, no un dato. + await _context.Console.Error.WriteLineAsync($"Webhook {id} borrado."); + } + + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs new file mode 100644 index 0000000..8fe126b --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs @@ -0,0 +1,28 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// fiscalapi webhooks list [--page n] [--page-size n]: GET /api/v4/webhooks. +public sealed class ListWebhooksCommand : Command +{ + private readonly PageOptions _page = new(); + private readonly ApiCommandContext _context; + + public ListWebhooksCommand(ApiCommandContext context) + : base("list", "Lista los webhooks del tenant, sin su secreto.") + { + _context = context; + _page.AddTo(this); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var data = await new WebhooksApi(_context.Client(settings)).ListAsync(_page.Read(parseResult), cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.PagedListWebhook, WebhookView.WriteList); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs new file mode 100644 index 0000000..a58f8be --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs @@ -0,0 +1,28 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// fiscalapi webhooks retrieve <id>: GET /api/v4/webhooks/{id}. +public sealed class RetrieveWebhookCommand : Command +{ + private readonly Argument _id = new("id") { Description = "Id del webhook." }; + private readonly ApiCommandContext _context; + + public RetrieveWebhookCommand(ApiCommandContext context) + : base("retrieve", "Muestra un webhook, sin su secreto.") + { + _context = context; + Arguments.Add(_id); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var data = await new WebhooksApi(_context.Client(settings)).RetrieveAsync(parseResult.GetValue(_id)!, cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.Webhook, WebhookView.WriteDetail); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs new file mode 100644 index 0000000..ca2e437 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs @@ -0,0 +1,31 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// fiscalapi webhooks rotate-secret <id>: POST /api/v4/webhooks/{id}/rotate-secret. Imprime el secreto +/// nuevo una sola vez; el anterior sigue firmando 24 horas. +/// +public sealed class RotateWebhookSecretCommand : Command +{ + private readonly Argument _id = new("id") { Description = "Id del webhook (o de tu escucha de CLI)." }; + private readonly ApiCommandContext _context; + + public RotateWebhookSecretCommand(ApiCommandContext context) + : base("rotate-secret", "Genera un secreto de firma nuevo y lo imprime una sola vez; el anterior sigue firmando 24 horas.") + { + _context = context; + Arguments.Add(_id); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var data = await new WebhooksApi(_context.Client(settings)).RotateSecretAsync(parseResult.GetValue(_id)!, cancellationToken); + WebhookWithSecretOutput.Write(_context, settings, data, rotated: true); + return ExitCodes.Success; + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs new file mode 100644 index 0000000..1475a0b --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs @@ -0,0 +1,96 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// fiscalapi webhooks update <id> [--url] [--description] [--events] [--person…] [--enabled|--disabled]. El +/// PUT /api/v4/webhooks/{id} reemplaza todo el webhook: primero se lee con GET y solo cambia lo que la persona +/// escribió, para no borrar lo demás. +/// +public sealed class UpdateWebhookCommand : Command +{ + // El texto de la API (WebhookMessages.CliListenerNotEditable). + private const string CliListenerNotEditable = + "Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger."; + + private readonly Argument _id = new("id") { Description = "Id del webhook." }; + private readonly WebhookOptions _webhook = new(required: false); + + private readonly Option _enabled = new("--enabled") + { + Description = "Habilita el webhook; limpia el motivo y la fecha de la deshabilitación." + }; + + private readonly Option _disabled = new("--disabled") + { + Description = "Deshabilita el webhook y cierra sus entregas pendientes." + }; + + private readonly ApiCommandContext _context; + + public UpdateWebhookCommand(ApiCommandContext context) + : base("update", "Cambia la URL, la descripción, los tipos de evento, las personas o el estado de un webhook.") + { + _context = context; + Arguments.Add(_id); + _webhook.AddTo(this); + Options.Add(_enabled); + Options.Add(_disabled); + Validators.Add(RequireOneChange); + Validators.Add(RejectEnabledWithDisabled); + SetAction(RunAsync); + } + + private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) + { + var settings = _context.Settings(parseResult); + var api = new WebhooksApi(_context.Client(settings)); + var current = ApiCommandContext.Read( + await api.RetrieveAsync(parseResult.GetValue(_id)!, cancellationToken), FiscalApiJsonContext.Default.Webhook); + + // La API también lo rechaza, pero su validación de la URL (vacía en una escucha) respondería antes y confundiría. + // Es el estado del recurso, no el uso del comando: sale con 1, como el 400 de la API. + if (current.IsCliListener) + { + throw new ResourceStateException(CliListenerNotEditable); + } + + var data = await api.ReplaceAsync(Changes(parseResult).ApplyTo(current), cancellationToken); + _context.WriteData(settings, data, FiscalApiJsonContext.Default.Webhook, WebhookView.WriteDetail); + return ExitCodes.Success; + } + + private WebhookChanges Changes(ParseResult parseResult) => new( + WebhookOptions.GivenValue(parseResult, _webhook.Url), + WebhookOptions.GivenValue(parseResult, _webhook.Description), + WebhookOptions.GivenValue(parseResult, _webhook.Events), + // --all-persons es la lista vacía, que la API lee como "todas las personas". + parseResult.GetValue(_webhook.AllPersons) ? [] : WebhookOptions.GivenValue(parseResult, _webhook.Persons), + parseResult.GetValue(_enabled) ? WebhookStatuses.Enabled + : parseResult.GetValue(_disabled) ? WebhookStatuses.Disabled + : null); + + // Sin cambios, el PUT reescribiría lo mismo: casi siempre es una opción olvidada. + private void RequireOneChange(CommandResult result) + { + Option[] changes = + [_webhook.Url, _webhook.Description, _webhook.Events, _webhook.Persons, _webhook.AllPersons, _enabled, _disabled]; + if (!changes.Any(option => result.GetResult(option) is { Implicit: false })) + { + result.AddError( + "Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled."); + } + } + + private void RejectEnabledWithDisabled(CommandResult result) + { + if (result.GetResult(_enabled) is { Implicit: false } && result.GetResult(_disabled) is { Implicit: false }) + { + result.AddError("--enabled y --disabled no se combinan."); + } + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs new file mode 100644 index 0000000..5532d9f --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs @@ -0,0 +1,89 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// Lo que se escribe de un webhook en el alta y en el cambio (IWebhookInput de fapi-back): URL, +/// descripción, tipos de evento y personas. La API valida cada valor con sus textos (URL https, tipos que existen, '*' +/// solo, hasta 100 personas). +/// +public sealed class WebhookOptions +{ + /// En el alta la URL y los tipos son obligatorios; en el cambio, cada opción es opcional. + public WebhookOptions(bool required) + { + Url.Required = required; + Events.Required = required; + } + + public Option Url { get; } = new("--url") + { + Description = "URL https que recibe las entregas.", + HelpName = "url" + }; + + public Option Description { get; } = new("--description") + { + Description = "Descripción libre, hasta 256 caracteres.", + HelpName = "texto" + }; + + public Option> Events { get; } = new("--events") + { + Description = "Tipos de evento separados por coma (p. ej. invoice.canceled,invoice.cancellation_rejected), o * para todos.", + HelpName = "tipos", + Arity = ArgumentArity.ExactlyOne, + CustomParser = CommaSeparatedValues.Parse + }; + + public Option> Persons { get; } = new("--person") + { + Description = "Id de una persona cuyos eventos recibe; se repite o va separado por coma.", + HelpName = "id", + Arity = ArgumentArity.OneOrMore, + CustomParser = ParsePersons + }; + + public Option AllPersons { get; } = new("--all-persons") + { + Description = "Recibe los eventos de todas las personas (quita el filtro de --person)." + }; + + public void AddTo(Command command) + { + command.Options.Add(Url); + command.Options.Add(Description); + command.Options.Add(Events); + command.Options.Add(Persons); + command.Options.Add(AllPersons); + command.Validators.Add(RejectAllPersonsWithPersons); + } + + /// El valor de solo si la persona la escribió; si no, null ("no lo cambies"). + public static T? GivenValue(ParseResult parseResult, Option option) => + parseResult.GetResult(option) is { Implicit: false } ? parseResult.GetValue(option) : default; + + /// + /// Para la API una lista vacía es "todas las personas": un --person "$PERSONAS" con la variable vacía ampliaría + /// el webhook sin avisar. Quitar el filtro se pide explícitamente con --all-persons. + /// + private static IReadOnlyList ParsePersons(ArgumentResult result) + { + // Cada --person tiene que traer al menos un id: uno vacío entre otros también delata una variable vacía. + if (result.Tokens.Any(token => CommaSeparatedValues.Split(token.Value).Length == 0)) + { + result.AddError("--person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons."); + } + + return CommaSeparatedValues.Parse(result); + } + + private void RejectAllPersonsWithPersons(CommandResult result) + { + if (result.GetResult(AllPersons) is { Implicit: false } && result.GetResult(Persons) is { Implicit: false }) + { + result.AddError("--all-persons no se combina con --person."); + } + } +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs new file mode 100644 index 0000000..ef310b9 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// La salida de create y rotate-secret: el webhook y su secreto. El secreto ya se generó en el servidor y es +/// la única vez que la API lo entrega, así que no depende del modelo de texto: se toma del JSON crudo y se muestra aunque +/// la vista del webhook falle con una respuesta de forma inesperada. +/// +public static class WebhookWithSecretOutput +{ + public static void Write(ApiCommandContext context, GlobalSettings settings, JsonElement data, bool rotated) + { + var secret = SecretIn(data); + try + { + // Con --json imprime el data crudo sin leer el modelo. + context.WriteData(settings, data, FiscalApiJsonContext.Default.Webhook, WebhookView.WriteDetail); + } + finally + { + WebhookView.WriteSigningSecret(context.Console, secret, settings.Json, rotated); + } + } + + private static string? SecretIn(JsonElement data) => + data.ValueKind == JsonValueKind.Object && + data.TryGetProperty("secret", out var secret) && + secret.ValueKind == JsonValueKind.String + ? secret.GetString() + : null; +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs new file mode 100644 index 0000000..d5c0738 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs @@ -0,0 +1,25 @@ +using System.CommandLine; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; + +namespace FiscalApi.Cli.Commands.Webhooks; + +/// +/// fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret: los webhooks del +/// tenant sobre /api/v4/webhooks. Sin subcomando muestra la ayuda. +/// +public sealed class WebhooksCommand : Command +{ + public WebhooksCommand( + GlobalOptions globalOptions, FiscalApiClientFactory apiClients, IConfirmationPrompt confirmation, CliConsole console) + : base("webhooks", "Administra los webhooks que reciben los eventos del tenant.") + { + var context = new ApiCommandContext(globalOptions, apiClients, console); + Subcommands.Add(new ListWebhooksCommand(context)); + Subcommands.Add(new RetrieveWebhookCommand(context)); + Subcommands.Add(new CreateWebhookCommand(context)); + Subcommands.Add(new UpdateWebhookCommand(context)); + Subcommands.Add(new DeleteWebhookCommand(context, confirmation)); + Subcommands.Add(new RotateWebhookSecretCommand(context)); + } +} diff --git a/src/FiscalApi.Cli/Credentials/ApiCredentials.cs b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs new file mode 100644 index 0000000..83b4684 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs @@ -0,0 +1,58 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// La API key y la tenant key con las que el CLI llama a la API (headers X-API-KEY y X-TENANT-KEY). +/// Solo se crean validadas, sin importar si vienen del teclado, de stdin, de las variables de entorno o del +/// almacén del SO: así un valor inválido nunca llega a un header ni a un mensaje de error. +/// +public sealed record ApiCredentials +{ + /// + /// Holgado frente a lo que emite fapi-back (API key de 51 caracteres, tenant key GUID) y acotado para que el + /// secreto guardado nunca pase de 2560 bytes (CRED_MAX_CREDENTIAL_BLOB_SIZE, el límite más chico de los tres + /// almacenes): en JSON un carácter puede escaparse en seis bytes (" → \u0022), y 2 × 200 × 6 más + /// las llaves del objeto da 2428. Acotar la entrada da esa garantía sin un error más al guardar. + /// + public const int MaxLength = 200; + + private ApiCredentials(string apiKey, string tenantKey) + { + ApiKey = apiKey; + TenantKey = tenantKey; + } + + public string ApiKey { get; } + + public string TenantKey { get; } + + /// + /// Quita los espacios de los extremos y exige ASCII visible. Un salto de línea en un header haría que + /// lanzara una excepción que repite el valor; por eso el mensaje nunca lo incluye. + /// + /// Un valor vacío, demasiado largo o con caracteres no válidos. + public static ApiCredentials Create(string? apiKey, string? tenantKey) => new(ValidApiKey(apiKey), ValidTenantKey(tenantKey)); + + /// La API key validada sola, para rechazarla antes de pedir la tenant key. + /// Un valor vacío, demasiado largo o con caracteres no válidos. + public static string ValidApiKey(string? apiKey) => Validated(apiKey, "La API key"); + + private static string ValidTenantKey(string? tenantKey) => Validated(tenantKey, "La tenant key"); + + /// Sin los valores: un registro o un volcado de excepción nunca los muestra. + public override string ToString() => "ApiCredentials { ApiKey = ***, TenantKey = *** }"; + + private static string Validated(string? value, string name) + { + var trimmed = value?.Trim() ?? string.Empty; + if (trimmed.Length == 0 || trimmed.Length > MaxLength || !trimmed.All(IsVisibleAscii)) + { + throw new UsageException($"{name} está vacía, es demasiado larga o tiene caracteres no válidos."); + } + + return trimmed; + } + + private static bool IsVisibleAscii(char character) => character is > ' ' and <= '~'; +} diff --git a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs new file mode 100644 index 0000000..c645e6b --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs @@ -0,0 +1,36 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// De dónde salen las credenciales de una invocación: las variables de entorno ganan sobre lo guardado en +/// el almacén del SO, para que el CI no dependa de un llavero. +/// +public sealed class CredentialResolver(ICredentialStore store, Func readEnvironmentVariable) +{ + public const string ApiKeyVariable = "FISCALAPI_API_KEY"; + public const string TenantKeyVariable = "FISCALAPI_TENANT_KEY"; + + /// Sin credenciales, con solo una de las dos variables o con un valor inválido. + /// Sin variables y sin almacén en el SO. + /// Sin variables, y el almacén está bloqueado o falló. + public ApiCredentials Resolve(ApiEnvironment environment) + { + var apiKey = ReadVariable(ApiKeyVariable); + var tenantKey = ReadVariable(TenantKeyVariable); + + return (apiKey, tenantKey) switch + { + (not null, not null) => ApiCredentials.Create(apiKey, tenantKey), + (null, null) => store.Read(environment.Host) ?? throw new UsageException( + $"No hay credenciales para {environment.Host}. Ejecuta `fiscalapi login` o define {ApiKeyVariable} y {TenantKeyVariable}."), + // Completar con lo guardado mezclaría la key de un tenant con la tenant key de otro. + _ => throw new UsageException( + $"Define {ApiKeyVariable} y {TenantKeyVariable} juntas; solo está definida {(apiKey is null ? TenantKeyVariable : ApiKeyVariable)}.") + }; + } + + // Una variable vacía cuenta como no definida: es lo que deja `export FISCALAPI_API_KEY=` en un script. + private string? ReadVariable(string name) => + readEnvironmentVariable(name) is { } value && !string.IsNullOrWhiteSpace(value) ? value : null; +} diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs new file mode 100644 index 0000000..50bafb8 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs @@ -0,0 +1,40 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// El almacén del SO existe, pero no se pudo usar: está bloqueado y no se puede pedir la contraseña, no respondió, +/// falló con un código inesperado, o la entrada guardada no se puede leer. Es distinto de "no hay almacén" +/// (): aquí las variables de entorno no son la salida. Código 1. +/// +public sealed class CredentialStoreException : CliException +{ + private CredentialStoreException(string message, string? detail) + : base(message) => Detail = detail; + + /// Lo que respondió el SO (un código o el error de D-Bus), para diagnosticar; nunca trae el secreto. + public string? Detail { get; } + + public override int ExitCode => ExitCodes.Failure; + + public static CredentialStoreException Locked(string detail) => + new("El almacén de credenciales del sistema existe, pero está bloqueado o no respondió.", detail); + + public static CredentialStoreException Failed(string operation, string detail) => + new($"El almacén de credenciales del sistema falló al {operation} la entrada.", detail); + + public static CredentialStoreException Unreadable(string host) => + new($"La entrada guardada para {host} no se puede leer. Ejecuta `fiscalapi logout` y luego `fiscalapi login`.", detail: null); + + /// El mismo error, precedido de lo que se estaba haciendo (p. ej. "No se pudieron borrar…"). + public CredentialStoreException During(string context) => new($"{context} {Message}", Detail); + + public override void WriteTo(TextWriter error) + { + base.WriteTo(error); + if (Detail is not null) + { + error.WriteLine($" Detalle: {Detail}"); + } + } +} diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs new file mode 100644 index 0000000..8590169 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs @@ -0,0 +1,23 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// El SO no tiene un almacén de credenciales utilizable: Linux sin Secret Service, WSL, un servidor o una sesión +/// sin llavero. No hay archivo plano de respaldo; la salida es usar las variables de entorno, por eso es un +/// error de uso (código 2) y no de la API. +/// +public sealed class CredentialStoreUnavailableException(string detail) + : CliException("No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY.") +{ + /// Lo que respondió el SO (un código o el error de D-Bus), para diagnosticar; nunca trae el secreto. + public string Detail { get; } = detail; + + public override int ExitCode => ExitCodes.Usage; + + public override void WriteTo(TextWriter error) + { + base.WriteTo(error); + error.WriteLine($" Detalle: {Detail}"); + } +} diff --git a/src/FiscalApi.Cli/Credentials/CredentialStores.cs b/src/FiscalApi.Cli/Credentials/CredentialStores.cs new file mode 100644 index 0000000..7701292 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialStores.cs @@ -0,0 +1,19 @@ +using FiscalApi.Cli.Credentials.Linux; +using FiscalApi.Cli.Credentials.MacOS; +using FiscalApi.Cli.Credentials.Windows; + +namespace FiscalApi.Cli.Credentials; + +/// Elige el almacén de credenciales del SO en que corre el binario. +public static class CredentialStores +{ + /// Nombre de servicio de las entradas del CLI; las pruebas usan otro para no tocarlas. + public const string ProductionService = "fiscalapi-cli"; + + /// Un SO para el que no se publica el CLI. + public static ICredentialStore ForCurrentOs(string service) => + OperatingSystem.IsWindows() ? new WindowsCredentialStore(service) + : OperatingSystem.IsMacOS() ? new KeychainCredentialStore(service) + : OperatingSystem.IsLinux() ? new LibSecretCredentialStore(service) + : throw new PlatformNotSupportedException("El CLI de FiscalAPI solo corre en Windows, macOS y Linux."); +} diff --git a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs new file mode 100644 index 0000000..ee2d799 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs @@ -0,0 +1,25 @@ +namespace FiscalApi.Cli.Credentials; + +/// +/// Almacén de credenciales del SO, con una entrada por host de API (), así las +/// credenciales de test, live y el back local no se pisan. Nunca hay un archivo plano ni otro respaldo: +/// si el SO no tiene almacén, cada operación lanza ; si lo tiene +/// pero está bloqueado o falla, . +/// +public interface ICredentialStore +{ + /// + /// Comprueba que el almacén se puede usar consultando la entrada del host, sin leer ni decodificar su secreto: + /// login lo hace antes de pedir las llaves, para no pedirlas si no se van a poder guardar. + /// + void EnsureAvailable(string host); + + /// Las credenciales guardadas para el host, o null si no hay. + ApiCredentials? Read(string host); + + /// Crea la entrada del host o reemplaza la que ya existía. + void Save(string host, ApiCredentials credentials); + + /// true si había una entrada y se borró; false si no había nada que borrar. + bool Delete(string host); +} diff --git a/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs new file mode 100644 index 0000000..1904700 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs @@ -0,0 +1,165 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace FiscalApi.Cli.Credentials.Linux; + +/// +/// Secret Service de freedesktop (gnome-keyring, KWallet) por medio de libsecret: un secreto por host en la +/// colección por omisión, con los atributos service y host del esquema com.fiscalapi.cli. +/// Se ve en Seahorse ("Contraseñas y claves"). Sin libsecret, sin sesión de D-Bus o sin el servicio +/// org.freedesktop.secrets no hay almacén; con ellos, cualquier otro error es un almacén bloqueado o que +/// no respondió. +/// +[SupportedOSPlatform("linux")] +internal sealed unsafe class LibSecretCredentialStore(string service) : ICredentialStore +{ + private const string SchemaName = "com.fiscalapi.cli"; + private const string ServiceAttribute = "service"; + private const string HostAttribute = "host"; + + private delegate T SecretDecoder(ReadOnlySpan secret, string host); + + // libsecret no tiene una consulta sin secreto: se busca la entrada y se libera sin decodificarla, así una + // entrada ilegible no impide que login la reemplace. + public void EnsureAvailable(string host) => ReadSecret(host, static (_, _) => true); + + public ApiCredentials? Read(string host) => ReadSecret(host, StoredCredentialsFormat.Decode); + + public void Save(string host, ApiCredentials credentials) + { + var secret = StoredCredentialsFormat.Encode(credentials); + // libsecret recibe el secreto como texto UTF-8 terminado en NUL. + var terminated = new byte[secret.Length + 1]; + try + { + secret.CopyTo(terminated, 0); + using var query = Open(host); + GLib.GError* error = null; + fixed (byte* password = terminated) + { + // Colección NULL: la de omisión, que persiste entre sesiones (la de sesión se pierde al salir). + LibSecret.PasswordStore( + query.Schema, query.Attributes, collection: null, $"FiscalAPI CLI ({host})", password, cancellable: 0, &error); + } + + ThrowIfFailed(error); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + CryptographicOperations.ZeroMemory(terminated); + } + } + + public bool Delete(string host) + { + using var query = Open(host); + GLib.GError* error = null; + var removed = LibSecret.PasswordClear(query.Schema, query.Attributes, cancellable: 0, &error); + ThrowIfFailed(error); + return removed != 0; + } + + /// Lo que devuelve , o el valor por omisión si no hay entrada. + private T? ReadSecret(string host, SecretDecoder decode) + { + using var query = Open(host); + GLib.GError* error = null; + var password = LibSecret.PasswordLookup(query.Schema, query.Attributes, cancellable: 0, &error); + ThrowIfFailed(error); + if (password == null) + { + return default; + } + + try + { + return decode(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(password), host); + } + finally + { + // Borra la memoria del secreto antes de liberarla. + LibSecret.PasswordFree(password); + } + } + + private Query Open(string host) + { + try + { + EnsureSessionBus(); + return new Query(service, host); + } + catch (DllNotFoundException) + { + throw new CredentialStoreUnavailableException("No se encontró libsecret-1.so.0 (paquete libsecret-1-0 o libsecret)."); + } + } + + /// + /// Sin sesión de D-Bus (WSL, SSH, un contenedor) no hay Secret Service posible. Se comprueba aparte porque el + /// error que deja libsecret en ese caso es genérico (G_IO_ERROR_FAILED) y no se distingue de otras fallas. + /// + private static void EnsureSessionBus() + { + GLib.GError* error = null; + var connection = Gio.BusGetSync(Gio.BusTypeSession, cancellable: 0, &error); + if (connection == 0) + { + throw new CredentialStoreUnavailableException($"D-Bus: {GLib.TakeMessage(error)}"); + } + + GObject.Unref(connection); + } + + /// + /// Con la sesión de D-Bus ya comprobada, "el servicio no existe" (ServiceUnknown, NameHasNoOwner) es que no hay + /// almacén. Cualquier otro error (colección bloqueada, desbloqueo cancelado, sin respuesta) es un almacén que + /// existe y no se pudo usar. El mensaje es de D-Bus o del servicio; nunca trae el secreto. + /// + private static void ThrowIfFailed(GLib.GError* error) + { + if (error == null) + { + return; + } + + var serviceMissing = error->Domain == Gio.DBusErrorQuark() + && error->Code is Gio.DBusErrorServiceUnknown or Gio.DBusErrorNameHasNoOwner; + var detail = $"Secret Service: {GLib.TakeMessage(error)}"; + throw serviceMissing ? new CredentialStoreUnavailableException(detail) : CredentialStoreException.Locked(detail); + } + + /// El esquema y los atributos de una entrada; libera lo nativo de ambos al terminar la operación. + private sealed class Query : IDisposable + { + private readonly StringHashTable _attributes; + + public Query(string service, string host) + { + _attributes = new StringHashTable([new(ServiceAttribute, service), new(HostAttribute, host)]); + try + { + // Los valores NULL son GINT_TO_POINTER(SECRET_SCHEMA_ATTRIBUTE_STRING): los dos atributos son texto. + using var attributeTypes = new StringHashTable([new(ServiceAttribute, null), new(HostAttribute, null)]); + Schema = LibSecret.SchemaNew(SchemaName, LibSecret.SchemaNone, attributeTypes.Handle); + } + catch + { + _attributes.Dispose(); + throw; + } + } + + public nint Schema { get; } + + public nint Attributes => _attributes.Handle; + + public void Dispose() + { + LibSecret.SchemaUnref(Schema); + _attributes.Dispose(); + } + } +} diff --git a/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs new file mode 100644 index 0000000..accb75c --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs @@ -0,0 +1,158 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Runtime.Versioning; + +namespace FiscalApi.Cli.Credentials.Linux; + +/// +/// GLib (ghash.h, gerror.h) con . libsecret depende de ella, así que está +/// instalada siempre que lo esté libsecret. +/// +[SupportedOSPlatform("linux")] +internal static unsafe partial class GLib +{ + private const string Library = "libglib-2.0.so.0"; + + /// GError: GQuark domain (guint32), gint code y gchar *message. + [StructLayout(LayoutKind.Sequential)] + internal struct GError + { + public uint Domain; + public int Code; + public byte* Message; + } + + [LibraryImport(Library, EntryPoint = "g_hash_table_new")] + internal static partial nint HashTableNew(nint hashFunction, nint keyEqualFunction); + + [LibraryImport(Library, EntryPoint = "g_hash_table_insert")] + internal static partial int HashTableInsert(nint table, nint key, nint value); + + [LibraryImport(Library, EntryPoint = "g_hash_table_unref")] + internal static partial void HashTableUnref(nint table); + + [LibraryImport(Library, EntryPoint = "g_error_free")] + internal static partial void ErrorFree(GError* error); + + // Lazy y no un campo estático: si GLib falta, el inicializador de tipo envolvería el DllNotFoundException en + // un TypeInitializationException. Lazy guarda la excepción y la vuelve a lanzar tal cual en cada intento. + private static readonly Lazy<(nint Hash, nint Equal)> StringHashFunctionPointers = new(() => + { + var library = NativeLibrary.Load(Library); + return (NativeLibrary.GetExport(library, "g_str_hash"), NativeLibrary.GetExport(library, "g_str_equal")); + }); + + /// + /// g_str_hash y g_str_equal como punteros a función para g_hash_table_new: llaves de texto + /// que se comparan por contenido, que es lo que libsecret espera de una tabla de atributos. Se cargan una vez. + /// + internal static (nint Hash, nint Equal) StringHashFunctions => StringHashFunctionPointers.Value; + + /// Copia el mensaje del error y lo libera. + internal static string TakeMessage(GError* error) + { + var message = Utf8StringMarshaller.ConvertToManaged(error->Message) ?? string.Empty; + ErrorFree(error); + return message; + } +} + +/// GIO (gdbusconnection.h, gdbuserror.h): la sesión de D-Bus que usa libsecret. +[SupportedOSPlatform("linux")] +internal static unsafe partial class Gio +{ + private const string Library = "libgio-2.0.so.0"; + + internal const int BusTypeSession = 2; + + // GDBusError: el nombre pedido (org.freedesktop.secrets) no existe en el bus ni se puede activar. + internal const int DBusErrorServiceUnknown = 2; + internal const int DBusErrorNameHasNoOwner = 3; + + /// La conexión (se libera con ), o 0 con . + [LibraryImport(Library, EntryPoint = "g_bus_get_sync")] + internal static partial nint BusGetSync(int busType, nint cancellable, GLib.GError** error); + + [LibraryImport(Library, EntryPoint = "g_dbus_error_quark")] + internal static partial uint DBusErrorQuark(); +} + +/// GObject: libera la referencia a la conexión de D-Bus. +[SupportedOSPlatform("linux")] +internal static partial class GObject +{ + [LibraryImport("libgobject-2.0.so.0", EntryPoint = "g_object_unref")] + internal static partial void Unref(nint instance); +} + +/// +/// libsecret (secret-password.h, secret-schema.h): las variantes v, que reciben los atributos en un +/// GHashTable en lugar de argumentos variádicos, que el interop de .NET no puede llamar. +/// +[SupportedOSPlatform("linux")] +internal static unsafe partial class LibSecret +{ + private const string Library = "libsecret-1.so.0"; + + internal const int SchemaNone = 0; + + [LibraryImport(Library, EntryPoint = "secret_schema_newv", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint SchemaNew(string name, int flags, nint attributeNamesAndTypes); + + [LibraryImport(Library, EntryPoint = "secret_schema_unref")] + internal static partial void SchemaUnref(nint schema); + + [LibraryImport(Library, EntryPoint = "secret_password_storev_sync", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int PasswordStore( + nint schema, nint attributes, string? collection, string label, byte* password, nint cancellable, GLib.GError** error); + + /// Un secreto nuevo que se libera con , o NULL si no hay. + [LibraryImport(Library, EntryPoint = "secret_password_lookupv_sync")] + internal static partial byte* PasswordLookup(nint schema, nint attributes, nint cancellable, GLib.GError** error); + + /// Distinto de 0 si borró algo. + [LibraryImport(Library, EntryPoint = "secret_password_clearv_sync")] + internal static partial int PasswordClear(nint schema, nint attributes, nint cancellable, GLib.GError** error); + + /// Borra la memoria del secreto (egg_secure_strfree) y luego la libera. + [LibraryImport(Library, EntryPoint = "secret_password_free")] + internal static partial void PasswordFree(byte* password); +} + +/// +/// Un GHashTable de texto a texto con las cadenas UTF-8 que le pertenecen. La tabla no copia ni libera sus +/// llaves y valores (sin funciones de destrucción), así que viven aquí hasta . +/// +[SupportedOSPlatform("linux")] +internal sealed unsafe class StringHashTable : IDisposable +{ + private readonly List _strings = []; + + public StringHashTable(IEnumerable> entries) + { + var (hash, equal) = GLib.StringHashFunctions; + Handle = GLib.HashTableNew(hash, equal); + foreach (var (key, value) in entries) + { + GLib.HashTableInsert(Handle, Utf8(key), value is null ? 0 : Utf8(value)); + } + } + + public nint Handle { get; } + + public void Dispose() + { + GLib.HashTableUnref(Handle); + foreach (var pointer in _strings) + { + Utf8StringMarshaller.Free((byte*)pointer); + } + } + + private nint Utf8(string value) + { + var pointer = (nint)Utf8StringMarshaller.ConvertToUnmanaged(value); + _strings.Add(pointer); + return pointer; + } +} diff --git a/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs new file mode 100644 index 0000000..f6194eb --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs @@ -0,0 +1,128 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; + +namespace FiscalApi.Cli.Credentials.MacOS; + +/// +/// Llavero de macOS: una contraseña genérica (kSecClassGenericPassword) por host, con servicio +/// fiscalapi-cli y cuenta igual al host. Se ve en "Acceso a Llaveros". +/// Usa el llavero de sesión del usuario y no el de protección de datos (kSecUseDataProtectionKeychain), +/// que exige firmar el binario con el entitlement keychain-access-groups. +/// +[SupportedOSPlatform("macos")] +internal sealed unsafe class KeychainCredentialStore(string service) : ICredentialStore +{ + // Sin kSecReturnData la consulta solo dice si la entrada existe: no lee el secreto ni pide permiso para leerlo. + public void EnsureAvailable(string host) + { + using var serviceName = CoreFoundation.CreateString(service); + using var account = CoreFoundation.CreateString(host); + using var query = CoreFoundation.CreateDictionary( + [SecurityFramework.Class, SecurityFramework.AttrService, SecurityFramework.AttrAccount], + [SecurityFramework.ClassGenericPassword, Raw(serviceName), Raw(account)]); + + var status = SecurityFramework.SecItemCopyMatching(Raw(query), result: null); + if (status != SecurityFramework.ItemNotFound) + { + ThrowIfFailed(status, "consultar"); + } + } + + public ApiCredentials? Read(string host) + { + using var serviceName = CoreFoundation.CreateString(service); + using var account = CoreFoundation.CreateString(host); + using var query = CoreFoundation.CreateDictionary( + [SecurityFramework.Class, SecurityFramework.AttrService, SecurityFramework.AttrAccount, SecurityFramework.ReturnData, SecurityFramework.MatchLimit], + [SecurityFramework.ClassGenericPassword, Raw(serviceName), Raw(account), CoreFoundation.BooleanTrue, SecurityFramework.MatchLimitOne]); + + nint result = 0; + var status = SecurityFramework.SecItemCopyMatching(Raw(query), &result); + if (status == SecurityFramework.ItemNotFound) + { + return null; + } + + ThrowIfFailed(status, "leer"); + // Un CFData inmutable no se puede borrar en su lugar: se libera en cuanto se decodifica. + using var data = CFHandle.Owned(result); + return StoredCredentialsFormat.Decode(CoreFoundation.GetBytes(data), host); + } + + public void Save(string host, ApiCredentials credentials) + { + using var serviceName = CoreFoundation.CreateString(service); + using var account = CoreFoundation.CreateString(host); + using var data = CreateSecretData(credentials); + using var query = CoreFoundation.CreateDictionary( + [SecurityFramework.Class, SecurityFramework.AttrService, SecurityFramework.AttrAccount], + [SecurityFramework.ClassGenericPassword, Raw(serviceName), Raw(account)]); + using var update = CoreFoundation.CreateDictionary([SecurityFramework.ValueData], [Raw(data)]); + + // Reemplazar primero conserva el permiso de acceso que la persona ya dio a la entrada. + var status = SecurityFramework.SecItemUpdate(Raw(query), Raw(update)); + if (status == SecurityFramework.ItemNotFound) + { + using var label = CoreFoundation.CreateString($"FiscalAPI CLI ({host})"); + using var item = CoreFoundation.CreateDictionary( + [SecurityFramework.Class, SecurityFramework.AttrService, SecurityFramework.AttrAccount, SecurityFramework.AttrLabel, SecurityFramework.ValueData], + [SecurityFramework.ClassGenericPassword, Raw(serviceName), Raw(account), Raw(label), Raw(data)]); + status = SecurityFramework.SecItemAdd(Raw(item), result: null); + } + + ThrowIfFailed(status, "guardar"); + } + + public bool Delete(string host) + { + using var serviceName = CoreFoundation.CreateString(service); + using var account = CoreFoundation.CreateString(host); + using var query = CoreFoundation.CreateDictionary( + [SecurityFramework.Class, SecurityFramework.AttrService, SecurityFramework.AttrAccount], + [SecurityFramework.ClassGenericPassword, Raw(serviceName), Raw(account)]); + + var status = SecurityFramework.SecItemDelete(Raw(query)); + if (status == SecurityFramework.ItemNotFound) + { + return false; + } + + ThrowIfFailed(status, "borrar"); + return true; + } + + private static CFHandle CreateSecretData(ApiCredentials credentials) + { + var secret = StoredCredentialsFormat.Encode(credentials); + try + { + return CoreFoundation.CreateData(secret); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + // Los diccionarios retienen sus llaves y valores; los handles siguen vivos por los using de cada operación. + private static nint Raw(CFHandle value) => value.DangerousGetHandle(); + + private static void ThrowIfFailed(int status, string operation) + { + if (status == SecurityFramework.Success) + { + return; + } + + var detail = $"llavero de macOS, OSStatus {status}"; + throw status switch + { + // Sin llavero de sesión: no hay almacén y la salida son las variables de entorno. + SecurityFramework.NotAvailable or SecurityFramework.NoSuchKeychain or SecurityFramework.NoDefaultKeychain => + new CredentialStoreUnavailableException(detail), + // El llavero existe pero está bloqueado y no se puede pedir la contraseña (p. ej. por SSH). + SecurityFramework.InteractionNotAllowed => CredentialStoreException.Locked(detail), + _ => CredentialStoreException.Failed(operation, detail) + }; + } +} diff --git a/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs b/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs new file mode 100644 index 0000000..bad5281 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace FiscalApi.Cli.Credentials.MacOS; + +/// +/// CoreFoundation (CFBase.h, CFString.h, CFData.h, CFDictionary.h) con . +/// CFIndex es signed long: 64 bits en macOS, por eso . +/// +[SupportedOSPlatform("macos")] +internal static unsafe partial class CoreFoundation +{ + private const string Library = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + + private const uint StringEncodingUtf8 = 0x08000100; + + private static readonly nint Handle = NativeLibrary.Load(Library); + + /// Retiene y libera llaves y valores como objetos CF: el diccionario no depende de nuestras referencias. + private static readonly nint TypeDictionaryKeyCallBacks = NativeLibrary.GetExport(Handle, "kCFTypeDictionaryKeyCallBacks"); + + private static readonly nint TypeDictionaryValueCallBacks = NativeLibrary.GetExport(Handle, "kCFTypeDictionaryValueCallBacks"); + + public static readonly nint BooleanTrue = *(nint*)NativeLibrary.GetExport(Handle, "kCFBooleanTrue"); + + public static CFHandle CreateString(string value) => + CFHandle.Owned(CFStringCreateWithCString(allocator: 0, value, StringEncodingUtf8)); + + /// CoreFoundation copia los bytes: quien llama puede borrar los suyos en cuanto vuelve. + public static CFHandle CreateData(ReadOnlySpan bytes) + { + fixed (byte* pointer = bytes) + { + return CFHandle.Owned(CFDataCreate(allocator: 0, pointer, bytes.Length)); + } + } + + /// Los bytes viven mientras viva . + public static ReadOnlySpan GetBytes(CFHandle data) => + new(CFDataGetBytePtr(data.DangerousGetHandle()), checked((int)CFDataGetLength(data.DangerousGetHandle()))); + + public static CFHandle CreateDictionary(ReadOnlySpan keys, ReadOnlySpan values) + { + fixed (nint* keyPointers = keys) + fixed (nint* valuePointers = values) + { + return CFHandle.Owned(CFDictionaryCreate( + allocator: 0, keyPointers, valuePointers, keys.Length, TypeDictionaryKeyCallBacks, TypeDictionaryValueCallBacks)); + } + } + + [LibraryImport(Library, StringMarshalling = StringMarshalling.Utf8)] + private static partial nint CFStringCreateWithCString(nint allocator, string value, uint encoding); + + [LibraryImport(Library)] + private static partial nint CFDataCreate(nint allocator, byte* bytes, nint length); + + [LibraryImport(Library)] + private static partial byte* CFDataGetBytePtr(nint data); + + [LibraryImport(Library)] + private static partial nint CFDataGetLength(nint data); + + [LibraryImport(Library)] + private static partial nint CFDictionaryCreate( + nint allocator, nint* keys, nint* values, nint count, nint keyCallBacks, nint valueCallBacks); + + [LibraryImport(Library)] + internal static partial void CFRelease(nint value); +} + +/// +/// Un objeto de CoreFoundation que nos pertenece (regla "Create/Copy"): se libera una sola vez con CFRelease, +/// aun si hay una excepción. +/// +[SupportedOSPlatform("macos")] +internal sealed class CFHandle : SafeHandle +{ + private CFHandle(nint handle) + : base(invalidHandleValue: 0, ownsHandle: true) => SetHandle(handle); + + public override bool IsInvalid => handle == 0; + + /// CoreFoundation devolvió NULL (sin memoria o sin resultado). + public static CFHandle Owned(nint handle) => + handle != 0 ? new CFHandle(handle) : throw new InvalidOperationException("CoreFoundation devolvió un objeto nulo."); + + protected override bool ReleaseHandle() + { + CoreFoundation.CFRelease(handle); + return true; + } +} + +/// Keychain Services de Security.framework (SecItem.h y SecBase.h). +[SupportedOSPlatform("macos")] +internal static unsafe partial class SecurityFramework +{ + private const string Library = "/System/Library/Frameworks/Security.framework/Security"; + + public const int Success = 0; + public const int NotAvailable = -25291; + public const int NoSuchKeychain = -25294; + public const int ItemNotFound = -25300; + public const int NoDefaultKeychain = -25307; + public const int InteractionNotAllowed = -25308; + + private static readonly nint Handle = NativeLibrary.Load(Library); + + public static readonly nint Class = Constant("kSecClass"); + public static readonly nint ClassGenericPassword = Constant("kSecClassGenericPassword"); + public static readonly nint AttrService = Constant("kSecAttrService"); + public static readonly nint AttrAccount = Constant("kSecAttrAccount"); + public static readonly nint AttrLabel = Constant("kSecAttrLabel"); + public static readonly nint ValueData = Constant("kSecValueData"); + public static readonly nint ReturnData = Constant("kSecReturnData"); + public static readonly nint MatchLimit = Constant("kSecMatchLimit"); + public static readonly nint MatchLimitOne = Constant("kSecMatchLimitOne"); + + /// Retenido (CF_RETURNS_RETAINED): se libera con CFRelease. + [LibraryImport(Library)] + public static partial int SecItemCopyMatching(nint query, nint* result); + + [LibraryImport(Library)] + public static partial int SecItemAdd(nint attributes, nint* result); + + [LibraryImport(Library)] + public static partial int SecItemUpdate(nint query, nint attributesToUpdate); + + [LibraryImport(Library)] + public static partial int SecItemDelete(nint query); + + // Las constantes de Security.framework son variables CFStringRef exportadas: GetExport da su dirección. + private static nint Constant(string name) => *(nint*)NativeLibrary.GetExport(Handle, name); +} diff --git a/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs new file mode 100644 index 0000000..bc89269 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// El secreto de una entrada del almacén: un JSON UTF-8 con las dos llaves ({"apiKey":…,"tenantKey":…}). +/// Van juntas en una sola entrada porque solo sirven en pareja; así una lectura nunca mezcla las de dos tenants. +/// +internal static class StoredCredentialsFormat +{ + /// Quien la recibe la borra con al terminar. + public static byte[] Encode(ApiCredentials credentials) => JsonSerializer.SerializeToUtf8Bytes( + new StoredCredentials { ApiKey = credentials.ApiKey, TenantKey = credentials.TenantKey }, + StoredCredentialsJsonContext.Default.StoredCredentials); + + /// La entrada no es de este CLI o se modificó a mano. + public static ApiCredentials Decode(ReadOnlySpan secret, string host) + { + try + { + var stored = JsonSerializer.Deserialize(secret, StoredCredentialsJsonContext.Default.StoredCredentials); + return ApiCredentials.Create(stored?.ApiKey, stored?.TenantKey); + } + catch (Exception exception) when (exception is JsonException or UsageException) + { + throw CredentialStoreException.Unreadable(host); + } + } +} + +internal sealed class StoredCredentials +{ + public string? ApiKey { get; init; } + + public string? TenantKey { get; init; } +} + +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(StoredCredentials))] +internal sealed partial class StoredCredentialsJsonContext : JsonSerializerContext; diff --git a/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs b/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs new file mode 100644 index 0000000..c1898fd --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Runtime.Versioning; + +namespace FiscalApi.Cli.Credentials.Windows; + +/// +/// Credential Manager de advapi32.dll (wincred.h), con : el marshalling +/// se genera al compilar y funciona en Native AOT. Siempre las variantes W (UTF-16). +/// +[SupportedOSPlatform("windows")] +internal static unsafe partial class AdvApi32 +{ + private const string Library = "advapi32.dll"; + + /// CREDENTIALW de wincred.h, campo por campo y en el mismo orden (blittable). + [StructLayout(LayoutKind.Sequential)] + internal struct CREDENTIALW + { + public uint Flags; + public uint Type; + public char* TargetName; + public char* Comment; + public FILETIME LastWritten; + public uint CredentialBlobSize; + public byte* CredentialBlob; + public uint Persist; + public uint AttributeCount; + public void* Attributes; + public char* TargetAlias; + public char* UserName; + } + + /// El resultado es un solo bloque que se libera con . + [LibraryImport(Library, EntryPoint = "CredReadW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CredReadW(string targetName, uint type, uint flags, CREDENTIALW** credential); + + [LibraryImport(Library, EntryPoint = "CredWriteW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CredWriteW(CREDENTIALW* credential, uint flags); + + [LibraryImport(Library, EntryPoint = "CredDeleteW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CredDeleteW(string targetName, uint type, uint flags); + + [LibraryImport(Library, EntryPoint = "CredFree")] + internal static partial void CredFree(void* buffer); +} diff --git a/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs new file mode 100644 index 0000000..566649e --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs @@ -0,0 +1,109 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials.Windows; + +/// +/// Windows Credential Manager: una credencial genérica (CRED_TYPE_GENERIC) por host, con nombre +/// {servicio}:{host} (p. ej. fiscalapi-cli:test.fiscalapi.com). Se ve y se borra también desde +/// "Administrador de credenciales" o con cmdkey /delete:fiscalapi-cli:test.fiscalapi.com. +/// +[SupportedOSPlatform("windows")] +internal sealed unsafe class WindowsCredentialStore(string service) : ICredentialStore +{ + private const uint CredTypeGeneric = 1; + + // Solo en este equipo y para esta persona. CRED_PERSIST_ENTERPRISE viajaría con un perfil móvil a otros equipos. + private const uint CredPersistLocalMachine = 2; + + private const int ErrorNotFound = 1168; + + // Sesiones de red o de servicio sin conjunto de credenciales: no hay almacén que usar. + private const int ErrorNoSuchLogonSession = 1312; + + private delegate T SecretDecoder(ReadOnlySpan secret, string host); + + // Credential Manager no tiene una consulta sin secreto: se lee la entrada y se borra sin decodificarla, así una + // entrada ilegible no impide que login la reemplace. + public void EnsureAvailable(string host) => ReadSecret(host, "consultar", static (_, _) => true); + + public ApiCredentials? Read(string host) => ReadSecret(host, "leer", StoredCredentialsFormat.Decode); + + public void Save(string host, ApiCredentials credentials) + { + var secret = StoredCredentialsFormat.Encode(credentials); + try + { + fixed (char* targetName = TargetName(host)) + fixed (byte* blob = secret) + { + var credential = new AdvApi32.CREDENTIALW + { + Type = CredTypeGeneric, + TargetName = targetName, + CredentialBlobSize = (uint)secret.Length, + CredentialBlob = blob, + Persist = CredPersistLocalMachine + }; + + // CredWriteW crea la entrada o reemplaza la que tenga el mismo TargetName y Type. + if (!AdvApi32.CredWriteW(&credential, flags: 0)) + { + throw Failure(Marshal.GetLastPInvokeError(), "guardar"); + } + } + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + public bool Delete(string host) + { + if (AdvApi32.CredDeleteW(TargetName(host), CredTypeGeneric, flags: 0)) + { + return true; + } + + var error = Marshal.GetLastPInvokeError(); + return error == ErrorNotFound ? false : throw Failure(error, "borrar"); + } + + /// Lo que devuelve , o el valor por omisión si no hay entrada. + private T? ReadSecret(string host, string operation, SecretDecoder decode) + { + AdvApi32.CREDENTIALW* credential; + if (!AdvApi32.CredReadW(TargetName(host), CredTypeGeneric, flags: 0, &credential)) + { + var error = Marshal.GetLastPInvokeError(); + return error == ErrorNotFound ? default : throw Failure(error, operation); + } + + try + { + // El bloque es nuestro hasta CredFree: se borra el secreto antes de devolverlo al sistema. + var secret = new Span(credential->CredentialBlob, checked((int)credential->CredentialBlobSize)); + try + { + return decode(secret, host); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + finally + { + AdvApi32.CredFree(credential); + } + } + + private string TargetName(string host) => $"{service}:{host}"; + + private static CliException Failure(int error, string operation) => error == ErrorNoSuchLogonSession + ? new CredentialStoreUnavailableException($"Credential Manager no tiene credenciales para esta sesión (Win32 {error}).") + : CredentialStoreException.Failed(operation, $"Credential Manager, Win32 {error}"); +} diff --git a/src/FiscalApi.Cli/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs new file mode 100644 index 0000000..6c4c1c8 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -0,0 +1,91 @@ +using System.Collections.Frozen; +using System.Net; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Errors; + +/// +/// La API respondió con un error. Se arma desde el envelope de fapi-back y muestra +/// message, details y el traceIdentifier que pide soporte. +/// +public sealed class ApiException : CliException +{ + // Pistas accionables por código HTTP. Viven aquí, en un mapa, y no en condicionales repartidos por los comandos. + private static readonly FrozenDictionary Hints = new Dictionary + { + // El back responde 401 tanto por la API key como por la tenant key (X-TENANT-KEY). + [HttpStatusCode.Unauthorized] = + "Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY." + }.ToFrozenDictionary(); + + private ApiException( + HttpStatusCode statusCode, + string message, + string? details, + string? traceIdentifier, + IReadOnlyList validationErrors) + : base(message) + { + StatusCode = statusCode; + Details = details; + TraceIdentifier = traceIdentifier; + ValidationErrors = validationErrors; + } + + public override int ExitCode => ExitCodes.Failure; + + /// 408, 429 y 5xx (incluido el 502/503 de nginx en un deploy); un 4xx distinto nunca. + public override bool IsTransient => TransientHttpStatus.Is(StatusCode); + + /// El de la respuesta HTTP: el envelope omite httpStatusCode cuando vale el default. + public HttpStatusCode StatusCode { get; } + + public string? Details { get; } + + public string? TraceIdentifier { get; } + + public string? Hint => Hints.GetValueOrDefault(StatusCode); + + /// El texto de cada regla que falló en un 400 de validación (). + public IReadOnlyList ValidationErrors { get; } + + /// Las fallas que trae el data de un 400 de validación, si las hay. + public static ApiException FromEnvelope( + HttpStatusCode statusCode, ApiResponse envelope, IReadOnlyList? validationErrors = null) => new( + statusCode, + string.IsNullOrWhiteSpace(envelope.Message) ? $"La API respondió {(int)statusCode} sin mensaje." : envelope.Message, + NullIfBlank(envelope.Details), + NullIfBlank(envelope.TraceIdentifier), + validationErrors ?? []); + + /// Un cuerpo que no es el envelope JSON, como el 502/503 en HTML de nginx. + public static ApiException UnexpectedResponse(HttpStatusCode statusCode) => + new(statusCode, $"Respuesta inesperada ({(int)statusCode}).", details: null, traceIdentifier: null, validationErrors: []); + + public override void WriteTo(TextWriter error) + { + base.WriteTo(error); + if (Details is not null && Details != Message) + { + error.WriteLine($" Detalle: {Details}"); + } + + foreach (var validationError in ValidationErrors) + { + error.WriteLine($" - {validationError}"); + } + + error.WriteLine($" Código HTTP: {(int)StatusCode}"); + if (TraceIdentifier is not null) + { + error.WriteLine($" traceIdentifier (para soporte): {TraceIdentifier}"); + } + + if (Hint is not null) + { + error.WriteLine($" Sugerencia: {Hint}"); + } + } + + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/src/FiscalApi.Cli/Errors/CliException.cs b/src/FiscalApi.Cli/Errors/CliException.cs new file mode 100644 index 0000000..115f1a9 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/CliException.cs @@ -0,0 +1,16 @@ +namespace FiscalApi.Cli.Errors; + +/// +/// Error esperado del CLI. Cada tipo sabe su código de salida y cómo mostrarse, así el único punto que atrapa +/// errores () no necesita condicionales por tipo. +/// +public abstract class CliException(string message, Exception? innerException = null) + : Exception(message, innerException) +{ + public abstract int ExitCode { get; } + + /// Si otro intento podría salir bien: lo consulta la reconexión de listen. + public virtual bool IsTransient => false; + + public virtual void WriteTo(TextWriter error) => error.WriteLine($"Error: {Message}"); +} diff --git a/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs b/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs new file mode 100644 index 0000000..f6c9e31 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs @@ -0,0 +1,11 @@ +namespace FiscalApi.Cli.Errors; + +/// +/// La API rechazó (401) la API key o la tenant key que se quería guardar con fiscalapi login. Es un +/// error de la API (código 1). La respuesta original queda como . +/// +public sealed class InvalidCredentialsException(string host, ApiException rejection) + : CliException($"Las credenciales no son válidas para {host}.", rejection) +{ + public override int ExitCode => ExitCodes.Failure; +} diff --git a/src/FiscalApi.Cli/Errors/NetworkException.cs b/src/FiscalApi.Cli/Errors/NetworkException.cs new file mode 100644 index 0000000..84d3125 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/NetworkException.cs @@ -0,0 +1,10 @@ +namespace FiscalApi.Cli.Errors; + +/// La API no respondió: conexión rechazada, DNS, TLS o tiempo de espera agotado. +public sealed class NetworkException(string message, Exception innerException) : CliException(message, innerException) +{ + public override int ExitCode => ExitCodes.Failure; + + /// Una red caída, un DNS que no responde o un timeout pueden resolverse solos. + public override bool IsTransient => true; +} diff --git a/src/FiscalApi.Cli/Errors/ResourceStateException.cs b/src/FiscalApi.Cli/Errors/ResourceStateException.cs new file mode 100644 index 0000000..094cb5e --- /dev/null +++ b/src/FiscalApi.Cli/Errors/ResourceStateException.cs @@ -0,0 +1,11 @@ +namespace FiscalApi.Cli.Errors; + +/// +/// El recurso no admite la operación en su estado (p. ej. editar una escucha de CLI). El CLI lo detecta con lo que ya leyó +/// de la API y lo informa con el texto que respondería la API; sale con 1, como un error de la API, porque el comando +/// estaba bien escrito. +/// +public sealed class ResourceStateException(string message) : CliException(message) +{ + public override int ExitCode => ExitCodes.Failure; +} diff --git a/src/FiscalApi.Cli/Errors/UsageException.cs b/src/FiscalApi.Cli/Errors/UsageException.cs new file mode 100644 index 0000000..807b002 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/UsageException.cs @@ -0,0 +1,9 @@ +namespace FiscalApi.Cli.Errors; + +/// +/// Uso incorrecto que System.CommandLine no puede detectar al parsear (p. ej. una combinación de opciones inválida). +/// +public sealed class UsageException(string message) : CliException(message) +{ + public override int ExitCode => ExitCodes.Usage; +} diff --git a/src/FiscalApi.Cli/ExitCodes.cs b/src/FiscalApi.Cli/ExitCodes.cs new file mode 100644 index 0000000..2b0d2ab --- /dev/null +++ b/src/FiscalApi.Cli/ExitCodes.cs @@ -0,0 +1,18 @@ +namespace FiscalApi.Cli; + +/// +/// Códigos de salida del contrato común del CLI. Los scripts y el CI de quien integra dependen de ellos. +/// +public static class ExitCodes +{ + public const int Success = 0; + + /// Error de la API o de la red. + public const int Failure = 1; + + /// Error de uso: argumentos inválidos (System.CommandLine) o . + public const int Usage = 2; + + /// Ctrl+C. 128 + SIGINT (2), la convención de los shells POSIX. + public const int Cancelled = 130; +} diff --git a/src/FiscalApi.Cli/FiscalApi.Cli.csproj b/src/FiscalApi.Cli/FiscalApi.Cli.csproj new file mode 100644 index 0000000..ad383b2 --- /dev/null +++ b/src/FiscalApi.Cli/FiscalApi.Cli.csproj @@ -0,0 +1,24 @@ + + + + Exe + fiscalapi + FiscalApi.Cli + true + 0.1.0 + FiscalAPI CLI + FiscalAPI + + true + + + + + + + + + + + + diff --git a/src/FiscalApi.Cli/GlobalOptions.cs b/src/FiscalApi.Cli/GlobalOptions.cs new file mode 100644 index 0000000..2c923c9 --- /dev/null +++ b/src/FiscalApi.Cli/GlobalOptions.cs @@ -0,0 +1,70 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace FiscalApi.Cli; + +/// +/// Opciones globales del contrato común. Son recursivas: valen en el comando raíz y en todos los subcomandos. +/// +public sealed class GlobalOptions +{ + private readonly Option _live = new("--live") + { + Description = "Usa el ambiente de producción (https://live.fiscalapi.com) en lugar de test.", + Recursive = true + }; + + private readonly Option _apiBase = new("--api-base") + { + Description = "URL base de la API (p. ej. el back local). Gana sobre --live.", + HelpName = "url", + Recursive = true, + CustomParser = ParseApiBase + }; + + private readonly Option _json = new("--json") + { + Description = "Escribe solo JSON en stdout.", + Recursive = true + }; + + private readonly Option _verbose = new("--verbose") + { + Description = "Muestra el detalle HTTP en stderr, sin headers de credenciales.", + Recursive = true + }; + + public void AddTo(Command command) + { + command.Options.Add(_live); + command.Options.Add(_apiBase); + command.Options.Add(_json); + command.Options.Add(_verbose); + } + + public GlobalSettings Read(ParseResult parseResult) => new( + ApiEnvironment.Resolve(parseResult.GetValue(_apiBase), parseResult.GetValue(_live)), + parseResult.GetValue(_json), + parseResult.GetValue(_verbose)); + + // Solo URLs absolutas http/https: una URL relativa o de otro esquema terminaría en un error de red confuso. + private static Uri? ParseApiBase(ArgumentResult result) + { + var value = result.Tokens.Single().Value; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + { + result.AddError($"--api-base debe ser una URL absoluta http o https; se recibió '{value}'."); + return null; + } + + // La API key y la tenant key viajan en headers: sin TLS solo se permite el back local (localhost, + // 127.0.0.0/8 o ::1), que no sale del equipo. + if (uri.Scheme == Uri.UriSchemeHttp && !uri.IsLoopback) + { + result.AddError($"--api-base con http solo se permite para localhost, 127.0.0.1 o ::1; usa https para '{value}'."); + return null; + } + + return uri; + } +} diff --git a/src/FiscalApi.Cli/GlobalSettings.cs b/src/FiscalApi.Cli/GlobalSettings.cs new file mode 100644 index 0000000..1b64a1b --- /dev/null +++ b/src/FiscalApi.Cli/GlobalSettings.cs @@ -0,0 +1,4 @@ +namespace FiscalApi.Cli; + +/// Valores de las opciones globales ya leídos de una invocación. +public sealed record GlobalSettings(ApiEnvironment Environment, bool Json, bool Verbose); diff --git a/src/FiscalApi.Cli/Http/ApiData.cs b/src/FiscalApi.Cli/Http/ApiData.cs new file mode 100644 index 0000000..aa7fbb0 --- /dev/null +++ b/src/FiscalApi.Cli/Http/ApiData.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace FiscalApi.Cli.Http; + +/// El data de una respuesta exitosa, leído como para conservar su texto exacto. +public static class ApiData +{ + /// + /// Las rutas que lo usan siempre responden data al tener éxito: sin él (o en null) la API rompió su + /// contrato, y el CLI lo reporta como un defecto en lugar de imprimir algo vacío. + /// + public static JsonElement Required(JsonElement data) => + data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? throw new InvalidOperationException("La API respondió con éxito pero sin data.") + : data; +} diff --git a/src/FiscalApi.Cli/Http/ApiResponse.cs b/src/FiscalApi.Cli/Http/ApiResponse.cs new file mode 100644 index 0000000..37ba668 --- /dev/null +++ b/src/FiscalApi.Cli/Http/ApiResponse.cs @@ -0,0 +1,21 @@ +namespace FiscalApi.Cli.Http; + +/// +/// Envelope de todas las respuestas de fapi-back (WebApi.Libs.Domain.Common.ApiResponse<T>), en camelCase. +/// httpStatusCode no se lee: el back lo omite cuando vale el default y el código real sale de la respuesta HTTP. +/// +public class ApiResponse +{ + public bool Succeeded { get; init; } + + public string? Message { get; init; } + + public string? Details { get; init; } + + public string? TraceIdentifier { get; init; } +} + +public sealed class ApiResponse : ApiResponse +{ + public T? Data { get; init; } +} diff --git a/src/FiscalApi.Cli/Http/ExponentialBackoff.cs b/src/FiscalApi.Cli/Http/ExponentialBackoff.cs new file mode 100644 index 0000000..0539813 --- /dev/null +++ b/src/FiscalApi.Cli/Http/ExponentialBackoff.cs @@ -0,0 +1,32 @@ +namespace FiscalApi.Cli.Http; + +/// +/// Espera exponencial con "equal jitter": la mitad fija y la otra mitad aleatoria, para que varios CLI no reintenten al +/// unísono, y nunca más que maxDelay. Inmutable: cada quien lleva su propio número de intento. +/// +public sealed class ExponentialBackoff +{ + // Con más de 2^30 veces la base cualquier espera ya superó el máximo; el tope evita desbordar el cálculo. + private const int MaxExponent = 30; + + private readonly TimeSpan _baseDelay; + + public ExponentialBackoff(TimeSpan baseDelay, TimeSpan maxDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(baseDelay, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxDelay, baseDelay); + _baseDelay = baseDelay; + MaxDelay = maxDelay; + } + + public TimeSpan MaxDelay { get; } + + /// El intento que falló, desde 1: la espera del primero ronda la base. + public TimeSpan Delay(int attempt) + { + ArgumentOutOfRangeException.ThrowIfLessThan(attempt, 1); + var exponent = Math.Min(attempt - 1, MaxExponent); + var exponential = TimeSpan.FromTicks((long)Math.Min(_baseDelay.Ticks * Math.Pow(2, exponent), MaxDelay.Ticks)); + return exponential / 2 + exponential / 2 * Random.Shared.NextDouble(); + } +} diff --git a/src/FiscalApi.Cli/Http/FiscalApiClient.cs b/src/FiscalApi.Cli/Http/FiscalApiClient.cs new file mode 100644 index 0000000..d5887db --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiClient.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Http; + +/// +/// Cliente de la API de FiscalAPI para los comandos. Devuelve el data del envelope y convierte cualquier +/// otra salida en o . El JSON pasa siempre por un +/// generado, nunca por reflexión. Con como tipo del data, +/// el comando recibe el JSON tal como lo escribió la API (). +/// +public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPolicy) +{ + /// Un GET es idempotente: se reintenta ante errores transitorios. + public async Task GetAsync( + string path, JsonTypeInfo> responseType, CancellationToken cancellationToken) + { + using var response = await SendAsync( + () => retryPolicy.SendAsync(httpClient, () => new HttpRequestMessage(HttpMethod.Get, path), cancellationToken), + cancellationToken).ConfigureAwait(false); + return await ReadDataAsync(response, responseType, cancellationToken).ConfigureAwait(false); + } + + /// Un POST no se reintenta: repetirlo podría crear el recurso dos veces. + public Task PostAsync( + string path, + TRequest body, + JsonTypeInfo requestType, + JsonTypeInfo> responseType, + CancellationToken cancellationToken) => + SendOnceAsync(HttpMethod.Post, path, JsonContent.Create(body, requestType), responseType, cancellationToken); + + /// Un POST sin cuerpo (una acción sobre el recurso de la ruta); tampoco se reintenta. + public Task PostAsync( + string path, JsonTypeInfo> responseType, CancellationToken cancellationToken) => + SendOnceAsync(HttpMethod.Post, path, content: null, responseType, cancellationToken); + + /// + /// Un PUT no se reintenta: aunque reemplazar es idempotente, repetirlo después de que otra persona cambió el recurso + /// pisaría su cambio. + /// + public Task PutAsync( + string path, + TRequest body, + JsonTypeInfo requestType, + JsonTypeInfo> responseType, + CancellationToken cancellationToken) => + SendOnceAsync(HttpMethod.Put, path, JsonContent.Create(body, requestType), responseType, cancellationToken); + + /// Un DELETE no se reintenta: la segunda vez respondería 404 sobre algo que sí se borró. + public Task DeleteAsync( + string path, JsonTypeInfo> responseType, CancellationToken cancellationToken) => + SendOnceAsync(HttpMethod.Delete, path, content: null, responseType, cancellationToken); + + private async Task SendOnceAsync( + HttpMethod method, + string path, + HttpContent? content, + JsonTypeInfo> responseType, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path) { Content = content }; + using var response = await SendAsync(() => httpClient.SendAsync(request, cancellationToken), cancellationToken) + .ConfigureAwait(false); + return await ReadDataAsync(response, responseType, cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync( + Func> send, CancellationToken cancellationToken) + { + try + { + return await send().ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + throw new NetworkException($"No se pudo conectar con {httpClient.BaseAddress}: {exception.Message}", exception); + } + // HttpClient reporta su timeout como TaskCanceledException sin que el token de quien llama se haya + // cancelado. Si el token sí se canceló (Ctrl+C), la excepción sigue su camino hasta el código 130. + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new NetworkException( + $"{httpClient.BaseAddress} no respondió en {httpClient.Timeout.TotalSeconds:0} s.", exception); + } + } + + private static async Task ReadDataAsync( + HttpResponseMessage response, JsonTypeInfo> responseType, CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + { + throw await ToApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); + } + + // En un 2xx el contrato es nuestro: un JsonException aquí es un defecto del CLI y sube tal cual. + var envelope = await response.Content.ReadFromJsonAsync(responseType, cancellationToken).ConfigureAwait(false) + ?? throw ApiException.UnexpectedResponse(response.StatusCode); + return envelope.Succeeded ? envelope.Data : throw ApiException.FromEnvelope(response.StatusCode, envelope); + } + + /// + /// Un error se lee con su data como JSON sin forma fija: solo el 400 de validación trae ahí las fallas, y la + /// forma de otro error no tiene por qué coincidir con la del éxito. Un cuerpo que no es JSON (el 502/503 en HTML de + /// nginx, un 3xx sin cuerpo) es una respuesta inesperada, no un fallo del CLI. + /// + private static async Task ToApiExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var envelope = await response.Content + .ReadFromJsonAsync(FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false); + return envelope is null + ? ApiException.UnexpectedResponse(response.StatusCode) + : ApiException.FromEnvelope(response.StatusCode, envelope, ValidationFailures.MessagesIn(envelope.Data)); + } + catch (JsonException) + { + return ApiException.UnexpectedResponse(response.StatusCode); + } + } +} diff --git a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs new file mode 100644 index 0000000..0a5e241 --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs @@ -0,0 +1,51 @@ +using System.Net.Http.Headers; +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Http; + +/// +/// El único lugar donde se fabrican los clientes HTTP del CLI. Comparte un solo handler (pool de conexiones) +/// y crea un por invocación, porque la URL base, --verbose, el timeout y las +/// credenciales dependen de las opciones globales y del comando. Llega a los comandos por constructor para que +/// sus pruebas inyecten un handler falso. +/// +public sealed class FiscalApiClientFactory( + HttpMessageHandler handler, CredentialResolver credentials, TextWriter diagnostics, RetryPolicy retryPolicy) +{ + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + // Los de fapi-back (ApiConstants.ApiKeyHeader y TenantHeader). + private const string ApiKeyHeader = "X-API-KEY"; + private const string TenantKeyHeader = "X-TENANT-KEY"; + + /// + /// El handler de producción, uno por proceso. Usa el proxy del sistema por omisión (UseProxy = true). + /// No sigue redirecciones: un 3xx termina como , así X-API-KEY y + /// X-TENANT-KEY nunca viajan a otro host y un POST no se convierte en GET. + /// + public static SocketsHttpHandler CreateHandler() => new() { AllowAutoRedirect = false }; + + /// + /// El cliente de los comandos de la API, con las credenciales del ambiente: las variables de entorno o, si no + /// están, las que guardó fiscalapi login (). + /// + public FiscalApiClient Create(GlobalSettings settings, TimeSpan timeout) => + Create(settings, credentials.Resolve(settings.Environment), timeout); + + /// Con credenciales dadas: fiscalapi login valida las que la persona acaba de escribir. + public FiscalApiClient Create(GlobalSettings settings, ApiCredentials apiCredentials, TimeSpan timeout) + { + // Cada HttpClient envuelve el handler compartido sin adueñarse de él: lo libera Program al salir. + var pipeline = settings.Verbose ? new VerboseLoggingHandler(diagnostics) { InnerHandler = handler } : handler; + var httpClient = new HttpClient(pipeline, disposeHandler: false) + { + BaseAddress = settings.Environment.BaseAddress, + Timeout = timeout + }; + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("fiscalapi-cli", CliVersion.Current)); + // ApiCredentials ya garantiza ASCII visible: Add no puede fallar con un mensaje que repita el valor. + httpClient.DefaultRequestHeaders.Add(ApiKeyHeader, apiCredentials.ApiKey); + httpClient.DefaultRequestHeaders.Add(TenantKeyHeader, apiCredentials.TenantKey); + return new FiscalApiClient(httpClient, retryPolicy); + } +} diff --git a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs new file mode 100644 index 0000000..bc28581 --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FiscalApi.Cli.Listen; +using FiscalApi.Cli.Webhooks; + +namespace FiscalApi.Cli.Http; + +/// +/// Contrato JSON del CLI generado en compilación: la serialización por reflexión no funciona en Native AOT. +/// Los comandos agregan aquí los tipos de sus requests y respuestas (los enums con JsonStringEnumConverter<TEnum>). +/// +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(OpenCliListenerRequest))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ApiResponse>))] +[JsonSerializable(typeof(CliDeliveryAck))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ListenSecretRecord))] +[JsonSerializable(typeof(ListenEventRecord))] +// El data se lee como JsonElement para imprimirlo sin cambios; los modelos solo alimentan la salida de texto. +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(Webhook))] +[JsonSerializable(typeof(PagedList))] +[JsonSerializable(typeof(CreateWebhookRequest))] +[JsonSerializable(typeof(UpdateWebhookRequest))] +[JsonSerializable(typeof(PagedList))] +[JsonSerializable(typeof(ResendWebhookEventRequest))] +[JsonSerializable(typeof(WebhookEventResend))] +[JsonSerializable(typeof(TriggerWebhookEventRequest))] +[JsonSerializable(typeof(WebhookEventTrigger))] +public sealed partial class FiscalApiJsonContext : JsonSerializerContext; diff --git a/src/FiscalApi.Cli/Http/PagedList.cs b/src/FiscalApi.Cli/Http/PagedList.cs new file mode 100644 index 0000000..79113ab --- /dev/null +++ b/src/FiscalApi.Cli/Http/PagedList.cs @@ -0,0 +1,30 @@ +namespace FiscalApi.Cli.Http; + +/// +/// Una página de un listado de fapi-back (WebApi.Libs.Application.Common.Models.PagedList<T>): items, +/// pageNumber, totalPages, totalCount, hasPreviousPage y hasNextPage. Solo para la +/// salida de texto: con --json la página se imprime tal como llegó. +/// +public sealed class PagedList +{ + public IReadOnlyList Items { get; init; } = []; + + public int PageNumber { get; init; } + + public int TotalPages { get; init; } + + public int TotalCount { get; init; } + + public bool HasNextPage { get; init; } +} + +/// La página que pide la persona; lo que no da lo decide la API (página 1 de 10). +public sealed record PageRequest(int? Number, int? Size) +{ + /// Los parámetros de fapi-back (pageNumber, pageSize); los rangos los valida la API. + public IEnumerable> QueryParameters => + [ + KeyValuePair.Create("pageNumber", Number?.ToString(System.Globalization.CultureInfo.InvariantCulture)), + KeyValuePair.Create("pageSize", Size?.ToString(System.Globalization.CultureInfo.InvariantCulture)) + ]; +} diff --git a/src/FiscalApi.Cli/Http/PersonFeatures.cs b/src/FiscalApi.Cli/Http/PersonFeatures.cs new file mode 100644 index 0000000..1de381c --- /dev/null +++ b/src/FiscalApi.Cli/Http/PersonFeatures.cs @@ -0,0 +1,10 @@ +namespace FiscalApi.Cli.Http; + +/// +/// Lo que el CLI lee de GET /api/v4/me/features (PersonFeaturesDto de fapi-back): solo los permisos +/// concedidos. Un permiso que los features del tenant niegan va en denied, no aquí. +/// +public sealed class PersonFeatures +{ + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/FiscalApi.Cli/Http/QueryString.cs b/src/FiscalApi.Cli/Http/QueryString.cs new file mode 100644 index 0000000..22d0811 --- /dev/null +++ b/src/FiscalApi.Cli/Http/QueryString.cs @@ -0,0 +1,15 @@ +namespace FiscalApi.Cli.Http; + +/// Arma la query string de una ruta: escapa cada valor y omite los que no se dieron. +public static class QueryString +{ + /// "?a=1&b=2", o vacío si ningún parámetro tiene valor. + public static string From(IEnumerable> parameters) + { + var pairs = parameters + .Where(parameter => parameter.Value is not null) + .Select(parameter => $"{parameter.Key}={Uri.EscapeDataString(parameter.Value!)}") + .ToList(); + return pairs.Count == 0 ? "" : "?" + string.Join('&', pairs); + } +} diff --git a/src/FiscalApi.Cli/Http/RetryPolicy.cs b/src/FiscalApi.Cli/Http/RetryPolicy.cs new file mode 100644 index 0000000..2e41b3e --- /dev/null +++ b/src/FiscalApi.Cli/Http/RetryPolicy.cs @@ -0,0 +1,78 @@ +namespace FiscalApi.Cli.Http; + +/// +/// Reintentos con backoff exponencial y jitter, solo ante errores transitorios: conexión fallida o cortada, +/// 408, 429 y 5xx. Un 4xx nunca se reintenta, y un timeout tampoco: el plazo que ve la persona es el del +/// mensaje. Quien la usa decide qué requests son seguros de repetir (solo GET). +/// +public sealed class RetryPolicy +{ + private readonly ExponentialBackoff _backoff; + private readonly TimeProvider _timeProvider; + + public RetryPolicy(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay, TimeProvider timeProvider) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 1); + MaxAttempts = maxAttempts; + _backoff = new ExponentialBackoff(baseDelay, maxDelay); + _timeProvider = timeProvider; + } + + /// 3 intentos, a partir de 0.5 s, sin esperar más de 10 s entre intentos: es una herramienta interactiva. + public static RetryPolicy Default { get; } = + new(maxAttempts: 3, baseDelay: TimeSpan.FromMilliseconds(500), maxDelay: TimeSpan.FromSeconds(10), TimeProvider.System); + + public int MaxAttempts { get; } + + /// Fábrica: un no puede enviarse dos veces. + public async Task SendAsync( + HttpClient httpClient, Func createRequest, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + HttpResponseMessage response; + try + { + using var request = createRequest(); + response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) when (attempt < MaxAttempts && IsTransient(exception.HttpRequestError)) + { + await Task.Delay(_backoff.Delay(attempt), _timeProvider, cancellationToken).ConfigureAwait(false); + continue; + } + + if (attempt == MaxAttempts || !TransientHttpStatus.Is(response.StatusCode) || DelayBeforeRetry(attempt, response) is not { } delay) + { + return response; + } + + response.Dispose(); + await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false); + } + } + + // Solo fallas que otro intento puede resolver. Un error de TLS o de DNS (casi siempre un --api-base mal + // escrito) se repetiría igual, y solo retrasaría el mensaje. + private static bool IsTransient(HttpRequestError error) => + error is HttpRequestError.ConnectionError or HttpRequestError.ResponseEnded; + + /// + /// Respeta Retry-After cuando viene. Si pide esperar más que el máximo, no se reintenta y el error + /// llega a la persona en lugar de dejar la terminal colgada. + /// + private TimeSpan? DelayBeforeRetry(int attempt, HttpResponseMessage response) + { + var delay = RetryAfter(response) ?? _backoff.Delay(attempt); + return delay <= _backoff.MaxDelay ? delay : null; + } + + private TimeSpan? RetryAfter(HttpResponseMessage response) => response.Headers.RetryAfter switch + { + { Delta: { } delta } => delta, + { Date: { } date } => Max(date - _timeProvider.GetUtcNow(), TimeSpan.Zero), + _ => null + }; + + private static TimeSpan Max(TimeSpan a, TimeSpan b) => a > b ? a : b; +} diff --git a/src/FiscalApi.Cli/Http/TransientHttpStatus.cs b/src/FiscalApi.Cli/Http/TransientHttpStatus.cs new file mode 100644 index 0000000..cf0e8fe --- /dev/null +++ b/src/FiscalApi.Cli/Http/TransientHttpStatus.cs @@ -0,0 +1,10 @@ +using System.Net; + +namespace FiscalApi.Cli.Http; + +/// Los códigos HTTP que otro intento puede resolver: 408, 429 y 5xx. Un 4xx distinto nunca. +public static class TransientHttpStatus +{ + public static bool Is(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; +} diff --git a/src/FiscalApi.Cli/Http/ValidationFailures.cs b/src/FiscalApi.Cli/Http/ValidationFailures.cs new file mode 100644 index 0000000..28445b8 --- /dev/null +++ b/src/FiscalApi.Cli/Http/ValidationFailures.cs @@ -0,0 +1,34 @@ +using System.Text.Json; + +namespace FiscalApi.Cli.Http; + +/// +/// El 400 de validación de fapi-back (GlobalExceptionMiddleware) responde un message y un details +/// genéricos, y el texto de cada regla que falló va en data: un arreglo de ValidationFailure de +/// FluentValidation (propertyName, errorMessage…). Sin esos textos la persona no sabría qué corregir. +/// +public static class ValidationFailures +{ + /// El errorMessage de cada falla, o ninguno si no es ese arreglo. + public static IReadOnlyList MessagesIn(JsonElement data) + { + if (data.ValueKind != JsonValueKind.Array) + { + return []; + } + + var messages = new List(); + foreach (var failure in data.EnumerateArray()) + { + if (failure.ValueKind == JsonValueKind.Object + && failure.TryGetProperty("errorMessage", out var message) + && message.ValueKind == JsonValueKind.String + && message.GetString() is { Length: > 0 } text) + { + messages.Add(text); + } + } + + return messages; + } +} diff --git a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs new file mode 100644 index 0000000..47cce8d --- /dev/null +++ b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.Http.Headers; + +namespace FiscalApi.Cli.Http; + +/// +/// --verbose: escribe cada request y su respuesta en stderr. Los headers de credenciales nunca se escriben, +/// ni siquiera enmascarados, porque la salida suele pegarse en tickets y chats de soporte. Los cuerpos tampoco: +/// pueden traer secretos (p. ej. el de un webhook). +/// +public sealed class VerboseLoggingHandler(TextWriter diagnostics) : DelegatingHandler +{ + private static readonly HashSet CredentialHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "X-API-KEY", + "X-TENANT-KEY", + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie" + }; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var line = $"{request.Method} {request.RequestUri}"; + diagnostics.WriteLine($"> {line}"); + WriteHeaders("> ", request.Headers); + WriteHeaders("> ", request.Content?.Headers); + + var startedAt = Stopwatch.GetTimestamp(); + HttpResponseMessage response; + try + { + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + diagnostics.WriteLine($"! {line} falló ({exception.HttpRequestError}) tras {Elapsed(startedAt)}: {exception.Message}"); + throw; + } + // Aquí no se distingue el timeout de HttpClient de un Ctrl+C: ambos cancelan el mismo token. + catch (OperationCanceledException) + { + diagnostics.WriteLine($"! {line} sin respuesta tras {Elapsed(startedAt)} (tiempo agotado o cancelado)"); + throw; + } + + diagnostics.WriteLine($"< {(int)response.StatusCode} {response.ReasonPhrase} ({Elapsed(startedAt)})"); + WriteHeaders("< ", response.Headers); + WriteHeaders("< ", response.Content.Headers); + return response; + } + + private static string Elapsed(long startedAt) => $"{Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds:0} ms"; + + private void WriteHeaders(string prefix, HttpHeaders? headers) + { + if (headers is null) + { + return; + } + + foreach (var (name, values) in headers) + { + if (!CredentialHeaders.Contains(name)) + { + diagnostics.WriteLine($"{prefix}{name}: {string.Join(", ", values)}"); + } + } + } +} diff --git a/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs b/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs new file mode 100644 index 0000000..d1ecd7e --- /dev/null +++ b/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs @@ -0,0 +1,24 @@ +namespace FiscalApi.Cli.Input; + +/// +/// La pregunta va a stderr (stdout es para datos) y la respuesta es una línea de la terminal. Solo "s", "si", "sí", "y" o +/// "yes" confirman; cualquier otra cosa, una línea vacía o el fin de la entrada, no. +/// +/// Donde se escribe la pregunta. +/// La terminal (Console.In) o, en las pruebas, un texto. +/// Si hay una persona en la terminal (!Console.IsInputRedirected). +public sealed class ConsoleConfirmationPrompt(TextWriter prompts, TextReader input, bool isInteractive) : IConfirmationPrompt +{ + private static readonly HashSet Yes = new(StringComparer.OrdinalIgnoreCase) { "s", "si", "sí", "y", "yes" }; + + public bool IsInteractive => isInteractive; + + public async Task ConfirmAsync(string question, CancellationToken cancellationToken) + { + await prompts.WriteAsync($"{question} [s/N] ").ConfigureAwait(false); + // Console.In.ReadLine bloquea sin token: se espera aparte para que Ctrl+C (la cancelación del token que hace + // Program) termine con 130 aunque la persona no haya respondido. + var answer = await Task.Run(input.ReadLine, CancellationToken.None).WaitAsync(cancellationToken).ConfigureAwait(false); + return answer is not null && Yes.Contains(answer.Trim()); + } +} diff --git a/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs new file mode 100644 index 0000000..53181f6 --- /dev/null +++ b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace FiscalApi.Cli.Input; + +/// +/// Una persona en la terminal: muestra el prompt en stderr y lee las teclas sin eco, así el secreto no queda en +/// pantalla ni en el scrollback. +/// +/// Donde se escriben el prompt y el salto de línea final; nunca una tecla. +/// +/// La siguiente tecla sin eco, o null si todavía no hay ninguna. En producción, +/// Console.KeyAvailable y Console.ReadKey(intercept: true); en las pruebas, teclas simuladas. +/// +public sealed class ConsoleSecretReader(TextWriter prompts, Func tryReadKey) : ISecretReader +{ + // Console.ReadKey bloquea sin token: se espera a que haya tecla en intervalos cortos para que Ctrl+C (que + // Program convierte en la cancelación del token) termine la lectura con el código 130. + private static readonly TimeSpan KeyPollInterval = TimeSpan.FromMilliseconds(50); + + public async Task ReadAsync(string prompt, CancellationToken cancellationToken) + { + await prompts.WriteAsync(prompt).ConfigureAwait(false); + var value = new StringBuilder(); + while (true) + { + var key = await NextKeyAsync(cancellationToken).ConfigureAwait(false); + if (key.Key == ConsoleKey.Enter) + { + await prompts.WriteLineAsync().ConfigureAwait(false); + return value.ToString(); + } + + if (key.Key == ConsoleKey.Backspace) + { + value.Length = Math.Max(0, value.Length - 1); + } + else if (!char.IsControl(key.KeyChar)) + { + value.Append(key.KeyChar); + } + } + } + + private async Task NextKeyAsync(CancellationToken cancellationToken) + { + while (true) + { + if (tryReadKey() is { } key) + { + return key; + } + + await Task.Delay(KeyPollInterval, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs b/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs new file mode 100644 index 0000000..22ac391 --- /dev/null +++ b/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs @@ -0,0 +1,11 @@ +namespace FiscalApi.Cli.Input; + +/// Una pregunta de sí o no antes de una acción que no se deshace (p. ej. webhooks delete). +public interface IConfirmationPrompt +{ + /// Si hay una persona en la terminal que pueda responder; con stdin redirigido, no. + bool IsInteractive { get; } + + /// true solo si la persona respondió que sí. + Task ConfirmAsync(string question, CancellationToken cancellationToken); +} diff --git a/src/FiscalApi.Cli/Input/ISecretReader.cs b/src/FiscalApi.Cli/Input/ISecretReader.cs new file mode 100644 index 0000000..ee6a100 --- /dev/null +++ b/src/FiscalApi.Cli/Input/ISecretReader.cs @@ -0,0 +1,11 @@ +namespace FiscalApi.Cli.Input; + +/// +/// Entrada de un secreto. Nunca llega por argumento: quedaría en el historial del shell y en la lista de procesos. +/// +public interface ISecretReader +{ + /// Lo que se le pide a la persona, si hay una persona en la terminal. + /// El valor leído, o null si la entrada terminó antes. + Task ReadAsync(string prompt, CancellationToken cancellationToken); +} diff --git a/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs new file mode 100644 index 0000000..3eb6683 --- /dev/null +++ b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs @@ -0,0 +1,25 @@ +namespace FiscalApi.Cli.Input; + +/// +/// Stdin redirigido (printf '%s\n%s\n' "$KEY" "$TENANT" | fiscalapi login): un valor por línea y sin +/// prompts, porque no hay una persona que los lea. +/// +public sealed class RedirectedSecretReader(TextReader input) : ISecretReader +{ + private const char ByteOrderMark = '\uFEFF'; + + private bool _firstLine = true; + + public async Task ReadAsync(string prompt, CancellationToken cancellationToken) + { + var line = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false); + // Un archivo guardado como "UTF-8 con BOM" y redirigido trae U+FEFF al inicio, y Trim no lo quita. + if (_firstLine && line is not null) + { + line = line.TrimStart(ByteOrderMark); + } + + _firstLine = false; + return line; + } +} diff --git a/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs b/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs new file mode 100644 index 0000000..087e2bd --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs @@ -0,0 +1,7 @@ +namespace FiscalApi.Cli.Listen; + +/// Una entrega que trajo el long-polling, con la sesión que la reclamó y hasta cuándo la API espera su acuse. +/// La sesión del long-polling que la trajo: su acuse lleva ese sessionId. +/// La entrega tal como llegó. +/// Cuándo vence su lease: después, la API ya la cerró sin acuse. +public sealed record ClaimedDelivery(CliListenerSession Session, CliDelivery Delivery, DateTimeOffset AckDeadline); diff --git a/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs new file mode 100644 index 0000000..7c0e466 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs @@ -0,0 +1,115 @@ +using System.Runtime.ExceptionServices; + +namespace FiscalApi.Cli.Listen; + +/// +/// Corre el trabajo de cada entrega de listen fuera del long-polling, con dos reglas: +/// +/// Orden por evento: las entregas del mismo evento (un reenvío con events resend) corren una tras otra, +/// en el orden en que llegaron; las de eventos distintos, en paralelo. +/// Concurrencia acotada: como mucho maxInFlight entregas corren a la vez. El cupo se toma cuando le +/// toca su turno a la entrega, así una cadena larga del mismo evento no ocupa cupo mientras espera. +/// +/// nunca espera: el long-polling sigue a tiempo y la sesión no caduca aunque el receptor sea lento. +/// Lo que espera su turno ocupa solo memoria (cada lote trae como mucho 25 entregas), y la que llega a su turno con el lease +/// vencido se descarta sin reenviarse (). +/// El trabajo no debe lanzar: una excepción es un defecto del CLI, se guarda y sale en , que +/// el long-polling revisa en cada vuelta para terminar por el único punto que atrapa errores. +/// +public sealed class DeliveryDispatcher : IAsyncDisposable +{ + private readonly SemaphoreSlim _slots; + private readonly CancellationTokenSource _stopping; + private readonly Lock _gate = new(); + + // La última entrega aceptada de cada evento con trabajo pendiente; la siguiente del mismo evento la espera. La entrada + // se borra cuando termina la última, así el diccionario no crece durante una escucha larga. Como cada entrega espera a + // la anterior de su evento, que terminen las últimas significa que terminó todo. + private readonly Dictionary _lastByEvent = new(StringComparer.Ordinal); + + private ExceptionDispatchInfo? _fault; + + /// Entregas que pueden correr a la vez. + /// Ctrl+C: cancela el trabajo en curso. + public DeliveryDispatcher(int maxInFlight, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxInFlight, 1); + _slots = new SemaphoreSlim(maxInFlight, maxInFlight); + _stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + + /// Acepta la entrega y vuelve de inmediato; su trabajo corre cuando le toca su turno y hay cupo. + /// El evento de la entrega: ordena las del mismo evento. + /// El trabajo de la entrega; recibe el token que cancela . + public void Enqueue(string eventId, Func work) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task previous; + lock (_gate) + { + previous = _lastByEvent.GetValueOrDefault(eventId) ?? Task.CompletedTask; + _lastByEvent[eventId] = done.Task; + } + + _ = RunAsync(eventId, previous, work, done); + } + + /// Relanza, una vez, la primera excepción que lanzó el trabajo de una entrega. + public void ThrowIfFaulted() => Interlocked.Exchange(ref _fault, null)?.Throw(); + + /// + /// Cancela el trabajo en curso, descarta lo que esperaba su turno y espera a que todo termine: después no queda nada + /// escribiendo en la consola. + /// + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync().ConfigureAwait(false); + Task[] pending; + lock (_gate) + { + pending = [.. _lastByEvent.Values]; + } + + await Task.WhenAll(pending).ConfigureAwait(false); + _stopping.Dispose(); + _slots.Dispose(); + } + + private async Task RunAsync(string eventId, Task previous, Func work, TaskCompletionSource done) + { + try + { + // ForceYielding: el trabajo nunca corre dentro de Enqueue, que así vuelve de inmediato al long-polling. + await previous.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + await _slots.WaitAsync(_stopping.Token).ConfigureAwait(false); + try + { + await work(_stopping.Token).ConfigureAwait(false); + } + finally + { + _slots.Release(); + } + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // La escucha terminó: lo que esperaba turno o cupo no corre; la API lo cierra al vencer su lease. + } + catch (Exception exception) + { + Interlocked.CompareExchange(ref _fault, ExceptionDispatchInfo.Capture(exception), null); + } + finally + { + lock (_gate) + { + if (_lastByEvent.TryGetValue(eventId, out var last) && last == done.Task) + { + _lastByEvent.Remove(eventId); + } + } + + done.SetResult(); + } + } +} diff --git a/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs b/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs new file mode 100644 index 0000000..21bc967 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs @@ -0,0 +1,18 @@ +namespace FiscalApi.Cli.Listen; + +/// +/// Lo que pasó con una entrega, decidido por su destino (): es lo que lleva su acuse y lo que +/// muestra la terminal. +/// +/// El código que se acusa; nulo si el receptor no respondió. +/// Por qué el receptor no respondió, con los nombres de WebhookAttemptError de fapi-back. +/// Lo que tardó la entrega. +/// Si hubo un reenvío cuyo resultado se muestra; sin --forward-to no lo hay. +public sealed record DeliveryOutcome(int? StatusCode, string? Error, int LatencyMs, bool Forwarded) +{ + public static DeliveryOutcome Responded(int statusCode, int latencyMs) => new(statusCode, null, latencyMs, Forwarded: true); + + public static DeliveryOutcome Failed(string error, int latencyMs) => new(null, error, latencyMs, Forwarded: true); + + public CliDeliveryAck ToAck(string sessionId) => new(sessionId, StatusCode, LatencyMs, Error); +} diff --git a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs new file mode 100644 index 0000000..6a9ec11 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs @@ -0,0 +1,103 @@ +using System.Net; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Listen; + +/// +/// El trabajo de una entrega: reenviarla (o solo mostrarla), escribir su línea y acusarla. Todo lo que tiene resultado se +/// acusa, también lo que el receptor no pudo recibir: una entrega sin acuse queda abierta hasta que la API la cierra al +/// vencer su lease, sin reintentos. +/// +public sealed class DeliveryProcessor( + IDeliveryTarget target, + ListenerApi api, + ListenOutput output, + ListenTimings timings, + TimeProvider timeProvider) +{ + /// La entrega, su sesión y el plazo de su acuse. + /// Ctrl+C o el fin de la escucha: cancela el reenvío en curso. + public async Task ProcessAsync(ClaimedDelivery claimed, CancellationToken cancellationToken) + { + // Con el lease vencido la API ya la cerró por AckTimeout: reenviarla tarde no se registraría y su firma podría + // salir de la tolerancia de 5 min del receptor. + if (timeProvider.GetUtcNow() >= claimed.AckDeadline) + { + output.WriteExpired(claimed.Delivery); + return; + } + + var outcome = await target.DeliverAsync(claimed.Delivery, cancellationToken).ConfigureAwait(false); + output.WriteDelivery(claimed.Delivery, outcome); + var ack = outcome.ToAck(claimed.Session.SessionId); + try + { + await AcknowledgeAsync(claimed, ack, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await AcknowledgeOnShutdownAsync(claimed, ack).ConfigureAwait(false); + } + } + + /// + /// Un acuse que falla por la red o con un 5xx se reintenta con backoff mientras la API lo siga esperando; repetirlo es + /// seguro, porque uno que ya quedó registrado responde 409. Cualquier otro rechazo se informa y la escucha sigue. + /// + private async Task AcknowledgeAsync(ClaimedDelivery claimed, CliDeliveryAck ack, CancellationToken cancellationToken) + { + var deliveryId = claimed.Delivery.DeliveryId; + for (var attempt = 1; ; attempt++) + { + try + { + await api.AckAsync(claimed.Session, deliveryId, ack, cancellationToken).ConfigureAwait(false); + return; + } + catch (CliException failure) when (failure.IsTransient && RetryIn(attempt, claimed.AckDeadline) is { } retryIn) + { + await Task.Delay(retryIn, timeProvider, cancellationToken).ConfigureAwait(false); + } + // 409: el acuse llegó tarde (la API ya cerró la entrega) o es de una sesión anterior. No hay nada que la persona + // pueda corregir, así que solo se informa con --verbose. + catch (ApiException notAwaiting) when (notAwaiting.StatusCode == HttpStatusCode.Conflict) + { + output.WriteAckNotRecorded(deliveryId, notAwaiting, verboseOnly: true); + return; + } + catch (CliException failure) + { + output.WriteAckNotRecorded(deliveryId, failure, verboseOnly: false); + return; + } + } + } + + /// + /// Ctrl+C con el resultado ya en la mano: un solo intento, con un plazo propio y corto, para que la entrega no quede + /// como AckTimeout. Si tampoco llega, la API la cierra así; la persona ya pidió salir. + /// + private async Task AcknowledgeOnShutdownAsync(ClaimedDelivery claimed, CliDeliveryAck ack) + { + using var deadline = new CancellationTokenSource(timings.ShutdownAckTimeout, timeProvider); + try + { + await api.AckAsync(claimed.Session, claimed.Delivery.DeliveryId, ack, deadline.Token).ConfigureAwait(false); + } + catch (CliException failure) + { + output.WriteAckNotRecorded(claimed.Delivery.DeliveryId, failure, verboseOnly: true); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + // Sin respuesta en el plazo: la API la cierra por AckTimeout. + } + } + + // Solo si el reintento sale antes de que venza el lease: después la API responde 409 de todos modos. + private TimeSpan? RetryIn(int attempt, DateTimeOffset ackDeadline) + { + var retryIn = timings.Reconnect.Delay(attempt); + return timeProvider.GetUtcNow() + retryIn < ackDeadline ? retryIn : null; + } +} diff --git a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs new file mode 100644 index 0000000..cfc36d3 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -0,0 +1,18 @@ +namespace FiscalApi.Cli.Listen; + +/// Sin --forward-to: la entrega solo se muestra en la terminal. +public sealed class DisplayOnlyTarget : IDeliveryTarget +{ + // La terminal es el destino y la entrega llegó. Se acusa como un 200 inmediato para que el log de + // entregas no la muestre fallida. + private static readonly Task Displayed = + Task.FromResult(new DeliveryOutcome(StatusCode: 200, Error: null, LatencyMs: 0, Forwarded: false)); + + public static DisplayOnlyTarget Instance { get; } = new(); + + private DisplayOnlyTarget() + { + } + + public Task DeliverAsync(CliDelivery delivery, CancellationToken cancellationToken) => Displayed; +} diff --git a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs new file mode 100644 index 0000000..e170e70 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Headers; +using System.Text; + +namespace FiscalApi.Cli.Listen; + +/// +/// Reenvía cada entrega a --forward-to con su propio , nunca el de la API: los headers de +/// credenciales (X-API-KEY, X-TENANT-KEY) no tienen cómo llegar al receptor. +/// +public sealed class HttpForwardTarget : IDeliveryTarget, IDisposable +{ + // UTF-8 sin BOM, como el envío de fapi-back (WebhookHttpSender): la firma es sobre los bytes exactos del cuerpo. + private static readonly UTF8Encoding BodyEncoding = new(encoderShouldEmitUTF8Identifier: false); + private static readonly MediaTypeHeaderValue JsonContentType = new("application/json") { CharSet = "utf-8" }; + + private const string TimeoutError = "Timeout"; + private const string InvalidResponseError = "InvalidResponse"; + + private readonly HttpClient _client; + private readonly Uri _forwardTo; + private readonly IReadOnlyList> _extraHeaders; + private readonly TimeProvider _timeProvider; + + private HttpForwardTarget( + HttpClient client, Uri forwardTo, IReadOnlyList> extraHeaders, TimeProvider timeProvider) + { + _client = client; + _forwardTo = forwardTo; + _extraHeaders = extraHeaders; + _timeProvider = timeProvider; + } + + /// La URL de --forward-to. + /// Los de --headers, ya validados; nunca uno de los firmados. + /// --skip-verify: acepta cualquier certificado, solo en este cliente. + /// El plazo de cada reenvío. + /// Mide la latencia que lleva el acuse. + public static HttpForwardTarget Create( + Uri forwardTo, + IReadOnlyList> extraHeaders, + bool skipVerify, + TimeSpan timeout, + TimeProvider timeProvider) + { + // Un handler propio y no el de la API: --skip-verify relaja TLS hacia el receptor local (un certificado de + // desarrollo) y nunca hacia FiscalAPI. Sin redirecciones, como el envío de fapi-back. + var handler = new SocketsHttpHandler { AllowAutoRedirect = false }; + if (skipVerify) + { + handler.SslOptions.RemoteCertificateValidationCallback = static (_, _, _, _) => true; + } + + var client = new HttpClient(handler, disposeHandler: true) { Timeout = timeout }; + return new HttpForwardTarget(client, forwardTo, extraHeaders, timeProvider); + } + + /// + /// Reenvía la entrega y responde lo que pasó; un receptor que no responde no es una excepción sino un resultado que se + /// acusa. Solo se leen los headers de la respuesta: el cuerpo no se usa. + /// + /// Solo si se canceló (Ctrl+C). + public async Task DeliverAsync(CliDelivery delivery, CancellationToken cancellationToken) + { + var startedAt = _timeProvider.GetTimestamp(); + try + { + using var request = CreateRequest(delivery); + using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + return Outcome((int)response.StatusCode, ElapsedMs(startedAt)); + } + // El timeout de HttpClient llega como cancelación sin que nadie haya cancelado el token de quien llama. + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return DeliveryOutcome.Failed(TimeoutError, ElapsedMs(startedAt)); + } + catch (HttpRequestException exception) + { + return DeliveryOutcome.Failed(Classify(exception.HttpRequestError), ElapsedMs(startedAt)); + } + } + + public void Dispose() => _client.Dispose(); + + private HttpRequestMessage CreateRequest(CliDelivery delivery) + { + // Los bytes del cuerpo tal como se firmaron: sin re-serializar ni normalizar. + var content = new ByteArrayContent(BodyEncoding.GetBytes(delivery.Body)); + content.Headers.ContentType = JsonContentType; + var request = new HttpRequestMessage(HttpMethod.Post, _forwardTo) { Content = content }; + foreach (var (name, value) in delivery.Headers.Concat(_extraHeaders)) + { + request.Headers.Add(name, value); + } + + return request; + } + + // HttpClient acepta cualquier código de tres cifras; la API solo acusa 100..599 (AckWebhookCliDeliveryCommandValidator). + // Otro código no es HTTP válido, como lo clasifica el envío de fapi-back. + private static DeliveryOutcome Outcome(int statusCode, int latencyMs) => + statusCode is >= 100 and <= 599 + ? DeliveryOutcome.Responded(statusCode, latencyMs) + : DeliveryOutcome.Failed(InvalidResponseError, latencyMs); + + // Los mismos nombres que WebhookAttemptError de fapi-back: el log de entregas de la escucha se lee igual que el de un + // endpoint con URL. + private static string Classify(HttpRequestError error) => error switch + { + HttpRequestError.SecureConnectionError => "TlsFailed", + HttpRequestError.InvalidResponse or HttpRequestError.ResponseEnded or HttpRequestError.ConfigurationLimitExceeded + => InvalidResponseError, + _ => "ConnectionFailed" + }; + + private int ElapsedMs(long startedAt) => + (int)Math.Min(_timeProvider.GetElapsedTime(startedAt).TotalMilliseconds, int.MaxValue); +} diff --git a/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs b/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs new file mode 100644 index 0000000..422022e --- /dev/null +++ b/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs @@ -0,0 +1,8 @@ +namespace FiscalApi.Cli.Listen; + +/// A dónde va cada entrega que trae el long-polling. +public interface IDeliveryTarget +{ + /// Entrega y responde lo que pasó. Solo lanza si se canceló . + Task DeliverAsync(CliDelivery delivery, CancellationToken cancellationToken); +} diff --git a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs new file mode 100644 index 0000000..ff6834d --- /dev/null +++ b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Listen; + +/// --json: un objeto JSON por línea en stdout (JSON Lines), para procesarlo con otra herramienta. +public sealed class JsonListenOutput(CliConsole console, bool verbose, TimeProvider timeProvider) + : ListenOutput(console, verbose, timeProvider) +{ + public override void WriteSecret(string secret) => + WriteData(JsonSerializer.Serialize(new ListenSecretRecord(secret), FiscalApiJsonContext.Default.ListenSecretRecord)); + + public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome) + { + var summary = WebhookEventSummary.Of(delivery); + // Sin --forward-to no hay resultado del receptor que mostrar. + var forwarded = outcome.Forwarded ? outcome : null; + var record = new ListenEventRecord( + TimeProvider.GetLocalNow(), + delivery.DeliveryId, + summary.Id, + summary.Type, + forwarded?.StatusCode, + forwarded?.LatencyMs, + forwarded?.Error, + summary.Envelope); + WriteData(JsonSerializer.Serialize(record, FiscalApiJsonContext.Default.ListenEventRecord)); + } +} + +/// La línea de --print-secret --json. +public sealed record ListenSecretRecord(string Secret); + +/// +/// La línea de una entrega con --json. Sin --forward-to no lleva statusCode, latencyMs ni error; con él, +/// statusCode o error, como su acuse. +/// +/// Cuando terminó el reenvío, en la hora local con su desfase. +/// El de webhook-id, o "?" si no vino. +/// El del sobre, o "?" si el cuerpo no es el sobre del evento. +/// El sobre del evento tal como llegó; se omite si el cuerpo no es JSON. +public sealed record ListenEventRecord( + DateTimeOffset Time, + string DeliveryId, + string EventId, + string Type, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? StatusCode, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? LatencyMs, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] JsonElement? Event); diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs new file mode 100644 index 0000000..fc1ac2c --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -0,0 +1,101 @@ +using System.Net; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Listen; + +/// +/// El bucle de fiscalapi listen: abre la sesión y hace long-polling mientras nadie lo detenga. Cada entrega pasa al +/// , así un receptor lento no frena el siguiente long-polling. +/// +public sealed class ListenLoop( + ListenerApi api, + IReadOnlyList enabledEvents, + DeliveryProcessor processor, + ListenOutput output, + ListenTimings timings, + TimeProvider timeProvider) +{ + /// + /// No termina por sí solo: sale con Ctrl+C (, código 130) o con un error que no + /// se resuelve reintentando (un 409 de sesión reemplazada, un 401, un 404), que atrapa . + /// Al salir, el despachador cancela los reenvíos en curso y espera a que terminen. + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + // La primera apertura no se reintenta: un --api-base equivocado o una key sin permiso se informan de inmediato. + CliListenerSession? session = await api.OpenAsync(enabledEvents, cancellationToken).ConfigureAwait(false); + output.WriteReady(session); + + await using var dispatcher = new DeliveryDispatcher(timings.MaxInFlightDeliveries, cancellationToken); + // Cada cuenta se reinicia con el primer long-polling exitoso. + var connectionFailures = 0; + var busyReopens = 0; + var expirationsInARow = 0; + while (true) + { + IReadOnlyList deliveries; + try + { + // Misma escucha y mismo secreto: la sesión nueva solo cambia su sessionId. + session ??= await api.OpenAsync(enabledEvents, cancellationToken).ConfigureAwait(false); + deliveries = await api.PollAsync(session, cancellationToken).ConfigureAwait(false); + } + // 410: la sesión caducó (más de 60 s sin long-polling, p. ej. tras un corte). Se reabre sin terminar; lo que + // pasó mientras tanto no llega y se recupera con events resend. El primero reabre de inmediato; otro + // sin un long-polling exitoso de por medio espera el backoff, para no quedar girando. + catch (ApiException expired) when (expired.StatusCode == HttpStatusCode.Gone) + { + output.WriteSessionExpired(); + session = null; + if (++expirationsInARow > 1) + { + await WaitAsync(timings.Reconnect.Delay(expirationsInARow - 1), cancellationToken).ConfigureAwait(false); + } + + continue; + } + // Con la sesión nula falló la reapertura, no el long-polling: su 409 dice que otra apertura de la misma persona + // tiene tomado el candado de escritura de sus webhooks en el back, y otro intento lo resuelve. El 409 del long-polling + // (otra sesión reemplazó a esta) no entra aquí y termina la escucha con el texto de la API. + catch (ApiException busy) when (session is null && busy.StatusCode == HttpStatusCode.Conflict) + { + var retryIn = timings.Reconnect.Delay(++busyReopens); + output.WriteReopenRetry(busy, retryIn); + await WaitAsync(retryIn, cancellationToken).ConfigureAwait(false); + continue; + } + // Una red caída o un 5xx (un deploy de la API) no terminan la escucha: se reintenta con backoff y jitter. Si + // el corte pasa de 60 s, la sesión caduca y el siguiente long-polling lo dice con un 410. + catch (CliException failure) when (failure.IsTransient) + { + var retryIn = timings.Reconnect.Delay(++connectionFailures); + output.WriteConnectionLost(failure, retryIn); + await WaitAsync(retryIn, cancellationToken).ConfigureAwait(false); + continue; + } + + if (connectionFailures > 0) + { + output.WriteReconnected(); + } + + connectionFailures = busyReopens = expirationsInARow = 0; + Dispatch(dispatcher, session, deliveries); + } + } + + private Task WaitAsync(TimeSpan delay, CancellationToken cancellationToken) => + Task.Delay(delay, timeProvider, cancellationToken); + + private void Dispatch(DeliveryDispatcher dispatcher, CliListenerSession session, IReadOnlyList deliveries) + { + dispatcher.ThrowIfFaulted(); + // El lease corre desde que la API reclamó las entregas, justo antes de responder: se cuenta desde la respuesta. + var ackDeadline = timeProvider.GetUtcNow() + timings.DeliveryLease; + foreach (var delivery in deliveries) + { + var claimed = new ClaimedDelivery(session, delivery, ackDeadline); + dispatcher.Enqueue(delivery.OrderingKey, token => processor.ProcessAsync(claimed, token)); + } + } +} diff --git a/src/FiscalApi.Cli/Listen/ListenOutput.cs b/src/FiscalApi.Cli/Listen/ListenOutput.cs new file mode 100644 index 0000000..0fc542d --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenOutput.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Listen; + +/// +/// Lo que listen escribe en la terminal. Los datos (el secreto de --print-secret y una línea por entrega) van +/// a stdout con el formato de cada subclase; los avisos para la persona van a stderr como texto, también con +/// --json (contrato común). Las entregas terminan en paralelo, así que cada línea se escribe completa bajo un +/// candado. +/// +public abstract class ListenOutput(CliConsole console, bool verbose, TimeProvider timeProvider) +{ + private readonly Lock _gate = new(); + + protected TimeProvider TimeProvider => timeProvider; + + public static ListenOutput For(GlobalSettings settings, CliConsole console, TimeProvider timeProvider) => + settings.Json + ? new JsonListenOutput(console, settings.Verbose, timeProvider) + : new TextListenOutput(console, settings.Verbose, timeProvider); + + /// --print-secret: el secreto solo, para usarlo en un script. + public abstract void WriteSecret(string secret); + + /// Una entrega con lo que pasó al reenviarla. + public abstract void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome); + + /// + /// El texto aprobado con el secreto. Es el único lugar, junto con --print-secret, donde se escribe el + /// secreto; va a stderr porque es para la persona, no un dato del flujo de eventos. + /// + public void WriteReady(CliListenerSession session) => + WriteDiagnostic($"Listo. Tu secreto de firma es {session.Secret} (escucha {session.Id})"); + + public void WriteConnectionLost(CliException failure, TimeSpan retryIn) => + WriteDiagnostic($"Se perdió la conexión con FiscalAPI ({Detail(failure)}); reintentando en {Seconds(retryIn)} s."); + + public void WriteReconnected() => WriteDiagnostic("Conexión con FiscalAPI restablecida."); + + public void WriteSessionExpired() => WriteDiagnostic("La sesión de fiscalapi listen caducó; se abre una nueva."); + + public void WriteReopenRetry(CliException failure, TimeSpan retryIn) => + WriteDiagnostic($"No se pudo reabrir la sesión de escucha ({Detail(failure)}); reintentando en {Seconds(retryIn)} s."); + + /// Una entrega que llegó a su turno con el lease vencido: la API ya la cerró y no se reenvía. + public void WriteExpired(CliDelivery delivery) => + WriteDiagnostic( + $"La entrega {delivery.DeliveryId} ({delivery.EventId ?? WebhookEventSummary.Unknown}) venció antes de " + + "reenviarse; la API la cerró sin acuse."); + + /// La entrega cuyo acuse no quedó registrado. + /// Lo que respondió la API o por qué no respondió. + /// Si solo se escribe con --verbose. + public void WriteAckNotRecorded(string deliveryId, CliException failure, bool verboseOnly) + { + if (verbose || !verboseOnly) + { + WriteDiagnostic($"No se registró el acuse de la entrega {deliveryId}: {failure.Message}"); + } + } + + protected void WriteData(string line) => WriteLine(console.Out, line); + + private static string Detail(CliException failure) => failure.Message.TrimEnd('.'); + + private static string Seconds(TimeSpan delay) => delay.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture); + + private void WriteDiagnostic(string line) => WriteLine(console.Error, line); + + private void WriteLine(TextWriter writer, string line) + { + lock (_gate) + { + writer.WriteLine(line); + } + } +} diff --git a/src/FiscalApi.Cli/Listen/ListenTimings.cs b/src/FiscalApi.Cli/Listen/ListenTimings.cs new file mode 100644 index 0000000..e4ada41 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenTimings.cs @@ -0,0 +1,43 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Listen; + +/// +/// Los plazos de fiscalapi listen. son los del contrato con la API; las pruebas los +/// acortan para no esperar 15 s a un receptor ni 30 s a una reconexión. +/// +/// Timeout del cliente de la API: cubre el long-polling de . +/// La espera que se pide a cada long-polling; la API la acota a 25 s. +/// Timeout de cada reenvío a --forward-to. +/// +/// Espera entre reintentos tras un error de red o un 5xx, tras un 409 al reabrir o entre dos 410 seguidos, con jitter. +/// +/// Entregas que se reenvían a la vez; las que esperan su turno no cuentan. +/// +/// Cuánto espera la API el acuse de una entrega que respondió el long-polling (WebhookConsts.DeliveryLease): +/// después la cierra sin acuse, así que ni se reenvía ni se reintenta su acuse más allá. +/// +/// +/// Al salir con Ctrl+C, el plazo del único intento de acusar lo que ya tenía resultado. +/// +public sealed record ListenTimings( + TimeSpan ApiTimeout, + int PollWaitSeconds, + TimeSpan ForwardTimeout, + ExponentialBackoff Reconnect, + int MaxInFlightDeliveries, + TimeSpan DeliveryLease, + TimeSpan ShutdownAckTimeout) +{ + public static ListenTimings Default { get; } = new( + ApiTimeout: TimeSpan.FromSeconds(35), + PollWaitSeconds: 25, + ForwardTimeout: TimeSpan.FromSeconds(15), + Reconnect: new ExponentialBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)), + // El long-polling nunca espera a los reenvíos (DeliveryDispatcher), así que este cupo solo acota cuántos receptores + // se atienden a la vez; no influye en que la sesión siga viva (60 s sin long-polling). + MaxInFlightDeliveries: 16, + DeliveryLease: TimeSpan.FromMinutes(2), + // Corto: la persona pidió salir. Un acuse que no llega en este plazo lo cierra la API por AckTimeout. + ShutdownAckTimeout: TimeSpan.FromSeconds(2)); +} diff --git a/src/FiscalApi.Cli/Listen/ListenerApi.cs b/src/FiscalApi.Cli/Listen/ListenerApi.cs new file mode 100644 index 0000000..091dc79 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenerApi.cs @@ -0,0 +1,44 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Listen; + +/// Las rutas de la escucha de CLI sobre . +public sealed class ListenerApi(FiscalApiClient client, int pollWaitSeconds) +{ + private const string WebhooksPath = "api/v4/webhooks"; + + /// Abre una sesión nueva, que reemplaza la anterior de la persona, y responde el secreto. + public async Task OpenAsync(IReadOnlyList enabledEvents, CancellationToken cancellationToken) => + await client.PostAsync( + $"{WebhooksPath}/cli-listener", + new OpenCliListenerRequest(enabledEvents), + FiscalApiJsonContext.Default.OpenCliListenerRequest, + FiscalApiJsonContext.Default.ApiResponseCliListenerSession, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("La API abrió la escucha de CLI sin responder la sesión."); + + /// + /// El long-polling: renueva la sesión y espera hasta pollWaitSeconds a que haya entregas. Cada entrega se + /// responde una sola vez y espera su acuse. + /// + public async Task> PollAsync(CliListenerSession session, CancellationToken cancellationToken) => + await client.GetAsync( + $"{ListenerPath(session)}/cli-deliveries?sessionId={Uri.EscapeDataString(session.SessionId)}&waitSeconds={pollWaitSeconds}", + FiscalApiJsonContext.Default.ApiResponseIReadOnlyListCliDelivery, + cancellationToken).ConfigureAwait(false) + ?? []; + + /// + /// El acuse de una entrega con lo que respondió el receptor. La API responde 409 si la entrega ya no lo espera (llegó + /// tarde o es de otra sesión). + /// + public Task AckAsync(CliListenerSession session, string deliveryId, CliDeliveryAck ack, CancellationToken cancellationToken) => + client.PostAsync( + $"{ListenerPath(session)}/cli-deliveries/{Uri.EscapeDataString(deliveryId)}/ack", + ack, + FiscalApiJsonContext.Default.CliDeliveryAck, + FiscalApiJsonContext.Default.ApiResponseBoolean, + cancellationToken); + + private static string ListenerPath(CliListenerSession session) => $"{WebhooksPath}/{Uri.EscapeDataString(session.Id)}"; +} diff --git a/src/FiscalApi.Cli/Listen/ListenerContracts.cs b/src/FiscalApi.Cli/Listen/ListenerContracts.cs new file mode 100644 index 0000000..0f9299d --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Listen; + +// Los contratos de la escucha de CLI de la API (WebhookCliListenerController), con sus mismos nombres. + +/// El cuerpo de POST /api/v4/webhooks/cli-listener (OpenCliListenerCommand). +public sealed record OpenCliListenerRequest(IReadOnlyList EnabledEvents); + +/// +/// La sesión que abre la API (CliListenerSessionDto). Clase y no record: su ToString no debe escribir el +/// secreto si algún día termina en un mensaje. +/// +public sealed class CliListenerSession +{ + /// El id de la escucha: el mismo en cada apertura de la persona. + public required string Id { get; init; } + + /// La sesión abierta: la llevan el long-polling y cada acuse; otra apertura la reemplaza. + public required string SessionId { get; init; } + + /// El secreto whsec_… con el que la API firma las entregas; estable entre sesiones. + public required string Secret { get; init; } +} + +/// Una entrega del long-polling (WebhookCliDeliveryDto): se reenvía tal cual. +/// El id que lleva su acuse. +/// webhook-id, webhook-timestamp y webhook-signature, ya firmados. +/// El sobre del evento: los mismos caracteres que se firmaron. +public sealed record CliDelivery(string DeliveryId, IReadOnlyDictionary Headers, string Body) +{ + /// El id del evento que la API manda en webhook-id; nulo si no vino. + public string? EventId => Headers.GetValueOrDefault(WebhookHeaderNames.Id); + + /// + /// Ordena las entregas del mismo evento. Sin webhook-id (un defecto del contrato) la entrega no espera a ninguna + /// otra, pero igual se muestra y se acusa. + /// + public string OrderingKey => EventId ?? DeliveryId; +} + +/// +/// El cuerpo del acuse (AckWebhookCliDeliveryCommand): lleva o , uno de +/// los dos; el que no aplica no se escribe. +/// +public sealed record CliDeliveryAck( + string SessionId, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? StatusCode, + int LatencyMs, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error); diff --git a/src/FiscalApi.Cli/Listen/TextListenOutput.cs b/src/FiscalApi.Cli/Listen/TextListenOutput.cs new file mode 100644 index 0000000..4d0e8c5 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/TextListenOutput.cs @@ -0,0 +1,25 @@ +using System.Globalization; + +namespace FiscalApi.Cli.Listen; + +/// La salida legible: "2026-10-12 09:47:46 --> invoice.canceled [evt_…] [200]", en la hora local. +public sealed class TextListenOutput(CliConsole console, bool verbose, TimeProvider timeProvider) + : ListenOutput(console, verbose, timeProvider) +{ + public override void WriteSecret(string secret) => WriteData(secret); + + public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome) + { + var summary = WebhookEventSummary.Of(delivery); + var time = TimeProvider.GetLocalNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + WriteData($"{time} --> {summary.Type} [{summary.Id}]{Result(outcome)}"); + } + + // Sin --forward-to no hay resultado que mostrar: la terminal es el destino. + private static string Result(DeliveryOutcome outcome) => outcome switch + { + { Forwarded: false } => "", + { StatusCode: { } statusCode } => $" [{statusCode}]", + _ => $" [error: {outcome.Error}]" + }; +} diff --git a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs new file mode 100644 index 0000000..a891d35 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs @@ -0,0 +1,41 @@ +using System.Text.Json; + +namespace FiscalApi.Cli.Listen; + +/// +/// Lo que la terminal muestra de una entrega: el id del evento (de webhook-id), su tipo y el sobre. Se lee +/// sin tocar el cuerpo, que se reenvía siempre tal cual, y sin fallar: un cuerpo que no es el sobre o una entrega sin +/// webhook-id se muestran con y se acusan como cualquier otra. +/// +/// El sobre completo para --json; nulo si el cuerpo no es JSON. +public sealed record WebhookEventSummary(string Id, string Type, JsonElement? Envelope) +{ + public const string Unknown = "?"; + + public static WebhookEventSummary Of(CliDelivery delivery) + { + var envelope = TryParse(delivery.Body); + return new WebhookEventSummary(delivery.EventId ?? Unknown, TypeOf(envelope), envelope); + } + + private static string TypeOf(JsonElement? envelope) => + envelope is { ValueKind: JsonValueKind.Object } root && + root.TryGetProperty("type", out var type) && + type.ValueKind == JsonValueKind.String + ? type.GetString()! + : Unknown; + + // Clone: el elemento sobrevive al documento que lo leyó. + private static JsonElement? TryParse(string body) + { + try + { + using var document = JsonDocument.Parse(body); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs new file mode 100644 index 0000000..4f01291 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs @@ -0,0 +1,17 @@ +namespace FiscalApi.Cli.Listen; + +/// Los headers de Standard Webhooks que la API firma y el CLI reenvía sin cambios. +public static class WebhookHeaderNames +{ + /// El id del evento. + public const string Id = "webhook-id"; + + public const string Timestamp = "webhook-timestamp"; + + public const string Signature = "webhook-signature"; + + public static bool IsSigned(string name) => + name.Equals(Id, StringComparison.OrdinalIgnoreCase) || + name.Equals(Timestamp, StringComparison.OrdinalIgnoreCase) || + name.Equals(Signature, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/FiscalApi.Cli/Output/LabeledValues.cs b/src/FiscalApi.Cli/Output/LabeledValues.cs new file mode 100644 index 0000000..2d1b2a8 --- /dev/null +++ b/src/FiscalApi.Cli/Output/LabeledValues.cs @@ -0,0 +1,20 @@ +namespace FiscalApi.Cli.Output; + +/// +/// El detalle de un recurso: una línea por dato, con las etiquetas alineadas. Los datos sin valor no se muestran: lo que +/// no aplica (p. ej. el motivo de un webhook habilitado) solo estorbaría. +/// +public static class LabeledValues +{ + private const string Separator = " "; + + public static void Write(TextWriter output, IEnumerable<(string Label, string? Value)> values) + { + var shown = values.Where(value => !string.IsNullOrEmpty(value.Value)).ToList(); + var width = shown.Count == 0 ? 0 : shown.Max(value => value.Label.Length); + foreach (var (label, value) in shown) + { + output.WriteLine($"{label.PadRight(width)}{Separator}{TextTable.Cell(value)}"); + } + } +} diff --git a/src/FiscalApi.Cli/Output/PagedListView.cs b/src/FiscalApi.Cli/Output/PagedListView.cs new file mode 100644 index 0000000..83b92d3 --- /dev/null +++ b/src/FiscalApi.Cli/Output/PagedListView.cs @@ -0,0 +1,26 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Output; + +/// Una página de un listado en la terminal: su tabla, o que no hay datos, y en qué página va. +public static class PagedListView +{ + /// Los encabezados de la tabla. + /// Las celdas de cada elemento, en el orden de . + public static void Write( + TextWriter output, PagedList page, IReadOnlyList headers, Func> row) + { + if (page.Items.Count == 0) + { + output.WriteLine("Sin datos."); + } + else + { + TextTable.Write(output, headers, page.Items.Select(row)); + } + + // Una página fuera de rango llega vacía con totalPages = 0 si no hay nada: "de 0" no le dice nada a la persona. + var footer = $"Página {page.PageNumber} de {Math.Max(page.TotalPages, 1)} ({page.TotalCount} en total)."; + output.WriteLine(page.HasNextPage ? $"{footer} Siguiente: --page {page.PageNumber + 1}" : footer); + } +} diff --git a/src/FiscalApi.Cli/Output/TextTable.cs b/src/FiscalApi.Cli/Output/TextTable.cs new file mode 100644 index 0000000..0d7a065 --- /dev/null +++ b/src/FiscalApi.Cli/Output/TextTable.cs @@ -0,0 +1,37 @@ +namespace FiscalApi.Cli.Output; + +/// +/// Una tabla de texto para la terminal: cada columna mide lo que su valor más largo, separadas por dos espacios y sin +/// espacios al final de la línea. Un valor vacío o nulo se muestra como . +/// +public static class TextTable +{ + public const string Missing = "-"; + + private const string ColumnSeparator = " "; + + public static void Write(TextWriter output, IReadOnlyList headers, IEnumerable> rows) + { + var lines = rows.Select(row => row.Select(Cell).ToArray()).Prepend([.. headers]).ToList(); + var widths = headers.Select((_, column) => lines.Max(line => line[column].Length)).ToArray(); + foreach (var line in lines) + { + output.WriteLine(string.Join(ColumnSeparator, line.Select((cell, column) => cell.PadRight(widths[column]))).TrimEnd()); + } + } + + /// + /// Un salto de línea o un tabulador dentro de un valor (p. ej. una descripción) partiría la fila: se muestran como + /// espacio. + /// + public static string Cell(string? value) => + string.IsNullOrWhiteSpace(value) + ? Missing + : string.Create(value.Length, value, (span, source) => + { + for (var i = 0; i < source.Length; i++) + { + span[i] = char.IsControl(source[i]) ? ' ' : source[i]; + } + }); +} diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs new file mode 100644 index 0000000..1717f50 --- /dev/null +++ b/src/FiscalApi.Cli/Program.cs @@ -0,0 +1,78 @@ +using System.CommandLine; +using System.Runtime.InteropServices; +using System.Text; +using FiscalApi.Cli; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Commands.Events; +using FiscalApi.Cli.Commands.Webhooks; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; +using FiscalApi.Cli.Listen; +using FiscalApi.Cli.Updates; + +// UTF-8 sin BOM: en Windows la consola usa la página de códigos OEM, que rompe los acentos de los mensajes +// de la API y el JSON que se redirige a otra herramienta. En Windows esto cambia la página de códigos de la +// consola misma, no solo la del proceso; por eso se restaura al salir. Fijar UTF-8 solo en los writers no +// sirve: escribirían bytes UTF-8 que la consola interactiva interpretaría con su página OEM. +var originalOutputEncoding = Console.OutputEncoding; +Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); +try +{ + // Composición manual (contrato común): sin contenedor DI, todo se pasa por constructor. + using var cancellation = new CancellationTokenSource(); + + // El primer Ctrl+C cancela la operación en curso y el CLI sale con 130; el segundo deja que el sistema + // termine el proceso, por si algo no respeta la cancelación. + using var interruption = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => + { + context.Cancel = !cancellation.IsCancellationRequested; + cancellation.Cancel(); + }); + + var console = new CliConsole(Console.Out, Console.Error); + var globalOptions = new GlobalOptions(); + + // El almacén del SO en que corre el binario; FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY ganan sobre él. + var credentialStore = CredentialStores.ForCurrentOs(CredentialStores.ProductionService); + var credentials = new CredentialResolver(credentialStore, Environment.GetEnvironmentVariable); + + // Un solo handler (y su pool de conexiones) para todo el proceso; los comandos reciben apiClients por + // constructor y piden su cliente con el timeout que necesitan. + using var httpHandler = FiscalApiClientFactory.CreateHandler(); + var apiClients = new FiscalApiClientFactory(httpHandler, credentials, console.Error, RetryPolicy.Default); + + // Con stdin redirigido no hay una persona que lea un prompt ni teclas que ocultar. + ISecretReader secretReader = Console.IsInputRedirected + ? new RedirectedSecretReader(Console.In) + : new ConsoleSecretReader(console.Error, () => Console.KeyAvailable ? Console.ReadKey(intercept: true) : null); + + // La confirmación de borrar solo se pregunta a una persona en la terminal; un script usa --yes. + var confirmation = new ConsoleConfirmationPrompt(console.Error, Console.In, isInteractive: !Console.IsInputRedirected); + + IReadOnlyList commands = + [ + new LoginCommand(globalOptions, apiClients, credentialStore, secretReader, console), + new LogoutCommand(globalOptions, credentialStore, console), + new ListenCommand(globalOptions, apiClients, console, TimeProvider.System, ListenTimings.Default), + new TriggerCommand(globalOptions, apiClients, console), + new WebhooksCommand(globalOptions, apiClients, confirmation, console), + new EventsCommand(globalOptions, apiClients, console) + ]; + + // El aviso de versión nueva comparte el handler (y el proxy del sistema), pero no las credenciales de la API. + var updateCheck = new UpdateCheck( + httpHandler, + UpdateCheckStateFile.ForCurrentUser(Environment.GetEnvironmentVariable), + Environment.GetEnvironmentVariable, + Console.IsErrorRedirected, + TimeProvider.System, + CliVersion.Current); + + var application = new CliApplication(globalOptions, commands, console, updateCheck); + return await application.RunAsync(args, cancellation.Token); +} +finally +{ + Console.OutputEncoding = originalOutputEncoding; +} diff --git a/src/FiscalApi.Cli/Updates/CliRepository.cs b/src/FiscalApi.Cli/Updates/CliRepository.cs new file mode 100644 index 0000000..4b0b004 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/CliRepository.cs @@ -0,0 +1,18 @@ +namespace FiscalApi.Cli.Updates; + +/// +/// El repositorio público del CLI en GitHub, en un solo lugar: de él salen la consulta del último release y la liga de +/// descarga del aviso. Si el repo cambia de nombre, solo cambia . +/// +internal static class CliRepository +{ + private const string Slug = "FiscalAPI/fiscalapi-cli"; + + /// + /// releases/latest ya descarta borradores y prereleases + /// (docs.github.com/rest/releases/releases#get-the-latest-release). + /// + public static readonly Uri LatestReleaseApi = new($"https://api.github.com/repos/{Slug}/releases/latest"); + + public static Uri ReleasePage(SemanticVersion version) => new($"https://github.com/{Slug}/releases/tag/v{version}"); +} diff --git a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs new file mode 100644 index 0000000..4dd9f25 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs @@ -0,0 +1,11 @@ +namespace FiscalApi.Cli.Updates; + +/// Aviso de versión nueva que corre en paralelo al comando. +public interface IUpdateCheck +{ + /// + /// Empieza la consulta, si toca, y devuelve en seguida. La tarea nunca falla y termina a más tardar en + /// ; su resultado es el aviso que mostrar o null. + /// + Task Start(GlobalSettings settings, CancellationToken cancellationToken); +} diff --git a/src/FiscalApi.Cli/Updates/SemanticVersion.cs b/src/FiscalApi.Cli/Updates/SemanticVersion.cs new file mode 100644 index 0000000..c1e7105 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/SemanticVersion.cs @@ -0,0 +1,127 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace FiscalApi.Cli.Updates; + +/// +/// Versión semántica 2.0.0 (semver.org): la del CLI y la de los tags v* de sus releases. Se compara por +/// precedencia, no como texto: 0.10.0 es más nueva que 0.9.0 y 1.0.0-rc.1 es anterior a 1.0.0. +/// +public sealed partial class SemanticVersion : IComparable +{ + private readonly int _major; + private readonly int _minor; + private readonly int _patch; + private readonly string[] _prerelease; + private readonly string _text; + + private SemanticVersion(int major, int minor, int patch, string[] prerelease, string text) + { + _major = major; + _minor = minor; + _patch = patch; + _prerelease = prerelease; + _text = text; + } + + public bool IsPrerelease => _prerelease.Length > 0; + + /// + /// Acepta una v inicial, como en los tags del repo. Rechaza lo demás que no sea semver estricto: espacios, + /// ceros a la izquierda, dígitos que no son ASCII o números que no caben en . + /// + public static bool TryParse(string? text, [NotNullWhen(true)] out SemanticVersion? version) + { + version = null; + if (text is null) + { + return false; + } + + var candidate = text.StartsWith('v') ? text[1..] : text; + var match = SemVerPattern().Match(candidate); + if (!match.Success + || !TryParseNumber(match.Groups["major"].Value, out var major) + || !TryParseNumber(match.Groups["minor"].Value, out var minor) + || !TryParseNumber(match.Groups["patch"].Value, out var patch)) + { + return false; + } + + var prerelease = match.Groups["prerelease"].Success ? match.Groups["prerelease"].Value.Split('.') : []; + version = new SemanticVersion(major, minor, patch, prerelease, candidate); + return true; + } + + /// Precedencia de semver §11: los metadatos de build (+…) no cuentan. + public int CompareTo(SemanticVersion? other) + { + if (other is null) + { + return 1; + } + + var core = (_major, _minor, _patch).CompareTo((other._major, other._minor, other._patch)); + return core != 0 ? core : ComparePrerelease(_prerelease, other._prerelease); + } + + /// La versión como se escribió, sin la v del tag. + public override string ToString() => _text; + + // Sin prerelease gana: 1.0.0-rc.1 < 1.0.0. Con prerelease se comparan identificador por identificador y, si todos + // coinciden, el conjunto más corto es menor. + private static int ComparePrerelease(string[] left, string[] right) + { + if (left.Length == 0 || right.Length == 0) + { + return right.Length.CompareTo(left.Length); + } + + for (var i = 0; i < Math.Min(left.Length, right.Length); i++) + { + var comparison = CompareIdentifier(left[i], right[i]); + if (comparison != 0) + { + return comparison; + } + } + + return left.Length.CompareTo(right.Length); + } + + // Numérico contra numérico por valor; numérico siempre antes que alfanumérico; alfanuméricos en orden ASCII. + // Los numéricos no traen ceros a la izquierda (lo exige el patrón): comparar la longitud y luego el texto da su + // valor sin desbordar, aunque tengan más dígitos de los que caben en un long. + private static int CompareIdentifier(string left, string right) + { + var leftIsNumeric = IsNumeric(left); + var rightIsNumeric = IsNumeric(right); + if (leftIsNumeric && rightIsNumeric) + { + var byLength = left.Length.CompareTo(right.Length); + return byLength != 0 ? byLength : string.CompareOrdinal(left, right); + } + + if (leftIsNumeric != rightIsNumeric) + { + return leftIsNumeric ? -1 : 1; + } + + return string.CompareOrdinal(left, right); + } + + private static bool IsNumeric(string identifier) => identifier.All(char.IsAsciiDigit); + + private static bool TryParseNumber(string text, out int value) => + int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value); + + // La expresión regular oficial de semver.org con [0-9] en lugar de \d (que en .NET acepta dígitos Unicode) y \z en + // lugar de $ (que también acepta un salto de línea final). + [GeneratedRegex( + @"^(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)\.(?0|[1-9][0-9]*)" + + @"(?:-(?(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + @"(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?\z", + RegexOptions.CultureInvariant)] + private static partial Regex SemVerPattern(); +} diff --git a/src/FiscalApi.Cli/Updates/UpdateCheck.cs b/src/FiscalApi.Cli/Updates/UpdateCheck.cs new file mode 100644 index 0000000..31e3ac4 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/UpdateCheck.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace FiscalApi.Cli.Updates; + +/// +/// Aviso de versión nueva, como el CheckCliVersionAsync del CLI de ABP pero sin bloquear: consulta el último +/// release de GitHub como mucho una vez cada , en paralelo al comando y con un tope de +/// . Un aviso nunca cambia el resultado del comando, así que cualquier falla termina sin aviso. +/// +public sealed class UpdateCheck( + HttpMessageHandler handler, + UpdateCheckStateFile? stateFile, + Func environment, + bool isErrorRedirected, + TimeProvider timeProvider, + string currentVersion) : IUpdateCheck +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); + public static readonly TimeSpan Interval = TimeSpan.FromHours(24); + + /// Apaga el aviso. CI lo definen GitHub Actions, GitLab CI y la mayoría de los CI. + public const string OptOutVariable = "FISCALAPI_NO_UPDATE_CHECK"; + private const string CiVariable = "CI"; + + // Anónimo: el repo es público y el límite de 60 consultas por hora e IP sobra para una al día. + private const string GitHubApiVersion = "2022-11-28"; + + // La respuesta trae las notas del release y la lista de binarios: unos KB. El tope corta algo desmedido. + private const int MaxResponseBytes = 1024 * 1024; + + public Task Start(GlobalSettings settings, CancellationToken cancellationToken) + { + if (IsTurnedOff(settings) || stateFile is null || !SemanticVersion.TryParse(currentVersion, out var current)) + { + return Task.FromResult(null); + } + + return CheckWithinBudgetAsync(stateFile, current, cancellationToken); + } + + // Con --json la salida es para una máquina; con stderr redirigido, el aviso ensuciaría el log de un script. + private bool IsTurnedOff(GlobalSettings settings) => + settings.Json || isErrorRedirected || IsOn(CiVariable) || IsOn(OptOutVariable); + + // Cualquier valor enciende la variable, salvo vacío, "0" o "false". + private bool IsOn(string variable) => + environment(variable) is { Length: > 0 } value && value != "0" && !value.Equals("false", StringComparison.OrdinalIgnoreCase); + + private async Task CheckWithinBudgetAsync(UpdateCheckStateFile state, SemanticVersion current, CancellationToken cancellationToken) + { + using var budget = new CancellationTokenSource(Timeout, timeProvider); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(budget.Token, cancellationToken); + try + { + // Task.Run: leer el archivo de estado y preparar la conexión son síncronos al principio y no deben demorar + // el comando. WaitAsync corta a los 2 s aunque algo no respete la cancelación (un disco de red colgado). + return await Task.Run(() => CheckAsync(state, current, linked.Token), linked.Token).WaitAsync(linked.Token); + } + catch (OperationCanceledException) + { + return null; + } + } + + private async Task CheckAsync(UpdateCheckStateFile state, SemanticVersion current, CancellationToken cancellationToken) + { + try + { + // La fecha se guarda antes de preguntar: una consulta que falla o se corta también cuenta, así una red caída + // no se reintenta en cada comando. Si no se puede guardar, no se pregunta, para no consultar cada vez. + // Una consulta cancelada (Ctrl+C o el tope) antes de empezar no cuenta como la del día. + var now = timeProvider.GetUtcNow(); + if (!state.IsDue(now, Interval) || cancellationToken.IsCancellationRequested || !state.TryRecordCheck(now)) + { + return null; + } + + var latest = await FetchLatestReleaseAsync(cancellationToken); + return latest is not null && latest.CompareTo(current) > 0 ? new UpdateNotice(current, latest) : null; + } + catch (Exception) + { + // Red, TLS, JSON, tope de tamaño, disco o cancelación: el aviso es opcional y nunca se muestra un error suyo. + return null; + } + } + + private async Task FetchLatestReleaseAsync(CancellationToken cancellationToken) + { + // Un cliente propio sobre el handler compartido (mismo proxy del sistema): no lleva los headers con las + // credenciales de FiscalAPI que agrega FiscalApiClientFactory. + using var client = new HttpClient(handler, disposeHandler: false) { MaxResponseContentBufferSize = MaxResponseBytes }; + using var request = new HttpRequestMessage(HttpMethod.Get, CliRepository.LatestReleaseApi); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + request.Headers.Add("X-GitHub-Api-Version", GitHubApiVersion); + // GitHub rechaza las peticiones sin User-Agent. + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("fiscalapi-cli", currentVersion)); + + using var response = await client.SendAsync(request, cancellationToken); + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + var release = await JsonSerializer.DeserializeAsync(body, UpdateCheckJsonContext.Default.GitHubRelease, cancellationToken); + + // Doble defensa: releases/latest no devuelve borradores ni prereleases, pero un tag como v1.0.0-rc.1 publicado + // sin marcarlo como prerelease tampoco se anuncia. + return release is { Draft: false, Prerelease: false } + && SemanticVersion.TryParse(release.TagName, out var version) + && !version.IsPrerelease + ? version + : null; + } +} diff --git a/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs b/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs new file mode 100644 index 0000000..fc5bde5 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Updates; + +/// +/// Lo que el aviso usa de la respuesta de GET /repos/{owner}/{repo}/releases/latest +/// (docs.github.com/rest/releases/releases#get-the-latest-release); lo demás se ignora. +/// +internal sealed record GitHubRelease(string? TagName, bool Draft, bool Prerelease); + +/// Contenido del archivo de estado: solo la fecha de la última consulta, nada secreto. +internal sealed record UpdateCheckState(DateTimeOffset LastCheckedAt); + +/// JSON generado en compilación, como el resto del CLI: la reflexión no funciona en Native AOT. +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +[JsonSerializable(typeof(GitHubRelease))] +[JsonSerializable(typeof(UpdateCheckState))] +internal sealed partial class UpdateCheckJsonContext : JsonSerializerContext; diff --git a/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs b/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs new file mode 100644 index 0000000..387e359 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs @@ -0,0 +1,104 @@ +using System.Text.Json; + +namespace FiscalApi.Cli.Updates; + +/// +/// Archivo del usuario con la fecha de la última consulta de versión nueva. No guarda secretos: perderlo o +/// corromperlo solo adelanta la siguiente consulta. +/// +public sealed class UpdateCheckStateFile(string path) +{ + private const string DirectoryName = "fiscalapi-cli"; + private const string FileName = "update-check.json"; + + public string Path { get; } = path; + + /// + /// La ubicación idiomática de cada SO: %LOCALAPPDATA%\fiscalapi-cli en Windows, + /// ~/Library/Application Support/fiscalapi-cli en macOS ( + /// desde .NET 8) y $XDG_STATE_HOME/fiscalapi-cli en Linux. null si el usuario no tiene dónde guardarlo. + /// + public static UpdateCheckStateFile? ForCurrentUser(Func environment) + { + var directory = OperatingSystem.IsLinux() ? LinuxStateDirectory(environment) : LocalApplicationDataDirectory(); + return directory is null ? null : new UpdateCheckStateFile(System.IO.Path.Combine(directory, FileName)); + } + + /// + /// XDG Base Directory: $XDG_STATE_HOME si es una ruta absoluta (la especificación pide ignorar las relativas) + /// y si no $HOME/.local/state. .NET no tiene un para él. + /// + internal static string? LinuxStateDirectory(Func environment) + { + var stateHome = environment("XDG_STATE_HOME"); + if (stateHome is not null && stateHome.StartsWith('/')) + { + return System.IO.Path.Combine(stateHome, DirectoryName); + } + + var home = environment("HOME"); + return string.IsNullOrEmpty(home) ? null : System.IO.Path.Combine(home, ".local", "state", DirectoryName); + } + + /// + /// Toca consultar si nunca se consultó, si pasó el intervalo o si la fecha guardada está en el futuro (el reloj del + /// equipo se atrasó). Un archivo que no se puede leer no permite respetar el intervalo, así que no toca. + /// + internal bool IsDue(DateTimeOffset now, TimeSpan interval) + { + UpdateCheckState? state; + try + { + state = JsonSerializer.Deserialize(File.ReadAllBytes(Path), UpdateCheckJsonContext.Default.UpdateCheckState); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException or JsonException) + { + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + + return state is null || state.LastCheckedAt > now || now - state.LastCheckedAt >= interval; + } + + /// + /// Guarda la fecha de la consulta. Escribe un temporal y lo mueve encima del archivo: otro proceso del CLI nunca lee + /// un archivo a medio escribir. false si no se pudo guardar (carpeta de solo lectura, disco lleno…). + /// + internal bool TryRecordCheck(DateTimeOffset now) + { + var temporary = $"{Path}.{Guid.NewGuid():N}.tmp"; + try + { + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(new UpdateCheckState(now), UpdateCheckJsonContext.Default.UpdateCheckState)); + File.Move(temporary, Path, overwrite: true); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + DeleteQuietly(temporary); + return false; + } + } + + private static string? LocalApplicationDataDirectory() + { + var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify); + return string.IsNullOrEmpty(root) ? null : System.IO.Path.Combine(root, DirectoryName); + } + + private static void DeleteQuietly(string file) + { + try + { + File.Delete(file); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Un temporal huérfano no rompe nada: el siguiente guardado usa otro nombre. + } + } +} diff --git a/src/FiscalApi.Cli/Updates/UpdateNotice.cs b/src/FiscalApi.Cli/Updates/UpdateNotice.cs new file mode 100644 index 0000000..4924cb1 --- /dev/null +++ b/src/FiscalApi.Cli/Updates/UpdateNotice.cs @@ -0,0 +1,19 @@ +namespace FiscalApi.Cli.Updates; + +/// Hay un release más nuevo que el CLI que corre: qué versión es y de dónde se descarga. +public sealed record UpdateNotice(SemanticVersion Current, SemanticVersion Latest) +{ + /// + /// La página del release se arma con la versión ya validada y no con el html_url de la respuesta: así a la + /// terminal solo llega texto que el CLI controla. + /// + public Uri DownloadUrl => CliRepository.ReleasePage(Latest); + + /// Va a stderr: stdout es de los datos del comando. + public async Task WriteToAsync(TextWriter writer) + { + await writer.WriteLineAsync(); + await writer.WriteLineAsync($"Hay una versión nueva de fiscalapi: {Latest} (tienes {Current})."); + await writer.WriteLineAsync($"Descárgala en {DownloadUrl}"); + } +} diff --git a/src/FiscalApi.Cli/Webhooks/EventsApi.cs b/src/FiscalApi.Cli/Webhooks/EventsApi.cs new file mode 100644 index 0000000..a13a6aa --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/EventsApi.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Text.Json; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Webhooks; + +/// +/// Las rutas de los eventos de webhook (/api/v4/events) sobre . Cada una responde +/// el data tal como lo escribió la API. +/// +public sealed class EventsApi(FiscalApiClient client) +{ + private const string EventsPath = "api/v4/events"; + + // El formato de los instantes que espera fapi-back (ISO-8601 en UTC); sin fracción si no la hay. + private const string UtcInstantFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'"; + + /// GET /events: una página de sobres de los últimos 30 días, del más nuevo al más viejo. + public async Task ListAsync(WebhookEventsQuery query, CancellationToken cancellationToken) + { + KeyValuePair[] filters = + [ + KeyValuePair.Create("type", query.Type), + KeyValuePair.Create("relatedObjectId", query.RelatedObjectId), + KeyValuePair.Create("from", Utc(query.From)), + KeyValuePair.Create("to", Utc(query.To)) + ]; + return ApiData.Required(await client.GetAsync( + EventsPath + QueryString.From(filters.Concat(query.Page.QueryParameters)), + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + } + + /// GET /events/{id}: el sobre exacto que se firmó y se entregó. + public async Task RetrieveAsync(string id, CancellationToken cancellationToken) => + ApiData.Required(await client.GetAsync( + EventPath(id), FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false)); + + /// + /// POST /events/{id}/resend: 202 con la entrega nueva. Sin va sin cuerpo y la + /// API lo entrega a la escucha de CLI de la persona, que tiene que tener un listen abierto. + /// + public async Task ResendAsync(string id, string? webhookId, CancellationToken cancellationToken) + { + var path = $"{EventPath(id)}/resend"; + var data = webhookId is null + ? await client.PostAsync(path, FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken) + .ConfigureAwait(false) + : await client.PostAsync( + path, + new ResendWebhookEventRequest(webhookId), + FiscalApiJsonContext.Default.ResendWebhookEventRequest, + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false); + return ApiData.Required(data); + } + + /// + /// POST /events/trigger: 202 con el evento sintético y su único destino. La API decide si el ambiente lo + /// permite (solo Development y Test) y si el tipo existe. + /// + public async Task TriggerAsync(TriggerWebhookEventRequest request, CancellationToken cancellationToken) => + ApiData.Required(await client.PostAsync( + $"{EventsPath}/trigger", + request, + FiscalApiJsonContext.Default.TriggerWebhookEventRequest, + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + + private static string EventPath(string id) => $"{EventsPath}/{Uri.EscapeDataString(id)}"; + + private static string? Utc(DateTimeOffset? instant) => + instant?.UtcDateTime.ToString(UtcInstantFormat, CultureInfo.InvariantCulture); +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs new file mode 100644 index 0000000..b88ceba --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Webhooks; + +// Los contratos de los webhooks de la API, con sus mismos nombres. Los enums de la API (kind, +// status, disabledReason) se leen como texto: el CLI solo los muestra, y un valor nuevo no debe romper la lectura. + +/// Los valores de kind (WebhookKind en camelCase) que el CLI distingue. +public static class WebhookKinds +{ + /// La escucha de fiscalapi listen: sin URL y no se edita con PUT. + public const string CliListener = "cliListener"; +} + +/// Los valores de status (WebhookStatus en camelCase). +public static class WebhookStatuses +{ + public const string Enabled = "enabled"; + public const string Disabled = "disabled"; +} + +/// +/// Un webhook como lo responde la API (WebhookDto), solo para la salida de texto. Las fechas se guardan como texto: +/// la API las escribe en la zona de la petición y el CLI las muestra tal cual. El secreto del alta y de la rotación no +/// pasa por aquí: se lee del JSON crudo para no depender de que la respuesta encaje en el modelo. +/// +public sealed class Webhook +{ + public required string Id { get; init; } + + public string? Kind { get; init; } + + /// null en una escucha de CLI. + public string? Url { get; init; } + + public string? Description { get; init; } + + /// Los tipos suscritos, o solo * para todos. + public IReadOnlyList EnabledEvents { get; init; } = []; + + /// Las personas cuyos eventos recibe; null recibe los de todas. + public IReadOnlyList? PersonIds { get; init; } + + public string? Status { get; init; } + + public string? DisabledReason { get; init; } + + public string? DisabledAt { get; init; } + + public string? FailingSince { get; init; } + + public string? ListenerPersonId { get; init; } + + public string? SessionExpiresAt { get; init; } + + public string? CreatedAt { get; init; } + + public string? UpdatedAt { get; init; } + + [JsonIgnore] + public bool IsCliListener => Kind == WebhookKinds.CliListener; +} + +/// El cuerpo del alta (CreateWebhookCommand); lo que la persona no dio no se escribe. +public sealed record CreateWebhookRequest( + string Url, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Description, + IReadOnlyList EnabledEvents, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PersonIds); + +/// +/// El cuerpo del reemplazo (UpdateWebhookCommand): PUT escribe todo tal como llega. Sin +/// recibe los eventos de todas las personas, y sin conserva el estado. +/// +public sealed record UpdateWebhookRequest( + string Id, + string? Url, + string Description, + IReadOnlyList EnabledEvents, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PersonIds, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Status); + +/// +/// Lo que la persona pidió cambiar con webhooks update; null es "no lo cambies". Como el PUT reemplaza +/// todo, los cambios se aplican sobre el webhook que se acaba de leer para no borrar lo demás. +/// +/// +/// Una lista vacía (--all-persons) recibe los eventos de todas las personas, igual que en la API. +/// +/// Un valor de . +public sealed record WebhookChanges( + string? Url, + string? Description, + IReadOnlyList? EnabledEvents, + IReadOnlyList? PersonIds, + string? Status) +{ + /// + /// El reemplazo completo: lo pedido y, de lo demás, lo que ya tiene. El estado solo viaja si + /// se pidió, así el PUT no deshace un cambio de estado que otra persona hizo entre la lectura y la escritura. + /// + public UpdateWebhookRequest ApplyTo(Webhook current) => new( + current.Id, + Url ?? current.Url, + Description ?? current.Description ?? "", + EnabledEvents ?? current.EnabledEvents, + PersonIds ?? current.PersonIds, + Status); +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs new file mode 100644 index 0000000..c582891 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Webhooks; + +// Los contratos de los eventos de webhook de la API, con sus mismos nombres. + +/// +/// Lo que la tabla de events list muestra del sobre del evento ({id, type, createdAt, personId, +/// data:{relatedObject, status, previousStatus}}). createdAt es texto: la API lo entrega en UTC y se muestra tal +/// cual. +/// +public sealed class WebhookEventEnvelope +{ + public string? Id { get; init; } + + public string? Type { get; init; } + + public string? CreatedAt { get; init; } + + public string? PersonId { get; init; } + + public WebhookEventData? Data { get; init; } +} + +public sealed class WebhookEventData +{ + public WebhookRelatedObject? RelatedObject { get; init; } +} + +/// El recurso del evento: la factura, la solicitud de descarga, la regla o el webhook. +public sealed class WebhookRelatedObject +{ + public string? Id { get; init; } +} + +/// Los filtros de GET /api/v4/events; los instantes van en UTC. +public sealed record WebhookEventsQuery( + string? Type, + string? RelatedObjectId, + DateTimeOffset? From, + DateTimeOffset? To, + Http.PageRequest Page); + +/// El cuerpo de POST /api/v4/events/{id}/resend cuando se indica el webhook (ResendWebhookEventCommand). +public sealed record ResendWebhookEventRequest(string WebhookId); + +/// El reenvío aceptado (WebhookEventResendDto): con estos ids se sigue en el log de entregas. +public sealed record WebhookEventResend(string DeliveryId, string WebhookId); + +/// +/// El cuerpo de POST /api/v4/events/trigger (TriggerWebhookEventCommand). +/// Lo que la persona no dio no se escribe: para la API, un id vacío es un error y uno omitido elige el valor por +/// omisión (sin , la escucha de CLI de quien llama). +/// +public sealed record TriggerWebhookEventRequest( + string Type, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? RelatedObjectId, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? PersonId, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? WebhookId); + +/// El evento disparado (WebhookEventTriggerDto) y el webhook que lo recibe. +public sealed record WebhookEventTrigger(string EventId, string WebhookId); diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs new file mode 100644 index 0000000..ca0e1d5 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs @@ -0,0 +1,31 @@ +namespace FiscalApi.Cli.Webhooks; + +/// +/// Los tipos de evento que se suscriben, con los nombres de WebhookEventTypes de los SDK. Son la +/// ayuda de autocompletado de trigger, no una validación: el catálogo puede crecer y quien valida es la API. +/// webhook.test no está porque no se suscribe ni se dispara: solo lo manda la prueba de un webhook. +/// +public static class WebhookEventTypes +{ + public const string InvoiceCancellationRequested = "invoice.cancellation_requested"; + + public const string InvoiceCanceled = "invoice.canceled"; + + public const string InvoiceCancellationRejected = "invoice.cancellation_rejected"; + + public const string DownloadRequestCompleted = "download_request.completed"; + + public const string DownloadRequestAbandoned = "download_request.abandoned"; + + public const string DownloadRuleSuspended = "download_rule.suspended"; + + public static IReadOnlyList Subscribable { get; } = + [ + InvoiceCancellationRequested, + InvoiceCanceled, + InvoiceCancellationRejected, + DownloadRequestCompleted, + DownloadRequestAbandoned, + DownloadRuleSuspended + ]; +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs new file mode 100644 index 0000000..636a5ad --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs @@ -0,0 +1,20 @@ +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Output; + +namespace FiscalApi.Cli.Webhooks; + +/// Cómo se ven los eventos de webhook en la terminal. +public static class WebhookEventView +{ + private static readonly string[] Headers = ["ID", "TIPO", "CREADO (UTC)", "RECURSO", "PERSONA"]; + + public static void WriteList(TextWriter output, PagedList page) => + PagedListView.Write(output, page, Headers, envelope => + [envelope.Id, envelope.Type, envelope.CreatedAt, envelope.Data?.RelatedObject?.Id, envelope.PersonId]); + + public static void WriteResend(TextWriter output, WebhookEventResend resend) => + output.WriteLine($"Reenvío creado: entrega {resend.DeliveryId} al webhook {resend.WebhookId}."); + + public static void WriteTrigger(TextWriter output, WebhookEventTrigger trigger) => + output.WriteLine($"Evento disparado: {trigger.EventId} al webhook {trigger.WebhookId}."); +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhookView.cs b/src/FiscalApi.Cli/Webhooks/WebhookView.cs new file mode 100644 index 0000000..0a71643 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookView.cs @@ -0,0 +1,65 @@ +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Output; + +namespace FiscalApi.Cli.Webhooks; + +/// +/// Cómo se ven los webhooks en la terminal. Ninguna vista escribe el secreto: solo +/// , una vez, en el alta y en la rotación. +/// +public static class WebhookView +{ + private static readonly string[] Headers = ["ID", "TIPO", "ESTADO", "URL", "EVENTOS", "DESCRIPCIÓN"]; + + public static void WriteList(TextWriter output, PagedList page) => + PagedListView.Write(output, page, Headers, webhook => + [webhook.Id, webhook.Kind, webhook.Status, webhook.Url, string.Join(',', webhook.EnabledEvents), webhook.Description]); + + public static void WriteDetail(TextWriter output, Webhook webhook) => + LabeledValues.Write(output, + [ + ("ID", webhook.Id), + ("Tipo", webhook.Kind), + ("Estado", webhook.Status), + ("Motivo", webhook.DisabledReason), + ("Deshabilitado", webhook.DisabledAt), + ("Fallando desde", webhook.FailingSince), + ("URL", webhook.Url), + ("Descripción", webhook.Description), + ("Eventos", string.Join(", ", webhook.EnabledEvents)), + // Sin personas, la API le entrega los eventos de todas. + ("Personas", webhook.PersonIds is { Count: > 0 } personIds ? string.Join(", ", personIds) : "todas"), + ("Persona de la escucha", webhook.ListenerPersonId), + ("Sesión vence", webhook.SessionExpiresAt), + ("Creado", webhook.CreatedAt), + ("Actualizado", webhook.UpdatedAt) + ]); + + /// + /// El secreto va a stdout, con el webhook (con --json ya va dentro del data); el aviso de guardarlo + /// (el mismo texto del dashboard) es para la persona y va a stderr, también con --json. Durante una + /// suplantación la API no lo responde y no hay nada que guardar. + /// + /// El de la respuesta, o null si no vino. + /// Si viene de rotate-secret: el anterior sigue firmando 24 horas. + public static void WriteSigningSecret(CliConsole console, string? secret, bool json, bool rotated) + { + if (secret is null) + { + return; + } + + if (!json) + { + console.Out.WriteLine(); + console.Out.WriteLine($"Secreto de firma: {secret}"); + } + + console.Error.WriteLine("Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI."); + if (rotated) + { + console.Error.WriteLine( + "El secreto anterior seguirá firmando durante 24 horas para que actualices tu receptor sin perder eventos."); + } + } +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs b/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs new file mode 100644 index 0000000..5e57dfc --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Webhooks; + +/// +/// Las rutas de los webhooks (/api/v4/webhooks) sobre . Cada una responde +/// el data tal como lo escribió la API: el comando lo imprime así con --json o lo lee para la tabla. +/// +public sealed class WebhooksApi(FiscalApiClient client) +{ + private const string WebhooksPath = "api/v4/webhooks"; + + /// GET /webhooks: una página de webhooks, en orden de alta y sin su secreto. + public async Task ListAsync(PageRequest page, CancellationToken cancellationToken) => + ApiData.Required(await client.GetAsync( + WebhooksPath + QueryString.From(page.QueryParameters), + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + + /// GET /webhooks/{id}: el webhook sin su secreto. + public async Task RetrieveAsync(string id, CancellationToken cancellationToken) => + ApiData.Required(await client.GetAsync( + WebhookPath(id), FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false)); + + /// POST /webhooks: 201 con el webhook y su secreto. + public async Task CreateAsync(CreateWebhookRequest request, CancellationToken cancellationToken) => + ApiData.Required(await client.PostAsync( + WebhooksPath, + request, + FiscalApiJsonContext.Default.CreateWebhookRequest, + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + + /// PUT /webhooks/{id}: reemplazo completo, con el mismo id en la ruta y en el cuerpo. + public async Task ReplaceAsync(UpdateWebhookRequest request, CancellationToken cancellationToken) => + ApiData.Required(await client.PutAsync( + WebhookPath(request.Id), + request, + FiscalApiJsonContext.Default.UpdateWebhookRequest, + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + + /// DELETE /webhooks/{id}: da de baja el webhook y cierra sus entregas pendientes. + public async Task DeleteAsync(string id, CancellationToken cancellationToken) => + ApiData.Required(await client.DeleteAsync( + WebhookPath(id), FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false)); + + /// POST /webhooks/{id}/rotate-secret, sin cuerpo: el webhook con el secreto nuevo. + public async Task RotateSecretAsync(string id, CancellationToken cancellationToken) => + ApiData.Required(await client.PostAsync( + $"{WebhookPath(id)}/rotate-secret", + FiscalApiJsonContext.Default.ApiResponseJsonElement, + cancellationToken).ConfigureAwait(false)); + + private static string WebhookPath(string id) => $"{WebhooksPath}/{Uri.EscapeDataString(id)}"; +} diff --git a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs new file mode 100644 index 0000000..5d72f60 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -0,0 +1,227 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; + +namespace FiscalApi.Cli.Tests; + +/// +/// Códigos de salida del contrato común: 0 éxito, 1 API o red, 2 uso, 130 Ctrl+C. +/// +public sealed class CliApplicationTests +{ + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + [Fact] + public async Task Version_prints_the_informational_version_and_exits_0() + { + var exitCode = await RunAsync(["--version"]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(CliVersion.Current, _out.ToString().Trim()); + } + + [Fact] + public async Task Help_lists_the_global_options_and_exits_0() + { + var exitCode = await RunAsync(["--help"]); + + Assert.Equal(ExitCodes.Success, exitCode); + var help = _out.ToString(); + Assert.Contains("--live", help); + Assert.Contains("--api-base", help); + Assert.Contains("--json", help); + Assert.Contains("--verbose", help); + } + + [Fact] + public async Task Without_arguments_prints_help_and_exits_0() + { + var exitCode = await RunAsync([]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("--api-base", _out.ToString()); + } + + [Fact] + public async Task Without_subcommand_and_with_json_the_help_goes_to_stderr() + { + var exitCode = await RunAsync(["--json"]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Empty(_out.ToString()); + Assert.Contains("--api-base", _error.ToString()); + } + + [Fact] + public async Task Unknown_option_is_a_usage_error() + { + var exitCode = await RunAsync(["--no-existe"]); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--no-existe", _error.ToString()); + Assert.Contains("fiscalapi --help", _error.ToString()); + } + + [Fact] + public async Task Usage_error_leaves_stdout_empty_even_with_json() + { + var exitCode = await RunAsync(["--json", "--no-existe"]); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Empty(_out.ToString()); + } + + [Fact] + public async Task Invalid_api_base_is_a_usage_error() + { + var exitCode = await RunAsync(["--api-base", "localhost:5001"]); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--api-base", _error.ToString()); + } + + [Fact] + public async Task Api_base_with_http_outside_loopback_exits_2_so_keys_never_travel_without_tls() + { + var exitCode = await RunAsync(["--api-base", "http://test.fiscalapi.com"]); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("https", _error.ToString()); + } + + [Fact] + public async Task Usage_exception_from_a_command_exits_2() + { + var exitCode = await RunAsync(["probe"], Throwing(new UsageException("Falta --url."))); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("Falta --url.", _error.ToString()); + } + + [Fact] + public async Task Api_exception_from_a_command_exits_1_and_goes_to_stderr() + { + var error = ApiException.FromEnvelope( + HttpStatusCode.NotFound, + new ApiResponse { Message = "Recurso no encontrado", Details = "", TraceIdentifier = "0HN7:00000003" }); + + var exitCode = await RunAsync(["probe"], Throwing(error)); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Empty(_out.ToString()); + Assert.Contains("Recurso no encontrado", _error.ToString()); + Assert.Contains("0HN7:00000003", _error.ToString()); + } + + [Fact] + public async Task Network_exception_from_a_command_exits_1() + { + var error = new NetworkException("No se pudo conectar con https://test.fiscalapi.com/.", new HttpRequestException()); + + var exitCode = await RunAsync(["probe"], Throwing(error)); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("No se pudo conectar", _error.ToString()); + } + + [Fact] + public async Task Unexpected_exception_exits_1_without_crashing() + { + var exitCode = await RunAsync(["probe"], Throwing(new InvalidOperationException("estado imposible"))); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("estado imposible", _error.ToString()); + } + + [Fact] + public async Task Cancellation_by_ctrl_c_exits_130() + { + using var cancellation = new CancellationTokenSource(); + var probe = new Command("probe"); + probe.SetAction(async (_, cancellationToken) => + { + await cancellation.CancelAsync(); + await Task.Delay(Timeout.Infinite, cancellationToken); + return ExitCodes.Success; + }); + + var exitCode = await RunAsync(["probe"], probe, cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + } + + [Fact] + public async Task Ctrl_c_during_an_api_request_exits_130_and_not_as_a_network_error() + { + using var cancellation = new CancellationTokenSource(); + var client = FiscalApiClientBuilder.Create(new StubHttpMessageHandler().NeverRespond()); + + var exitCode = await RunAsync(["probe"], CallingApi(client, cancellation), cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Empty(_error.ToString()); + } + + [Fact] + public async Task Success_response_with_data_of_another_shape_exits_1_showing_the_exception() + { + var client = FiscalApiClientBuilder.Create(new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.OK, """{"succeeded":true,"data":"no es un objeto"}""")); + + var exitCode = await RunAsync(["probe"], CallingApi(client)); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("JsonException", _error.ToString()); + Assert.DoesNotContain("Respuesta inesperada", _error.ToString()); + } + + [Fact] + public async Task Command_exit_code_is_returned_as_is() + { + var probe = new Command("probe"); + probe.SetAction((_, _) => Task.FromResult(ExitCodes.Success)); + + var exitCode = await RunAsync(["probe"], probe); + + Assert.Equal(ExitCodes.Success, exitCode); + } + + private static Command Throwing(Exception exception) + { + var probe = new Command("probe"); + probe.SetAction((_, _) => Task.FromException(exception)); + return probe; + } + + /// Un comando que hace un GET; si recibe , simula Ctrl+C en pleno request. + private static Command CallingApi(FiscalApiClient client, CancellationTokenSource? cancelAfterStart = null) + { + var probe = new Command("probe"); + probe.SetAction(async (_, cancellationToken) => + { + var request = client.GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, cancellationToken); + if (cancelAfterStart is not null) + { + await cancelAfterStart.CancelAsync(); + } + + await request; + return ExitCodes.Success; + }); + return probe; + } + + private Task RunAsync(string[] args, Command? command = null, CancellationToken cancellationToken = default) + { + var application = new CliApplication( + new GlobalOptions(), + command is null ? [] : [command], + new CliConsole(_out, _error), + NoUpdateCheck.Instance); + return application.RunAsync(args, cancellationToken); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs new file mode 100644 index 0000000..0f4ff3d --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -0,0 +1,97 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Nodes; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Commands.Events; +using FiscalApi.Cli.Commands.Webhooks; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; +using FiscalApi.Cli.Tests.Credentials; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// Corre fiscalapi webhooks …, fiscalapi events … y fiscalapi trigger … con la misma composición que Program, sobre +/// la API simulada () y las credenciales de las variables de entorno. +/// +internal sealed class ApiCommandHarness +{ + public const string ApiKey = "sk_test_clave-de-comandos"; + public const string TenantKey = "tenant-de-comandos-0001"; + + public StubHttpMessageHandler Api { get; } = new(); + + public string Out => _out.ToString(); + + public string Error => _error.ToString(); + + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + /// El comando sin --api-base, que se agrega aquí. + /// Lo que la persona escribe al confirmar (delete sin --yes). + /// Si hay una persona en la terminal; con stdin redirigido, no. + /// Otra confirmación en lugar de la de consola (p. ej. una que simula Ctrl+C). + /// El token que Program cancela con Ctrl+C. + public Task RunAsync( + IEnumerable args, + string confirmationInput = "", + bool interactive = false, + IConfirmationPrompt? confirmation = null, + CancellationToken cancellationToken = default) + { + var globalOptions = new GlobalOptions(); + var console = new CliConsole(_out, _error); + var credentials = new CredentialResolver(new InMemoryCredentialStore(), name => name switch + { + CredentialResolver.ApiKeyVariable => ApiKey, + CredentialResolver.TenantKeyVariable => TenantKey, + _ => null + }); + var apiClients = new FiscalApiClientFactory(Api, credentials, console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); + confirmation ??= new ConsoleConfirmationPrompt(console.Error, new StringReader(confirmationInput), interactive); + Command[] commands = + [ + new WebhooksCommand(globalOptions, apiClients, confirmation, console), + new EventsCommand(globalOptions, apiClients, console), + new TriggerCommand(globalOptions, apiClients, console) + ]; + + return new CliApplication(globalOptions, commands, console, NoUpdateCheck.Instance) + .RunAsync([.. args, "--api-base", FiscalApiClientBuilder.BaseAddress.ToString()], cancellationToken); + } + + public Task RunAsync(params string[] args) => RunAsync((IEnumerable)args); + + /// El envelope de éxito de fapi-back con tal cual. + public static string Success(string data) => $$"""{"data":{{data}},"succeeded":true,"message":"","details":""}"""; + + /// La única request que recibió la API. + public HttpRequestMessage SingleRequest() => Assert.Single(Api.Requests); + + public JsonNode? Body(int index = 0) => Api.RequestBodies[index] is { } body ? JsonNode.Parse(body) : null; + + /// Stdout es exactamente más el salto de línea, y es JSON válido. + public void AssertJsonOut(string rawData) + { + Assert.Equal(rawData + Environment.NewLine, Out); + using var _ = JsonDocument.Parse(Out); + } + + public static void AssertJsonEqual(string expected, JsonNode? actual) => + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expected), actual), $"Se esperaba {expected} y llegó {actual?.ToJsonString()}"); + + public static int Occurrences(string text, string value) + { + var count = 0; + for (var index = text.IndexOf(value, StringComparison.Ordinal); index >= 0; index = text.IndexOf(value, index + value.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs new file mode 100644 index 0000000..5d2963a --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs @@ -0,0 +1,252 @@ +using System.Net; +using System.Text; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// fiscalapi events list|retrieve|resend contra las rutas de EventsController de la API, +/// simuladas con . retrieve imprime el sobre exactamente como lo entregó la +/// API. +/// +public sealed class EventsCommandTests +{ + private const string EventsUrl = "https://api.example.test/api/v4/events"; + private const string EventId = "0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f"; + + // El sobre del evento como lo guarda y entrega la API (WebhookEnvelopeSerializer): sin espacios y con las nulas. + private const string Envelope = + $$$"""{"id":"{{{EventId}}}","type":"invoice.canceled","createdAt":"2026-10-12T15:47:46Z","personId":"persona-1","data":{"relatedObject":{"id":"factura-1","type":"invoice","url":"/api/v4/invoices/factura-1"},"status":"X","previousStatus":"C"}}"""; + + private const string OtherEnvelope = + """{"id":"evt-2","type":"webhook.test","createdAt":"2026-10-12T15:40:00Z","personId":null,"data":{"relatedObject":{"id":"we-1","type":"webhook","url":"/api/v4/webhooks/we-1"},"status":null,"previousStatus":null}}"""; + + private const string Page = + $$"""{"items":[{{Envelope}},{{OtherEnvelope}}],"pageNumber":1,"totalPages":1,"totalCount":2,"hasPreviousPage":false,"hasNextPage":false}"""; + + private readonly ApiCommandHarness _cli = new(); + + // ---------------------------------------------------------------- list + + [Fact] + public async Task List_gets_the_events_without_query_by_default() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + var exitCode = await _cli.RunAsync("events", "list"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal(new Uri(EventsUrl), request.RequestUri); + } + + [Fact] + public async Task List_sends_every_filter_with_the_dates_in_utc() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync( + "events", "list", + "--type", "invoice.canceled", + "--related-object-id", "factura 1", + "--from", "2026-10-01T08:00:00-06:00", + "--to", "2026-10-13T23:59:59.5Z", + "--page", "3", + "--page-size", "20"); + + Assert.Equal( + $"{EventsUrl}?type=invoice.canceled&relatedObjectId=factura%201&from=2026-10-01T14%3A00%3A00Z" + + "&to=2026-10-13T23%3A59%3A59.5Z&pageNumber=3&pageSize=20", + _cli.SingleRequest().RequestUri!.AbsoluteUri); + } + + // La API compara to incluido (createdAt <= to): con solo la fecha, el último instante de ese día en UTC. + [Theory] + [InlineData("2026-10-13", "2026-10-13T23%3A59%3A59.9999999Z")] + [InlineData("2026-12-31", "2026-12-31T23%3A59%3A59.9999999Z")] + [InlineData("2026-10-13T00:00", "2026-10-13T00%3A00%3A00Z")] + [InlineData("2026-10-13T08:30:15-06:00", "2026-10-13T14%3A30%3A15Z")] + public async Task To_with_only_a_date_includes_that_whole_day(string to, string sent) + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("events", "list", "--to", to); + + Assert.Equal($"{EventsUrl}?to={sent}", _cli.SingleRequest().RequestUri!.AbsoluteUri); + } + + [Theory] + [InlineData("2026-10-01", "2026-10-01T00%3A00%3A00Z")] + [InlineData("2026-10-01T08:30", "2026-10-01T08%3A30%3A00Z")] + [InlineData("2026-10-01T08:30:15", "2026-10-01T08%3A30%3A15Z")] + [InlineData("2026-10-01T08:30:15+02:00", "2026-10-01T06%3A30%3A15Z")] + public async Task A_date_without_zone_is_utc(string from, string sent) + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("events", "list", "--from", from); + + Assert.Equal($"{EventsUrl}?from={sent}", _cli.SingleRequest().RequestUri!.AbsoluteUri); + } + + [Theory] + [InlineData("--from", "10/01/2026")] + [InlineData("--to", "ayer")] + [InlineData("--to", "2026-13-01")] + public async Task A_date_that_is_not_iso_8601_is_a_usage_error_without_calling_the_api(string option, string value) + { + var exitCode = await _cli.RunAsync("events", "list", option, value); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains($"{option} debe ser una fecha ISO 8601", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task List_prints_a_table_with_one_row_per_event() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("events", "list"); + + var lines = _cli.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Matches(@"^ID\s+TIPO\s+CREADO \(UTC\)\s+RECURSO\s+PERSONA$", lines[0]); + Assert.Matches($@"^{EventId}\s+invoice\.canceled\s+2026-10-12T15:47:46Z\s+factura-1\s+persona-1$", lines[1]); + Assert.Matches(@"^evt-2\s+webhook.test\s+2026-10-12T15:40:00Z\s+we-1\s+-$", lines[2]); + Assert.Equal("Página 1 de 1 (2 en total).", lines[3]); + } + + [Fact] + public async Task List_with_json_prints_the_data_unchanged() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("events", "list", "--json"); + + _cli.AssertJsonOut(Page); + } + + [Fact] + public async Task List_rejected_by_validation_exits_1_with_the_failure_text() + { + _cli.Api.RespondJson(HttpStatusCode.BadRequest, + """{"data":[{"propertyName":"From","errorMessage":"El rango de fechas no puede exceder 30 días.","attemptedValue":"2026-08-01T00:00:00Z"}],"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"One or more validation failures have occurred."}"""); + + var exitCode = await _cli.RunAsync("events", "list", "--from", "2026-08-01"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("El rango de fechas no puede exceder 30 días.", _cli.Error); + } + + // ---------------------------------------------------------------- retrieve + + [Fact] + public async Task Retrieve_gets_the_event_by_its_id() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Envelope)); + + var exitCode = await _cli.RunAsync("events", "retrieve", EventId); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal(new Uri($"{EventsUrl}/{EventId}"), request.RequestUri); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Retrieve_prints_the_envelope_byte_for_byte(bool json) + { + // Espacios, escapes y caracteres fuera de ASCII que una re-serialización cambiaría. + const string envelope = + "{ \"id\" : \"evt-1\",\n \"type\":\"invoice.canceled\", \"nota\":\"Facturación de ñandú: 🚀\", " + + "\"escape\":\"\\u00e9\\\"\\/\", \"monto\": 1.50e2 }"; + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(envelope)); + + await _cli.RunAsync(json ? ["events", "retrieve", "evt-1", "--json"] : ["events", "retrieve", "evt-1"]); + + Assert.Equal(Encoding.UTF8.GetBytes(envelope + Environment.NewLine), Encoding.UTF8.GetBytes(_cli.Out)); + } + + [Fact] + public async Task Retrieve_of_a_missing_event_exits_1_with_the_api_text() + { + _cli.Api.RespondJson(HttpStatusCode.NotFound, + $$"""{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"No such resource for the given Id: {{EventId}}"}"""); + + var exitCode = await _cli.RunAsync("events", "retrieve", EventId); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"No such resource for the given Id: {EventId}", _cli.Error); + Assert.Empty(_cli.Out); + } + + // ---------------------------------------------------------------- resend + + [Fact] + public async Task Resend_without_webhook_posts_without_body_so_it_goes_to_the_cli_listener() + { + _cli.Api.RespondJson(HttpStatusCode.Accepted, + ApiCommandHarness.Success("""{"deliveryId":"entrega-1","webhookId":"escucha-1"}""")); + + var exitCode = await _cli.RunAsync("events", "resend", EventId); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(new Uri($"{EventsUrl}/{EventId}/resend"), request.RequestUri); + Assert.Null(_cli.Api.RequestBodies[0]); + Assert.Contains("entrega-1", _cli.Out); + Assert.Contains("escucha-1", _cli.Out); + } + + [Fact] + public async Task Resend_with_webhook_sends_its_id() + { + _cli.Api.RespondJson(HttpStatusCode.Accepted, + ApiCommandHarness.Success("""{"deliveryId":"entrega-1","webhookId":"we-1"}""")); + + await _cli.RunAsync("events", "resend", EventId, "--webhook", "we-1"); + + ApiCommandHarness.AssertJsonEqual("""{"webhookId":"we-1"}""", _cli.Body()); + } + + [Fact] + public async Task Resend_with_json_prints_the_data_unchanged() + { + const string accepted = """{"deliveryId":"entrega-1","webhookId":"we-1"}"""; + _cli.Api.RespondJson(HttpStatusCode.Accepted, ApiCommandHarness.Success(accepted)); + + await _cli.RunAsync("events", "resend", EventId, "--webhook", "we-1", "--json"); + + _cli.AssertJsonOut(accepted); + } + + [Fact] + public async Task Resend_without_a_listen_session_exits_1_with_the_api_text() + { + _cli.Api.RespondJson(HttpStatusCode.Conflict, + """{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"La escucha de CLI no está conectada; ejecuta fiscalapi listen.","traceIdentifier":"0HN7:00000409"}"""); + + var exitCode = await _cli.RunAsync("events", "resend", EventId); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("La escucha de CLI no está conectada; ejecuta fiscalapi listen.", _cli.Error); + Assert.Contains("Código HTTP: 409", _cli.Error); + Assert.Contains("0HN7:00000409", _cli.Error); + Assert.Single(_cli.Api.Requests); + } + + [Fact] + public async Task Resend_is_never_retried() + { + _cli.Api.RespondHtml(HttpStatusCode.BadGateway, "502"); + + var exitCode = await _cli.RunAsync("events", "resend", EventId); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Single(_cli.Api.Requests); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs new file mode 100644 index 0000000..2cd823d --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -0,0 +1,283 @@ +using System.Net; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; +using FiscalApi.Cli.Tests.Credentials; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Input; +using FiscalApi.Cli.Tests.Updates; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// fiscalapi login lee la API key y la tenant key (por stdin redirigido o tecla por tecla), las valida con +/// GET /api/v4/me/features y las guarda por host. La API se simula con . +/// +public sealed class LoginCommandTests +{ + private const string ApiKey = "sk_test_clave-de-login"; + private const string TenantKey = "tenant-de-login-0001"; + + private static readonly string Host = FiscalApiClientBuilder.BaseAddress.Authority; + + private readonly InMemoryCredentialStore _store = new(); + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + [Fact] + public async Task Valid_credentials_are_saved_for_the_host_and_reported() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains($"Listo. Credenciales guardadas para {Host}.", _error.ToString()); + Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); + Assert.DoesNotContain("Webhooks.Manage", _error.ToString()); + } + + [Fact] + public async Task Credentials_are_validated_against_me_features_with_their_headers() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal(new Uri("https://api.example.test/api/v4/me/features"), request.RequestUri); + Assert.Equal([ApiKey], request.Headers.GetValues("X-API-KEY")); + Assert.Equal([TenantKey], request.Headers.GetValues("X-TENANT-KEY")); + } + + [Fact] + public async Task Rejected_credentials_are_not_saved_and_exit_1() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.Unauthorized, + """{"succeeded":false,"message":"Invalid API KEY","details":"","traceIdentifier":"0HN7:00000401"}"""); + + var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"Las credenciales no son válidas para {Host}.", _error.ToString()); + Assert.Empty(_store.Entries); + } + + [Fact] + public async Task Rejection_does_not_overwrite_the_credentials_already_saved() + { + var saved = ApiCredentials.Create("sk_test_ya-guardada", "tenant-ya-guardado"); + _store.Save(Host, saved); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.Unauthorized, + """{"succeeded":false,"message":"Invalid tenant key","details":""}"""); + + await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); + + Assert.Equal(saved, _store.Read(Host)); + } + + [Fact] + public async Task Without_webhooks_manage_it_warns_and_still_saves() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithoutWebhooksManage); + + var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains( + "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403.", + _error.ToString()); + Assert.Contains($"Listo. Credenciales guardadas para {Host}.", _error.ToString()); + Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); + } + + [Fact] + public async Task Without_a_store_fails_with_the_approved_text_before_asking_for_keys_or_calling_the_api() + { + var handler = new StubHttpMessageHandler(); + var keys = new ScriptedKeys($"{ApiKey}\r{TenantKey}\r"); + + var exitCode = await RunAsync(handler, InteractiveReader(keys), new UnavailableCredentialStore()); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains( + "No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY.", + _error.ToString()); + Assert.DoesNotContain("API key: ", _error.ToString()); + Assert.Equal(0, keys.Read); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task Locked_store_fails_before_asking_for_keys_or_calling_the_api() + { + var handler = new StubHttpMessageHandler(); + var keys = new ScriptedKeys($"{ApiKey}\r{TenantKey}\r"); + + var exitCode = await RunAsync(handler, InteractiveReader(keys), new LockedCredentialStore()); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("El almacén de credenciales del sistema existe, pero está bloqueado o no respondió.", _error.ToString()); + Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); + Assert.Equal(0, keys.Read); + Assert.Empty(handler.Requests); + } + + [Theory] + [InlineData("\r")] + [InlineData("sk_test_con eñe\r")] + public async Task Invalid_api_key_fails_before_asking_for_the_tenant_key(string typed) + { + var handler = new StubHttpMessageHandler(); + + var exitCode = await RunAsync(handler, InteractiveReader(new ScriptedKeys(typed + $"{TenantKey}\r"))); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("La API key", _error.ToString()); + Assert.Contains("API key: ", _error.ToString()); + Assert.DoesNotContain("Tenant key: ", _error.ToString()); + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task Interactive_input_saves_the_typed_keys_without_echoing_them() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunAsync(handler, InteractiveReader(new ScriptedKeys($"{ApiKey}\r{TenantKey}\r"))); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); + Assert.DoesNotContain(ApiKey, _out.ToString() + _error.ToString()); + Assert.DoesNotContain(TenantKey, _out.ToString() + _error.ToString()); + } + + [Fact] + public async Task Ctrl_c_while_waiting_for_a_key_exits_130() + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + var handler = new StubHttpMessageHandler(); + + var exitCode = await RunAsync(handler, InteractiveReader(new ScriptedKeys("")), cancellationToken: cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Empty(handler.Requests); + Assert.Empty(_store.Entries); + } + + [Fact] + public async Task Redirected_input_with_a_byte_order_mark_is_accepted() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunAsync(handler, $"\uFEFF{ApiKey}\n{TenantKey}\n"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); + } + + [Fact] + public async Task Redirected_input_is_read_line_by_line_and_trimmed() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunAsync(handler, $" {ApiKey} \r\n\t{TenantKey}\r\n"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); + } + + [Theory] + [InlineData("")] + [InlineData("\n\n")] + [InlineData("sk_test_sin-tenant\n")] + public async Task Missing_or_empty_input_is_a_usage_error_without_calling_the_api(string input) + { + var handler = new StubHttpMessageHandler(); + + var exitCode = await RunAsync(handler, input); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Empty(handler.Requests); + Assert.Empty(_store.Entries); + } + + [Fact] + public async Task Credentials_never_reach_stdout_or_stderr_even_with_verbose() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithoutWebhooksManage); + + var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n", verbose: true); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("> GET https://api.example.test/api/v4/me/features", _error.ToString()); + var output = _out.ToString() + _error.ToString(); + Assert.DoesNotContain(ApiKey, output); + Assert.DoesNotContain(TenantKey, output); + } + + [Fact] + public async Task Stdout_stays_empty_so_json_mode_is_not_polluted() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n", json: true); + + Assert.Empty(_out.ToString()); + } + + [Fact] + public async Task Credentials_are_saved_per_environment_host() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n", apiBase: "http://localhost:5000"); + + Assert.Equal(["localhost:5000"], _store.Entries.Keys); + Assert.Contains("Listo. Credenciales guardadas para localhost:5000.", _error.ToString()); + } + + private ConsoleSecretReader InteractiveReader(ScriptedKeys keys) => new(_error, keys.TryRead); + + private Task RunAsync( + StubHttpMessageHandler handler, + string input, + ICredentialStore? store = null, + bool verbose = false, + bool json = false, + string? apiBase = null) => + RunAsync(handler, new RedirectedSecretReader(new StringReader(input)), store, verbose, json, apiBase); + + private Task RunAsync( + StubHttpMessageHandler handler, + ISecretReader input, + ICredentialStore? store = null, + bool verbose = false, + bool json = false, + string? apiBase = null, + CancellationToken cancellationToken = default) + { + var globalOptions = new GlobalOptions(); + var console = new CliConsole(_out, _error); + var credentialStore = store ?? _store; + // Las variables de entorno no intervienen en login: la persona da las credenciales que se validan y guardan. + var apiClients = new FiscalApiClientFactory( + handler, new CredentialResolver(credentialStore, _ => null), console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); + var login = new LoginCommand(globalOptions, apiClients, credentialStore, input, console); + + List args = ["login", "--api-base", apiBase ?? FiscalApiClientBuilder.BaseAddress.ToString()]; + if (verbose) + { + args.Add("--verbose"); + } + + if (json) + { + args.Add("--json"); + } + + return new CliApplication(globalOptions, [login], console, NoUpdateCheck.Instance).RunAsync(args, cancellationToken); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs new file mode 100644 index 0000000..3bde647 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -0,0 +1,81 @@ +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Tests.Credentials; +using FiscalApi.Cli.Tests.Updates; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// fiscalapi logout es local (no recibe cliente HTTP). Termina con éxito si borró o si no había +/// nada guardado; un almacén que existe pero falla (bloqueado, sin respuesta) es un error con código 1. +/// +public sealed class LogoutCommandTests +{ + private readonly InMemoryCredentialStore _store = new(); + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + [Fact] + public async Task Deletes_only_the_credentials_of_the_selected_environment() + { + var live = ApiCredentials.Create("sk_live_clave", "tenant-live"); + _store.Save("test.fiscalapi.com", ApiCredentials.Create("sk_test_clave", "tenant-test")); + _store.Save("live.fiscalapi.com", live); + + var exitCode = await RunAsync(["logout"]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Null(_store.Read("test.fiscalapi.com")); + Assert.Equal(live, _store.Read("live.fiscalapi.com")); + Assert.Contains("Credenciales borradas para test.fiscalapi.com.", _error.ToString()); + Assert.Empty(_out.ToString()); + } + + [Fact] + public async Task Live_selects_the_live_entry() + { + _store.Save("live.fiscalapi.com", ApiCredentials.Create("sk_live_clave", "tenant-live")); + + await RunAsync(["logout", "--live"]); + + Assert.Empty(_store.Entries); + } + + [Fact] + public async Task Without_saved_credentials_still_succeeds() + { + var exitCode = await RunAsync(["logout"]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("No había credenciales guardadas para test.fiscalapi.com.", _error.ToString()); + } + + [Fact] + public async Task Without_a_store_still_succeeds_because_nothing_could_be_saved() + { + var exitCode = await RunAsync(["logout"], new UnavailableCredentialStore()); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("No había credenciales guardadas para test.fiscalapi.com.", _error.ToString()); + } + + [Fact] + public async Task Locked_store_reports_that_it_could_not_delete_and_exits_1() + { + var exitCode = await RunAsync(["logout"], new LockedCredentialStore()); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("No se pudieron borrar las credenciales guardadas para test.fiscalapi.com.", _error.ToString()); + Assert.Contains("El almacén de credenciales del sistema existe, pero está bloqueado o no respondió.", _error.ToString()); + Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); + Assert.DoesNotContain("No había credenciales", _error.ToString()); + } + + private Task RunAsync(string[] args, ICredentialStore? store = null) + { + var globalOptions = new GlobalOptions(); + var console = new CliConsole(_out, _error); + var logout = new LogoutCommand(globalOptions, store ?? _store, console); + return new CliApplication(globalOptions, [logout], console, NoUpdateCheck.Instance).RunAsync(args, CancellationToken.None); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs new file mode 100644 index 0000000..9895515 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs @@ -0,0 +1,184 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// fiscalapi trigger contra POST /api/v4/events/trigger de la API, +/// simulada con . Quien valida el tipo y el ambiente es la +/// API: el CLI solo arma la petición y muestra su respuesta. +/// +public sealed class TriggerCommandTests +{ + private const string TriggerUrl = "https://api.example.test/api/v4/events/trigger"; + private const string EventId = "0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f"; + private const string Accepted = $$"""{"eventId":"{{EventId}}","webhookId":"escucha-1"}"""; + + private readonly ApiCommandHarness _cli = new(); + + [Fact] + public async Task Trigger_posts_only_the_type_so_the_api_sends_it_to_the_cli_listener() + { + _cli.Api.RespondJson(HttpStatusCode.Accepted, ApiCommandHarness.Success(Accepted)); + + var exitCode = await _cli.RunAsync("trigger", "invoice.canceled"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(new Uri(TriggerUrl), request.RequestUri); + // Sin --webhook no viaja webhookId (ni las demás opciones): la API elige la escucha de quien llama. + ApiCommandHarness.AssertJsonEqual("""{"type":"invoice.canceled"}""", _cli.Body()); + } + + [Fact] + public async Task Trigger_sends_every_option_with_the_api_names() + { + _cli.Api.RespondJson(HttpStatusCode.Accepted, + ApiCommandHarness.Success($$"""{"eventId":"{{EventId}}","webhookId":"we-1"}""")); + + await _cli.RunAsync( + "trigger", "download_rule.suspended", + "--related-object-id", "regla-1", + "--person-id", "persona-1", + "--webhook", "we-1"); + + ApiCommandHarness.AssertJsonEqual( + """{"type":"download_rule.suspended","relatedObjectId":"regla-1","personId":"persona-1","webhookId":"we-1"}""", + _cli.Body()); + } + + [Fact] + public async Task Trigger_prints_the_event_id_and_its_destination() + { + _cli.Api.RespondJson(HttpStatusCode.Accepted, ApiCommandHarness.Success(Accepted)); + + await _cli.RunAsync("trigger", "invoice.canceled"); + + Assert.Equal($"Evento disparado: {EventId} al webhook escucha-1.{Environment.NewLine}", _cli.Out); + Assert.Empty(_cli.Error); + } + + [Fact] + public async Task Trigger_with_json_prints_the_data_unchanged() + { + // Espacios y un campo que el CLI no conoce: --json no re-serializa. + const string accepted = $$"""{ "eventId" : "{{EventId}}", "webhookId":"escucha-1", "nuevo": 1.50e2 }"""; + _cli.Api.RespondJson(HttpStatusCode.Accepted, ApiCommandHarness.Success(accepted)); + + await _cli.RunAsync("trigger", "invoice.canceled", "--json"); + + _cli.AssertJsonOut(accepted); + } + + // El CLI no bloquea --live: la regla vive en la API, que responde 403 fuera de Development y Test. El harness + // siempre agrega --api-base, que gana sobre --live, así que solo prueba que el flag no se rechaza en el cliente. + [Fact] + public async Task Trigger_with_live_flag_is_not_blocked_by_the_cli_and_shows_the_api_403() + { + _cli.Api.RespondJson(HttpStatusCode.Forbidden, + """{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas.","traceIdentifier":"0HN7:00000403"}"""); + + var exitCode = await _cli.RunAsync("trigger", "invoice.canceled", "--live"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Single(_cli.Api.Requests); + Assert.Contains("fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas.", _cli.Error); + Assert.Contains("Código HTTP: 403", _cli.Error); + Assert.Contains("0HN7:00000403", _cli.Error); + Assert.Empty(_cli.Out); + } + + [Fact] + public async Task Trigger_without_a_listen_session_exits_1_with_the_api_text() + { + _cli.Api.RespondJson(HttpStatusCode.Conflict, + """{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"La escucha de CLI no está conectada; ejecuta fiscalapi listen."}"""); + + var exitCode = await _cli.RunAsync("trigger", "invoice.canceled"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("La escucha de CLI no está conectada; ejecuta fiscalapi listen.", _cli.Error); + Assert.Contains("Código HTTP: 409", _cli.Error); + Assert.Empty(_cli.Out); + } + + // El tipo lo valida la API: el CLI manda incluso uno que no autocompleta (webhook.test, que trigger rechaza). + [Fact] + public async Task Trigger_of_a_type_the_api_rejects_exits_1_with_the_validation_text() + { + _cli.Api.RespondJson(HttpStatusCode.BadRequest, + """{"data":[{"propertyName":"Type","errorMessage":"El tipo de evento 'webhook.test' no existe.","attemptedValue":"webhook.test"}],"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"One or more validation failures have occurred."}"""); + + var exitCode = await _cli.RunAsync("trigger", "webhook.test"); + + Assert.Equal(ExitCodes.Failure, exitCode); + ApiCommandHarness.AssertJsonEqual("""{"type":"webhook.test"}""", _cli.Body()); + Assert.Contains("El tipo de evento 'webhook.test' no existe.", _cli.Error); + Assert.Contains("Código HTTP: 400", _cli.Error); + Assert.Empty(_cli.Out); + } + + [Fact] + public async Task Trigger_is_never_retried() + { + _cli.Api.RespondHtml(HttpStatusCode.BadGateway, "502"); + + var exitCode = await _cli.RunAsync("trigger", "invoice.canceled"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Single(_cli.Api.Requests); + } + + [Fact] + public async Task Trigger_without_type_is_a_usage_error_without_calling_the_api() + { + var exitCode = await _cli.RunAsync("trigger"); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public void Type_completes_with_the_six_subscribable_event_types() + { + // En orden ordinal, sin webhook.test (no se dispara: la API lo rechaza en trigger). + string[] expected = + [ + "download_request.abandoned", + "download_request.completed", + "download_rule.suspended", + "invoice.canceled", + "invoice.cancellation_rejected", + "invoice.cancellation_requested" + ]; + + Assert.Equal(expected, ArgumentCompletions("trigger ")); + } + + [Fact] + public void Type_completion_filters_by_what_was_typed() + { + Assert.Equal(["download_request.abandoned", "download_request.completed"], ArgumentCompletions("trigger download_req")); + } + + /// + /// Las sugerencias de valor al final de , sin las opciones (--webhook, -h, + /// /?…). + /// + private static string[] ArgumentCompletions(string line) + { + var root = new RootCommand(); + root.Subcommands.Add(new TriggerCommand( + new GlobalOptions(), FiscalApiClientBuilder.Factory(new StubHttpMessageHandler()), new CliConsole(TextWriter.Null, TextWriter.Null))); + + return root.Parse(line) + .GetCompletions(line.Length) + .Select(completion => completion.Label) + .Where(label => !label.StartsWith('-') && !label.StartsWith('/')) + .Order(StringComparer.Ordinal) + .ToArray(); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs new file mode 100644 index 0000000..4bfa47a --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs @@ -0,0 +1,704 @@ +using System.Net; +using FiscalApi.Cli.Input; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret contra las rutas de +/// WebhooksController de la API, simuladas con . Cada +/// subcomando arma la ruta, el método, la query y el cuerpo; --json deja en stdout el data sin cambios; el +/// secreto sale una sola vez. +/// +public sealed class WebhooksCommandTests +{ + private const string WebhooksUrl = "https://api.example.test/api/v4/webhooks"; + private const string WebhookId = "6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d"; + private const string Secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + + // Como lo escribe fapi-back (WebhookDto): camelCase, enums en camelCase y las nulas presentes. + private const string UrlWebhook = + $$"""{"id":"{{WebhookId}}","kind":"url","url":"https://example.com/webhooks","description":"Producción","enabledEvents":["invoice.canceled","invoice.cancellation_requested"],"personIds":["persona-1","persona-2"],"status":"enabled","disabledReason":null,"disabledAt":null,"failingSince":null,"listenerPersonId":null,"sessionExpiresAt":null,"createdAt":"2026-10-13T09:30:00.000","updatedAt":null}"""; + + private const string WebhookWithSecret = + $$"""{"id":"{{WebhookId}}","kind":"url","url":"https://example.com/webhooks","description":"","enabledEvents":["*"],"personIds":null,"status":"enabled","disabledReason":null,"disabledAt":null,"failingSince":null,"listenerPersonId":null,"sessionExpiresAt":null,"createdAt":"2026-10-13T09:30:00.000","updatedAt":null,"secret":"{{Secret}}"}"""; + + private const string CliListener = + """{"id":"escucha-1","kind":"cliListener","url":null,"description":"","enabledEvents":["*"],"personIds":null,"status":"enabled","disabledReason":null,"disabledAt":null,"failingSince":null,"listenerPersonId":"persona-1","sessionExpiresAt":"2026-10-13T09:31:00.000","createdAt":"2026-10-13T09:00:00.000","updatedAt":null}"""; + + private const string Page = + $$"""{"items":[{{UrlWebhook}},{{CliListener}}],"pageNumber":1,"totalPages":3,"totalCount":5,"hasPreviousPage":false,"hasNextPage":true}"""; + + private const string NotFound = + """{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"No such resource for the given Id: 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d","traceIdentifier":"0HN7:00000404"}"""; + + private readonly ApiCommandHarness _cli = new(); + + // ---------------------------------------------------------------- list + + [Fact] + public async Task List_gets_the_webhooks_without_query_by_default() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + var exitCode = await _cli.RunAsync("webhooks", "list"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal(new Uri(WebhooksUrl), request.RequestUri); + Assert.Equal([ApiCommandHarness.ApiKey], request.Headers.GetValues("X-API-KEY")); + } + + [Fact] + public async Task List_sends_the_page_options_as_page_number_and_page_size() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("webhooks", "list", "--page", "2", "--page-size", "50"); + + Assert.Equal(new Uri($"{WebhooksUrl}?pageNumber=2&pageSize=50"), _cli.SingleRequest().RequestUri); + } + + [Fact] + public async Task List_prints_a_table_with_one_row_per_webhook_and_the_page() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("webhooks", "list"); + + var lines = _cli.Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Matches(@"^ID\s+TIPO\s+ESTADO\s+URL\s+EVENTOS\s+DESCRIPCIÓN$", lines[0]); + Assert.Matches($@"^{WebhookId}\s+url\s+enabled\s+https://example\.com/webhooks\s+invoice\.canceled,invoice\.cancellation_requested\s+Producción$", lines[1]); + // Lo que no hay (la URL de una escucha, una descripción vacía) se muestra como "-". + Assert.Matches(@"^escucha-1\s+cliListener\s+enabled\s+-\s+\*\s+-$", lines[2]); + Assert.Equal("Página 1 de 3 (5 en total). Siguiente: --page 2", lines[3]); + Assert.Empty(_cli.Error); + } + + [Fact] + public async Task List_without_webhooks_says_so() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success( + """{"items":[],"pageNumber":1,"totalPages":0,"totalCount":0,"hasPreviousPage":false,"hasNextPage":false}""")); + + var exitCode = await _cli.RunAsync("webhooks", "list"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.StartsWith("Sin datos.", _cli.Out); + } + + [Fact] + public async Task List_with_json_prints_the_data_unchanged() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(Page)); + + await _cli.RunAsync("webhooks", "list", "--json"); + + _cli.AssertJsonOut(Page); + } + + // ---------------------------------------------------------------- retrieve + + [Fact] + public async Task Retrieve_gets_the_webhook_by_its_escaped_id() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + var exitCode = await _cli.RunAsync("webhooks", "retrieve", "we/1 2"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal($"{WebhooksUrl}/we%2F1%202", request.RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task Retrieve_prints_the_webhook_detail() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + await _cli.RunAsync("webhooks", "retrieve", WebhookId); + + Assert.Matches($@"(?m)^ID\s+{WebhookId}\r?$", _cli.Out); + Assert.Matches(@"(?m)^Estado\s+enabled\r?$", _cli.Out); + Assert.Matches(@"(?m)^URL\s+https://example\.com/webhooks\r?$", _cli.Out); + Assert.Matches(@"(?m)^Eventos\s+invoice\.canceled, invoice\.cancellation_requested\r?$", _cli.Out); + Assert.Matches(@"(?m)^Personas\s+persona-1, persona-2\r?$", _cli.Out); + Assert.Matches(@"(?m)^Creado\s+2026-10-13T09:30:00\.000\r?$", _cli.Out); + // Lo que no aplica no se muestra. + Assert.DoesNotContain("Motivo", _cli.Out); + Assert.DoesNotContain("Secreto", _cli.Out); + } + + [Fact] + public async Task Retrieve_with_json_prints_the_data_unchanged() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + await _cli.RunAsync("webhooks", "retrieve", WebhookId, "--json"); + + _cli.AssertJsonOut(UrlWebhook); + } + + [Fact] + public async Task Retrieve_of_a_missing_webhook_exits_1_with_the_api_text() + { + _cli.Api.RespondJson(HttpStatusCode.NotFound, NotFound); + + var exitCode = await _cli.RunAsync("webhooks", "retrieve", WebhookId); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"No such resource for the given Id: {WebhookId}", _cli.Error); + Assert.Contains("Código HTTP: 404", _cli.Error); + Assert.Empty(_cli.Out); + } + + // ---------------------------------------------------------------- create + + [Fact] + public async Task Create_posts_the_webhook_with_its_events_and_persons() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + var exitCode = await _cli.RunAsync( + "webhooks", "create", + "--url", "https://example.com/webhooks", + "--description", "Producción", + "--events", "invoice.canceled, invoice.cancellation_requested", + "--person", "persona-1,persona-2", + "--person", "persona-3"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(new Uri(WebhooksUrl), request.RequestUri); + ApiCommandHarness.AssertJsonEqual( + """{"url":"https://example.com/webhooks","description":"Producción","enabledEvents":["invoice.canceled","invoice.cancellation_requested"],"personIds":["persona-1","persona-2","persona-3"]}""", + _cli.Body()); + } + + [Fact] + public async Task Create_without_description_or_persons_omits_them() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*"); + + ApiCommandHarness.AssertJsonEqual("""{"url":"https://example.com/webhooks","enabledEvents":["*"]}""", _cli.Body()); + } + + [Fact] + public async Task Create_sends_every_person_and_lets_the_api_apply_its_limit() + { + var persons = string.Join(',', Enumerable.Range(1, 101).Select(i => $"persona-{i}")); + _cli.Api.RespondJson(HttpStatusCode.BadRequest, ValidationFailure("PersonIds", "Un webhook filtra como máximo 100 personas.")); + + var exitCode = await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--person", persons); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Equal(101, _cli.Body()!["personIds"]!.AsArray().Count); + Assert.Contains("Un webhook filtra como máximo 100 personas.", _cli.Error); + } + + [Fact] + public async Task Create_prints_the_secret_once_with_the_warning_to_keep_it() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*"); + + Assert.Matches($@"(?m)^ID\s+{WebhookId}\r?$", _cli.Out); + Assert.Contains($"Secreto de firma: {Secret}", _cli.Out); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); + Assert.Contains("Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI.", _cli.Error); + } + + [Fact] + public async Task Create_with_json_prints_the_data_unchanged_with_the_secret_once() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--json"); + + _cli.AssertJsonOut(WebhookWithSecret); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); + } + + [Fact] + public async Task Create_never_writes_the_secret_in_verbose() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--verbose"); + + Assert.Contains($"> POST {WebhooksUrl}", _cli.Error); + Assert.DoesNotContain(Secret, _cli.Error); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out, Secret)); + } + + [Theory] + [InlineData("--url")] + [InlineData("--events")] + public async Task Create_requires_url_and_events_without_calling_the_api(string missing) + { + List args = ["webhooks", "create"]; + if (missing != "--url") + { + args.AddRange(["--url", "https://example.com/webhooks"]); + } + + if (missing != "--events") + { + args.AddRange(["--events", "*"]); + } + + var exitCode = await _cli.RunAsync(args); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains(missing, _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Create_rejected_by_validation_exits_1_with_each_failure_text() + { + _cli.Api.RespondJson(HttpStatusCode.BadRequest, ValidationFailure("EnabledEvents[0]", "El tipo de evento 'invoice.paid' no existe.")); + + var exitCode = await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "invoice.paid"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("El tipo de evento 'invoice.paid' no existe.", _cli.Error); + Assert.Contains("Código HTTP: 400", _cli.Error); + Assert.Empty(_cli.Out); + } + + [Fact] + public async Task Create_is_never_retried() + { + _cli.Api.RespondHtml(HttpStatusCode.ServiceUnavailable, "503"); + + var exitCode = await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Single(_cli.Api.Requests); + } + + // ---------------------------------------------------------------- update + + [Fact] + public async Task Update_reads_the_webhook_and_replaces_it_changing_only_what_was_given() + { + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--url", "https://example.com/v2/webhooks"); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal(2, _cli.Api.Requests.Count); + Assert.Equal(HttpMethod.Get, _cli.Api.Requests[0].Method); + Assert.Equal(new Uri($"{WebhooksUrl}/{WebhookId}"), _cli.Api.Requests[0].RequestUri); + Assert.Equal(HttpMethod.Put, _cli.Api.Requests[1].Method); + Assert.Equal(new Uri($"{WebhooksUrl}/{WebhookId}"), _cli.Api.Requests[1].RequestUri); + // Sin status: el PUT conserva el estado. + ApiCommandHarness.AssertJsonEqual( + $$"""{"id":"{{WebhookId}}","url":"https://example.com/v2/webhooks","description":"Producción","enabledEvents":["invoice.canceled","invoice.cancellation_requested"],"personIds":["persona-1","persona-2"]}""", + _cli.Body(1)); + } + + [Fact] + public async Task Update_applies_every_option_given() + { + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + await _cli.RunAsync( + "webhooks", "update", WebhookId, + "--description", "", + "--events", "*", + "--person", "persona-9", + "--disabled"); + + ApiCommandHarness.AssertJsonEqual( + $$"""{"id":"{{WebhookId}}","url":"https://example.com/webhooks","description":"","enabledEvents":["*"],"personIds":["persona-9"],"status":"disabled"}""", + _cli.Body(1)); + } + + [Fact] + public async Task Update_with_all_persons_removes_the_person_filter_explicitly() + { + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--all-persons", "--enabled"); + + Assert.Equal(ExitCodes.Success, exitCode); + var body = _cli.Body(1)!; + Assert.Empty(body["personIds"]!.AsArray()); + Assert.Equal("enabled", (string?)body["status"]); + } + + [Fact] + public async Task Update_of_a_webhook_for_every_person_keeps_omitting_person_ids() + { + const string forEveryPerson = + $$"""{"id":"{{WebhookId}}","kind":"url","url":"https://example.com/webhooks","description":"","enabledEvents":["*"],"personIds":null,"status":"enabled"}"""; + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(forEveryPerson)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(forEveryPerson)); + + await _cli.RunAsync("webhooks", "update", WebhookId, "--description", "Sin filtro"); + + ApiCommandHarness.AssertJsonEqual( + $$"""{"id":"{{WebhookId}}","url":"https://example.com/webhooks","description":"Sin filtro","enabledEvents":["*"]}""", + _cli.Body(1)); + } + + // Un script con la variable vacía (--person "$PERSONAS") no debe ampliar el webhook a todas las personas sin avisar. + [Theory] + [InlineData("update", "")] + [InlineData("update", ",")] + [InlineData("update", " ")] + [InlineData("create", "")] + [InlineData("create", " , ")] + public async Task An_empty_person_list_is_a_usage_error_without_calling_the_api(string subcommand, string persons) + { + List args = subcommand == "update" + ? ["webhooks", "update", WebhookId, "--person", persons] + : ["webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--person", persons]; + + var exitCode = await _cli.RunAsync(args); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons.", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task One_empty_person_among_others_is_also_a_usage_error() + { + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--person", "persona-1", "--person", ""); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Empty(_cli.Api.Requests); + } + + [Theory] + [InlineData("update")] + [InlineData("create")] + public async Task All_persons_with_person_is_a_usage_error(string subcommand) + { + List args = subcommand == "update" + ? ["webhooks", "update", WebhookId] + : ["webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*"]; + args.AddRange(["--person", "persona-1", "--all-persons"]); + + var exitCode = await _cli.RunAsync(args); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--all-persons no se combina con --person.", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Create_with_all_persons_omits_person_ids() + { + _cli.Api.RespondJson(HttpStatusCode.Created, ApiCommandHarness.Success(WebhookWithSecret)); + + var exitCode = await _cli.RunAsync("webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--all-persons"); + + Assert.Equal(ExitCodes.Success, exitCode); + ApiCommandHarness.AssertJsonEqual("""{"url":"https://example.com/webhooks","enabledEvents":["*"]}""", _cli.Body()); + } + + [Fact] + public async Task Update_with_json_prints_the_data_of_the_replacement_unchanged() + { + const string replaced = $$"""{"id":"{{WebhookId}}","kind":"url","url":"https://example.com/v2/webhooks","status":"disabled"}"""; + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(replaced)); + + await _cli.RunAsync("webhooks", "update", WebhookId, "--disabled", "--json"); + + _cli.AssertJsonOut(replaced); + } + + [Fact] + public async Task Update_without_changes_is_a_usage_error_without_calling_the_api() + { + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("Indica qué cambiar", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Update_with_enabled_and_disabled_is_a_usage_error() + { + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--enabled", "--disabled"); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--enabled y --disabled no se combinan.", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Update_of_a_cli_listener_stops_before_replacing_it() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(CliListener)); + + var exitCode = await _cli.RunAsync("webhooks", "update", "escucha-1", "--events", "invoice.canceled"); + + // Es el estado del recurso, no el uso del comando: 1, como el 400 que respondería la API. + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger.", _cli.Error); + Assert.Single(_cli.Api.Requests); + } + + [Fact] + public async Task Update_of_a_missing_webhook_exits_1_without_replacing() + { + _cli.Api.RespondJson(HttpStatusCode.NotFound, NotFound); + + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--disabled"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"No such resource for the given Id: {WebhookId}", _cli.Error); + Assert.Single(_cli.Api.Requests); + } + + [Fact] + public async Task Update_conflict_exits_1_with_the_api_text() + { + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.Conflict, + """{"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"Otro cambio a los webhooks está en curso; reintenta."}"""); + + var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId, "--disabled"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("Otro cambio a los webhooks está en curso; reintenta.", _cli.Error); + Assert.Contains("Código HTTP: 409", _cli.Error); + } + + // ---------------------------------------------------------------- delete + + [Fact] + public async Task Delete_with_yes_deletes_without_asking() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success("true")); + + var exitCode = await _cli.RunAsync("webhooks", "delete", WebhookId, "--yes"); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.Equal(new Uri($"{WebhooksUrl}/{WebhookId}"), request.RequestUri); + Assert.Contains($"Webhook {WebhookId} borrado.", _cli.Error); + Assert.DoesNotContain("¿", _cli.Error); + } + + [Fact] + public async Task Delete_with_json_and_yes_prints_the_data_unchanged() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success("true")); + + await _cli.RunAsync("webhooks", "delete", WebhookId, "--yes", "--json"); + + _cli.AssertJsonOut("true"); + } + + [Fact] + public async Task Delete_without_yes_and_redirected_input_is_a_usage_error() + { + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId], confirmationInput: "s\n", interactive: false); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("--yes", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Delete_without_yes_and_json_is_a_usage_error_even_in_a_terminal() + { + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId, "--json"], confirmationInput: "s\n", interactive: true); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("Para borrar sin confirmación interactiva usa --yes.", _cli.Error); + Assert.DoesNotContain("¿", _cli.Error); + Assert.Empty(_cli.Api.Requests); + Assert.Empty(_cli.Out); + } + + [Theory] + [InlineData("s\n")] + [InlineData("Sí\n")] + [InlineData(" y \n")] + public async Task Delete_confirmed_in_the_terminal_deletes(string answer) + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success("true")); + + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId], confirmationInput: answer, interactive: true); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains($"¿Borrar el webhook {WebhookId}?", _cli.Error); + Assert.Equal(HttpMethod.Delete, _cli.SingleRequest().Method); + } + + [Theory] + [InlineData("n\n")] + [InlineData("\n")] + [InlineData("")] + public async Task Delete_not_confirmed_in_the_terminal_deletes_nothing(string answer) + { + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId], confirmationInput: answer, interactive: true); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains($"No se borró el webhook {WebhookId}.", _cli.Error); + Assert.Empty(_cli.Api.Requests); + } + + [Fact] + public async Task Delete_of_a_missing_webhook_exits_1() + { + _cli.Api.RespondJson(HttpStatusCode.NotFound, NotFound); + + var exitCode = await _cli.RunAsync("webhooks", "delete", WebhookId, "--yes"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"No such resource for the given Id: {WebhookId}", _cli.Error); + Assert.DoesNotContain("borrado", _cli.Error); + } + + // ---------------------------------------------------------------- rotate-secret + + [Fact] + public async Task Rotate_secret_posts_without_body_and_prints_the_new_secret_once() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(WebhookWithSecret)); + + var exitCode = await _cli.RunAsync("webhooks", "rotate-secret", WebhookId); + + Assert.Equal(ExitCodes.Success, exitCode); + var request = _cli.SingleRequest(); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(new Uri($"{WebhooksUrl}/{WebhookId}/rotate-secret"), request.RequestUri); + Assert.Null(_cli.Api.RequestBodies[0]); + Assert.Contains($"Secreto de firma: {Secret}", _cli.Out); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); + Assert.Contains("Guarda este secreto de firma.", _cli.Error); + Assert.Contains("El secreto anterior seguirá firmando durante 24 horas", _cli.Error); + } + + [Fact] + public async Task Rotate_secret_with_json_prints_the_data_unchanged() + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(WebhookWithSecret)); + + await _cli.RunAsync("webhooks", "rotate-secret", WebhookId, "--json"); + + _cli.AssertJsonOut(WebhookWithSecret); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); + } + + [Fact] + public async Task Ctrl_c_while_confirming_the_delete_exits_130_without_deleting() + { + using var ctrlC = new CancellationTokenSource(); + + var exitCode = await _cli.RunAsync( + ["webhooks", "delete", WebhookId], confirmation: new CtrlCAtPrompt(ctrlC), cancellationToken: ctrlC.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Empty(_cli.Api.Requests); + Assert.DoesNotContain("No se borró", _cli.Error); + } + + // ---------------------------------------------------------------- secreto con una respuesta inesperada + + // Un id numérico rompe el modelo tipado: el secreto ya se generó en el servidor y no se puede perder por eso. + private const string UnexpectedWithSecret = $$"""{"id":123,"enabledEvents":"*","secret":"{{Secret}}"}"""; + + [Theory] + [InlineData("create")] + [InlineData("rotate-secret")] + public async Task The_secret_is_shown_even_if_the_response_does_not_match_the_model(string subcommand) + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UnexpectedWithSecret)); + string[] args = subcommand == "create" + ? ["webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*"] + : ["webhooks", "rotate-secret", WebhookId]; + + var exitCode = await _cli.RunAsync(args); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains($"Secreto de firma: {Secret}", _cli.Out); + Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); + Assert.Contains("Guarda este secreto de firma.", _cli.Error); + } + + [Theory] + [InlineData("create")] + [InlineData("rotate-secret")] + public async Task With_json_the_raw_data_is_printed_even_if_it_does_not_match_the_model(string subcommand) + { + _cli.Api.RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UnexpectedWithSecret)); + string[] args = subcommand == "create" + ? ["webhooks", "create", "--url", "https://example.com/webhooks", "--events", "*", "--json"] + : ["webhooks", "rotate-secret", WebhookId, "--json"]; + + var exitCode = await _cli.RunAsync(args); + + Assert.Equal(ExitCodes.Success, exitCode); + _cli.AssertJsonOut(UnexpectedWithSecret); + Assert.Contains("Guarda este secreto de firma.", _cli.Error); + } + + [Fact] + public async Task A_null_data_is_reported_as_an_unexpected_response() + { + _cli.Api.RespondJson(HttpStatusCode.OK, """{"data":null,"succeeded":true,"message":"","details":""}"""); + + var exitCode = await _cli.RunAsync("webhooks", "retrieve", WebhookId, "--json"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("La API respondió con éxito pero sin data.", _cli.Error); + Assert.Empty(_cli.Out); + } + + [Fact] + public async Task Unauthorized_shows_the_login_hint() + { + _cli.Api.RespondJson(HttpStatusCode.Unauthorized, """{"succeeded":false,"message":"Invalid API KEY","details":""}"""); + + var exitCode = await _cli.RunAsync("webhooks", "list"); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("fiscalapi login", _cli.Error); + } + + /// + /// Ctrl+C con la pregunta en pantalla, como en Windows: Console.ReadLine devuelve null (no confirma) en + /// lugar de cancelar, y Program ya canceló el token. + /// + private sealed class CtrlCAtPrompt(CancellationTokenSource ctrlC) : IConfirmationPrompt + { + public bool IsInteractive => true; + + public async Task ConfirmAsync(string question, CancellationToken cancellationToken) + { + await ctrlC.CancelAsync(); + return false; + } + } + + // El 400 de validación de fapi-back (GlobalExceptionMiddleware): el texto de cada falla va en data. + private static string ValidationFailure(string propertyName, string errorMessage) => + $$$"""{"data":[{"propertyName":"{{{propertyName}}}","errorMessage":"{{{errorMessage}}}","attemptedValue":null,"customState":null,"severity":0,"errorCode":"PredicateValidator","formattedMessagePlaceholderValues":{}}],"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"One or more validation failures have occurred.","traceIdentifier":"0HN7:00000400"}"""; +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs new file mode 100644 index 0000000..f1bbfa0 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -0,0 +1,245 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; + +namespace FiscalApi.Cli.Tests.Credentials; + +/// +/// FISCALAPI_API_KEY/FISCALAPI_TENANT_KEY ganan sobre lo guardado, y las credenciales nunca +/// aparecen en --verbose ni en un error. +/// +public sealed class CredentialResolutionTests +{ + private const string EnvironmentApiKey = "sk_test_clave-del-entorno"; + private const string EnvironmentTenantKey = "tenant-del-entorno-0001"; + private const string StoredApiKey = "sk_test_clave-guardada"; + private const string StoredTenantKey = "tenant-guardado-0002"; + + private static readonly string Host = FiscalApiClientBuilder.BaseAddress.Authority; + + private readonly InMemoryCredentialStore _store = new(); + private readonly Dictionary _variables = []; + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + [Fact] + public void Environment_variables_win_over_the_stored_credentials() + { + _store.Save(Host, ApiCredentials.Create(StoredApiKey, StoredTenantKey)); + DefineEnvironmentCredentials(); + + var credentials = Resolver().Resolve(Environment()); + + Assert.Equal(ApiCredentials.Create(EnvironmentApiKey, EnvironmentTenantKey), credentials); + } + + [Fact] + public void Without_environment_variables_the_stored_credentials_of_the_host_are_used() + { + _store.Save(Host, ApiCredentials.Create(StoredApiKey, StoredTenantKey)); + _store.Save("otro-host.example.test", ApiCredentials.Create("sk_test_de-otro-host", "tenant-de-otro-host")); + + var credentials = Resolver().Resolve(Environment()); + + Assert.Equal(ApiCredentials.Create(StoredApiKey, StoredTenantKey), credentials); + } + + [Fact] + public void Blank_environment_variables_count_as_undefined() + { + _store.Save(Host, ApiCredentials.Create(StoredApiKey, StoredTenantKey)); + _variables[CredentialResolver.ApiKeyVariable] = " "; + _variables[CredentialResolver.TenantKeyVariable] = ""; + + var credentials = Resolver().Resolve(Environment()); + + Assert.Equal(ApiCredentials.Create(StoredApiKey, StoredTenantKey), credentials); + } + + [Theory] + [InlineData(CredentialResolver.ApiKeyVariable)] + [InlineData(CredentialResolver.TenantKeyVariable)] + public async Task Only_one_environment_variable_is_a_usage_error_and_is_not_mixed_with_the_stored_entry(string variable) + { + _store.Save(Host, ApiCredentials.Create(StoredApiKey, StoredTenantKey)); + _variables[variable] = variable == CredentialResolver.ApiKeyVariable ? EnvironmentApiKey : EnvironmentTenantKey; + + var exitCode = await RunProbeAsync(); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains(CredentialResolver.ApiKeyVariable, _error.ToString()); + Assert.Contains(CredentialResolver.TenantKeyVariable, _error.ToString()); + AssertNoCredentialIn(_out.ToString() + _error.ToString()); + } + + [Fact] + public async Task Without_credentials_is_a_usage_error_that_points_to_login() + { + var exitCode = await RunProbeAsync(); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains($"No hay credenciales para {Host}.", _error.ToString()); + Assert.Contains("fiscalapi login", _error.ToString()); + Assert.Contains(CredentialResolver.ApiKeyVariable, _error.ToString()); + } + + [Fact] + public async Task Without_store_and_without_environment_variables_shows_the_approved_text() + { + var exitCode = await RunProbeAsync(store: new UnavailableCredentialStore()); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains( + "No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY.", + _error.ToString()); + Assert.Contains(UnavailableCredentialStore.Detail, _error.ToString()); + } + + [Fact] + public async Task Locked_store_without_environment_variables_is_a_store_error_with_its_detail() + { + var exitCode = await RunProbeAsync(store: new LockedCredentialStore()); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("El almacén de credenciales del sistema existe, pero está bloqueado o no respondió.", _error.ToString()); + Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); + Assert.DoesNotContain("No hay un almacén", _error.ToString()); + } + + [Fact] + public async Task Environment_variables_do_not_touch_a_locked_store() + { + DefineEnvironmentCredentials(); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunProbeAsync(handler, new LockedCredentialStore()); + + Assert.Equal(ExitCodes.Success, exitCode); + } + + [Fact] + public async Task Environment_variables_work_without_a_store() + { + DefineEnvironmentCredentials(); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunProbeAsync(handler, new UnavailableCredentialStore()); + + Assert.Equal(ExitCodes.Success, exitCode); + } + + [Fact] + public async Task Requests_carry_the_resolved_credentials_in_their_headers() + { + DefineEnvironmentCredentials(); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + await RunProbeAsync(handler); + + var request = Assert.Single(handler.Requests); + Assert.Equal([EnvironmentApiKey], request.Headers.GetValues("X-API-KEY")); + Assert.Equal([EnvironmentTenantKey], request.Headers.GetValues("X-TENANT-KEY")); + } + + [Fact] + public async Task Verbose_and_a_rejected_request_never_show_the_environment_credentials() + { + DefineEnvironmentCredentials(); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.Unauthorized, + """{"succeeded":false,"message":"Invalid API KEY","details":"","traceIdentifier":"0HN7:00000401"}"""); + + var exitCode = await RunProbeAsync(handler, verbose: true); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("> GET https://api.example.test/api/v4/me/features", _error.ToString()); + Assert.Contains("Invalid API KEY", _error.ToString()); + AssertNoCredentialIn(_out.ToString() + _error.ToString()); + } + + [Fact] + public async Task Verbose_and_a_network_failure_never_show_the_stored_credentials() + { + _store.Save(Host, ApiCredentials.Create(StoredApiKey, StoredTenantKey)); + var handler = new StubHttpMessageHandler() + .FailWithNetworkError(HttpRequestError.NameResolutionError); + + var exitCode = await RunProbeAsync(handler, verbose: true); + + Assert.Equal(ExitCodes.Failure, exitCode); + Assert.Contains("No se pudo conectar", _error.ToString()); + AssertNoCredentialIn(_out.ToString() + _error.ToString()); + } + + [Theory] + [InlineData("sk_test_con\r\nsalto", EnvironmentTenantKey)] + [InlineData("sk_test_con espacio", EnvironmentTenantKey)] + [InlineData(EnvironmentApiKey, "tenant-con-eñe")] + public async Task Invalid_credential_value_is_a_usage_error_that_does_not_echo_the_value(string apiKey, string tenantKey) + { + _variables[CredentialResolver.ApiKeyVariable] = apiKey; + _variables[CredentialResolver.TenantKeyVariable] = tenantKey; + + var exitCode = await RunProbeAsync(); + + Assert.Equal(ExitCodes.Usage, exitCode); + var output = _out.ToString() + _error.ToString(); + Assert.DoesNotContain(apiKey, output); + Assert.DoesNotContain(tenantKey, output); + Assert.DoesNotContain("sk_test_con", output); + Assert.DoesNotContain("tenant-con", output); + } + + [Fact] + public void Text_representation_of_the_credentials_hides_their_values() + { + var text = ApiCredentials.Create(StoredApiKey, StoredTenantKey).ToString(); + + Assert.DoesNotContain(StoredApiKey, text); + Assert.DoesNotContain(StoredTenantKey, text); + } + + private void DefineEnvironmentCredentials() + { + _variables[CredentialResolver.ApiKeyVariable] = EnvironmentApiKey; + _variables[CredentialResolver.TenantKeyVariable] = EnvironmentTenantKey; + } + + private CredentialResolver Resolver(ICredentialStore? store = null) => + new(store ?? _store, name => _variables.GetValueOrDefault(name)); + + private static ApiEnvironment Environment() => ApiEnvironment.Resolve(FiscalApiClientBuilder.BaseAddress, live: false); + + private static void AssertNoCredentialIn(string output) + { + foreach (var secret in new[] { EnvironmentApiKey, EnvironmentTenantKey, StoredApiKey, StoredTenantKey }) + { + Assert.DoesNotContain(secret, output); + } + } + + /// + /// Un comando de prueba que pide su cliente como lo harán los comandos de la API: la fábrica resuelve las + /// credenciales. Corre en para ver todo lo que llega a stdout y stderr. + /// + private Task RunProbeAsync(StubHttpMessageHandler? handler = null, ICredentialStore? store = null, bool verbose = false) + { + var globalOptions = new GlobalOptions(); + var console = new CliConsole(_out, _error); + var apiClients = new FiscalApiClientFactory( + handler ?? new StubHttpMessageHandler(), Resolver(store), console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); + + var probe = new Command("probe"); + probe.SetAction(async (parseResult, cancellationToken) => + { + var client = apiClients.Create(globalOptions.Read(parseResult), FiscalApiClientFactory.DefaultTimeout); + await client.GetAsync("api/v4/me/features", FiscalApiJsonContext.Default.ApiResponsePersonFeatures, cancellationToken); + return ExitCodes.Success; + }); + + string[] args = ["probe", "--api-base", FiscalApiClientBuilder.BaseAddress.ToString(), .. verbose ? new[] { "--verbose" } : []]; + return new CliApplication(globalOptions, [probe], console, NoUpdateCheck.Instance).RunAsync(args, CancellationToken.None); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs new file mode 100644 index 0000000..af8dd46 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs @@ -0,0 +1,19 @@ +namespace FiscalApi.Cli.Tests.Credentials; + +/// +/// Prueba contra el almacén real del SO. En Windows (Credential Manager) y macOS (llavero de sesión) siempre corre. +/// En Linux solo corre si vale 1, porque exige un Secret Service en la +/// sesión de D-Bus, que no hay en WSL, en un servidor ni en un contenedor sin preparar (el CI lo levanta). +/// +public sealed class CredentialStoreFactAttribute : FactAttribute +{ + public const string LinuxOptInVariable = "FISCALAPI_CLI_SECRET_SERVICE_TESTS"; + + public CredentialStoreFactAttribute() + { + if (OperatingSystem.IsLinux() && Environment.GetEnvironmentVariable(LinuxOptInVariable) != "1") + { + Skip = $"Requiere un Secret Service de prueba; define {LinuxOptInVariable}=1 dentro de una sesión de D-Bus con gnome-keyring."; + } + } +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs new file mode 100644 index 0000000..e3a236e --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs @@ -0,0 +1,88 @@ +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Tests.Credentials; + +/// +/// Ida y vuelta contra el almacén real del SO local (Credential Manager, llavero de macOS o Secret Service). +/// Usa un servicio de prueba propio y hosts únicos por prueba, y borra lo que escribe: nunca toca las entradas +/// de producción (). +/// +public sealed class CredentialStoreTests : IDisposable +{ + private const string TestService = "fiscalapi-cli-test"; + + private readonly ICredentialStore _store = CredentialStores.ForCurrentOs(TestService); + private readonly string _host = UniqueHost(); + private readonly string _otherHost = UniqueHost() + ":5001"; + + private static readonly ApiCredentials Credentials = ApiCredentials.Create("sk_test_ida-y-vuelta", "tenant-ida-y-vuelta"); + private static readonly ApiCredentials OtherCredentials = ApiCredentials.Create("sk_test_otro-host", "tenant-otro-host"); + + [CredentialStoreFact] + public void Saved_credentials_are_read_back_and_gone_after_delete() + { + _store.Save(_host, Credentials); + + Assert.Equal(Credentials, _store.Read(_host)); + Assert.True(_store.Delete(_host)); + Assert.Null(_store.Read(_host)); + } + + [CredentialStoreFact] + public void Saving_again_replaces_the_entry_of_the_host() + { + _store.Save(_host, OtherCredentials); + _store.Save(_host, Credentials); + + Assert.Equal(Credentials, _store.Read(_host)); + } + + [CredentialStoreFact] + public void Credentials_of_another_host_do_not_cross() + { + _store.Save(_host, Credentials); + _store.Save(_otherHost, OtherCredentials); + + Assert.Equal(Credentials, _store.Read(_host)); + Assert.Equal(OtherCredentials, _store.Read(_otherHost)); + + Assert.True(_store.Delete(_otherHost)); + Assert.Equal(Credentials, _store.Read(_host)); + } + + [CredentialStoreFact] + public void Host_without_entry_reads_as_null() + { + Assert.Null(_store.Read(_host)); + } + + [CredentialStoreFact] + public void Deleting_a_missing_entry_does_not_fail() + { + Assert.False(_store.Delete(_host)); + } + + [CredentialStoreFact] + public void Store_of_this_system_is_available_with_or_without_an_entry() + { + _store.EnsureAvailable(_host); + _store.Save(_host, Credentials); + + _store.EnsureAvailable(_host); + } + + // Cada borrado por separado: si el primero falla, el segundo igual limpia su entrada. + public void Dispose() + { + try + { + _store.Delete(_host); + } + finally + { + _store.Delete(_otherHost); + } + } + + private static string UniqueHost() => $"{Guid.NewGuid():N}.example.test"; +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs new file mode 100644 index 0000000..ddb6203 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs @@ -0,0 +1,52 @@ +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Tests.Credentials; + +/// Almacén en memoria para las pruebas de comandos: no toca el almacén real del SO. +internal sealed class InMemoryCredentialStore : ICredentialStore +{ + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary Entries => _entries; + + public void EnsureAvailable(string host) + { + } + + public ApiCredentials? Read(string host) => _entries.GetValueOrDefault(host); + + public void Save(string host, ApiCredentials credentials) => _entries[host] = credentials; + + public bool Delete(string host) => _entries.Remove(host); +} + +/// Como Linux sin Secret Service, WSL o un servidor: cada operación reporta que no hay almacén. +internal sealed class UnavailableCredentialStore : ICredentialStore +{ + public const string Detail = "org.freedesktop.secrets no está disponible (prueba)"; + + public void EnsureAvailable(string host) => throw new CredentialStoreUnavailableException(Detail); + + public ApiCredentials Read(string host) => throw new CredentialStoreUnavailableException(Detail); + + public void Save(string host, ApiCredentials credentials) => throw new CredentialStoreUnavailableException(Detail); + + public bool Delete(string host) => throw new CredentialStoreUnavailableException(Detail); +} + +/// +/// El almacén existe pero no se puede usar: el llavero está bloqueado y no se puede pedir la contraseña, o el +/// Secret Service no respondió. No es "no hay almacén". +/// +internal sealed class LockedCredentialStore : ICredentialStore +{ + public const string Detail = "Secret Service: Cannot create an item in a locked collection (prueba)"; + + public void EnsureAvailable(string host) => throw CredentialStoreException.Locked(Detail); + + public ApiCredentials Read(string host) => throw CredentialStoreException.Locked(Detail); + + public void Save(string host, ApiCredentials credentials) => throw CredentialStoreException.Locked(Detail); + + public bool Delete(string host) => throw CredentialStoreException.Locked(Detail); +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs new file mode 100644 index 0000000..f2d21e3 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs @@ -0,0 +1,68 @@ +using System.Text; +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Tests.Credentials; + +/// +/// El secreto de cada entrada del almacén: JSON UTF-8 con las dos llaves. Una entrada que no es de este CLI o se +/// editó a mano es un error del almacén que pide volver a hacer login, sin repetir lo que trae. +/// +public sealed class StoredCredentialsFormatTests +{ + private const string Host = "test.fiscalapi.com"; + + [Fact] + public void Encoded_secret_is_json_with_both_keys_and_decodes_back() + { + var credentials = ApiCredentials.Create("sk_test_formato", "tenant-formato"); + + var secret = StoredCredentialsFormat.Encode(credentials); + + Assert.Equal("""{"apiKey":"sk_test_formato","tenantKey":"tenant-formato"}""", Encoding.UTF8.GetString(secret)); + Assert.Equal(credentials, StoredCredentialsFormat.Decode(secret, Host)); + } + + /// + /// CRED_MAX_CREDENTIAL_BLOB_SIZE de Credential Manager (5 * 512 bytes), el límite más chico de los tres almacenes. + /// El escape de JSON puede volver un carácter en seis bytes (" → "), así que se prueba el peor. + /// + [Fact] + public void Longest_credentials_fit_the_smallest_store_limit_even_with_json_escapes() + { + const int SmallestStoreLimit = 5 * 512; + + for (var character = '!'; character <= '~'; character++) + { + var longest = new string(character, ApiCredentials.MaxLength); + + var secret = StoredCredentialsFormat.Encode(ApiCredentials.Create(longest, longest)); + + Assert.True(secret.Length <= SmallestStoreLimit, $"'{character}': {secret.Length} bytes"); + } + } + + [Fact] + public void Credentials_longer_than_the_maximum_are_rejected() + { + var tooLong = new string('a', ApiCredentials.MaxLength + 1); + + Assert.Throws(() => ApiCredentials.Create(tooLong, "tenant")); + Assert.Throws(() => ApiCredentials.Create("sk_test", tooLong)); + } + + [Theory] + [InlineData("contraseña-escrita-a-mano")] + [InlineData("""{"apiKey":"sk_test_sin-tenant"}""")] + [InlineData("""{"apiKey":"sk_test_con espacio","tenantKey":"tenant-valido"}""")] + [InlineData("null")] + public void Entry_that_cannot_be_read_asks_to_log_in_again_without_echoing_it(string stored) + { + var error = Assert.Throws(() => StoredCredentialsFormat.Decode(Encoding.UTF8.GetBytes(stored), Host)); + + Assert.Equal(ExitCodes.Failure, error.ExitCode); + Assert.Contains(Host, error.Message); + Assert.Contains("fiscalapi login", error.Message); + Assert.DoesNotContain("contraseña", error.Message); + Assert.DoesNotContain("sk_test", error.Message); + } +} diff --git a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj new file mode 100644 index 0000000..6eccdd4 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj @@ -0,0 +1,32 @@ + + + + false + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs new file mode 100644 index 0000000..885de92 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -0,0 +1,136 @@ +using System.CommandLine; + +namespace FiscalApi.Cli.Tests; + +/// +/// --api-base gana sobre --live, y este sobre test. +/// +public sealed class GlobalOptionsTests +{ + [Fact] + public void Without_options_the_environment_is_test() + { + var settings = Parse(); + + Assert.Equal(new Uri("https://test.fiscalapi.com"), settings.Environment.BaseAddress); + } + + [Fact] + public void Live_selects_the_live_environment() + { + var settings = Parse("--live"); + + Assert.Equal(new Uri("https://live.fiscalapi.com"), settings.Environment.BaseAddress); + } + + [Fact] + public void Api_base_wins_over_live() + { + var settings = Parse("--live", "--api-base", "https://localhost:5001"); + + Assert.Equal(new Uri("https://localhost:5001"), settings.Environment.BaseAddress); + } + + [Fact] + public void Api_base_wins_over_the_default() + { + var settings = Parse("--api-base", "http://localhost:5000/"); + + Assert.Equal(new Uri("http://localhost:5000/"), settings.Environment.BaseAddress); + } + + [Theory] + [InlineData("https://dev.example.test/fiscalapi")] + [InlineData("https://dev.example.test/fiscalapi/")] + public void Api_base_keeps_its_path_prefix_when_resolving_api_routes(string apiBase) + { + var settings = Parse("--api-base", apiBase); + + Assert.Equal(new Uri("https://dev.example.test/fiscalapi/"), settings.Environment.BaseAddress); + Assert.Equal( + new Uri("https://dev.example.test/fiscalapi/api/v4/me/features"), + new Uri(settings.Environment.BaseAddress, "api/v4/me/features")); + } + + [Theory] + [InlineData("localhost:5001")] + [InlineData("ftp://localhost")] + [InlineData("/api/v4")] + public void Api_base_must_be_an_absolute_http_url(string apiBase) + { + var parseResult = ParseWith(new GlobalOptions(), "--api-base", apiBase); + + Assert.Contains(parseResult.Errors, error => error.Message.Contains("--api-base")); + } + + [Theory] + [InlineData("http://localhost:5000")] + [InlineData("http://127.0.0.1:5000")] + [InlineData("http://[::1]:5000")] + public void Api_base_with_http_is_allowed_for_loopback(string apiBase) + { + var settings = Parse("--api-base", apiBase); + + Assert.Equal(Uri.UriSchemeHttp, settings.Environment.BaseAddress.Scheme); + } + + [Theory] + [InlineData("http://test.fiscalapi.com")] + [InlineData("http://192.168.1.20:5000")] + [InlineData("http://localhost.example.test:5000")] + public void Api_base_with_http_to_another_host_is_a_usage_error(string apiBase) + { + var parseResult = ParseWith(new GlobalOptions(), "--api-base", apiBase); + + var error = Assert.Single(parseResult.Errors); + Assert.Contains("--api-base", error.Message); + Assert.Contains("https", error.Message); + Assert.Contains("localhost", error.Message); + } + + [Fact] + public void Json_and_verbose_are_off_by_default() + { + var settings = Parse(); + + Assert.False(settings.Json); + Assert.False(settings.Verbose); + } + + [Fact] + public void Json_and_verbose_are_read_from_the_command_line() + { + var settings = Parse("--json", "--verbose"); + + Assert.True(settings.Json); + Assert.True(settings.Verbose); + } + + [Fact] + public void Global_options_reach_subcommands() + { + var globalOptions = new GlobalOptions(); + var root = new RootCommand(); + globalOptions.AddTo(root); + root.Subcommands.Add(new Command("probe")); + + var settings = globalOptions.Read(root.Parse(["probe", "--live"])); + + Assert.Equal(ApiEnvironment.LiveBaseAddress, settings.Environment.BaseAddress); + } + + private static GlobalSettings Parse(params string[] args) + { + var globalOptions = new GlobalOptions(); + var parseResult = ParseWith(globalOptions, args); + Assert.Empty(parseResult.Errors); + return globalOptions.Read(parseResult); + } + + private static ParseResult ParseWith(GlobalOptions globalOptions, params string[] args) + { + var root = new RootCommand(); + globalOptions.AddTo(root); + return root.Parse(args); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs b/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs new file mode 100644 index 0000000..b718ef5 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs @@ -0,0 +1,56 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Backoff exponencial con "equal jitter": la mitad de la espera es fija y la otra aleatoria, y nunca pasa del tope. Lo +/// usan los reintentos de GET y la reconexión de listen (tope de 30 s). +/// +public sealed class ExponentialBackoffTests +{ + private static readonly ExponentialBackoff Backoff = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(3, 4)] + [InlineData(5, 16)] + public void Each_attempt_doubles_the_wait_with_jitter_in_its_upper_half(int attempt, int exponentialSeconds) + { + var exponential = TimeSpan.FromSeconds(exponentialSeconds); + + for (var sample = 0; sample < 200; sample++) + { + var delay = Backoff.Delay(attempt); + + Assert.InRange(delay, exponential / 2, exponential); + } + } + + [Theory] + [InlineData(6)] + [InlineData(31)] + [InlineData(int.MaxValue)] + public void The_wait_never_exceeds_the_cap(int attempt) + { + for (var sample = 0; sample < 200; sample++) + { + Assert.InRange(Backoff.Delay(attempt), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30)); + } + } + + [Fact] + public void Jitter_spreads_the_waits_of_the_same_attempt() + { + var delays = Enumerable.Range(0, 50).Select(_ => Backoff.Delay(4)).Distinct().Count(); + + Assert.True(delays > 1, "Todas las esperas salieron iguales: sin jitter, varios CLI reintentarían al unísono."); + } + + [Fact] + public void Rejects_a_cap_below_the_base_or_an_attempt_below_one() + { + Assert.Throws(() => new ExponentialBackoff(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(1))); + Assert.Throws(() => Backoff.Delay(0)); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs new file mode 100644 index 0000000..a17c109 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs @@ -0,0 +1,33 @@ +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Credentials; + +namespace FiscalApi.Cli.Tests.Http; + +/// Arma el cliente con la misma fábrica que usa producción, sobre un handler falso. +internal static class FiscalApiClientBuilder +{ + public static readonly Uri BaseAddress = new("https://api.example.test/"); + + public static readonly ApiCredentials Credentials = ApiCredentials.Create("sk_test_clave-de-prueba", "tenant-de-prueba-0001"); + + /// Política real (3 intentos) sin espera entre ellos, para las pruebas que no miden tiempos. + public static readonly RetryPolicy NoDelayRetryPolicy = + new(maxAttempts: 3, baseDelay: TimeSpan.Zero, maxDelay: TimeSpan.FromSeconds(1), TimeProvider.System); + + /// Fábrica sin credenciales guardadas ni variables de entorno: las pruebas pasan las suyas. + public static FiscalApiClientFactory Factory( + HttpMessageHandler handler, TextWriter? diagnostics = null, RetryPolicy? retryPolicy = null) => + new( + handler, + new CredentialResolver(new InMemoryCredentialStore(), _ => null), + diagnostics ?? TextWriter.Null, + retryPolicy ?? NoDelayRetryPolicy); + + public static FiscalApiClient Create( + StubHttpMessageHandler handler, RetryPolicy? retryPolicy = null, TimeSpan? timeout = null) => + Factory(handler, retryPolicy: retryPolicy).Create( + new GlobalSettings(ApiEnvironment.Resolve(BaseAddress, live: false), Json: false, Verbose: false), + Credentials, + timeout ?? FiscalApiClientFactory.DefaultTimeout); +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs new file mode 100644 index 0000000..352580b --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Text.Json; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Traducción del envelope ApiResponse de fapi-back a . +/// +public sealed class FiscalApiClientErrorTests +{ + private static readonly int AllAttempts = FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts; + + [Fact] + public async Task Success_returns_the_data_of_the_envelope() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.OK, """{"succeeded":true,"message":"","details":"","data":{"name":"ok"}}"""); + + var data = await GetAsync(handler); + + Assert.Equal(new ProbeData("ok"), data); + } + + [Fact] + public async Task Unauthorized_carries_the_api_message_and_the_login_hint() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.Unauthorized, + """{"succeeded":false,"message":"No autorizado","details":"La API key no es válida","traceIdentifier":"0HN7:00000001"}"""); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(HttpStatusCode.Unauthorized, error.StatusCode); + Assert.Equal("No autorizado", error.Message); + Assert.Equal("La API key no es válida", error.Details); + Assert.Equal("0HN7:00000001", error.TraceIdentifier); + Assert.Contains("fiscalapi login", error.Hint); + Assert.Contains("FISCALAPI_API_KEY", error.Hint); + // El back también responde 401 cuando falta X-TENANT-KEY. + Assert.Contains("FISCALAPI_TENANT_KEY", error.Hint); + } + + [Theory] + [InlineData(HttpStatusCode.Found)] + [InlineData(HttpStatusCode.TemporaryRedirect)] + public async Task Redirect_is_an_api_error_with_its_status_code(HttpStatusCode status) + { + var handler = new StubHttpMessageHandler().Respond(() => + { + var response = new HttpResponseMessage(status); + response.Headers.Location = new Uri("https://otro-host.example.test/api/v4/probe"); + return response; + }); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(status, error.StatusCode); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Success_status_with_data_of_another_shape_is_a_cli_defect_not_an_unexpected_response() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.OK, """{"succeeded":true,"data":"no es un objeto"}"""); + + await Assert.ThrowsAsync(() => GetAsync(handler)); + } + + [Fact] + public async Task Not_found_has_no_hint() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.NotFound, """{"succeeded":false,"message":"Recurso no encontrado","details":""}"""); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(HttpStatusCode.NotFound, error.StatusCode); + Assert.Equal("Recurso no encontrado", error.Message); + Assert.Null(error.Hint); + } + + [Fact] + public async Task Server_error_shows_the_trace_identifier_for_support() + { + // Un 500 en un GET se reintenta: cada intento recibe la misma respuesta. + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.InternalServerError, + """{"succeeded":false,"message":"Error interno","details":"NullReferenceException","traceIdentifier":"0HN7:0000002A"}""", + times: AllAttempts); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + var rendered = Render(error); + + Assert.Equal(HttpStatusCode.InternalServerError, error.StatusCode); + Assert.Contains("Error interno", rendered); + Assert.Contains("NullReferenceException", rendered); + Assert.Contains("500", rendered); + Assert.Contains("0HN7:0000002A", rendered); + } + + [Fact] + public async Task Status_code_comes_from_the_response_even_when_the_envelope_omits_it() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.Conflict, """{"succeeded":false,"message":"Ya existe","details":"","httpStatusCode":0}"""); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(HttpStatusCode.Conflict, error.StatusCode); + } + + [Fact] + public async Task Body_that_is_not_json_is_reported_as_an_unexpected_response() + { + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.BadGateway, "502 Bad Gateway", times: AllAttempts); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(HttpStatusCode.BadGateway, error.StatusCode); + Assert.Equal("Respuesta inesperada (502).", error.Message); + Assert.Null(error.TraceIdentifier); + } + + [Fact] + public async Task Success_status_with_succeeded_false_is_an_api_error() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.OK, """{"succeeded":false,"message":"Operación rechazada","details":""}"""); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(HttpStatusCode.OK, error.StatusCode); + Assert.Equal("Operación rechazada", error.Message); + } + + [Fact] + public async Task Error_without_message_still_reports_the_status_code() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.Forbidden, """{"succeeded":false}"""); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal("La API respondió 403 sin mensaje.", error.Message); + } + + [Fact] + public async Task Network_failure_is_a_network_exception_with_exit_code_1() + { + var handler = new StubHttpMessageHandler().FailWithNetworkError(times: AllAttempts); + + var error = await Assert.ThrowsAsync(() => GetAsync(handler)); + + Assert.Equal(ExitCodes.Failure, error.ExitCode); + Assert.Contains(FiscalApiClientBuilder.BaseAddress.Host, error.Message); + } + + private static Task GetAsync(StubHttpMessageHandler handler) => + FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + private static string Render(CliException error) + { + using var writer = new StringWriter(); + error.WriteTo(writer); + return writer.ToString(); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs new file mode 100644 index 0000000..ac41e24 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +public sealed class FiscalApiClientFactoryTests +{ + private const string Success = """{"succeeded":true,"data":{"name":"ok"}}"""; + + [Fact] + public async Task Client_targets_the_resolved_environment_with_the_cli_user_agent() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success); + var factory = FiscalApiClientBuilder.Factory(handler); + + await factory.Create(Settings(live: true), FiscalApiClientBuilder.Credentials, FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + var request = Assert.Single(handler.Requests); + Assert.Equal(new Uri("https://live.fiscalapi.com/api/v4/probe"), request.RequestUri); + Assert.Equal($"fiscalapi-cli/{CliVersion.Current}", request.Headers.UserAgent.ToString()); + } + + [Fact] + public async Task Clients_share_the_handler_without_disposing_it() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success, times: 2); + var factory = FiscalApiClientBuilder.Factory(handler); + + await factory.Create(Settings(live: false), FiscalApiClientBuilder.Credentials, FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + await factory.Create(Settings(live: true), FiscalApiClientBuilder.Credentials, FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Verbose_wraps_the_shared_handler_with_the_http_log() + { + using var diagnostics = new StringWriter(); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success); + var factory = FiscalApiClientBuilder.Factory(handler, diagnostics); + + await factory.Create(Settings(live: false, verbose: true), FiscalApiClientBuilder.Credentials, FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + Assert.Contains("> GET https://test.fiscalapi.com/api/v4/probe", diagnostics.ToString()); + } + + private static GlobalSettings Settings(bool live, bool verbose = false) => + new(ApiEnvironment.Resolve(apiBase: null, live), Json: false, verbose); +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs new file mode 100644 index 0000000..16c474f --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs @@ -0,0 +1,255 @@ +using System.Net; +using System.Net.Http.Headers; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Contrato común del CLI: solo un GET se reintenta y solo ante errores transitorios. +/// Las esperas se miden con un reloj falso (), nunca durmiendo. +/// +public sealed class FiscalApiClientRetryTests +{ + private const string Success = """{"succeeded":true,"message":"","details":"","data":{"name":"ok"}}"""; + private const string Unauthorized = """{"succeeded":false,"message":"API key inválida","details":"","traceIdentifier":"0HN-401"}"""; + private static readonly int AllAttempts = FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts; + + private readonly SteppedTimeProvider _time = new(); + + [Fact] + public async Task Get_with_503_is_retried_until_it_succeeds() + { + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.ServiceUnavailable, "503 Service Temporarily Unavailable") + .RespondJson(HttpStatusCode.OK, Success); + + var data = await GetAsync(FiscalApiClientBuilder.Create(handler)); + + Assert.Equal(new ProbeData("ok"), data); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Get_with_503_on_every_attempt_fails_after_the_last_attempt() + { + var handler = new StubHttpMessageHandler().RespondHtml(HttpStatusCode.ServiceUnavailable, "503", AllAttempts); + + var error = await Assert.ThrowsAsync(() => GetAsync(FiscalApiClientBuilder.Create(handler))); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); + Assert.Equal(AllAttempts, handler.Requests.Count); + } + + [Fact] + public async Task Get_with_408_is_retried() + { + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.RequestTimeout, "408") + .RespondJson(HttpStatusCode.OK, Success); + + var data = await GetAsync(FiscalApiClientBuilder.Create(handler)); + + Assert.Equal(new ProbeData("ok"), data); + Assert.Equal(2, handler.Requests.Count); + } + + [Theory] + [InlineData(HttpRequestError.ConnectionError)] + [InlineData(HttpRequestError.ResponseEnded)] + public async Task Get_after_a_transient_network_error_is_retried(HttpRequestError error) + { + var handler = new StubHttpMessageHandler() + .FailWithNetworkError(error) + .RespondJson(HttpStatusCode.OK, Success); + + var data = await GetAsync(FiscalApiClientBuilder.Create(handler)); + + Assert.Equal(new ProbeData("ok"), data); + Assert.Equal(2, handler.Requests.Count); + } + + [Theory] + [InlineData(HttpRequestError.SecureConnectionError)] + [InlineData(HttpRequestError.NameResolutionError)] + public async Task Get_after_a_permanent_network_error_is_not_retried(HttpRequestError error) + { + var handler = new StubHttpMessageHandler() + .FailWithNetworkError(error) + .RespondJson(HttpStatusCode.OK, Success); + + await Assert.ThrowsAsync(() => GetAsync(FiscalApiClientBuilder.Create(handler))); + + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Get_that_times_out_is_not_retried() + { + var handler = new StubHttpMessageHandler().NeverRespond(); + var client = FiscalApiClientBuilder.Create(handler, timeout: TimeSpan.FromMilliseconds(50)); + + var error = await Assert.ThrowsAsync(() => GetAsync(client)); + + Assert.Contains("no respondió", error.Message); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Post_with_503_is_not_retried() + { + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.ServiceUnavailable, "503") + .RespondJson(HttpStatusCode.OK, Success); + + var error = await Assert.ThrowsAsync(() => PostAsync(FiscalApiClientBuilder.Create(handler))); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Post_after_a_network_error_is_not_retried() + { + var handler = new StubHttpMessageHandler() + .FailWithNetworkError() + .RespondJson(HttpStatusCode.OK, Success); + + await Assert.ThrowsAsync(() => PostAsync(FiscalApiClientBuilder.Create(handler))); + + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Get_with_401_is_never_retried() + { + var handler = new StubHttpMessageHandler() + .RespondJson(HttpStatusCode.Unauthorized, Unauthorized) + .RespondJson(HttpStatusCode.OK, Success); + + var error = await Assert.ThrowsAsync(() => GetAsync(FiscalApiClientBuilder.Create(handler))); + + Assert.Equal(HttpStatusCode.Unauthorized, error.StatusCode); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Get_with_429_waits_the_retry_after_seconds_before_retrying() + { + var handler = new StubHttpMessageHandler() + .RespondWithRetryAfter(HttpStatusCode.TooManyRequests, new RetryConditionHeaderValue(TimeSpan.FromSeconds(7))) + .RespondJson(HttpStatusCode.OK, Success); + + var pending = GetAsync(TimedClient(handler)); + await _time.WaitForDelayAsync(); + + // 7 s es más que cualquier backoff de esta política (máximo 1 s en el primer reintento). + _time.Advance(TimeSpan.FromSeconds(7) - TimeSpan.FromMilliseconds(1)); + Assert.Single(handler.Requests); + _time.Advance(TimeSpan.FromMilliseconds(1)); + Assert.Equal(new ProbeData("ok"), await Finished(pending)); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Get_with_503_waits_until_the_retry_after_http_date() + { + var handler = new StubHttpMessageHandler() + .RespondWithRetryAfter(HttpStatusCode.ServiceUnavailable, new RetryConditionHeaderValue(_time.GetUtcNow().AddSeconds(5))) + .RespondJson(HttpStatusCode.OK, Success); + + var pending = GetAsync(TimedClient(handler)); + await _time.WaitForDelayAsync(); + + _time.Advance(TimeSpan.FromSeconds(5) - TimeSpan.FromMilliseconds(1)); + Assert.Single(handler.Requests); + _time.Advance(TimeSpan.FromMilliseconds(1)); + Assert.Equal(new ProbeData("ok"), await Finished(pending)); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Get_with_a_retry_after_longer_than_the_maximum_delay_is_not_retried() + { + var handler = new StubHttpMessageHandler() + .RespondWithRetryAfter(HttpStatusCode.TooManyRequests, new RetryConditionHeaderValue(TimeSpan.FromMinutes(5))) + .RespondJson(HttpStatusCode.OK, Success); + + // Sin avanzar el reloj: si esperara, la operación no terminaría. + var error = await Assert.ThrowsAsync(() => Finished(GetAsync(TimedClient(handler)))); + + Assert.Equal(HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Backoff_doubles_the_wait_with_jitter_between_half_and_all_of_it() + { + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.ServiceUnavailable, "503", times: 2) + .RespondJson(HttpStatusCode.OK, Success); + + var pending = GetAsync(TimedClient(handler)); + + // Primer reintento: entre 0.5 s y 1 s (base de 1 s). + await _time.WaitForDelayAsync(); + _time.Advance(TimeSpan.FromMilliseconds(499)); + Assert.Single(handler.Requests); + _time.Advance(TimeSpan.FromMilliseconds(501)); + + // Segundo reintento: entre 1 s y 2 s. + await _time.WaitForDelayAsync(); + Assert.Equal(2, handler.Requests.Count); + _time.Advance(TimeSpan.FromMilliseconds(999)); + Assert.Equal(2, handler.Requests.Count); + _time.Advance(TimeSpan.FromMilliseconds(1001)); + Assert.Equal(new ProbeData("ok"), await Finished(pending)); + Assert.Equal(3, handler.Requests.Count); + } + + [Fact] + public async Task User_cancellation_during_the_request_propagates_and_is_not_a_network_error() + { + using var cancellation = new CancellationTokenSource(); + var handler = new StubHttpMessageHandler().NeverRespond(); + + var pending = GetAsync(FiscalApiClientBuilder.Create(handler), cancellation.Token); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => Finished(pending)); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task User_cancellation_during_the_backoff_propagates_and_is_not_a_network_error() + { + using var cancellation = new CancellationTokenSource(); + var handler = new StubHttpMessageHandler() + .RespondHtml(HttpStatusCode.ServiceUnavailable, "503") + .RespondJson(HttpStatusCode.OK, Success); + + var pending = GetAsync(TimedClient(handler), cancellation.Token); + await _time.WaitForDelayAsync(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => Finished(pending)); + Assert.Single(handler.Requests); + } + + /// Una espera de más hace fallar la prueba en vez de colgar la suite. + private static Task Finished(Task task) => task.WaitAsync(TimeSpan.FromSeconds(5)); + + private FiscalApiClient TimedClient(StubHttpMessageHandler handler) => FiscalApiClientBuilder.Create( + handler, new RetryPolicy(maxAttempts: 3, baseDelay: TimeSpan.FromSeconds(1), maxDelay: TimeSpan.FromSeconds(10), _time)); + + private static Task GetAsync(FiscalApiClient client, CancellationToken cancellationToken = default) => + client.GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, cancellationToken); + + private static Task PostAsync(FiscalApiClient client) => + client.PostAsync( + "api/v4/probe", + new ProbeRequest("x"), + TestJsonContext.Default.ProbeRequest, + TestJsonContext.Default.ApiResponseProbeData, + CancellationToken.None); +} diff --git a/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs b/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs new file mode 100644 index 0000000..028ba32 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs @@ -0,0 +1,29 @@ +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Respuestas de GET /api/v4/me/features con la forma de PersonFeaturesDto de fapi-back +/// (WebApi.Libs/Application/AppFeatures/Me/PersonFeaturesDto.cs), en camelCase. +/// +internal static class PersonFeaturesJson +{ + public const string WithWebhooksManage = """ + {"succeeded":true,"message":"","details":"","data":{ + "isOwner":true,"roles":["Owner"], + "permissions":["Invoices.Create","Webhooks.Manage"], + "denied":{}, + "features":{"webhooks":{"values":{}}}, + "subscription":{"status":"active","isAvailable":true}, + "seatAssignments":[]}} + """; + + /// Un tenant sin el feature de webhooks: el permiso aparece en denied, no en permissions. + public const string WithoutWebhooksManage = """ + {"succeeded":true,"message":"","details":"","data":{ + "isOwner":false,"roles":["Member"], + "permissions":["Invoices.Create"], + "denied":{"Webhooks.Manage":{"missingFeatures":["webhooks"]}}, + "features":{}, + "subscription":{"status":"active","isAvailable":true}, + "seatAssignments":[]}} + """; +} diff --git a/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs new file mode 100644 index 0000000..517effb --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// El handler de producción no sigue redirecciones: así un 3xx no lleva X-API-KEY ni X-TENANT-KEY a otro host +/// y un POST no se convierte en GET. Usa un servidor HTTP local en proceso, nunca Test ni Live. +/// +public sealed class RedirectTests : IDisposable +{ + private readonly HttpListener _server = new(); + private readonly ConcurrentQueue _hits = new(); + private readonly Uri _baseAddress; + + public RedirectTests() + { + _baseAddress = new Uri($"http://localhost:{FreePort()}/"); + _server.Prefixes.Add(_baseAddress.ToString()); + _server.Start(); + _ = ServeAsync(); + } + + [Fact] + public void Production_handler_does_not_follow_redirects() + { + using var handler = FiscalApiClientFactory.CreateHandler(); + + Assert.False(handler.AllowAutoRedirect); + } + + [Fact] + public async Task Redirect_from_the_api_ends_as_an_api_exception_without_a_second_request() + { + using var handler = FiscalApiClientFactory.CreateHandler(); + var settings = new GlobalSettings(ApiEnvironment.Resolve(_baseAddress, live: false), Json: false, Verbose: false); + var client = FiscalApiClientBuilder.Factory(handler) + .Create(settings, FiscalApiClientBuilder.Credentials, TimeSpan.FromSeconds(10)); + + var error = await Assert.ThrowsAsync(() => + client.GetAsync("origen", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + + Assert.Equal(HttpStatusCode.Found, error.StatusCode); + Assert.Equal(["/origen"], _hits); + } + + public void Dispose() => _server.Close(); + + private async Task ServeAsync() + { + while (_server.IsListening) + { + HttpListenerContext context; + try + { + context = await _server.GetContextAsync(); + } + catch (Exception exception) when (exception is HttpListenerException or ObjectDisposedException) + { + return; + } + + _hits.Enqueue(context.Request.Url!.AbsolutePath); + context.Response.StatusCode = (int)HttpStatusCode.Found; + context.Response.RedirectLocation = new Uri(_baseAddress, "destino").ToString(); + context.Response.Close(); + } + } + + private static int FreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs b/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs new file mode 100644 index 0000000..fb22f19 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Time.Testing; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// que avisa cuando alguien crea un timer (cada Task.Delay crea uno). +/// Así la prueba avanza el reloj solo cuando la espera ya existe: sin carreras con el hilo que hace el request. +/// +internal sealed class SteppedTimeProvider : TimeProvider +{ + private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(5); + + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 13, 12, 0, 0, TimeSpan.Zero)); + private readonly SemaphoreSlim _timersCreated = new(0); + + public override DateTimeOffset GetUtcNow() => _clock.GetUtcNow(); + + public override long GetTimestamp() => _clock.GetTimestamp(); + + public override long TimestampFrequency => _clock.TimestampFrequency; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = _clock.CreateTimer(callback, state, dueTime, period); + _timersCreated.Release(); + return timer; + } + + /// + /// Avanza sin contexto de sincronización: así lo que libera el timer (el reintento) corre en línea dentro + /// de esta llamada y la siguiente aserción ve su efecto. Con el contexto de xUnit iría a otro hilo, tarde. + /// + public void Advance(TimeSpan delta) + { + var context = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + _clock.Advance(delta); + } + finally + { + SynchronizationContext.SetSynchronizationContext(context); + } + } + + /// Espera (en tiempo real, con límite) a que la operación empiece a esperar en este reloj. + public async Task WaitForDelayAsync() + { + Assert.True(await _timersCreated.WaitAsync(WaitLimit), "La operación nunca empezó a esperar."); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs new file mode 100644 index 0000000..60c0ab9 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Simula la API: responde en orden lo que se le encola y guarda cada request recibido. +/// Así las pruebas nunca salen a Test ni a Live. +/// +public sealed class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue>> _responses = new(); + + public List Requests { get; } = []; + + /// + /// El cuerpo de cada request, en el orden de (null si no llevó). Se lee al recibirlo: el + /// cliente libera el request (y su contenido) en cuanto termina. + /// + public List RequestBodies { get; } = []; + + public StubHttpMessageHandler RespondJson(HttpStatusCode status, string json, int times = 1) => + Respond(() => Json(status, json), times); + + /// Como el 502/503 de nginx: un cuerpo que no es JSON. + public StubHttpMessageHandler RespondHtml(HttpStatusCode status, string html, int times = 1) => + Respond(() => new HttpResponseMessage(status) + { + Content = new StringContent(html, Encoding.UTF8, "text/html") + }, times); + + public StubHttpMessageHandler RespondWithRetryAfter(HttpStatusCode status, RetryConditionHeaderValue retryAfter) => + Respond(() => + { + var response = Json(status, """{"succeeded":false,"message":"Intenta más tarde"}"""); + response.Headers.RetryAfter = retryAfter; + return response; + }); + + public StubHttpMessageHandler Respond(Func createResponse, int times = 1) => + Enqueue(times, _ => Task.FromResult(createResponse())); + + public StubHttpMessageHandler FailWithNetworkError( + HttpRequestError error = HttpRequestError.ConnectionError, int times = 1) => + Enqueue(times, _ => Task.FromException( + new HttpRequestException(error, $"Falla de red simulada ({error})."))); + + /// Una API que no responde: el request termina solo cuando alguien cancela su token. + public StubHttpMessageHandler NeverRespond() => + Enqueue(1, async cancellationToken => + { + await Task.Delay(Timeout.Infinite, cancellationToken); + throw new UnreachableException(); + }); + + private static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private StubHttpMessageHandler Enqueue(int times, Func> response) + { + for (var i = 0; i < times; i++) + { + _responses.Enqueue(response); + } + + return this; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + RequestBodies.Add(request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken)); + return _responses.TryDequeue(out var respond) + ? await respond(cancellationToken) + : throw new InvalidOperationException($"Request no esperado: {request.Method} {request.RequestUri}."); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs b/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs new file mode 100644 index 0000000..8eb4f6e --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +public sealed record ProbeData(string Name); + +public sealed record ProbeRequest(string Name); + +/// Tipos de prueba con el mismo contrato JSON que el contexto del CLI. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ProbeRequest))] +public sealed partial class TestJsonContext : JsonSerializerContext; diff --git a/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs new file mode 100644 index 0000000..7b53133 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Text; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// --verbose muestra el detalle HTTP sin headers de credenciales ni cuerpos. +/// +public sealed class VerboseLoggingHandlerTests +{ + private readonly StringWriter _diagnostics = new(); + + [Fact] + public async Task Writes_request_and_response_lines_without_credential_headers() + { + var stub = new StubHttpMessageHandler().Respond(() => + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"succeeded":true,"data":"cuerpo-secreto"}""", Encoding.UTF8, "application/json") + }; + response.Headers.Add("X-Request-ID", "req-123"); + response.Headers.Add("Set-Cookie", "session=cookie-secreta"); + return response; + }); + using var client = CreateClient(stub); + client.DefaultRequestHeaders.Add("X-API-KEY", "sk_test_secreto"); + client.DefaultRequestHeaders.Add("X-TENANT-KEY", "tenant-secreto"); + client.DefaultRequestHeaders.Authorization = new("Bearer", "jwt-secreto"); + client.DefaultRequestHeaders.ProxyAuthorization = new("Basic", "proxy-secreto"); + client.DefaultRequestHeaders.Add("Cookie", "session=cookie-secreta"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + + using var content = new StringContent("""{"name":"cuerpo-secreto"}""", Encoding.UTF8, "application/json"); + using var _ = await client.PostAsync("api/v4/probe", content); + + var log = _diagnostics.ToString(); + Assert.Contains("> POST https://api.example.test/api/v4/probe", log); + Assert.Contains("> Accept: application/json", log); + Assert.Contains("> Content-Type: application/json; charset=utf-8", log); + Assert.Contains("< 200 OK", log); + Assert.Contains("< X-Request-ID: req-123", log); + Assert.Contains("< Content-Type: application/json; charset=utf-8", log); + Assert.DoesNotContain("secret", log); + foreach (var header in new[] { "X-API-KEY", "X-TENANT-KEY", "Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie" }) + { + Assert.DoesNotContain($" {header}:", log, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Writes_a_line_when_the_request_fails_on_the_network() + { + var stub = new StubHttpMessageHandler().FailWithNetworkError(HttpRequestError.ConnectionError); + using var client = CreateClient(stub); + + await Assert.ThrowsAsync(() => client.GetAsync("api/v4/probe")); + + Assert.Contains("! GET https://api.example.test/api/v4/probe falló (ConnectionError)", _diagnostics.ToString()); + } + + [Fact] + public async Task Writes_a_line_when_the_request_gets_no_response() + { + var stub = new StubHttpMessageHandler().NeverRespond(); + using var client = CreateClient(stub); + client.Timeout = TimeSpan.FromMilliseconds(50); + + await Assert.ThrowsAsync(() => client.GetAsync("api/v4/probe")); + + Assert.Contains("! GET https://api.example.test/api/v4/probe sin respuesta", _diagnostics.ToString()); + } + + private HttpClient CreateClient(StubHttpMessageHandler stub) => + new(new VerboseLoggingHandler(_diagnostics) { InnerHandler = stub }) + { + BaseAddress = FiscalApiClientBuilder.BaseAddress + }; +} diff --git a/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs b/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs new file mode 100644 index 0000000..a7f7cb6 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs @@ -0,0 +1,53 @@ +using FiscalApi.Cli.Input; + +namespace FiscalApi.Cli.Tests.Input; + +/// Entrada interactiva sin eco: el secreto nunca se escribe en la terminal. +public sealed class ConsoleSecretReaderTests +{ + private readonly StringWriter _prompts = new(); + + [Fact] + public async Task Enter_ends_the_value() + { + var value = await ReadAsync("sk_test_abc\rresto-que-no-se-lee"); + + Assert.Equal("sk_test_abc", value); + } + + [Fact] + public async Task Backspace_removes_the_last_character_and_does_nothing_on_an_empty_value() + { + var value = await ReadAsync("\b\bsk_test_abX\bc\r"); + + Assert.Equal("sk_test_abc", value); + } + + [Fact] + public async Task Control_characters_other_than_enter_and_backspace_are_ignored() + { + var value = await ReadAsync("sk_\ttest\r"); + + Assert.Equal("sk_test", value); + } + + [Fact] + public async Task Only_the_prompt_and_the_line_break_are_written_never_the_secret() + { + await ReadAsync("sk_test_secreto\r"); + + Assert.Equal("API key: " + Environment.NewLine, _prompts.ToString()); + } + + [Fact] + public async Task Cancellation_while_waiting_for_a_key_ends_the_read() + { + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var reader = new ConsoleSecretReader(_prompts, new ScriptedKeys("").TryRead); + + await Assert.ThrowsAnyAsync(() => reader.ReadAsync("API key: ", cancellation.Token)); + } + + private Task ReadAsync(string typed) => + new ConsoleSecretReader(_prompts, new ScriptedKeys(typed).TryRead).ReadAsync("API key: ", CancellationToken.None); +} diff --git a/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs b/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs new file mode 100644 index 0000000..3aae3f1 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs @@ -0,0 +1,35 @@ +using FiscalApi.Cli.Input; + +namespace FiscalApi.Cli.Tests.Input; + +/// Stdin redirigido: un valor por línea. +public sealed class RedirectedSecretReaderTests +{ + [Fact] + public async Task Byte_order_mark_at_the_start_of_the_input_is_removed() + { + // Un archivo guardado como "UTF-8 con BOM" (p. ej. por el Bloc de notas) y luego redirigido. + var reader = new RedirectedSecretReader(new StringReader("sk_test_con-bom\ntenant\n")); + + Assert.Equal("sk_test_con-bom", await reader.ReadAsync("API key: ", CancellationToken.None)); + Assert.Equal("tenant", await reader.ReadAsync("Tenant key: ", CancellationToken.None)); + } + + [Fact] + public async Task Only_the_first_line_can_carry_the_byte_order_mark() + { + var reader = new RedirectedSecretReader(new StringReader("sk_test\ntenant\n")); + + await reader.ReadAsync("API key: ", CancellationToken.None); + + Assert.Equal("tenant", await reader.ReadAsync("Tenant key: ", CancellationToken.None)); + } + + [Fact] + public async Task End_of_input_reads_as_null() + { + var reader = new RedirectedSecretReader(new StringReader("")); + + Assert.Null(await reader.ReadAsync("API key: ", CancellationToken.None)); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs b/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs new file mode 100644 index 0000000..8f24444 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs @@ -0,0 +1,30 @@ +namespace FiscalApi.Cli.Tests.Input; + +/// +/// Teclas escritas por una persona simulada, para ConsoleSecretReader. \r es Enter y \b es +/// Backspace. Cuando se acaban, no hay tecla disponible (devuelve null), como una terminal en espera. +/// +internal sealed class ScriptedKeys(string typed) +{ + private readonly Queue _pending = new(typed); + + /// Cuántas teclas se leyeron: 0 demuestra que no se le pidió nada a la persona. + public int Read { get; private set; } + + public ConsoleKeyInfo? TryRead() + { + if (!_pending.TryDequeue(out var character)) + { + return null; + } + + Read++; + return character switch + { + '\r' => new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false), + '\b' => new ConsoleKeyInfo('\b', ConsoleKey.Backspace, shift: false, alt: false, control: false), + '\t' => new ConsoleKeyInfo('\t', ConsoleKey.Tab, shift: false, alt: false, control: false), + _ => new ConsoleKeyInfo(character, ConsoleKey.NoName, shift: false, alt: false, control: false) + }; + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs b/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs new file mode 100644 index 0000000..0a519cc --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs @@ -0,0 +1,21 @@ +using System.Net; +using System.Net.Sockets; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// Un puerto local apartado sin escuchar: la conexión se rechaza, y como el socket lo retiene mientras dura la prueba, +/// ningún otro proceso puede tomarlo entre que se elige y se usa. +/// +internal sealed class ClosedPort : IDisposable +{ + private readonly Socket _socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + public ClosedPort() => _socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + public int Port => ((IPEndPoint)_socket.LocalEndPoint!).Port; + + public Uri Url(string path = "") => new($"http://127.0.0.1:{Port}/{path}"); + + public void Dispose() => _socket.Dispose(); +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs new file mode 100644 index 0000000..204a9c9 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs @@ -0,0 +1,198 @@ +using FiscalApi.Cli.Listen; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// Los reenvíos de listen corren con concurrencia acotada y ordenados por evento: las entregas de eventos distintos +/// van en paralelo, las del mismo evento en el orden en que llegaron, y el long-polling solo espera cuando todo el cupo +/// está ocupado. +/// +public sealed class DeliveryDispatcherTests +{ + private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(5); + + [Fact] + public async Task Deliveries_of_different_events_run_concurrently() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 4, CancellationToken.None); + var first = new BlockingWork(); + var second = new BlockingWork(); + + dispatcher.Enqueue("evt_1", first.RunAsync); + dispatcher.Enqueue("evt_2", second.RunAsync); + + await first.Started.WaitAsync(WaitLimit); + await second.Started.WaitAsync(WaitLimit); + first.Release(); + second.Release(); + } + + [Fact] + public async Task Deliveries_of_the_same_event_run_in_arrival_order() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 4, CancellationToken.None); + var first = new BlockingWork(); + var second = new BlockingWork(); + + dispatcher.Enqueue("evt_1", first.RunAsync); + dispatcher.Enqueue("evt_1", second.RunAsync); + await first.Started.WaitAsync(WaitLimit); + await Task.Delay(100); + + Assert.False(second.Started.IsCompleted, "La segunda entrega del evento empezó antes de que terminara la primera."); + first.Release(); + await second.Started.WaitAsync(WaitLimit); + second.Release(); + } + + [Fact] + public async Task A_failed_delivery_does_not_block_the_next_one_of_its_event() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 4, CancellationToken.None); + var next = new BlockingWork(); + + dispatcher.Enqueue("evt_1", _ => Task.FromException(new InvalidOperationException("defecto"))); + dispatcher.Enqueue("evt_1", next.RunAsync); + + await next.Started.WaitAsync(WaitLimit); + next.Release(); + } + + [Fact] + public async Task At_most_max_in_flight_deliveries_run_and_enqueue_never_waits() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + BlockingWork[] works = [new(), new(), new()]; + + for (var i = 0; i < works.Length; i++) + { + dispatcher.Enqueue($"evt_{i}", works[i].RunAsync); + } + + // Cuáles dos toman el cupo primero no está definido; lo que importa es que sean dos. + await FakeListenerApi.WaitUntilAsync(() => works.Count(work => work.Started.IsCompleted) == 2, "No corrieron dos."); + await Task.Delay(100); + Assert.Equal(2, works.Count(work => work.Started.IsCompleted)); + var waiting = works.Single(work => !work.Started.IsCompleted); + works.First(work => work.Started.IsCompleted).Release(); + await waiting.Started.WaitAsync(WaitLimit); + foreach (var work in works) + { + work.Release(); + } + } + + [Fact] + public async Task A_delivery_waiting_for_its_event_does_not_take_a_slot() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var running = new BlockingWork(); + var waitingTurn = new BlockingWork(); + var otherEvent = new BlockingWork(); + + dispatcher.Enqueue("evt_1", running.RunAsync); + dispatcher.Enqueue("evt_1", waitingTurn.RunAsync); + dispatcher.Enqueue("evt_2", otherEvent.RunAsync); + + await otherEvent.Started.WaitAsync(WaitLimit); + Assert.False(waitingTurn.Started.IsCompleted, "La segunda entrega del evento empezó antes de su turno."); + running.Release(); + await waitingTurn.Started.WaitAsync(WaitLimit); + waitingTurn.Release(); + otherEvent.Release(); + } + + [Fact] + public async Task Dispose_skips_the_deliveries_still_waiting_for_their_turn() + { + var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var running = new BlockingWork(); + var waitingTurn = new BlockingWork(); + dispatcher.Enqueue("evt_1", running.RunAsync); + dispatcher.Enqueue("evt_1", waitingTurn.RunAsync); + await running.Started.WaitAsync(WaitLimit); + + await dispatcher.DisposeAsync().AsTask().WaitAsync(WaitLimit); + + Assert.False(waitingTurn.Started.IsCompleted, "Una entrega en espera de turno corrió después de detener la escucha."); + } + + [Fact] + public async Task Dispose_cancels_the_work_in_flight_and_waits_for_it() + { + var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var started = new TaskCompletionSource(); + var observedCancellation = new TaskCompletionSource(); + dispatcher.Enqueue("evt_1", async cancellationToken => + { + started.SetResult(); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + finally + { + observedCancellation.SetResult(); + } + }); + await started.Task.WaitAsync(WaitLimit); + + await dispatcher.DisposeAsync().AsTask().WaitAsync(WaitLimit); + + Assert.True(observedCancellation.Task.IsCompleted, "Dispose terminó con trabajo en curso."); + } + + [Fact] + public async Task An_unexpected_failure_surfaces_on_the_next_check() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var failed = new TaskCompletionSource(); + dispatcher.Enqueue("evt_1", _ => + { + failed.SetResult(); + throw new InvalidOperationException("defecto del CLI"); + }); + await failed.Task.WaitAsync(WaitLimit); + + var error = await WaitForFaultAsync(dispatcher); + + Assert.Equal("defecto del CLI", error.Message); + } + + private static async Task WaitForFaultAsync(DeliveryDispatcher dispatcher) + { + var deadline = DateTime.UtcNow + WaitLimit; + while (DateTime.UtcNow < deadline) + { + try + { + dispatcher.ThrowIfFaulted(); + } + catch (InvalidOperationException exception) + { + return exception; + } + + await Task.Delay(10); + } + + throw new TimeoutException("El despachador nunca informó la falla."); + } + + /// Trabajo que avisa cuando empieza y no termina hasta que la prueba lo suelta. + private sealed class BlockingWork + { + private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Started => _started.Task; + + public void Release() => _release.TrySetResult(); + + public async Task RunAsync(CancellationToken cancellationToken) + { + _started.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken); + } + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs new file mode 100644 index 0000000..32dff86 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -0,0 +1,317 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// La escucha de CLI de la API en Kestrel, en proceso: abre sesiones, responde el long-polling con lo +/// que la prueba encola y guarda cada acuse. Sin pasos encolados, el long-polling espera hasta que el CLI se va, como el +/// real cuando no hay entregas. Nunca se llama a Test ni a Live. +/// +internal sealed class FakeListenerApi : IAsyncDisposable +{ + public const string ListenerId = "wh_listener_0001"; + public const string Secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + + private const string Route = "/api/v4/webhooks"; + private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(10); + + private readonly WebApplication _app; + private readonly ConcurrentQueue> _polls = new(); + private readonly ConcurrentDictionary> _ackRejections = new(); + private readonly ConcurrentQueue<(int Status, string Message)> _openFailures = new(); + private readonly ConcurrentDictionary _heldAcks = new(); + private readonly ConcurrentQueue _opens = new(); + private readonly ConcurrentQueue _openTimes = new(); + private readonly ConcurrentQueue _pollsReceived = new(); + private readonly ConcurrentQueue _acks = new(); + private int _sessions; + + private FakeListenerApi(WebApplication app) => _app = app; + + public Uri BaseAddress { get; private set; } = null!; + + public IReadOnlyCollection Opens => _opens; + + /// Cuándo llegó cada apertura, incluidas las rechazadas. + public IReadOnlyCollection OpenTimes => _openTimes; + + public IReadOnlyCollection PollsReceived => _pollsReceived; + + public IReadOnlyCollection Acks => _acks; + + /// Con certificado, escucha en https. + public static async Task StartAsync(X509Certificate2? certificate = null) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseShutdownTimeout(TimeSpan.FromSeconds(1)); + builder.WebHost.UseKestrel(kestrel => kestrel.Listen(System.Net.IPAddress.Loopback, 0, listen => + { + if (certificate is not null) + { + listen.UseHttps(certificate); + } + })); + var app = builder.Build(); + var api = new FakeListenerApi(app); + api.MapRoutes(); + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()! + .Addresses.Single(); + api.BaseAddress = new Uri(address + "/"); + return api; + } + + /// El próximo long-polling responde estas entregas. + public FakeListenerApi Deliver(params FakeDelivery[] deliveries) + { + var data = new JsonArray(deliveries.Select(delivery => (JsonNode)delivery.ToJson()).ToArray()); + _polls.Enqueue(context => WriteJsonAsync(context, StatusCodes.Status200OK, + new JsonObject { ["succeeded"] = true, ["data"] = data })); + return this; + } + + /// El próximo long-polling responde un error con el envelope de fapi-back. + public FakeListenerApi FailPoll(int status, string message) + { + _polls.Enqueue(context => WriteErrorAsync(context, status, message)); + return this; + } + + /// El próximo long-polling corta la conexión sin responder: una caída de red a la mitad. + public FakeListenerApi DropPoll(int times = 1) + { + for (var i = 0; i < times; i++) + { + _polls.Enqueue(context => + { + context.Abort(); + return Task.CompletedTask; + }); + } + + return this; + } + + /// La próxima apertura responde un error (después de la primera, que la prueba no puede rechazar). + public FakeListenerApi FailOpen(int status, string message) + { + _openFailures.Enqueue((status, message)); + return this; + } + + /// Los próximos acuses de esta entrega no responden hasta que el CLI se va. + public FakeListenerApi HoldAck(string deliveryId, int times = 1) + { + _heldAcks[deliveryId] = times; + return this; + } + + /// Los próximos acuses de esta entrega responden un error en lugar de 200. + public FakeListenerApi RejectAck(string deliveryId, int status, string message, int times = 1) + { + var rejections = _ackRejections.GetOrAdd(deliveryId, _ => new ConcurrentQueue<(int, string)>()); + for (var i = 0; i < times; i++) + { + rejections.Enqueue((status, message)); + } + + return this; + } + + public Task WaitForAcksAsync(int count) => + WaitUntilAsync(() => _acks.Count >= count, $"La API no recibió {count} acuses."); + + public Task WaitForPollsAsync(int count) => + WaitUntilAsync(() => _pollsReceived.Count >= count, $"La API no recibió {count} long-polling."); + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + internal static async Task WaitUntilAsync(Func condition, string failure) + { + var deadline = DateTime.UtcNow + WaitLimit; + while (!condition()) + { + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException(failure); + } + + await Task.Delay(10); + } + } + + private void MapRoutes() + { + _app.MapPost($"{Route}/cli-listener", OpenAsync); + _app.MapGet($"{Route}/{{id}}/cli-deliveries", PollAsync); + _app.MapPost($"{Route}/{{id}}/cli-deliveries/{{deliveryId}}/ack", AckAsync); + } + + private async Task OpenAsync(HttpContext context) + { + _opens.Enqueue((await JsonNode.ParseAsync(context.Request.Body))!.AsObject()); + _openTimes.Enqueue(DateTime.UtcNow); + // La primera apertura siempre sale bien: las fallas encoladas son para las reaperturas. + if (_opens.Count > 1 && _openFailures.TryDequeue(out var failure)) + { + await WriteErrorAsync(context, failure.Status, failure.Message); + return; + } + + var session = Interlocked.Increment(ref _sessions); + await WriteJsonAsync(context, StatusCodes.Status200OK, new JsonObject + { + ["succeeded"] = true, + ["data"] = new JsonObject + { + ["id"] = ListenerId, + ["sessionId"] = SessionId(session), + ["sessionExpiresAt"] = "2026-10-13T12:01:00Z", + ["secret"] = Secret + } + }); + } + + public static string SessionId(int number) => $"ses_{number:0000}"; + + private async Task PollAsync(HttpContext context, string id) + { + _pollsReceived.Enqueue(new RecordedPoll(id, context.Request.Query["sessionId"].ToString(), + context.Request.Query["waitSeconds"].ToString())); + if (_polls.TryDequeue(out var respond)) + { + await respond(context); + return; + } + + // Sin entregas: espera hasta que el CLI se va (Ctrl+C o fin de la prueba) y responde la lista vacía. + try + { + await Task.Delay(Timeout.Infinite, context.RequestAborted); + } + catch (OperationCanceledException) + { + } + } + + private async Task AckAsync(HttpContext context, string id, string deliveryId) + { + var body = (await JsonNode.ParseAsync(context.Request.Body))!.AsObject(); + _acks.Enqueue(new RecordedAck(id, deliveryId, body)); + if (_heldAcks.TryGetValue(deliveryId, out var held) && held > 0) + { + _heldAcks[deliveryId] = held - 1; + try + { + await Task.Delay(Timeout.Infinite, context.RequestAborted); + } + catch (OperationCanceledException) + { + return; + } + } + + if (_ackRejections.TryGetValue(deliveryId, out var rejections) && rejections.TryDequeue(out var rejection)) + { + await WriteErrorAsync(context, rejection.Status, rejection.Message); + return; + } + + await WriteJsonAsync(context, StatusCodes.Status200OK, new JsonObject { ["succeeded"] = true, ["data"] = true }); + } + + private static Task WriteErrorAsync(HttpContext context, int status, string message) => + WriteJsonAsync(context, status, new JsonObject + { + ["succeeded"] = false, + ["message"] = message, + ["details"] = message, + ["httpStatusCode"] = status, + ["traceIdentifier"] = "0HN7:00000001" + }); + + private static async Task WriteJsonAsync(HttpContext context, int status, JsonObject body) + { + context.Response.StatusCode = status; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsync(body.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web))); + } + + public sealed record RecordedPoll(string ListenerId, string SessionId, string WaitSeconds); + + public sealed record RecordedAck(string ListenerId, string DeliveryId, JsonObject Body) + { + public string? SessionId => Body["sessionId"]?.GetValue(); + + public int? StatusCode => Body["statusCode"]?.GetValue(); + + public string? Error => Body["error"]?.GetValue(); + + public int? LatencyMs => Body["latencyMs"]?.GetValue(); + } +} + +/// Una entrega como la responde el long-polling: deliveryId, los headers firmados y el cuerpo tal cual. +internal sealed record FakeDelivery( + string DeliveryId, + string EventId, + string Type, + string? Body = null, + string? SignatureHeader = null, + bool WithoutEventIdHeader = false) +{ + public const string Timestamp = "1791900000"; + + /// La firma real de Standard Webhooks con el secreto de la escucha: el receptor la puede verificar. + public string Signature => + SignatureHeader ?? StandardWebhooksSignature.Sign(EventId, Timestamp, Encoding.UTF8.GetBytes(EnvelopeBody)); + + /// El sobre del evento con un formato que un re-serializador cambiaría (sangría y espacios). + public string EnvelopeBody => Body ?? $$$""" + {"id":"{{{EventId}}}","type":"{{{Type}}}","createdAt":"2026-10-12T18:47:53Z","personId":null, + "data":{"relatedObject":{"id":"inv_1","type":"invoice","url":"/api/v4/invoices/inv_1"},"status":"X","previousStatus":"V"}} + """; + + public IReadOnlyDictionary Headers + { + get + { + var headers = new Dictionary + { + ["webhook-id"] = EventId, + ["webhook-timestamp"] = Timestamp, + ["webhook-signature"] = Signature + }; + if (WithoutEventIdHeader) + { + headers.Remove("webhook-id"); + } + + return headers; + } + } + + public JsonObject ToJson() => new() + { + ["deliveryId"] = DeliveryId, + ["headers"] = new JsonObject(Headers.Select(header => + KeyValuePair.Create(header.Key, (JsonNode?)JsonValue.Create(header.Value)))), + ["body"] = EnvelopeBody + }; +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs new file mode 100644 index 0000000..e817859 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// El receptor local de la persona (--forward-to) en Kestrel, en proceso: guarda cada request con sus bytes y +/// headers exactos y responde lo que decide la prueba (200 por omisión). +/// +internal sealed class FakeReceiver : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly ConcurrentQueue _requests = new(); + + private FakeReceiver(WebApplication app, Func> respond) + { + _app = app; + _app.Run(async context => + { + var request = await ReceivedRequest.ReadAsync(context.Request); + _requests.Enqueue(request); + context.Response.StatusCode = await respond(request, context.RequestAborted); + }); + } + + public Uri Url { get; private set; } = null!; + + public IReadOnlyCollection Requests => _requests; + + /// El código de cada request; puede tardar para simular un receptor lento. + /// Con certificado, escucha en https. + public static async Task StartAsync( + Func>? respond = null, X509Certificate2? certificate = null) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseShutdownTimeout(TimeSpan.FromSeconds(1)); + builder.WebHost.UseKestrel(kestrel => kestrel.Listen(System.Net.IPAddress.Loopback, 0, listen => + { + if (certificate is not null) + { + listen.UseHttps(certificate); + } + })); + var receiver = new FakeReceiver(builder.Build(), respond ?? ((_, _) => Task.FromResult(StatusCodes.Status200OK))); + await receiver._app.StartAsync(); + var address = receiver._app.Services.GetRequiredService().Features + .Get()!.Addresses.Single(); + receiver.Url = new Uri(address + "/webhooks/fiscalapi"); + return receiver; + } + + public Task WaitForRequestsAsync(int count) => + FakeListenerApi.WaitUntilAsync(() => _requests.Count >= count, $"El receptor no recibió {count} requests."); + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} + +/// Lo que llegó al receptor, copiado: Kestrel recicla los headers de un request al terminarlo. +internal sealed record ReceivedRequest(string Method, string Path, IReadOnlyDictionary Headers, byte[] Body) +{ + public static async Task ReadAsync(HttpRequest request) + { + using var body = new MemoryStream(); + await request.Body.CopyToAsync(body); + var headers = request.Headers.ToDictionary(header => header.Key, header => header.Value.ToString(), + StringComparer.OrdinalIgnoreCase); + return new ReceivedRequest(request.Method, request.Path.ToString(), headers, body.ToArray()); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs new file mode 100644 index 0000000..8faa5d3 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -0,0 +1,865 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Listen; +using FiscalApi.Cli.Tests.Credentials; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; +using Microsoft.AspNetCore.Http; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// fiscalapi listen contra la escucha de CLI de la API simulada en Kestrel () +/// y un receptor local también en Kestrel (), con sockets reales. Integridad de la firma, +/// acuses, reconexión y salida. +/// +public sealed class ListenCommandTests : IAsyncLifetime +{ + private const string ApiKey = "sk_test_clave-de-listen"; + private const string TenantKey = "tenant-de-listen-0001"; + private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(10); + + // Los plazos del contrato, más cortos: la prueba no espera 15 s a un receptor ni 30 s a una reconexión. El reenvío + // conserva unos segundos porque Windows tarda ~2 s en rechazar una conexión a un puerto local cerrado. + private static readonly ListenTimings FastTimings = ListenTimings.Default with + { + ForwardTimeout = TimeSpan.FromSeconds(5), + Reconnect = new ExponentialBackoff(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)) + }; + + private readonly StringWriter _outBuffer = new(); + private readonly StringWriter _errorBuffer = new(); + private readonly CancellationTokenSource _ctrlC = new(); + private FakeListenerApi _api = null!; + private FakeReceiver? _receiver; + private Task? _run; + private ListenTimings _timings = FastTimings; + + private string Out => _outBuffer.ToString(); + + private string Error => _errorBuffer.ToString(); + + public async Task InitializeAsync() => _api = await FakeListenerApi.StartAsync(); + + public async Task DisposeAsync() + { + await _ctrlC.CancelAsync(); + if (_run is not null) + { + await _run.WaitAsync(WaitLimit); + } + + if (_receiver is not null) + { + await _receiver.DisposeAsync(); + } + + await _api.DisposeAsync(); + _ctrlC.Dispose(); + } + + [Fact] + public async Task Receiver_gets_the_body_and_signed_headers_byte_for_byte() + { + const string body = "{\"id\":\"evt_0001\",\"type\":\"invoice.canceled\",\n \"data\":{\"nota\":\"Facturación de ñandú: 🚀✅\"," + + "\"espacios\":\" a b \", \"escape\":\"\\u00e9\\\"\"} }\n"; + var delivery = new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled", body); + _api.Deliver(delivery); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + + var request = Assert.Single(_receiver.Requests); + Assert.Equal("POST", request.Method); + Assert.Equal("/webhooks/fiscalapi", request.Path); + Assert.Equal(Encoding.UTF8.GetBytes(body), request.Body); + Assert.Equal("evt_0001", request.Headers["webhook-id"]); + Assert.Equal(FakeDelivery.Timestamp, request.Headers["webhook-timestamp"]); + Assert.Equal(delivery.Signature, request.Headers["webhook-signature"]); + Assert.Equal(StandardWebhooksSignature.Sign("evt_0001", FakeDelivery.Timestamp, request.Body), + request.Headers["webhook-signature"]); + Assert.Equal("application/json; charset=utf-8", request.Headers["Content-Type"]); + } + + [Fact] + public async Task During_a_secret_rotation_both_signatures_reach_the_receiver_unchanged() + { + // Standard Webhooks: varias firmas separadas por un espacio mientras rota el secreto. + const string rotation = "v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,bm9uY2UtZGUtbGEtZmlybWEtYW50ZXJpb3I="; + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled", SignatureHeader: rotation)); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + + Assert.Equal(rotation, Assert.Single(_receiver.Requests).Headers["webhook-signature"]); + } + + [Theory] + [InlineData(200)] + [InlineData(422)] + public async Task Ack_carries_the_receiver_status_code_and_the_session(int receiverStatus) + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync((_, _) => Task.FromResult(receiverStatus)); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var ack = Assert.Single(_api.Acks); + Assert.Equal(FakeListenerApi.ListenerId, ack.ListenerId); + Assert.Equal("dlv_0001", ack.DeliveryId); + Assert.Equal(FakeListenerApi.SessionId(1), ack.SessionId); + Assert.Equal(receiverStatus, ack.StatusCode); + Assert.InRange(ack.LatencyMs!.Value, 0, 10_000); + Assert.False(ack.Body.ContainsKey("error"), "Un acuse con statusCode no lleva error: la API pide uno de los dos."); + } + + [Fact] + public async Task A_refused_connection_is_acked_with_an_error() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + using var closed = new ClosedPort(); + + StartListen("--forward-to", closed.Url("webhooks").ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var ack = Assert.Single(_api.Acks); + Assert.Equal("ConnectionFailed", ack.Error); + Assert.Null(ack.StatusCode); + Assert.Equal(FakeListenerApi.SessionId(1), ack.SessionId); + } + + [Fact] + public async Task A_receiver_that_does_not_answer_in_time_is_acked_with_a_timeout() + { + _timings = FastTimings with { ForwardTimeout = TimeSpan.FromMilliseconds(500) }; + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(async (_, aborted) => + { + await Task.Delay(Timeout.Infinite, aborted); + return StatusCodes.Status200OK; + }); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var ack = Assert.Single(_api.Acks); + Assert.Equal("Timeout", ack.Error); + Assert.Null(ack.StatusCode); + Assert.InRange(ack.LatencyMs!.Value, 400, 10_000); + } + + [Fact] + public async Task Without_forward_to_events_are_only_shown_and_acked() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled"), + new FakeDelivery("dlv_0002", "evt_0002", "invoice.created")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(2)); + + Assert.Equal(["dlv_0001", "dlv_0002"], _api.Acks.Select(ack => ack.DeliveryId).Order()); + Assert.All(_api.Acks, ack => + { + // Asumido: sin destino local, la entrega llegó a su destino, la terminal. + Assert.Equal(200, ack.StatusCode); + Assert.Equal(0, ack.LatencyMs); + Assert.Null(ack.Error); + Assert.Equal(FakeListenerApi.SessionId(1), ack.SessionId); + }); + Assert.Matches(EventLine("invoice.canceled", "evt_0001", suffix: ""), Out); + Assert.Matches(EventLine("invoice.created", "evt_0002", suffix: ""), Out); + } + + [Fact] + public async Task Each_forwarded_event_prints_a_line_with_the_receiver_result() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync((_, _) => Task.FromResult(StatusCodes.Status500InternalServerError)); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Matches(EventLine("invoice.canceled", "evt_0001", suffix: " [500]"), Out); + } + + [Fact] + public async Task A_forward_without_response_prints_the_error_on_its_line() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + using var closed = new ClosedPort(); + + StartListen("--forward-to", closed.Url("webhooks").ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Matches(EventLine("invoice.canceled", "evt_0001", suffix: " [error: ConnectionFailed]"), Out); + } + + [Theory] + [InlineData(null, new[] { "*" })] + [InlineData("invoice.canceled", new[] { "invoice.canceled" })] + [InlineData("invoice.canceled,invoice.created", new[] { "invoice.canceled", "invoice.created" })] + [InlineData(" invoice.canceled , invoice.created ", new[] { "invoice.canceled", "invoice.created" })] + public async Task Events_option_reaches_the_session_opening(string? events, string[] enabledEvents) + { + StartListen(events is null ? [] : ["--events", events]); + await ExpectAsync(_api.WaitForPollsAsync(1)); + + var open = Assert.Single(_api.Opens); + Assert.Equal(enabledEvents, open["enabledEvents"]!.AsArray().Select(type => type!.GetValue())); + } + + [Fact] + public async Task Headers_option_is_added_to_each_forward() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString(), "--headers", "X-Origen:fiscalapi-cli, X-Traza: abc:123"); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + + var request = Assert.Single(_receiver.Requests); + Assert.Equal("fiscalapi-cli", request.Headers["X-Origen"]); + Assert.Equal("abc:123", request.Headers["X-Traza"]); + Assert.Equal("evt_0001", request.Headers["webhook-id"]); + } + + [Fact] + public async Task A_slow_delivery_does_not_hold_the_long_polling() + { + var releaseSlow = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _api.Deliver(new FakeDelivery("dlv_lenta", "evt_0001", "invoice.canceled")) + .Deliver(new FakeDelivery("dlv_rapida", "evt_0002", "invoice.created")); + _receiver = await FakeReceiver.StartAsync(async (request, aborted) => + { + if (request.Headers["webhook-id"] == "evt_0001") + { + await releaseSlow.Task.WaitAsync(aborted); + } + + return StatusCodes.Status200OK; + }); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Equal("dlv_rapida", Assert.Single(_api.Acks).DeliveryId); + releaseSlow.SetResult(); + await ExpectAsync(_api.WaitForAcksAsync(2)); + Assert.Equal(200, _api.Acks.Single(ack => ack.DeliveryId == "dlv_lenta").StatusCode); + } + + [Fact] + public async Task A_network_drop_reconnects_with_backoff_without_losing_deliveries() + { + // RetryPolicy reintenta dentro de cada long-polling (los 503 hasta 3 veces); lo que queda llega al bucle. + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")) + .DropPoll(times: 3) + .FailPoll(StatusCodes.Status503ServiceUnavailable, "Servicio no disponible") + .FailPoll(StatusCodes.Status503ServiceUnavailable, "Servicio no disponible") + .FailPoll(StatusCodes.Status503ServiceUnavailable, "Servicio no disponible") + .Deliver(new FakeDelivery("dlv_0002", "evt_0002", "invoice.created")); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(2)); + + Assert.Equal(["dlv_0001", "dlv_0002"], _api.Acks.Select(ack => ack.DeliveryId).Order()); + Assert.Equal(["evt_0001", "evt_0002"], _receiver.Requests.Select(request => request.Headers["webhook-id"]).Order()); + Assert.All(_api.PollsReceived, poll => Assert.Equal(FakeListenerApi.SessionId(1), poll.SessionId)); + Assert.Single(_api.Opens); + Assert.Matches(@"Se perdió la conexión con FiscalAPI \(.+\); reintentando en \d+(\.\d)? s\.", Error); + Assert.Contains("Conexión con FiscalAPI restablecida.", Error); + Assert.False(_run!.IsCompleted, "listen terminó por una caída de red."); + } + + [Fact] + public async Task An_expired_session_is_reopened_without_exiting() + { + _api.FailPoll(StatusCodes.Status410Gone, "La sesión de fiscalapi listen caducó; vuelve a abrirla.") + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen("--events", "invoice.canceled"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Equal(2, _api.Opens.Count); + Assert.All(_api.Opens, open => Assert.Equal("invoice.canceled", open["enabledEvents"]![0]!.GetValue())); + Assert.Equal( + [FakeListenerApi.SessionId(1), FakeListenerApi.SessionId(2)], + _api.PollsReceived.Take(2).Select(poll => poll.SessionId)); + Assert.Equal(FakeListenerApi.SessionId(2), Assert.Single(_api.Acks).SessionId); + Assert.Contains("La sesión de fiscalapi listen caducó; se abre una nueva.", Error); + Assert.False(_run!.IsCompleted, "listen terminó por una sesión caducada."); + } + + [Fact] + public async Task A_replaced_session_exits_1_with_the_api_text() + { + _api.FailPoll(StatusCodes.Status409Conflict, "Otra sesión de fiscalapi listen reemplazó a esta."); + + StartListen(); + + Assert.Equal(ExitCodes.Failure, await ExitCodeAsync()); + Assert.Contains("Error: Otra sesión de fiscalapi listen reemplazó a esta.", Error); + Assert.Single(_api.Opens); + } + + [Fact] + public async Task Ctrl_c_exits_130() + { + StartListen(); + await ExpectAsync(_api.WaitForPollsAsync(1)); + + Assert.Equal(ExitCodes.Cancelled, await PressCtrlCAsync()); + } + + [Fact] + public async Task Ctrl_c_during_a_forward_exits_130_without_waiting_for_the_receiver() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(async (_, aborted) => + { + await Task.Delay(Timeout.Infinite, aborted); + return StatusCodes.Status200OK; + }); + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + + Assert.Equal(ExitCodes.Cancelled, await PressCtrlCAsync()); + Assert.Empty(_api.Acks); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_409_on_the_ack_is_reported_only_with_verbose(bool verbose) + { + const string notAwaiting = "La entrega ya no espera un acuse."; + // La segunda entrega es del mismo evento: el orden por evento garantiza que el acuse de la primera, con su línea, + // terminó antes de que la segunda se acusara. + _api.RejectAck("dlv_0001", StatusCodes.Status409Conflict, notAwaiting) + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")) + .Deliver(new FakeDelivery("dlv_0002", "evt_0001", "invoice.canceled")); + + StartListen(verbose ? ["--verbose"] : []); + await ExpectAsync(_api.WaitForAcksAsync(2)); + + Assert.Equal(verbose, Error.Contains($"No se registró el acuse de la entrega dlv_0001: {notAwaiting}")); + Assert.Equal(2, _api.Acks.Count); + Assert.False(_run!.IsCompleted, "listen terminó por un acuse tardío."); + } + + [Fact] + public async Task Another_rejected_ack_is_reported_and_listen_goes_on() + { + _api.RejectAck("dlv_0001", StatusCodes.Status404NotFound, "No se encontró el recurso dlv_0001.") + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")) + .Deliver(new FakeDelivery("dlv_0002", "evt_0002", "invoice.created")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(2)); + await ExpectAsync(_api.WaitForPollsAsync(3)); + + Assert.Contains("No se registró el acuse de la entrega dlv_0001: No se encontró el recurso dlv_0001.", Error); + Assert.False(_run!.IsCompleted, "listen terminó por un acuse rechazado."); + } + + [Fact] + public async Task An_ack_that_fails_on_the_network_or_with_a_5xx_is_retried() + { + _api.RejectAck("dlv_0001", StatusCodes.Status503ServiceUnavailable, "Servicio no disponible", times: 2) + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(3)); + + Assert.All(_api.Acks, ack => Assert.Equal(("dlv_0001", 200), (ack.DeliveryId, ack.StatusCode))); + Assert.DoesNotContain("No se registró el acuse", Error); + } + + [Fact] + public async Task Print_secret_prints_only_the_secret_and_exits_0() + { + StartListen("--print-secret", "--events", "invoice.canceled"); + + Assert.Equal(ExitCodes.Success, await ExitCodeAsync()); + Assert.Equal(FakeListenerApi.Secret + Environment.NewLine, Out); + Assert.Empty(Error); + Assert.Equal("invoice.canceled", Assert.Single(_api.Opens)["enabledEvents"]![0]!.GetValue()); + Assert.Empty(_api.PollsReceived); + } + + [Fact] + public async Task Print_secret_with_json_prints_a_json_object() + { + StartListen("--print-secret", "--json"); + + Assert.Equal(ExitCodes.Success, await ExitCodeAsync()); + var line = Assert.Single(Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + Assert.Equal(FakeListenerApi.Secret, JsonNode.Parse(line)!["secret"]!.GetValue()); + } + + [Fact] + public async Task Json_mode_writes_one_json_object_per_event_and_nothing_else_to_stdout() + { + var forwarded = new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled"); + var failed = new FakeDelivery("dlv_0002", "evt_0002", "invoice.created"); + _api.Deliver(forwarded, failed); + _receiver = await FakeReceiver.StartAsync((request, _) => Task.FromResult( + request.Headers["webhook-id"] == "evt_0001" ? StatusCodes.Status200OK : StatusCodes.Status400BadRequest)); + + StartListen("--forward-to", _receiver.Url.ToString(), "--json"); + await ExpectAsync(_api.WaitForAcksAsync(2)); + + var lines = Out.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonNode.Parse(line)!.AsObject()) + .OrderBy(line => line["deliveryId"]!.GetValue()) + .ToList(); + Assert.Equal(2, lines.Count); + AssertEventRecord(lines[0], forwarded, statusCode: 200); + AssertEventRecord(lines[1], failed, statusCode: 400); + Assert.Contains($"Listo. Tu secreto de firma es {FakeListenerApi.Secret}", Error); + } + + private static void AssertEventRecord(JsonObject record, FakeDelivery delivery, int statusCode) + { + Assert.True(DateTimeOffset.TryParse(record["time"]!.GetValue(), out _), "time no es una fecha ISO 8601."); + Assert.Equal(delivery.DeliveryId, record["deliveryId"]!.GetValue()); + Assert.Equal(delivery.EventId, record["eventId"]!.GetValue()); + Assert.Equal(delivery.Type, record["type"]!.GetValue()); + Assert.Equal(statusCode, record["statusCode"]!.GetValue()); + Assert.True(record["latencyMs"]!.GetValue() >= 0); + Assert.False(record.ContainsKey("error"), "Con statusCode no va error."); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(delivery.EnvelopeBody), record["event"]), "event no es el sobre recibido."); + } + + [Fact] + public async Task Json_mode_reports_a_forward_without_response_with_its_error() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + using var closed = new ClosedPort(); + + StartListen("--forward-to", closed.Url("webhooks").ToString(), "--json"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var record = JsonNode.Parse(Out)!.AsObject(); + Assert.Equal("ConnectionFailed", record["error"]!.GetValue()); + Assert.False(record.ContainsKey("statusCode"), "Sin respuesta no va statusCode."); + } + + [Fact] + public async Task Json_mode_without_forward_to_writes_the_event_without_a_result() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen("--json"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var record = JsonNode.Parse(Out)!.AsObject(); + Assert.Equal("evt_0001", record["eventId"]!.GetValue()); + Assert.Equal(["time", "deliveryId", "eventId", "type", "event"], record.Select(property => property.Key)); + } + + [Fact] + public async Task Skip_verify_accepts_a_self_signed_receiver() + { + using var certificate = SelfSignedCertificate.ForLoopback(); + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(certificate: certificate); + + StartListen("--forward-to", _receiver.Url.ToString(), "--skip-verify"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Equal(Uri.UriSchemeHttps, _receiver.Url.Scheme); + Assert.Single(_receiver.Requests); + Assert.Equal(200, Assert.Single(_api.Acks).StatusCode); + } + + [Fact] + public async Task Without_skip_verify_a_self_signed_receiver_is_acked_as_a_tls_failure() + { + using var certificate = SelfSignedCertificate.ForLoopback(); + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(certificate: certificate); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Empty(_receiver.Requests); + Assert.Equal("TlsFailed", Assert.Single(_api.Acks).Error); + } + + [Fact] + public async Task Skip_verify_never_relaxes_tls_toward_the_api() + { + using var certificate = SelfSignedCertificate.ForLoopback(); + await _api.DisposeAsync(); + _api = await FakeListenerApi.StartAsync(certificate); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString(), "--skip-verify"); + + Assert.Equal(ExitCodes.Failure, await ExitCodeAsync()); + Assert.Contains($"No se pudo conectar con {_api.BaseAddress}", Error); + Assert.Empty(_api.Opens); + } + + [Fact] + public async Task An_unreachable_api_at_start_exits_1_without_retrying() + { + using var closed = new ClosedPort(); + var unreachable = closed.Url(); + + StartListen(apiBase: unreachable); + + Assert.Equal(ExitCodes.Failure, await ExitCodeAsync()); + Assert.Contains($"No se pudo conectar con {unreachable}", Error); + Assert.DoesNotContain("reintentando", Error); + } + + [Fact] + public async Task Api_credentials_never_reach_the_forward_target() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + _receiver = await FakeReceiver.StartAsync(); + + StartListen("--forward-to", _receiver.Url.ToString(), "--verbose"); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + + var request = Assert.Single(_receiver.Requests); + Assert.DoesNotContain(request.Headers, header => + header.Key.Equals("X-API-KEY", StringComparison.OrdinalIgnoreCase) || + header.Key.Equals("X-TENANT-KEY", StringComparison.OrdinalIgnoreCase) || + header.Value.Contains(ApiKey, StringComparison.Ordinal) || + header.Value.Contains(TenantKey, StringComparison.Ordinal)); + } + + [Fact] + public async Task Opening_prints_the_approved_ready_line_with_the_secret() + { + StartListen(); + await ExpectAsync(_api.WaitForPollsAsync(1)); + + Assert.Contains($"Listo. Tu secreto de firma es {FakeListenerApi.Secret} (escucha {FakeListenerApi.ListenerId})", Error); + var poll = Assert.Single(_api.PollsReceived); + Assert.Equal(new FakeListenerApi.RecordedPoll(FakeListenerApi.ListenerId, FakeListenerApi.SessionId(1), "25"), poll); + } + + [Fact] + public async Task The_secret_appears_only_on_the_ready_line_even_with_verbose() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen("--verbose"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var output = Out + Error; + Assert.Equal(1, Regex.Count(output, Regex.Escape(FakeListenerApi.Secret))); + Assert.Contains("> POST", Error); + Assert.DoesNotContain(ApiKey, output); + Assert.DoesNotContain(TenantKey, output); + } + + [Fact] + public async Task Print_secret_help_warns_that_it_replaces_a_running_listen() + { + StartListen("--help"); + + Assert.Equal(ExitCodes.Success, await ExitCodeAsync()); + Assert.Contains("Abre una sesión: un listen en curso con la misma API key termina.", Out); + } + + [Fact] + public async Task A_chain_of_one_event_neither_holds_the_long_polling_nor_forwards_expired_deliveries() + { + // 25 entregas del mismo evento (un lote completo) y un receptor de 300 ms: con un lease de 1 s solo caben unas + // cuantas; el resto vence esperando su turno y no se reenvía (la API ya las cerró con AckTimeout). + _timings = FastTimings with { DeliveryLease = TimeSpan.FromSeconds(1), MaxInFlightDeliveries = 2 }; + var chain = Enumerable.Range(1, 25) + .Select(n => new FakeDelivery($"dlv_c{n:00}", "evt_0001", "invoice.canceled")) + .ToArray(); + _api.Deliver(chain).Deliver(new FakeDelivery("dlv_otra", "evt_0002", "invoice.created")); + _receiver = await FakeReceiver.StartAsync(async (request, aborted) => + { + if (request.Headers["webhook-id"] == "evt_0001") + { + await Task.Delay(300, aborted); + } + + return StatusCodes.Status200OK; + }); + var started = Stopwatch.StartNew(); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(FakeListenerApi.WaitUntilAsync(() => _api.Acks.Any(ack => ack.DeliveryId == "dlv_otra"), + "La entrega del otro evento nunca se acusó.")); + + Assert.True(started.Elapsed < TimeSpan.FromSeconds(2), + $"El long-polling esperó a la cadena del mismo evento ({started.Elapsed.TotalSeconds:0.0} s)."); + await ExpectAsync(FakeListenerApi.WaitUntilAsync(() => Error.Contains("dlv_c25"), "dlv_c25 nunca se descartó.")); + var forwarded = _receiver.Requests.Count(request => request.Headers["webhook-id"] == "evt_0001"); + Assert.InRange(forwarded, 1, 6); + Assert.Equal(forwarded, _api.Acks.Count(ack => ack.DeliveryId.StartsWith("dlv_c", StringComparison.Ordinal))); + Assert.Contains("La entrega dlv_c25 (evt_0001) venció antes de reenviarse; la API la cerró sin acuse.", Error); + Assert.False(_run!.IsCompleted, "listen terminó."); + } + + [Fact] + public async Task Deliveries_of_the_same_event_reach_the_receiver_in_arrival_order() + { + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _api.Deliver( + new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled", Body: """{"id":"evt_0001","type":"invoice.canceled","n":1}"""), + new FakeDelivery("dlv_0002", "evt_0001", "invoice.canceled", Body: """{"id":"evt_0001","type":"invoice.canceled","n":2}""")); + _receiver = await FakeReceiver.StartAsync(async (request, aborted) => + { + if (Encoding.UTF8.GetString(request.Body).Contains("\"n\":1", StringComparison.Ordinal)) + { + await releaseFirst.Task.WaitAsync(aborted); + } + + return StatusCodes.Status200OK; + }); + + StartListen("--forward-to", _receiver.Url.ToString()); + await ExpectAsync(_receiver.WaitForRequestsAsync(1)); + await Task.Delay(300); + + Assert.Single(_receiver.Requests); + releaseFirst.SetResult(); + await ExpectAsync(_receiver.WaitForRequestsAsync(2)); + Assert.Equal(["\"n\":1", "\"n\":2"], _receiver.Requests.Select(request => Encoding.UTF8.GetString(request.Body)[^6..^1])); + } + + [Fact] + public async Task A_409_while_reopening_is_retried_with_backoff_instead_of_exiting() + { + _api.FailPoll(StatusCodes.Status410Gone, "La sesión de fiscalapi listen caducó; vuelve a abrirla.") + .FailOpen(StatusCodes.Status409Conflict, "Otro cambio a los webhooks está en curso; intenta de nuevo.") + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Equal(3, _api.Opens.Count); + Assert.Equal(FakeListenerApi.SessionId(2), Assert.Single(_api.Acks).SessionId); + Assert.Matches(@"No se pudo reabrir la sesión de escucha \(Otro cambio a los webhooks está en curso; " + + @"intenta de nuevo\); reintentando en \d+(\.\d)? s\.", Error); + Assert.False(_run!.IsCompleted, "listen terminó por el candado de la apertura."); + } + + [Fact] + public async Task A_410_after_another_without_a_successful_poll_waits_before_reopening() + { + _timings = FastTimings with + { + Reconnect = new ExponentialBackoff(TimeSpan.FromMilliseconds(400), TimeSpan.FromMilliseconds(400)) + }; + const string expired = "La sesión de fiscalapi listen caducó; vuelve a abrirla."; + _api.FailPoll(StatusCodes.Status410Gone, expired) + .FailPoll(StatusCodes.Status410Gone, expired) + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var opens = _api.OpenTimes.ToArray(); + Assert.Equal(3, opens.Length); + // El primer 410 reabre de inmediato; el segundo seguido espera el backoff (equal jitter: al menos 200 ms). + Assert.True(opens[2] - opens[1] >= TimeSpan.FromMilliseconds(190), + $"Reabrió a los {(opens[2] - opens[1]).TotalMilliseconds:0} ms."); + } + + [Theory] + [InlineData("HTTP/1.1 700 Fuera de rango")] + [InlineData("HTTP/1.1 999 Fuera de rango")] + public async Task A_receiver_status_outside_100_599_is_acked_as_an_invalid_response(string statusLine) + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + using var receiver = new RawHttpReceiver(statusLine); + + StartListen("--forward-to", receiver.Url.ToString()); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var ack = Assert.Single(_api.Acks); + Assert.Equal("InvalidResponse", ack.Error); + Assert.Null(ack.StatusCode); + Assert.Equal(1, receiver.Requests); + Assert.Matches(EventLine("invoice.canceled", "evt_0001", suffix: " [error: InvalidResponse]"), Out); + } + + [Fact] + public async Task A_body_that_is_not_the_envelope_without_webhook_id_is_still_shown_and_acked() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled", Body: "esto no es el sobre", WithoutEventIdHeader: true)) + .Deliver(new FakeDelivery("dlv_0002", "evt_0002", "invoice.created")); + + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(2)); + + Assert.Matches(EventLine("?", "?", suffix: ""), Out); + Assert.Matches(EventLine("invoice.created", "evt_0002", suffix: ""), Out); + Assert.Equal(200, _api.Acks.Single(ack => ack.DeliveryId == "dlv_0001").StatusCode); + Assert.False(_run!.IsCompleted, "listen terminó por un cuerpo inesperado."); + } + + [Fact] + public async Task A_body_that_is_not_json_keeps_the_header_id_and_json_mode_omits_the_event() + { + _api.Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled", Body: "esto no es el sobre")); + + StartListen("--json"); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + var record = JsonNode.Parse(Out)!.AsObject(); + Assert.Equal("evt_0001", record["eventId"]!.GetValue()); + Assert.Equal("?", record["type"]!.GetValue()); + Assert.False(record.ContainsKey("event"), "Un cuerpo que no es JSON no va como event."); + } + + [Fact] + public async Task Ctrl_c_sends_the_pending_acks_of_results_already_obtained_and_exits_130() + { + _api.HoldAck("dlv_0001").Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(1)); + + Assert.Equal(ExitCodes.Cancelled, await PressCtrlCAsync()); + + Assert.Equal(2, _api.Acks.Count); + Assert.All(_api.Acks, ack => + Assert.Equal(("dlv_0001", 200, FakeListenerApi.SessionId(1)), (ack.DeliveryId, ack.StatusCode, ack.SessionId))); + } + + [Fact] + public async Task Ctrl_c_gives_the_pending_acks_only_a_short_deadline() + { + _timings = FastTimings with { ShutdownAckTimeout = TimeSpan.FromMilliseconds(300) }; + _api.HoldAck("dlv_0001", times: 2).Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + StartListen(); + await ExpectAsync(_api.WaitForAcksAsync(1)); + var pressed = Stopwatch.StartNew(); + + Assert.Equal(ExitCodes.Cancelled, await PressCtrlCAsync()); + + Assert.True(pressed.Elapsed < TimeSpan.FromSeconds(3), $"Ctrl+C tardó {pressed.Elapsed.TotalSeconds:0.0} s."); + Assert.Equal(2, _api.Acks.Count); + } + + [Fact] + public async Task Ack_retries_stop_when_the_lease_expires() + { + _timings = FastTimings with { DeliveryLease = TimeSpan.FromMilliseconds(500) }; + _api.RejectAck("dlv_0001", StatusCodes.Status503ServiceUnavailable, "Servicio no disponible", times: 1000) + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); + + StartListen(); + await ExpectAsync(FakeListenerApi.WaitUntilAsync( + () => Error.Contains("No se registró el acuse de la entrega dlv_0001: Servicio no disponible"), + "El acuse nunca dejó de reintentarse.")); + + var attempts = _api.Acks.Count; + Assert.InRange(attempts, 2, 60); + await Task.Delay(200); + Assert.Equal(attempts, _api.Acks.Count); + } + + [Theory] + [InlineData(StatusCodes.Status401Unauthorized, "Invalid API KEY")] + [InlineData(StatusCodes.Status403Forbidden, "No tienes permiso para esta operación.")] + [InlineData(StatusCodes.Status404NotFound, "No se encontró el recurso wh_listener_0001.")] + public async Task A_4xx_on_the_long_polling_exits_1(int status, string message) + { + _api.FailPoll(status, message); + + StartListen(); + + Assert.Equal(ExitCodes.Failure, await ExitCodeAsync()); + Assert.Contains($"Error: {message}", Error); + Assert.Single(_api.Opens); + } + + [Theory] + [InlineData("--forward-to|localhost:3000/webhooks", "--forward-to debe ser una URL absoluta http o https")] + [InlineData("--forward-to|ftp://localhost/webhooks", "--forward-to debe ser una URL absoluta http o https")] + [InlineData("--forward-to|http://localhost:3000|--headers|X-Origen", "--headers espera pares Nombre:Valor")] + [InlineData("--forward-to|http://localhost:3000|--headers|:valor", "--headers espera pares Nombre:Valor")] + [InlineData("--forward-to|http://localhost:3000|--headers|Webhook-Signature:v1,falsa", "--headers no puede cambiar Webhook-Signature")] + [InlineData("--forward-to|http://localhost:3000|--headers|Content-Type:text/plain", "--headers trae un header que HTTP no admite: 'Content-Type'")] + [InlineData("--forward-to|http://localhost:3000|--headers|X Malo:1", "--headers trae un header que HTTP no admite: 'X Malo'")] + [InlineData("--headers|X-Origen:cli", "--headers solo aplica con --forward-to.")] + [InlineData("--skip-verify", "--skip-verify solo aplica con --forward-to.")] + [InlineData("--events|invoice.canceled|--events|invoice.created", "'--events'")] + [InlineData("--forward-to|http://localhost:3000|--headers|X-A:1|--headers|X-B:2", "'--headers'")] + [InlineData("--forward-to|http://localhost:3000|--forward-to|http://localhost:3001", "'--forward-to'")] + [InlineData("--print-secret|--forward-to|http://localhost:3000", "--print-secret no se combina con --forward-to.")] + [InlineData("--print-secret|--forward-to|http://localhost:3000|--headers|X-A:1", "--print-secret no se combina con --headers.")] + [InlineData("--print-secret|--forward-to|http://localhost:3000|--skip-verify", "--print-secret no se combina con --skip-verify.")] + public async Task Invalid_options_are_usage_errors_without_calling_the_api(string options, string error) + { + StartListen(options.Split('|')); + + Assert.Equal(ExitCodes.Usage, await ExitCodeAsync()); + Assert.Contains(error, Error); + Assert.Empty(_api.Opens); + Assert.Empty(Out); + } + + // La línea de cada evento en la hora local: fecha, hora, flecha, tipo, [eventId] y el resultado. + private static string EventLine(string type, string eventId, string suffix) => + $@"(?m)^\d{{4}}-\d{{2}}-\d{{2}} \d{{2}}:\d{{2}}:\d{{2}} --> {Regex.Escape(type)} \[{Regex.Escape(eventId)}\]{Regex.Escape(suffix)}\r?$"; + + /// Espera una condición de la API o del receptor; si no llega, la falla trae lo que escribió el CLI. + private async Task ExpectAsync(Task condition) + { + try + { + await condition; + } + catch (TimeoutException exception) + { + var exit = _run is { IsCompleted: true } ? $"salió con {await _run}" : "sigue corriendo"; + throw new TimeoutException( + $"{exception.Message} El CLI {exit}.{Environment.NewLine}stdout:{Environment.NewLine}{Out}" + + $"{Environment.NewLine}stderr:{Environment.NewLine}{Error}", exception); + } + } + + /// El código de salida, cuando el CLI termina solo (un 409, un error de uso, --print-secret). + private Task ExitCodeAsync() => _run!.WaitAsync(WaitLimit); + + /// Ctrl+C y el código de salida. + private async Task PressCtrlCAsync() + { + await _ctrlC.CancelAsync(); + return await ExitCodeAsync(); + } + + private void StartListen(params string[] options) => StartListen(_api.BaseAddress, options); + + private void StartListen(Uri apiBase, params string[] options) + { + var globalOptions = new GlobalOptions(); + var console = new CliConsole(TextWriter.Synchronized(_outBuffer), TextWriter.Synchronized(_errorBuffer)); + var credentials = new CredentialResolver(new InMemoryCredentialStore(), name => name switch + { + CredentialResolver.ApiKeyVariable => ApiKey, + CredentialResolver.TenantKeyVariable => TenantKey, + _ => null + }); + var apiClients = new FiscalApiClientFactory( + FiscalApiClientFactory.CreateHandler(), credentials, console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); + var listen = new ListenCommand(globalOptions, apiClients, console, TimeProvider.System, _timings); + var application = new CliApplication(globalOptions, [listen], console, NoUpdateCheck.Instance); + + string[] args = ["listen", "--api-base", apiBase.ToString(), .. options]; + _run = Task.Run(() => application.RunAsync(args, _ctrlC.Token)); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs new file mode 100644 index 0000000..3e4a61b --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs @@ -0,0 +1,32 @@ +using FiscalApi.Cli.Listen; + +namespace FiscalApi.Cli.Tests.Listen; + +/// Los plazos de listen son los del contrato con la API. +public sealed class ListenTimingsTests +{ + [Fact] + public void Defaults_follow_the_listen_contract() + { + var timings = ListenTimings.Default; + + // El long-polling espera hasta 25 s; el cliente de la API le da 35 s. + Assert.Equal(25, timings.PollWaitSeconds); + Assert.Equal(TimeSpan.FromSeconds(35), timings.ApiTimeout); + Assert.Equal(TimeSpan.FromSeconds(15), timings.ForwardTimeout); + Assert.Equal(TimeSpan.FromSeconds(30), timings.Reconnect.MaxDelay); + // WebhookConsts.DeliveryLease de fapi-back: después, la API cierra la entrega sin acuse. + Assert.Equal(TimeSpan.FromMinutes(2), timings.DeliveryLease); + // Al salir con Ctrl+C, el único intento de acusar lo que ya tenía resultado. + Assert.Equal(TimeSpan.FromSeconds(2), timings.ShutdownAckTimeout); + } + + [Fact] + public void Reconnection_waits_never_exceed_30_seconds() + { + for (var attempt = 1; attempt <= 64; attempt++) + { + Assert.InRange(ListenTimings.Default.Reconnect.Delay(attempt), TimeSpan.Zero, TimeSpan.FromSeconds(30)); + } + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs b/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs new file mode 100644 index 0000000..4e3bb25 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// Un receptor HTTP/1.1 a nivel de socket que responde una línea de estado fija, incluso una que Kestrel no permitiría +/// (p. ej. HTTP/1.1 700). Atiende cada conexión: lee los headers y el cuerpo por Content-Length o chunked. +/// +internal sealed class RawHttpReceiver : IDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly string _statusLine; + private int _requests; + + public RawHttpReceiver(string statusLine) + { + _statusLine = statusLine; + _listener.Start(); + _ = AcceptAsync(); + } + + public Uri Url => new($"http://127.0.0.1:{((IPEndPoint)_listener.LocalEndpoint).Port}/webhooks"); + + public int Requests => Volatile.Read(ref _requests); + + public void Dispose() => _listener.Stop(); + + private async Task AcceptAsync() + { + while (true) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(); + } + catch (Exception exception) when (exception is SocketException or ObjectDisposedException) + { + return; + } + + _ = RespondAsync(client); + } + } + + private async Task RespondAsync(TcpClient client) + { + using (client) + { + var stream = client.GetStream(); + await ReadRequestAsync(stream); + Interlocked.Increment(ref _requests); + var response = Encoding.ASCII.GetBytes($"{_statusLine}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + await stream.WriteAsync(response); + } + } + + // Basta con consumir el request completo: el contenido no importa a estas pruebas. + private static async Task ReadRequestAsync(NetworkStream stream) + { + var received = new List(); + var buffer = new byte[4096]; + int headerEnd; + while ((headerEnd = IndexOf(received, "\r\n\r\n"u8)) < 0) + { + var read = await stream.ReadAsync(buffer); + if (read == 0) + { + return; + } + + received.AddRange(buffer.AsSpan(0, read)); + } + + var headers = Encoding.ASCII.GetString(received.ToArray(), 0, headerEnd); + var bodyLength = headers.Split("\r\n") + .Where(line => line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + .Select(line => int.Parse(line["Content-Length:".Length..].Trim())) + .FirstOrDefault(); + var chunked = headers.Contains("Transfer-Encoding: chunked", StringComparison.OrdinalIgnoreCase); + while (chunked ? IndexOf(received, "\r\n0\r\n\r\n"u8) < 0 : received.Count < headerEnd + 4 + bodyLength) + { + var read = await stream.ReadAsync(buffer); + if (read == 0) + { + return; + } + + received.AddRange(buffer.AsSpan(0, read)); + } + } + + private static int IndexOf(List bytes, ReadOnlySpan value) => + System.Runtime.InteropServices.CollectionsMarshal.AsSpan(bytes).IndexOf(value); +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs b/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs new file mode 100644 index 0000000..48227c3 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs @@ -0,0 +1,22 @@ +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace FiscalApi.Cli.Tests.Listen; + +/// Un certificado autofirmado para 127.0.0.1, como el de un receptor local en https. +internal static class SelfSignedCertificate +{ + public static X509Certificate2 ForLoopback() + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=fiscalapi-cli-tests", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var subjectAlternativeNames = new SubjectAlternativeNameBuilder(); + subjectAlternativeNames.AddIpAddress(IPAddress.Loopback); + subjectAlternativeNames.AddDnsName("localhost"); + request.CertificateExtensions.Add(subjectAlternativeNames.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddHours(1)); + // Ida y vuelta por PFX: en Windows, SslStream no usa como servidor una llave efímera. + return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), password: null); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs b/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs new file mode 100644 index 0000000..7012ce0 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using System.Text; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// La firma de Standard Webhooks: v1, + HMAC-SHA256 en base64 sobre {id}.{timestamp}.{body}, con la +/// llave que va en base64 después de whsec_. El cuerpo entra en bytes: así la verificación prueba que el reenvío +/// no cambió ni un byte. +/// +internal static class StandardWebhooksSignature +{ + public static string Sign(string id, string timestamp, byte[] body) + { + var key = Convert.FromBase64String(FakeListenerApi.Secret["whsec_".Length..]); + var content = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(body).ToArray(); + return $"v1,{Convert.ToBase64String(HMACSHA256.HashData(key, content))}"; + } +} diff --git a/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs new file mode 100644 index 0000000..64008af --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs @@ -0,0 +1,168 @@ +using System.CommandLine; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Updates; + +namespace FiscalApi.Cli.Tests.Updates; + +/// +/// Cómo usa el aviso de versión nueva: corre en paralelo al comando, se escribe en stderr al +/// final y nunca cambia stdout ni el código de salida. +/// +public sealed class CliApplicationUpdateCheckTests +{ + private readonly StringWriter _out = new(); + private readonly StringWriter _error = new(); + + [Fact] + public async Task Notice_goes_to_stderr_after_the_command_without_touching_stdout_or_the_exit_code() + { + var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); + var probe = new Command("probe"); + probe.SetAction(async (_, cancellationToken) => + { + await _out.WriteLineAsync("datos".AsMemory(), cancellationToken); + await _error.WriteLineAsync("diagnóstico del comando".AsMemory(), cancellationToken); + return ExitCodes.Success; + }); + + var exitCode = await RunAsync(["probe"], probe, check); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Equal("datos" + Environment.NewLine, _out.ToString()); + var error = _error.ToString(); + Assert.Contains("https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0", error); + Assert.True(error.IndexOf("diagnóstico del comando", StringComparison.Ordinal) < error.IndexOf("0.2.0", StringComparison.Ordinal)); + } + + [Fact] + public async Task Failed_command_keeps_its_exit_code_and_still_shows_the_notice() + { + var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); + var probe = new Command("probe"); + probe.SetAction((_, _) => Task.FromException(new UsageException("Falta --url."))); + + var exitCode = await RunAsync(["probe"], probe, check); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Contains("Falta --url.", _error.ToString()); + Assert.Contains("0.2.0", _error.ToString()); + } + + [Fact] + public async Task Command_runs_while_the_check_is_still_pending() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var check = new ScriptedUpdateCheck(pending.Task); + var ranBeforeTheCheckEnded = false; + var probe = new Command("probe"); + probe.SetAction((_, _) => + { + ranBeforeTheCheckEnded = !pending.Task.IsCompleted; + pending.SetResult(null); + return Task.FromResult(ExitCodes.Success); + }); + + var exitCode = await RunAsync(["probe"], probe, check); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.True(ranBeforeTheCheckEnded); + Assert.Empty(_error.ToString()); + } + + [Fact] + public async Task Ctrl_c_still_shows_a_notice_that_was_already_known() + { + // fiscalapi listen siempre termina con Ctrl+C: si el aviso ya llegó durante la escucha, se muestra. + using var cancellation = new CancellationTokenSource(); + var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); + + var exitCode = await RunAsync(["probe"], CancelledByCtrlC(cancellation), check, cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Contains("https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0", _error.ToString()); + } + + [Fact] + public async Task Ctrl_c_exits_at_once_without_waiting_for_a_pending_check() + { + using var cancellation = new CancellationTokenSource(); + var neverEnds = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var check = new ScriptedUpdateCheck(neverEnds.Task); + + var exitCode = await RunAsync(["probe"], CancelledByCtrlC(cancellation), check, cancellation.Token) + .WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Empty(_error.ToString()); + } + + [Fact] + public async Task Ctrl_c_ignores_a_check_that_ended_without_a_notice() + { + using var cancellation = new CancellationTokenSource(); + var check = new ScriptedUpdateCheck(Task.FromResult(null)); + + var exitCode = await RunAsync(["probe"], CancelledByCtrlC(cancellation), check, cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.Empty(_error.ToString()); + } + + /// Un comando que espera hasta que llega Ctrl+C, como fiscalapi listen. + private static Command CancelledByCtrlC(CancellationTokenSource cancellation) + { + var probe = new Command("probe"); + probe.SetAction(async (_, cancellationToken) => + { + await cancellation.CancelAsync(); + await Task.Delay(Timeout.Infinite, cancellationToken); + return ExitCodes.Success; + }); + return probe; + } + + [Fact] + public async Task The_check_receives_the_json_setting_of_the_invocation() + { + var check = new ScriptedUpdateCheck(Task.FromResult(null)); + var probe = new Command("probe"); + probe.SetAction((_, _) => Task.FromResult(ExitCodes.Success)); + + await RunAsync(["probe", "--json"], probe, check); + + Assert.True(Assert.Single(check.Settings).Json); + } + + [Fact] + public async Task Usage_errors_from_parsing_do_not_start_the_check() + { + var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); + + var exitCode = await RunAsync(["--no-existe"], new Command("probe"), check); + + Assert.Equal(ExitCodes.Usage, exitCode); + Assert.Empty(check.Settings); + } + + private static UpdateNotice Notice() + { + Assert.True(SemanticVersion.TryParse("0.1.0", out var current)); + Assert.True(SemanticVersion.TryParse("v0.2.0", out var latest)); + return new UpdateNotice(current, latest); + } + + private Task RunAsync(string[] args, Command command, IUpdateCheck check, CancellationToken cancellationToken = default) => + new CliApplication(new GlobalOptions(), [command], new CliConsole(_out, _error), check).RunAsync(args, cancellationToken); + + /// Devuelve el resultado que se le da y anota con qué opciones globales se le pidió empezar. + private sealed class ScriptedUpdateCheck(Task result) : IUpdateCheck + { + public List Settings { get; } = []; + + public Task Start(GlobalSettings settings, CancellationToken cancellationToken) + { + Settings.Add(settings); + return result; + } + } +} diff --git a/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs b/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs new file mode 100644 index 0000000..c0c7bb5 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs @@ -0,0 +1,16 @@ +using FiscalApi.Cli.Updates; + +namespace FiscalApi.Cli.Tests.Updates; + +/// Para las pruebas de los comandos: ningún aviso de versión nueva y ninguna consulta a GitHub. +public sealed class NoUpdateCheck : IUpdateCheck +{ + public static readonly NoUpdateCheck Instance = new(); + + private NoUpdateCheck() + { + } + + public Task Start(GlobalSettings settings, CancellationToken cancellationToken) => + Task.FromResult(null); +} diff --git a/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs b/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs new file mode 100644 index 0000000..ce9cd89 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs @@ -0,0 +1,85 @@ +using FiscalApi.Cli.Updates; + +namespace FiscalApi.Cli.Tests.Updates; + +/// Versiones semánticas 2.0.0 (semver.org): las del CLI y las de los tags de sus releases. +public sealed class SemanticVersionTests +{ + [Theory] + [InlineData("0.1.0", "0.1.0")] + [InlineData("v1.2.3", "1.2.3")] + [InlineData("1.0.0-rc.1", "1.0.0-rc.1")] + [InlineData("1.0.0-alpha-1.x-y", "1.0.0-alpha-1.x-y")] + [InlineData("1.0.0+20261013.sha.4fd9a7f", "1.0.0+20261013.sha.4fd9a7f")] + [InlineData("v10.20.30-beta.2+exp", "10.20.30-beta.2+exp")] + public void Valid_versions_are_parsed_without_the_tag_prefix(string text, string expected) + { + Assert.True(SemanticVersion.TryParse(text, out var version)); + Assert.Equal(expected, version.ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("v")] + [InlineData("1.0")] + [InlineData("1.0.0.0")] + [InlineData("01.0.0")] + [InlineData("1.0.0-")] + [InlineData("1.0.0-01")] + [InlineData("1.0.0-rc..1")] + [InlineData("1.0.0+")] + [InlineData(" 1.0.0")] + [InlineData("1.0.0\n")] + [InlineData("V1.0.0")] + [InlineData("vv1.0.0")] + [InlineData("1.0.0")] + [InlineData("99999999999.0.0")] + [InlineData("latest")] + public void Invalid_versions_are_rejected(string? text) + { + Assert.False(SemanticVersion.TryParse(text, out _)); + } + + [Fact] + public void Precedence_follows_the_semver_example_chain() + { + string[] ordered = + [ + "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", + "1.0.0-rc.1", "1.0.0", "1.0.1", "1.1.0", "1.10.0", "2.0.0" + ]; + + for (var i = 1; i < ordered.Length; i++) + { + Assert.True(Parse(ordered[i - 1]).CompareTo(Parse(ordered[i])) < 0, $"{ordered[i - 1]} < {ordered[i]}"); + Assert.True(Parse(ordered[i]).CompareTo(Parse(ordered[i - 1])) > 0, $"{ordered[i]} > {ordered[i - 1]}"); + } + } + + [Fact] + public void Build_metadata_does_not_change_precedence() + { + Assert.Equal(0, Parse("1.0.0+a").CompareTo(Parse("1.0.0+b"))); + Assert.Equal(0, Parse("v1.0.0").CompareTo(Parse("1.0.0"))); + } + + [Fact] + public void Long_numeric_prerelease_identifiers_compare_by_value_without_overflow() + { + Assert.True(Parse("1.0.0-99999999999999999999").CompareTo(Parse("1.0.0-100000000000000000000")) < 0); + } + + [Fact] + public void Prerelease_is_detected() + { + Assert.True(Parse("1.0.0-rc.1").IsPrerelease); + Assert.False(Parse("1.0.0+build").IsPrerelease); + } + + private static SemanticVersion Parse(string text) + { + Assert.True(SemanticVersion.TryParse(text, out var version), text); + return version; + } +} diff --git a/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs new file mode 100644 index 0000000..c375f64 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs @@ -0,0 +1,398 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; +using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Updates; +using Microsoft.Extensions.Time.Testing; + +namespace FiscalApi.Cli.Tests.Updates; + +/// +/// Aviso de versión nueva: consulta releases/latest como mucho una vez cada 24 h, con 2 s de tope, y nunca +/// falla ni bloquea el comando. GitHub se simula con : las pruebas no salen a la red. +/// +public sealed class UpdateCheckTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 10, 13, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RealTimeLimit = TimeSpan.FromSeconds(10); + + private readonly string _directory = Path.Combine(Path.GetTempPath(), "fiscalapi-cli-tests", Guid.NewGuid().ToString("N")); + private readonly FakeTimeProvider _clock = new(Now); + private readonly StubHttpMessageHandler _github = new(); + private readonly Dictionary _environment = []; + + private string StatePath => Path.Combine(_directory, "update-check.json"); + + /// + /// Una consulta cortada por el tope o por Ctrl+C sigue en segundo plano hasta terminar (por diseño, nadie la espera): + /// puede estar escribiendo el archivo de estado mientras se borra la carpeta. Se reintenta un momento. + /// + public void Dispose() + { + for (var attempt = 1; ; attempt++) + { + try + { + if (File.Exists(_directory)) + { + File.Delete(_directory); + } + else if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + + return; + } + catch (IOException) when (attempt < 20) + { + Thread.Sleep(50); + } + } + } + + [Fact] + public async Task Newer_release_gives_a_notice_with_the_download_url() + { + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + + var notice = await StartAsync(); + + Assert.NotNull(notice); + Assert.Equal("0.1.0", notice.Current.ToString()); + Assert.Equal("0.2.0", notice.Latest.ToString()); + Assert.Equal("https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0", notice.DownloadUrl.ToString()); + } + + [Fact] + public async Task Asks_github_anonymously_for_the_latest_release_of_the_cli() + { + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + + await StartAsync(); + + var request = Assert.Single(_github.Requests); + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://api.github.com/repos/FiscalAPI/fiscalapi-cli/releases/latest", request.RequestUri!.ToString()); + Assert.Equal("fiscalapi-cli/0.1.0", request.Headers.UserAgent.ToString()); + Assert.Contains("application/vnd.github+json", request.Headers.Accept.ToString()); + // Las credenciales de FiscalAPI nunca viajan a GitHub. + Assert.False(request.Headers.Contains("X-API-KEY")); + Assert.False(request.Headers.Contains("X-TENANT-KEY")); + Assert.Null(request.Headers.Authorization); + } + + [Fact] + public async Task Notice_names_both_versions_and_the_download_url() + { + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + var notice = await StartAsync(); + var error = new StringWriter(); + + await notice!.WriteToAsync(error); + + var text = error.ToString(); + Assert.Contains("0.2.0", text); + Assert.Contains("0.1.0", text); + Assert.Contains("https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0", text); + } + + [Fact] + public async Task Asks_at_most_once_every_24_hours() + { + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0"), times: 2); + await StartAsync(); + + _clock.Advance(TimeSpan.FromHours(24) - TimeSpan.FromSeconds(1)); + var withinTheDay = await StartAsync(); + + Assert.Null(withinTheDay); + Assert.Single(_github.Requests); + + _clock.Advance(TimeSpan.FromSeconds(1)); + var nextDay = await StartAsync(); + + Assert.NotNull(nextDay); + Assert.Equal(2, _github.Requests.Count); + } + + [Fact] + public async Task The_daily_check_survives_between_processes_in_the_state_file() + { + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + await StartAsync(); + + // Otro proceso: otra instancia que lee el mismo archivo de estado. + var anotherProcess = await StartAsync(CreateCheck()); + + Assert.Null(anotherProcess); + Assert.Single(_github.Requests); + using var state = JsonDocument.Parse(await File.ReadAllTextAsync(StatePath)); + Assert.Equal(Now, state.RootElement.GetProperty("last_checked_at").GetDateTimeOffset()); + } + + [Fact] + public async Task Json_output_turns_the_check_off() + { + var notice = await StartAsync(json: true); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + Assert.False(File.Exists(StatePath)); + } + + [Theory] + [InlineData("CI", "true")] + [InlineData("CI", "1")] + [InlineData("CI", "TRUE")] + [InlineData("FISCALAPI_NO_UPDATE_CHECK", "1")] + [InlineData("FISCALAPI_NO_UPDATE_CHECK", "true")] + public async Task Ci_or_the_opt_out_variable_turns_the_check_off(string variable, string value) + { + _environment[variable] = value; + + var notice = await StartAsync(); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + Assert.False(File.Exists(StatePath)); + } + + [Theory] + [InlineData("CI", "false")] + [InlineData("CI", "0")] + [InlineData("CI", "")] + [InlineData("FISCALAPI_NO_UPDATE_CHECK", "0")] + [InlineData("FISCALAPI_NO_UPDATE_CHECK", "false")] + public async Task False_values_of_the_variables_keep_the_check_on(string variable, string value) + { + _environment[variable] = value; + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + + var notice = await StartAsync(); + + Assert.NotNull(notice); + } + + [Fact] + public async Task Redirected_stderr_turns_the_check_off() + { + var notice = await StartAsync(CreateCheck(errorRedirected: true)); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + } + + [Fact] + public async Task Network_failure_gives_no_notice_and_still_counts_as_the_daily_check() + { + _github.FailWithNetworkError(); + + var notice = await StartAsync(); + var again = await StartAsync(); + + Assert.Null(notice); + Assert.Null(again); + Assert.Single(_github.Requests); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, """{"message":"Not Found","status":"404"}""")] + [InlineData(HttpStatusCode.Forbidden, """{"message":"API rate limit exceeded"}""")] + [InlineData(HttpStatusCode.InternalServerError, "error")] + [InlineData(HttpStatusCode.MovedPermanently, "")] + [InlineData(HttpStatusCode.OK, "no es json")] + [InlineData(HttpStatusCode.OK, "[]")] + [InlineData(HttpStatusCode.OK, "{}")] + [InlineData(HttpStatusCode.OK, """{"tag_name":null}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":42}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"latest"}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"v0.2"}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"v0.2.0\u001b[31m"}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"v0.2.0","draft":true,"prerelease":false}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"v0.2.0","draft":false,"prerelease":true}""")] + [InlineData(HttpStatusCode.OK, """{"tag_name":"v0.2.0-rc.1","draft":false,"prerelease":false}""")] + public async Task Unusable_responses_give_no_notice_without_failing(HttpStatusCode status, string body) + { + _github.Respond(() => new HttpResponseMessage(status) { Content = new StringContent(body) }); + + var notice = await StartAsync(); + + Assert.Null(notice); + Assert.Single(_github.Requests); + } + + [Fact] + public async Task Oversized_response_gives_no_notice() + { + var notes = new string('x', 2 * 1024 * 1024); + _github.RespondJson(HttpStatusCode.OK, $$"""{"tag_name":"v0.2.0","draft":false,"prerelease":false,"body":"{{notes}}"}"""); + + var notice = await StartAsync(); + + Assert.Null(notice); + } + + [Fact] + public async Task Api_that_never_answers_does_not_block_and_gives_up_at_2_seconds() + { + _github.NeverRespond(); + + var check = CreateCheck().Start(Settings(), CancellationToken.None); + + // Start devuelve en seguida: el comando corre mientras GitHub no contesta. + Assert.False(check.IsCompleted); + _clock.Advance(UpdateCheck.Timeout - TimeSpan.FromMilliseconds(1)); + Assert.False(check.IsCompleted); + + _clock.Advance(TimeSpan.FromMilliseconds(1)); + + Assert.Null(await check.WaitAsync(RealTimeLimit)); + } + + [Fact] + public async Task With_the_real_clock_a_silent_api_costs_at_most_the_2_second_budget() + { + _github.NeverRespond(); + var check = new UpdateCheck(_github, new UpdateCheckStateFile(StatePath), Environment, false, TimeProvider.System, "0.1.0"); + var elapsed = Stopwatch.StartNew(); + + var notice = await check.Start(Settings(), CancellationToken.None).WaitAsync(RealTimeLimit); + + Assert.Null(notice); + Assert.InRange(elapsed.Elapsed, UpdateCheck.Timeout - TimeSpan.FromMilliseconds(100), UpdateCheck.Timeout + TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Ctrl_c_ends_the_check_without_failing() + { + _github.NeverRespond(); + using var cancellation = new CancellationTokenSource(); + + var check = CreateCheck().Start(Settings(), cancellation.Token); + await cancellation.CancelAsync(); + + Assert.Null(await check.WaitAsync(RealTimeLimit)); + } + + [Fact] + public async Task Corrupt_state_file_counts_as_never_checked_and_is_rewritten() + { + Directory.CreateDirectory(_directory); + await File.WriteAllTextAsync(StatePath, "{ esto no es json"); + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + + var notice = await StartAsync(); + var again = await StartAsync(); + + Assert.NotNull(notice); + Assert.Null(again); + Assert.Single(_github.Requests); + } + + [Fact] + public async Task Check_time_in_the_future_counts_as_due_after_the_clock_goes_back() + { + Directory.CreateDirectory(_directory); + await File.WriteAllTextAsync(StatePath, $$"""{"last_checked_at":"{{Now.AddDays(3):O}}"}"""); + _github.RespondJson(HttpStatusCode.OK, Release("v0.2.0")); + + var notice = await StartAsync(); + + Assert.NotNull(notice); + } + + [Fact] + public async Task State_location_that_cannot_be_written_skips_the_check_without_failing() + { + // Un archivo donde debería ir la carpeta de estado: crear la carpeta falla en cualquier SO. + Directory.CreateDirectory(Path.GetDirectoryName(_directory)!); + await File.WriteAllTextAsync(_directory, "no soy una carpeta"); + + var notice = await StartAsync(); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + } + + [Fact] + public async Task State_file_that_cannot_be_read_skips_the_check_without_failing() + { + // Una carpeta en lugar del archivo: leerlo falla (acceso denegado o "es un directorio") en cualquier SO. + Directory.CreateDirectory(StatePath); + + var notice = await StartAsync(); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + } + + [Theory] + [InlineData("0.1.0", "v0.2.0", true)] + [InlineData("0.1.0", "0.2.0", true)] + [InlineData("0.9.0", "v0.10.0", true)] + [InlineData("0.1.9", "v0.1.10", true)] + [InlineData("1.0.0-rc.1", "v1.0.0", true)] + [InlineData("0.1.0", "v0.1.0", false)] + [InlineData("0.2.0", "v0.1.9", false)] + [InlineData("1.0.0", "v1.0.0+build.5", false)] + [InlineData("1.1.0-rc.1", "v1.0.0", false)] + public async Task Versions_are_compared_semantically(string current, string latestTag, bool expectNotice) + { + _github.RespondJson(HttpStatusCode.OK, Release(latestTag)); + + var notice = await StartAsync(CreateCheck(currentVersion: current)); + + Assert.Equal(expectNotice, notice is not null); + } + + [Fact] + public async Task Current_version_that_is_not_semantic_skips_the_check() + { + var notice = await StartAsync(CreateCheck(currentVersion: "dev")); + + Assert.Null(notice); + Assert.Empty(_github.Requests); + } + + [Fact] + public void Linux_state_directory_follows_xdg_state_home_or_falls_back_to_local_state() + { + Assert.Equal( + Path.Combine("/xdg/state", "fiscalapi-cli"), + UpdateCheckStateFile.LinuxStateDirectory(name => name == "XDG_STATE_HOME" ? "/xdg/state" : "/home/ana")); + Assert.Equal( + Path.Combine("/home/ana", ".local", "state", "fiscalapi-cli"), + UpdateCheckStateFile.LinuxStateDirectory(name => name == "HOME" ? "/home/ana" : null)); + // La especificación XDG pide ignorar una ruta relativa. + Assert.Equal( + Path.Combine("/home/ana", ".local", "state", "fiscalapi-cli"), + UpdateCheckStateFile.LinuxStateDirectory(name => name == "XDG_STATE_HOME" ? "relativa" : "/home/ana")); + Assert.Null(UpdateCheckStateFile.LinuxStateDirectory(_ => null)); + } + + [Fact] + public void State_file_for_the_current_user_lives_in_a_folder_of_the_cli() + { + var stateFile = UpdateCheckStateFile.ForCurrentUser(System.Environment.GetEnvironmentVariable); + + Assert.NotNull(stateFile); + Assert.Equal("update-check.json", Path.GetFileName(stateFile.Path)); + Assert.Equal("fiscalapi-cli", Path.GetFileName(Path.GetDirectoryName(stateFile.Path))); + } + + private string? Environment(string name) => _environment.GetValueOrDefault(name); + + private UpdateCheck CreateCheck(string currentVersion = "0.1.0", bool errorRedirected = false) => + new(_github, new UpdateCheckStateFile(StatePath), Environment, errorRedirected, _clock, currentVersion); + + private Task StartAsync(bool json = false) => StartAsync(CreateCheck(), json); + + private static Task StartAsync(UpdateCheck check, bool json = false) => + check.Start(Settings(json), CancellationToken.None).WaitAsync(RealTimeLimit); + + private static GlobalSettings Settings(bool json = false) => new(ApiEnvironment.Resolve(null, live: false), json, Verbose: false); + + private static string Release(string tag) => + $$"""{"tag_name":"{{tag}}","html_url":"https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/{{tag}}","draft":false,"prerelease":false}"""; +}