From 27edfcbece9a9848ee0d09b3feba045f401b1fcc Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 13:21:23 -0600 Subject: [PATCH 01/19] CLI-001: esqueleto AOT, contrato de salida y CI de fapi-cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fiscalapi (.NET 10, PublishAot, System.CommandLine 2.0.13): --live, --api-base, --json, --verbose, --help, --version. - FiscalApiClient con JsonSerializerContext; ApiResponse -> ApiException; reintentos solo en GET (red, 408, 429, 5xx). - Códigos de salida 0/1/2/130 con un solo punto que atrapa errores; errores de uso solo en stderr. - dotnet test: Passed 41, Failed 0 (FiscalApiClientRetryTests: Passed 8). - publish linux-x64 AOT (Docker sdk:10.0-noble-aot): 0 IL/warning; --version -> 0.1.0. win-x64: sin linker de C++. - CI: build, test y publish AOT linux-x64 en cada PR (ubuntu-latest); actionlint sin hallazgos. --- .github/workflows/build.yml | 46 ++ .gitignore | 482 ++++++++++++++++++ Directory.Build.props | 29 ++ FiscalApi.Cli.slnx | 8 + src/FiscalApi.Cli/ApiEnvironment.cs | 15 + src/FiscalApi.Cli/CliApplication.cs | 103 ++++ src/FiscalApi.Cli/CliConsole.cs | 7 + src/FiscalApi.Cli/CliVersion.cs | 14 + src/FiscalApi.Cli/Errors/ApiException.cs | 69 +++ src/FiscalApi.Cli/Errors/CliException.cs | 13 + src/FiscalApi.Cli/Errors/NetworkException.cs | 7 + src/FiscalApi.Cli/Errors/UsageException.cs | 9 + src/FiscalApi.Cli/ExitCodes.cs | 18 + src/FiscalApi.Cli/FiscalApi.Cli.csproj | 16 + src/FiscalApi.Cli/GlobalOptions.cs | 62 +++ src/FiscalApi.Cli/GlobalSettings.cs | 4 + src/FiscalApi.Cli/Http/ApiResponse.cs | 21 + src/FiscalApi.Cli/Http/FiscalApiClient.cs | 90 ++++ .../Http/FiscalApiClientFactory.cs | 33 ++ .../Http/FiscalApiJsonContext.cs | 12 + src/FiscalApi.Cli/Http/RetryPolicy.cs | 95 ++++ .../Http/VerboseLoggingHandler.cs | 46 ++ src/FiscalApi.Cli/Program.cs | 22 + .../CliApplicationTests.cs | 162 ++++++ .../FiscalApi.Cli.Tests.csproj | 25 + .../FiscalApi.Cli.Tests/GlobalOptionsTests.cs | 98 ++++ .../Http/FiscalApiClientBuilder.cs | 15 + .../Http/FiscalApiClientErrorTests.cs | 141 +++++ .../Http/FiscalApiClientFactoryTests.cs | 18 + .../Http/FiscalApiClientRetryTests.cs | 133 +++++ .../Http/StubHttpMessageHandler.cs | 66 +++ .../Http/TestJsonContext.cs | 15 + .../Http/VerboseLoggingHandlerTests.cs | 36 ++ 33 files changed, 1930 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 FiscalApi.Cli.slnx create mode 100644 src/FiscalApi.Cli/ApiEnvironment.cs create mode 100644 src/FiscalApi.Cli/CliApplication.cs create mode 100644 src/FiscalApi.Cli/CliConsole.cs create mode 100644 src/FiscalApi.Cli/CliVersion.cs create mode 100644 src/FiscalApi.Cli/Errors/ApiException.cs create mode 100644 src/FiscalApi.Cli/Errors/CliException.cs create mode 100644 src/FiscalApi.Cli/Errors/NetworkException.cs create mode 100644 src/FiscalApi.Cli/Errors/UsageException.cs create mode 100644 src/FiscalApi.Cli/ExitCodes.cs create mode 100644 src/FiscalApi.Cli/FiscalApi.Cli.csproj create mode 100644 src/FiscalApi.Cli/GlobalOptions.cs create mode 100644 src/FiscalApi.Cli/GlobalSettings.cs create mode 100644 src/FiscalApi.Cli/Http/ApiResponse.cs create mode 100644 src/FiscalApi.Cli/Http/FiscalApiClient.cs create mode 100644 src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs create mode 100644 src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs create mode 100644 src/FiscalApi.Cli/Http/RetryPolicy.cs create mode 100644 src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs create mode 100644 src/FiscalApi.Cli/Program.cs create mode 100644 tests/FiscalApi.Cli.Tests/CliApplicationTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj create mode 100644 tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a843879 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: CI-Pipeline + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read # Required for actions/checkout + +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@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # Prerrequisitos de Native AOT en Linux: el linker (clang) y zlib. + - name: Install Native AOT prerequisites + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + - name: Restore + run: dotnet restore FiscalApi.Cli.slnx + + - name: Build + run: dotnet build FiscalApi.Cli.slnx -c Release --no-restore + + - name: Test + run: 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: | + ./artifacts/linux-x64/fiscalapi --version + ./artifacts/linux-x64/fiscalapi --help 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..68d4552 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + true + + true + + false + + true + + + + + 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/src/FiscalApi.Cli/ApiEnvironment.cs b/src/FiscalApi.Cli/ApiEnvironment.cs new file mode 100644 index 0000000..c47a1df --- /dev/null +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -0,0 +1,15 @@ +namespace FiscalApi.Cli; + +/// +/// Ambiente de la API contra el que trabaja una invocación (D-25). Se resuelve una sola vez por invocación +/// y viaja como valor inmutable, sin estáticos mutables. +/// +public sealed record ApiEnvironment(Uri BaseAddress) +{ + public static readonly Uri TestBaseAddress = new("https://test.fiscalapi.com"); + public static readonly Uri LiveBaseAddress = new("https://live.fiscalapi.com"); + + /// Precedencia: --api-base gana sobre --live, y este sobre test. + public static ApiEnvironment Resolve(Uri? apiBase, bool live) => + new(apiBase ?? (live ? LiveBaseAddress : TestBaseAddress)); +} diff --git a/src/FiscalApi.Cli/CliApplication.cs b/src/FiscalApi.Cli/CliApplication.cs new file mode 100644 index 0000000..63a6e3a --- /dev/null +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -0,0 +1,103 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using FiscalApi.Cli.Errors; + +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 CliConsole _console; + private readonly InvocationConfiguration _invocation; + + public CliApplication(GlobalOptions globalOptions, IReadOnlyList commands, CliConsole console) + { + _console = console; + _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 = new InvocationConfiguration + { + Output = console.Out, + Error = console.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 + }; + } + + public async Task RunAsync(IReadOnlyList args, CancellationToken cancellationToken) + { + var parseResult = _rootCommand.Parse(args); + if (parseResult.Action is ParseErrorAction) + { + return await ReportUsageErrorsAsync(parseResult); + } + + try + { + 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; + } + + // Síncrona como la acción integrada de --version que reemplaza. + private sealed class PrintVersionAction : SynchronousCommandLineAction + { + public override int Invoke(ParseResult parseResult) + { + parseResult.InvocationConfiguration.Output.WriteLine(CliVersion.Current); + 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/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs new file mode 100644 index 0000000..bee7d24 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -0,0 +1,69 @@ +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 + { + [HttpStatusCode.Unauthorized] = "Ejecuta `fiscalapi login` o revisa la variable de entorno FISCALAPI_API_KEY." + }.ToFrozenDictionary(); + + private ApiException(HttpStatusCode statusCode, string message, string? details, string? traceIdentifier) + : base(message) + { + StatusCode = statusCode; + Details = details; + TraceIdentifier = traceIdentifier; + } + + public override int ExitCode => ExitCodes.Failure; + + /// 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); + + public static ApiException FromEnvelope(HttpStatusCode statusCode, ApiResponse envelope) => new( + statusCode, + string.IsNullOrWhiteSpace(envelope.Message) ? $"La API respondió {(int)statusCode} sin mensaje." : envelope.Message, + NullIfBlank(envelope.Details), + NullIfBlank(envelope.TraceIdentifier)); + + /// 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); + + public override void WriteTo(TextWriter error) + { + base.WriteTo(error); + if (Details is not null && Details != Message) + { + error.WriteLine($" Detalle: {Details}"); + } + + 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..41771a9 --- /dev/null +++ b/src/FiscalApi.Cli/Errors/CliException.cs @@ -0,0 +1,13 @@ +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; } + + public virtual void WriteTo(TextWriter error) => error.WriteLine($"Error: {Message}"); +} diff --git a/src/FiscalApi.Cli/Errors/NetworkException.cs b/src/FiscalApi.Cli/Errors/NetworkException.cs new file mode 100644 index 0000000..0ba985d --- /dev/null +++ b/src/FiscalApi.Cli/Errors/NetworkException.cs @@ -0,0 +1,7 @@ +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; +} 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..034b903 --- /dev/null +++ b/src/FiscalApi.Cli/FiscalApi.Cli.csproj @@ -0,0 +1,16 @@ + + + + Exe + fiscalapi + FiscalApi.Cli + 0.1.0 + FiscalAPI CLI + FiscalAPI + + + + + + + diff --git a/src/FiscalApi.Cli/GlobalOptions.cs b/src/FiscalApi.Cli/GlobalOptions.cs new file mode 100644 index 0000000..d6d92ff --- /dev/null +++ b/src/FiscalApi.Cli/GlobalOptions.cs @@ -0,0 +1,62 @@ +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)) + { + return uri; + } + + result.AddError($"--api-base debe ser una URL absoluta http o https; se recibió '{value}'."); + return null; + } +} 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/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/FiscalApiClient.cs b/src/FiscalApi.Cli/Http/FiscalApiClient.cs new file mode 100644 index 0000000..de4ed87 --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiClient.cs @@ -0,0 +1,90 @@ +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. +/// +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); + return await ReadDataAsync(response, responseType, cancellationToken); + } + + /// Un POST no se reintenta: repetirlo podría crear el recurso dos veces. + public async Task PostAsync( + string path, + TRequest body, + JsonTypeInfo requestType, + JsonTypeInfo> responseType, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = JsonContent.Create(body, requestType) + }; + using var response = await SendAsync(() => httpClient.SendAsync(request, cancellationToken), cancellationToken); + return await ReadDataAsync(response, responseType, cancellationToken); + } + + private async Task SendAsync( + Func> send, CancellationToken cancellationToken) + { + try + { + return await send(); + } + catch (HttpRequestException exception) + { + throw new NetworkException($"No se pudo conectar con {httpClient.BaseAddress}: {exception.Message}", exception); + } + 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) + { + // Un error se lee sin "data": su forma no tiene por qué coincidir con T y no debe ocultar el mensaje. + if (!response.IsSuccessStatusCode) + { + var error = await TryReadAsync(response, FiscalApiJsonContext.Default.ApiResponse, cancellationToken); + throw error is null + ? ApiException.UnexpectedResponse(response.StatusCode) + : ApiException.FromEnvelope(response.StatusCode, error); + } + + var envelope = await TryReadAsync(response, responseType, cancellationToken) + ?? throw ApiException.UnexpectedResponse(response.StatusCode); + return envelope.Succeeded ? envelope.Data : throw ApiException.FromEnvelope(response.StatusCode, envelope); + } + + // Un 502/503 de nginx trae HTML o nada: eso es una respuesta inesperada, no un fallo del CLI. + private static async Task TryReadAsync( + HttpResponseMessage response, JsonTypeInfo envelopeType, CancellationToken cancellationToken) + where TEnvelope : ApiResponse + { + try + { + return await response.Content.ReadFromJsonAsync(envelopeType, cancellationToken); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs new file mode 100644 index 0000000..3d8b542 --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs @@ -0,0 +1,33 @@ +using System.Net.Http.Headers; + +namespace FiscalApi.Cli.Http; + +/// +/// El único lugar donde se fabrica el del CLI. Se crea uno por invocación, después de +/// parsear, porque la URL base y --verbose dependen de las opciones globales. +/// +public static class FiscalApiClientFactory +{ + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); + + public static FiscalApiClient Create(GlobalSettings settings, TextWriter diagnostics) => + new(CreateHttpClient(settings, diagnostics), RetryPolicy.Default); + + public static HttpClient CreateHttpClient(GlobalSettings settings, TextWriter diagnostics) + { + // SocketsHttpHandler usa el proxy del sistema por omisión (UseProxy = true), como pide el contrato. + HttpMessageHandler handler = new SocketsHttpHandler(); + if (settings.Verbose) + { + handler = new VerboseLoggingHandler(diagnostics) { InnerHandler = handler }; + } + + var httpClient = new HttpClient(handler) + { + BaseAddress = settings.Environment.BaseAddress, + Timeout = RequestTimeout + }; + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("fiscalapi-cli", CliVersion.Current)); + return httpClient; + } +} diff --git a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs new file mode 100644 index 0000000..6b22704 --- /dev/null +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +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))] +public sealed partial class FiscalApiJsonContext : JsonSerializerContext; diff --git a/src/FiscalApi.Cli/Http/RetryPolicy.cs b/src/FiscalApi.Cli/Http/RetryPolicy.cs new file mode 100644 index 0000000..9286e5a --- /dev/null +++ b/src/FiscalApi.Cli/Http/RetryPolicy.cs @@ -0,0 +1,95 @@ +using System.Net; + +namespace FiscalApi.Cli.Http; + +/// +/// Reintentos con backoff exponencial y jitter, solo ante errores transitorios: red, 408, 429 y 5xx. +/// Un 4xx nunca se reintenta. Quien la usa decide qué requests son seguros de repetir (solo GET). +/// +public sealed class RetryPolicy +{ + private readonly TimeSpan _baseDelay; + private readonly TimeSpan _maxDelay; + private readonly TimeProvider _timeProvider; + + public RetryPolicy(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay, TimeProvider timeProvider) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(baseDelay, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxDelay, baseDelay); + MaxAttempts = maxAttempts; + _baseDelay = baseDelay; + _maxDelay = 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); + } + catch (Exception exception) when (attempt < MaxAttempts && IsTransientFailure(exception, cancellationToken)) + { + await Task.Delay(Backoff(attempt), _timeProvider, cancellationToken); + continue; + } + + if (attempt == MaxAttempts || !IsTransient(response.StatusCode) || DelayBeforeRetry(attempt, response) is not { } delay) + { + return response; + } + + response.Dispose(); + await Task.Delay(delay, _timeProvider, cancellationToken); + } + } + + private static bool IsTransient(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; + + // HttpClient reporta su timeout como TaskCanceledException sin que el token de quien llama se haya cancelado. + private static bool IsTransientFailure(Exception exception, CancellationToken cancellationToken) => + exception is HttpRequestException + || (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested); + + /// + /// 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(attempt); + return delay <= _maxDelay ? delay : null; + } + + private TimeSpan? RetryAfter(HttpResponseMessage response) => response.Headers.RetryAfter switch + { + { Delta: { } delta } => delta, + { Date: { } date } => Max(date - _timeProvider.GetUtcNow(), TimeSpan.Zero), + _ => null + }; + + // "Equal jitter": la mitad fija y la otra mitad aleatoria, para que varios CLI no reintenten al unísono. + private TimeSpan Backoff(int attempt) + { + var exponential = Min(_baseDelay * Math.Pow(2, attempt - 1), _maxDelay); + return exponential / 2 + exponential / 2 * Random.Shared.NextDouble(); + } + + private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b; + + private static TimeSpan Max(TimeSpan a, TimeSpan b) => a > b ? a : b; +} diff --git a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs new file mode 100644 index 0000000..9324fe0 --- /dev/null +++ b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs @@ -0,0 +1,46 @@ +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", + "Cookie" + }; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + diagnostics.WriteLine($"> {request.Method} {request.RequestUri}"); + WriteHeaders("> ", request.Headers); + + var startedAt = Stopwatch.GetTimestamp(); + var response = await base.SendAsync(request, cancellationToken); + + var elapsed = Stopwatch.GetElapsedTime(startedAt); + diagnostics.WriteLine($"< {(int)response.StatusCode} {response.ReasonPhrase} ({elapsed.TotalMilliseconds:0} ms)"); + WriteHeaders("< ", response.Headers); + return response; + } + + private void WriteHeaders(string prefix, HttpHeaders headers) + { + foreach (var (name, values) in headers) + { + if (!CredentialHeaders.Contains(name)) + { + diagnostics.WriteLine($"{prefix}{name}: {string.Join(", ", values)}"); + } + } + } +} diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs new file mode 100644 index 0000000..cee90e6 --- /dev/null +++ b/src/FiscalApi.Cli/Program.cs @@ -0,0 +1,22 @@ +using System.Runtime.InteropServices; +using System.Text; +using FiscalApi.Cli; + +// 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. +Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + +// 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 application = new CliApplication(new GlobalOptions(), commands: [], console); +return await application.RunAsync(args, cancellation.Token); diff --git a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs new file mode 100644 index 0000000..3097cd0 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -0,0 +1,162 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; + +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 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 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 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; + } + + 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)); + return application.RunAsync(args, cancellationToken); + } +} 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..70eba37 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj @@ -0,0 +1,25 @@ + + + + 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..eaab3c9 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -0,0 +1,98 @@ +using System.CommandLine; + +namespace FiscalApi.Cli.Tests; + +/// +/// D-25: --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("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")); + } + + [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/FiscalApiClientBuilder.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs new file mode 100644 index 0000000..a4c582a --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs @@ -0,0 +1,15 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +internal static class FiscalApiClientBuilder +{ + public static readonly Uri BaseAddress = new("https://api.example.test"); + + /// Política real (3 intentos) sin espera entre ellos, para que las pruebas no duerman. + public static readonly RetryPolicy NoDelayRetryPolicy = + new(maxAttempts: 3, baseDelay: TimeSpan.Zero, maxDelay: TimeSpan.FromSeconds(1), TimeProvider.System); + + public static FiscalApiClient Create(StubHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = BaseAddress }, NoDelayRetryPolicy); +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs new file mode 100644 index 0000000..c410481 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs @@ -0,0 +1,141 @@ +using System.Net; +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); + } + + [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..25a04f7 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs @@ -0,0 +1,18 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +public sealed class FiscalApiClientFactoryTests +{ + [Fact] + public void Http_client_targets_the_resolved_environment_with_the_cli_user_agent_and_timeout() + { + var settings = new GlobalSettings(ApiEnvironment.Resolve(apiBase: null, live: true), Json: false, Verbose: false); + + using var httpClient = FiscalApiClientFactory.CreateHttpClient(settings, TextWriter.Null); + + Assert.Equal(ApiEnvironment.LiveBaseAddress, httpClient.BaseAddress); + Assert.Equal(TimeSpan.FromSeconds(30), httpClient.Timeout); + Assert.Equal($"fiscalapi-cli/{CliVersion.Current}", httpClient.DefaultRequestHeaders.UserAgent.ToString()); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs new file mode 100644 index 0000000..153986a --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// Contrato común del CLI: solo un GET se reintenta y solo ante errores transitorios. +/// +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"}"""; + + [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 FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + 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", times: FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts); + + var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); + Assert.Equal(FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts, handler.Requests.Count); + } + + [Fact] + public async Task Get_after_a_network_error_is_retried() + { + var handler = new StubHttpMessageHandler() + .FailWithNetworkError() + .RespondJson(HttpStatusCode.OK, Success); + + var data = await FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + Assert.Equal(new ProbeData("ok"), data); + Assert.Equal(2, handler.Requests.Count); + } + + [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(() => FiscalApiClientBuilder.Create(handler) + .PostAsync( + "api/v4/probe", + new ProbeRequest("x"), + TestJsonContext.Default.ProbeRequest, + TestJsonContext.Default.ApiResponseProbeData, + CancellationToken.None)); + + 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(() => FiscalApiClientBuilder.Create(handler) + .PostAsync( + "api/v4/probe", + new ProbeRequest("x"), + TestJsonContext.Default.ProbeRequest, + TestJsonContext.Default.ApiResponseProbeData, + CancellationToken.None)); + + 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(() => FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + + Assert.Equal(HttpStatusCode.Unauthorized, error.StatusCode); + Assert.Single(handler.Requests); + } + + [Fact] + public async Task Get_with_429_is_retried_after_the_retry_after_delay() + { + var handler = new StubHttpMessageHandler() + .RespondTooManyRequests(retryAfter: TimeSpan.Zero) + .RespondJson(HttpStatusCode.OK, Success); + + var data = await FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + + Assert.Equal(new ProbeData("ok"), data); + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task Get_with_429_and_a_retry_after_longer_than_the_maximum_delay_is_not_retried() + { + var handler = new StubHttpMessageHandler() + .RespondTooManyRequests(retryAfter: TimeSpan.FromMinutes(5)) + .RespondJson(HttpStatusCode.OK, Success); + + var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + + Assert.Equal(HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Single(handler.Requests); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs new file mode 100644 index 0000000..f8463b9 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs @@ -0,0 +1,66 @@ +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; } = []; + + public StubHttpMessageHandler RespondJson(HttpStatusCode status, string json, int times = 1) => + Enqueue(times, _ => new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + + /// Como el 502/503 de nginx: un cuerpo que no es JSON. + public StubHttpMessageHandler RespondHtml(HttpStatusCode status, string html, int times = 1) => + Enqueue(times, _ => new HttpResponseMessage(status) + { + Content = new StringContent(html, Encoding.UTF8, "text/html") + }); + + public StubHttpMessageHandler RespondTooManyRequests(TimeSpan retryAfter) => + Enqueue(_ => + { + var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("""{"succeeded":false,"message":"Demasiadas solicitudes"}""", Encoding.UTF8, "application/json") + }; + response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter); + return response; + }); + + public StubHttpMessageHandler FailWithNetworkError(int times = 1) => + Enqueue(times, _ => throw new HttpRequestException("Conexión rechazada.")); + + private StubHttpMessageHandler Enqueue(Func response) => Enqueue(1, response); + + private StubHttpMessageHandler Enqueue(int times, Func response) + { + for (var i = 0; i < times; i++) + { + _responses.Enqueue(response); + } + + return this; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + if (!_responses.TryDequeue(out var respond)) + { + throw new InvalidOperationException($"Request no esperado: {request.Method} {request.RequestUri}."); + } + + return Task.FromResult(respond(request)); + } +} 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..94b460f --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Tests.Http; + +/// +/// --verbose muestra el detalle HTTP sin headers de credenciales. +/// +public sealed class VerboseLoggingHandlerTests +{ + [Fact] + public async Task Writes_request_and_response_lines_without_credential_headers() + { + using var diagnostics = new StringWriter(); + var stub = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, """{"succeeded":true}"""); + using var client = new HttpClient(new VerboseLoggingHandler(diagnostics) { InnerHandler = stub }) + { + BaseAddress = FiscalApiClientBuilder.BaseAddress + }; + 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.Add("Accept", "application/json"); + + using var _ = await client.GetAsync("api/v4/probe"); + + var log = diagnostics.ToString(); + Assert.Contains("GET https://api.example.test/api/v4/probe", log); + Assert.Contains("Accept: application/json", log); + Assert.Contains("200", log); + Assert.DoesNotContain("secreto", log); + Assert.DoesNotContain("X-API-KEY", log, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("X-TENANT-KEY", log, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Authorization", log, StringComparison.OrdinalIgnoreCase); + } +} From 4c6996cb600ad79d04f78698c968911927fdbb09 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 13:55:58 -0600 Subject: [PATCH 02/19] =?UTF-8?q?CLI-001:=20correcciones=20de=20la=20revis?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sin redirecciones (3xx -> ApiException); fábrica por instancia con handler compartido y timeout por cliente. - Reintentos solo por ConnectionError/ResponseEnded, 408, 429 y 5xx; timeout, TLS y DNS no se reintentan. - JsonException en 2xx sube como defecto; --verbose sin Set-Cookie/Proxy-Authorization, con headers de Content y fallas. - --json manda la ayuda a stderr; --version asíncrona; consola restaura su codificación; --api-base conserva el prefijo. - Rojo visto: 13 pruebas nuevas por aserción; 8 de tiempo/408/cancelación por mutación (FakeTimeProvider). - dotnet test (Debug y Release): Passed 64, Failed 0 (FiscalApiClientRetryTests: 17). Build Release: 0 avisos. - publish AOT linux-x64 (Docker sdk:10.0-noble-aot), normal y enraizado: 0 IL/warning; --version = 0.1.0. - CI: checkout v5.1.0 y setup-dotnet v5.4.0 por SHA, global.json 10.0.401, concurrency; actionlint sin hallazgos. --- .github/workflows/build.yml | 19 +- Directory.Build.props | 11 +- global.json | 6 + src/FiscalApi.Cli/ApiEnvironment.cs | 20 +- src/FiscalApi.Cli/CliApplication.cs | 37 ++-- src/FiscalApi.Cli/Errors/ApiException.cs | 4 +- src/FiscalApi.Cli/FiscalApi.Cli.csproj | 1 + src/FiscalApi.Cli/Http/FiscalApiClient.cs | 39 ++-- .../Http/FiscalApiClientFactory.cs | 35 ++-- src/FiscalApi.Cli/Http/RetryPolicy.cs | 29 +-- .../Http/VerboseLoggingHandler.cs | 38 +++- src/FiscalApi.Cli/Program.cs | 44 ++-- .../CliApplicationTests.cs | 54 +++++ .../FiscalApi.Cli.Tests.csproj | 4 +- .../FiscalApi.Cli.Tests/GlobalOptionsTests.cs | 13 ++ .../Http/FiscalApiClientBuilder.cs | 13 +- .../Http/FiscalApiClientErrorTests.cs | 30 +++ .../Http/FiscalApiClientFactoryTests.cs | 47 ++++- .../Http/FiscalApiClientRetryTests.cs | 194 ++++++++++++++---- .../FiscalApi.Cli.Tests/Http/RedirectTests.cs | 79 +++++++ .../Http/SteppedTimeProvider.cs | 52 +++++ .../Http/StubHttpMessageHandler.cs | 56 ++--- .../Http/VerboseLoggingHandlerTests.cs | 74 +++++-- 23 files changed, 721 insertions(+), 178 deletions(-) create mode 100644 global.json create mode 100644 tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a843879..d0381c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,11 @@ on: 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. @@ -14,12 +19,13 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + # El SDK sale de global.json, el mismo que se usa en local. - name: Set up .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: - dotnet-version: 10.0.x + global-json-file: global.json # Prerrequisitos de Native AOT en Linux: el linker (clang) y zlib. - name: Install Native AOT prerequisites @@ -42,5 +48,10 @@ jobs: - name: Smoke test run: | - ./artifacts/linux-x64/fiscalapi --version + 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/Directory.Build.props b/Directory.Build.props index 68d4552..e976240 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,6 +6,12 @@ enable true + + $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 true - true true 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 index c47a1df..7eec3a5 100644 --- a/src/FiscalApi.Cli/ApiEnvironment.cs +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -4,12 +4,24 @@ namespace FiscalApi.Cli; /// Ambiente de la API contra el que trabaja una invocación (D-25). Se resuelve una sola vez por invocación /// y viaja como valor inmutable, sin estáticos mutables. /// -public sealed record ApiEnvironment(Uri BaseAddress) +public sealed record ApiEnvironment { - public static readonly Uri TestBaseAddress = new("https://test.fiscalapi.com"); - public static readonly Uri LiveBaseAddress = new("https://live.fiscalapi.com"); + 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; } /// Precedencia: --api-base gana sobre --live, y este sobre test. public static ApiEnvironment Resolve(Uri? apiBase, bool live) => - new(apiBase ?? (live ? LiveBaseAddress : TestBaseAddress)); + 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 index 63a6e3a..8e5750c 100644 --- a/src/FiscalApi.Cli/CliApplication.cs +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -13,11 +13,14 @@ namespace FiscalApi.Cli; public sealed class CliApplication { private readonly RootCommand _rootCommand; + private readonly GlobalOptions _globalOptions; private readonly CliConsole _console; private readonly InvocationConfiguration _invocation; + private readonly InvocationConfiguration _jsonInvocation; public CliApplication(GlobalOptions globalOptions, IReadOnlyList commands, CliConsole console) { + _globalOptions = globalOptions; _console = console; _rootCommand = new RootCommand("CLI de FiscalAPI: webhooks y eventos desde la terminal."); globalOptions.AddTo(_rootCommand); @@ -35,15 +38,10 @@ public CliApplication(GlobalOptions globalOptions, IReadOnlyList comman // 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 = new InvocationConfiguration - { - Output = console.Out, - Error = console.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 - }; + _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) @@ -56,7 +54,8 @@ public async Task RunAsync(IReadOnlyList args, CancellationToken ca try { - return await parseResult.InvokeAsync(_invocation, cancellationToken); + var invocation = _globalOptions.Read(parseResult).Json ? _jsonInvocation : _invocation; + return await parseResult.InvokeAsync(invocation, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -91,12 +90,22 @@ private async Task ReportUsageErrorsAsync(ParseResult parseResult) return ExitCodes.Usage; } - // Síncrona como la acción integrada de --version que reemplaza. - private sealed class PrintVersionAction : SynchronousCommandLineAction + 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 int Invoke(ParseResult parseResult) + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) { - parseResult.InvocationConfiguration.Output.WriteLine(CliVersion.Current); + await parseResult.InvocationConfiguration.Output.WriteLineAsync(CliVersion.Current.AsMemory(), cancellationToken); return ExitCodes.Success; } } diff --git a/src/FiscalApi.Cli/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs index bee7d24..0c0bc67 100644 --- a/src/FiscalApi.Cli/Errors/ApiException.cs +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -13,7 +13,9 @@ 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 { - [HttpStatusCode.Unauthorized] = "Ejecuta `fiscalapi login` o revisa la variable de entorno FISCALAPI_API_KEY." + // 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) diff --git a/src/FiscalApi.Cli/FiscalApi.Cli.csproj b/src/FiscalApi.Cli/FiscalApi.Cli.csproj index 034b903..01e8b8e 100644 --- a/src/FiscalApi.Cli/FiscalApi.Cli.csproj +++ b/src/FiscalApi.Cli/FiscalApi.Cli.csproj @@ -4,6 +4,7 @@ Exe fiscalapi FiscalApi.Cli + true 0.1.0 FiscalAPI CLI FiscalAPI diff --git a/src/FiscalApi.Cli/Http/FiscalApiClient.cs b/src/FiscalApi.Cli/Http/FiscalApiClient.cs index de4ed87..802ff8e 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClient.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClient.cs @@ -18,8 +18,8 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli { using var response = await SendAsync( () => retryPolicy.SendAsync(httpClient, () => new HttpRequestMessage(HttpMethod.Get, path), cancellationToken), - cancellationToken); - return await ReadDataAsync(response, responseType, 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. @@ -34,8 +34,9 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli { Content = JsonContent.Create(body, requestType) }; - using var response = await SendAsync(() => httpClient.SendAsync(request, cancellationToken), cancellationToken); - return await ReadDataAsync(response, responseType, cancellationToken); + using var response = await SendAsync(() => httpClient.SendAsync(request, cancellationToken), cancellationToken) + .ConfigureAwait(false); + return await ReadDataAsync(response, responseType, cancellationToken).ConfigureAwait(false); } private async Task SendAsync( @@ -43,12 +44,14 @@ private async Task SendAsync( { try { - return await send(); + 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( @@ -59,32 +62,34 @@ private async Task SendAsync( private static async Task ReadDataAsync( HttpResponseMessage response, JsonTypeInfo> responseType, CancellationToken cancellationToken) { - // Un error se lee sin "data": su forma no tiene por qué coincidir con T y no debe ocultar el mensaje. if (!response.IsSuccessStatusCode) { - var error = await TryReadAsync(response, FiscalApiJsonContext.Default.ApiResponse, cancellationToken); - throw error is null - ? ApiException.UnexpectedResponse(response.StatusCode) - : ApiException.FromEnvelope(response.StatusCode, error); + throw await ToApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); } - var envelope = await TryReadAsync(response, responseType, cancellationToken) + // 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 502/503 de nginx trae HTML o nada: eso es una respuesta inesperada, no un fallo del CLI. - private static async Task TryReadAsync( - HttpResponseMessage response, JsonTypeInfo envelopeType, CancellationToken cancellationToken) - where TEnvelope : ApiResponse + /// + /// Un error se lee sin data, cuya forma 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 { - return await response.Content.ReadFromJsonAsync(envelopeType, cancellationToken); + var envelope = await response.Content + .ReadFromJsonAsync(FiscalApiJsonContext.Default.ApiResponse, cancellationToken).ConfigureAwait(false); + return envelope is null + ? ApiException.UnexpectedResponse(response.StatusCode) + : ApiException.FromEnvelope(response.StatusCode, envelope); } catch (JsonException) { - return null; + return ApiException.UnexpectedResponse(response.StatusCode); } } } diff --git a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs index 3d8b542..9538b47 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs @@ -3,31 +3,32 @@ namespace FiscalApi.Cli.Http; /// -/// El único lugar donde se fabrica el del CLI. Se crea uno por invocación, después de -/// parsear, porque la URL base y --verbose dependen de las opciones globales. +/// 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 y el timeout +/// dependen de las opciones globales y del comando. Llega a los comandos por constructor para que sus +/// pruebas inyecten un handler falso. /// -public static class FiscalApiClientFactory +public sealed class FiscalApiClientFactory(HttpMessageHandler handler, TextWriter diagnostics, RetryPolicy retryPolicy) { - private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); - public static FiscalApiClient Create(GlobalSettings settings, TextWriter diagnostics) => - new(CreateHttpClient(settings, diagnostics), RetryPolicy.Default); + /// + /// 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 }; - public static HttpClient CreateHttpClient(GlobalSettings settings, TextWriter diagnostics) + public FiscalApiClient Create(GlobalSettings settings, TimeSpan timeout) { - // SocketsHttpHandler usa el proxy del sistema por omisión (UseProxy = true), como pide el contrato. - HttpMessageHandler handler = new SocketsHttpHandler(); - if (settings.Verbose) - { - handler = new VerboseLoggingHandler(diagnostics) { InnerHandler = handler }; - } - - var httpClient = new HttpClient(handler) + // 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 = RequestTimeout + Timeout = timeout }; httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("fiscalapi-cli", CliVersion.Current)); - return httpClient; + return new FiscalApiClient(httpClient, retryPolicy); } } diff --git a/src/FiscalApi.Cli/Http/RetryPolicy.cs b/src/FiscalApi.Cli/Http/RetryPolicy.cs index 9286e5a..3ae6935 100644 --- a/src/FiscalApi.Cli/Http/RetryPolicy.cs +++ b/src/FiscalApi.Cli/Http/RetryPolicy.cs @@ -3,11 +3,15 @@ namespace FiscalApi.Cli.Http; /// -/// Reintentos con backoff exponencial y jitter, solo ante errores transitorios: red, 408, 429 y 5xx. -/// Un 4xx nunca se reintenta. Quien la usa decide qué requests son seguros de repetir (solo GET). +/// 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 { + // 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 MaxBackoffExponent = 30; + private readonly TimeSpan _baseDelay; private readonly TimeSpan _maxDelay; private readonly TimeProvider _timeProvider; @@ -39,11 +43,11 @@ public async Task SendAsync( try { using var request = createRequest(); - response = await httpClient.SendAsync(request, cancellationToken); + response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (attempt < MaxAttempts && IsTransientFailure(exception, cancellationToken)) + catch (HttpRequestException exception) when (attempt < MaxAttempts && IsTransient(exception.HttpRequestError)) { - await Task.Delay(Backoff(attempt), _timeProvider, cancellationToken); + await Task.Delay(Backoff(attempt), _timeProvider, cancellationToken).ConfigureAwait(false); continue; } @@ -53,17 +57,17 @@ public async Task SendAsync( } response.Dispose(); - await Task.Delay(delay, _timeProvider, cancellationToken); + await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false); } } private static bool IsTransient(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; - // HttpClient reporta su timeout como TaskCanceledException sin que el token de quien llama se haya cancelado. - private static bool IsTransientFailure(Exception exception, CancellationToken cancellationToken) => - exception is HttpRequestException - || (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested); + // 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 @@ -85,11 +89,10 @@ exception is HttpRequestException // "Equal jitter": la mitad fija y la otra mitad aleatoria, para que varios CLI no reintenten al unísono. private TimeSpan Backoff(int attempt) { - var exponential = Min(_baseDelay * Math.Pow(2, attempt - 1), _maxDelay); + var exponent = Math.Min(attempt - 1, MaxBackoffExponent); + var exponential = TimeSpan.FromTicks((long)Math.Min(_baseDelay.Ticks * Math.Pow(2, exponent), _maxDelay.Ticks)); return exponential / 2 + exponential / 2 * Random.Shared.NextDouble(); } - private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b; - private static TimeSpan Max(TimeSpan a, TimeSpan b) => a > b ? a : b; } diff --git a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs index 9324fe0..47cce8d 100644 --- a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs +++ b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs @@ -15,26 +15,52 @@ public sealed class VerboseLoggingHandler(TextWriter diagnostics) : DelegatingHa "X-API-KEY", "X-TENANT-KEY", "Authorization", - "Cookie" + "Proxy-Authorization", + "Cookie", + "Set-Cookie" }; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { - diagnostics.WriteLine($"> {request.Method} {request.RequestUri}"); + var line = $"{request.Method} {request.RequestUri}"; + diagnostics.WriteLine($"> {line}"); WriteHeaders("> ", request.Headers); + WriteHeaders("> ", request.Content?.Headers); var startedAt = Stopwatch.GetTimestamp(); - var response = await base.SendAsync(request, cancellationToken); + 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; + } - var elapsed = Stopwatch.GetElapsedTime(startedAt); - diagnostics.WriteLine($"< {(int)response.StatusCode} {response.ReasonPhrase} ({elapsed.TotalMilliseconds:0} ms)"); + diagnostics.WriteLine($"< {(int)response.StatusCode} {response.ReasonPhrase} ({Elapsed(startedAt)})"); WriteHeaders("< ", response.Headers); + WriteHeaders("< ", response.Content.Headers); return response; } - private void WriteHeaders(string prefix, HttpHeaders headers) + 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)) diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs index cee90e6..493641c 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -1,22 +1,40 @@ +using System.CommandLine; using System.Runtime.InteropServices; using System.Text; using FiscalApi.Cli; +using FiscalApi.Cli.Http; // 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. +// 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(); -// 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(); + }); -// 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 console = new CliConsole(Console.Out, Console.Error); -var application = new CliApplication(new GlobalOptions(), commands: [], console); -return await application.RunAsync(args, cancellation.Token); + // 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, console.Error, RetryPolicy.Default); + IReadOnlyList commands = []; + + var application = new CliApplication(new GlobalOptions(), commands, console); + return await application.RunAsync(args, cancellation.Token); +} +finally +{ + Console.OutputEncoding = originalOutputEncoding; +} diff --git a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs index 3097cd0..85d55f8 100644 --- a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -2,6 +2,7 @@ using System.Net; using FiscalApi.Cli.Errors; using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Http; namespace FiscalApi.Cli.Tests; @@ -44,6 +45,16 @@ public async Task Without_arguments_prints_help_and_exits_0() 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() { @@ -133,6 +144,31 @@ public async Task Cancellation_by_ctrl_c_exits_130() 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() { @@ -151,6 +187,24 @@ private static Command Throwing(Exception 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( diff --git a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj index 70eba37..450472f 100644 --- a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj +++ b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj @@ -4,8 +4,10 @@ false - + + + diff --git a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs index eaab3c9..648eab8 100644 --- a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -39,6 +39,19 @@ public void Api_base_wins_over_the_default() 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")] diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs index a4c582a..15bb2f0 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs @@ -2,14 +2,19 @@ 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 Uri BaseAddress = new("https://api.example.test/"); - /// Política real (3 intentos) sin espera entre ellos, para que las pruebas no duerman. + /// 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); - public static FiscalApiClient Create(StubHttpMessageHandler handler) => - new(new HttpClient(handler) { BaseAddress = BaseAddress }, NoDelayRetryPolicy); + public static FiscalApiClient Create( + StubHttpMessageHandler handler, RetryPolicy? retryPolicy = null, TimeSpan? timeout = null) => + new FiscalApiClientFactory(handler, TextWriter.Null, retryPolicy ?? NoDelayRetryPolicy) + .Create( + new GlobalSettings(ApiEnvironment.Resolve(BaseAddress, live: false), Json: false, Verbose: false), + timeout ?? FiscalApiClientFactory.DefaultTimeout); } diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs index c410481..352580b 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text.Json; using FiscalApi.Cli.Errors; namespace FiscalApi.Cli.Tests.Http; @@ -36,6 +37,35 @@ public async Task Unauthorized_carries_the_api_message_and_the_login_hint() 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] diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs index 25a04f7..92a0fb8 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs @@ -1,18 +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 = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy); + + await factory.Create(Settings(live: true), 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 void Http_client_targets_the_resolved_environment_with_the_cli_user_agent_and_timeout() + public async Task Clients_share_the_handler_without_disposing_it() { - var settings = new GlobalSettings(ApiEnvironment.Resolve(apiBase: null, live: true), Json: false, Verbose: false); + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success, times: 2); + var factory = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy); - using var httpClient = FiscalApiClientFactory.CreateHttpClient(settings, TextWriter.Null); + await factory.Create(Settings(live: false), FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + await factory.Create(Settings(live: true), FiscalApiClientFactory.DefaultTimeout) + .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); - Assert.Equal(ApiEnvironment.LiveBaseAddress, httpClient.BaseAddress); - Assert.Equal(TimeSpan.FromSeconds(30), httpClient.Timeout); - Assert.Equal($"fiscalapi-cli/{CliVersion.Current}", httpClient.DefaultRequestHeaders.UserAgent.ToString()); + 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 = new FiscalApiClientFactory(handler, diagnostics, FiscalApiClientBuilder.NoDelayRetryPolicy); + + await factory.Create(Settings(live: false, verbose: true), 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 index 153986a..16c474f 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs @@ -1,15 +1,21 @@ 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() @@ -18,8 +24,7 @@ public async Task Get_with_503_is_retried_until_it_succeeds() .RespondHtml(HttpStatusCode.ServiceUnavailable, "503 Service Temporarily Unavailable") .RespondJson(HttpStatusCode.OK, Success); - var data = await FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + var data = await GetAsync(FiscalApiClientBuilder.Create(handler)); Assert.Equal(new ProbeData("ok"), data); Assert.Equal(2, handler.Requests.Count); @@ -28,30 +33,68 @@ public async Task Get_with_503_is_retried_until_it_succeeds() [Fact] public async Task Get_with_503_on_every_attempt_fails_after_the_last_attempt() { - var handler = new StubHttpMessageHandler().RespondHtml( - HttpStatusCode.ServiceUnavailable, "503", times: FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts); + var handler = new StubHttpMessageHandler().RespondHtml(HttpStatusCode.ServiceUnavailable, "503", AllAttempts); - var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + var error = await Assert.ThrowsAsync(() => GetAsync(FiscalApiClientBuilder.Create(handler))); Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); - Assert.Equal(FiscalApiClientBuilder.NoDelayRetryPolicy.MaxAttempts, handler.Requests.Count); + Assert.Equal(AllAttempts, handler.Requests.Count); } [Fact] - public async Task Get_after_a_network_error_is_retried() + public async Task Get_with_408_is_retried() { var handler = new StubHttpMessageHandler() - .FailWithNetworkError() + .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 FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + 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() { @@ -59,13 +102,7 @@ public async Task Post_with_503_is_not_retried() .RespondHtml(HttpStatusCode.ServiceUnavailable, "503") .RespondJson(HttpStatusCode.OK, Success); - var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) - .PostAsync( - "api/v4/probe", - new ProbeRequest("x"), - TestJsonContext.Default.ProbeRequest, - TestJsonContext.Default.ApiResponseProbeData, - CancellationToken.None)); + var error = await Assert.ThrowsAsync(() => PostAsync(FiscalApiClientBuilder.Create(handler))); Assert.Equal(HttpStatusCode.ServiceUnavailable, error.StatusCode); Assert.Single(handler.Requests); @@ -78,13 +115,7 @@ public async Task Post_after_a_network_error_is_not_retried() .FailWithNetworkError() .RespondJson(HttpStatusCode.OK, Success); - await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) - .PostAsync( - "api/v4/probe", - new ProbeRequest("x"), - TestJsonContext.Default.ProbeRequest, - TestJsonContext.Default.ApiResponseProbeData, - CancellationToken.None)); + await Assert.ThrowsAsync(() => PostAsync(FiscalApiClientBuilder.Create(handler))); Assert.Single(handler.Requests); } @@ -96,38 +127,129 @@ public async Task Get_with_401_is_never_retried() .RespondJson(HttpStatusCode.Unauthorized, Unauthorized) .RespondJson(HttpStatusCode.OK, Success); - var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + 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_is_retried_after_the_retry_after_delay() + public async Task Get_with_429_waits_the_retry_after_seconds_before_retrying() { var handler = new StubHttpMessageHandler() - .RespondTooManyRequests(retryAfter: TimeSpan.Zero) + .RespondWithRetryAfter(HttpStatusCode.TooManyRequests, new RetryConditionHeaderValue(TimeSpan.FromSeconds(7))) .RespondJson(HttpStatusCode.OK, Success); - var data = await FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None); + var pending = GetAsync(TimedClient(handler)); + await _time.WaitForDelayAsync(); - Assert.Equal(new ProbeData("ok"), data); + // 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_429_and_a_retry_after_longer_than_the_maximum_delay_is_not_retried() + public async Task Get_with_503_waits_until_the_retry_after_http_date() { var handler = new StubHttpMessageHandler() - .RespondTooManyRequests(retryAfter: TimeSpan.FromMinutes(5)) + .RespondWithRetryAfter(HttpStatusCode.ServiceUnavailable, new RetryConditionHeaderValue(_time.GetUtcNow().AddSeconds(5))) .RespondJson(HttpStatusCode.OK, Success); - var error = await Assert.ThrowsAsync(() => FiscalApiClientBuilder.Create(handler) - .GetAsync("api/v4/probe", TestJsonContext.Default.ApiResponseProbeData, CancellationToken.None)); + 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/RedirectTests.cs b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs new file mode 100644 index 0000000..4f95a54 --- /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 = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy) + .Create(settings, 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 index f8463b9..d08c69b 100644 --- a/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs +++ b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text; @@ -10,40 +11,50 @@ namespace FiscalApi.Cli.Tests.Http; /// public sealed class StubHttpMessageHandler : HttpMessageHandler { - private readonly Queue> _responses = new(); + private readonly Queue>> _responses = new(); public List Requests { get; } = []; public StubHttpMessageHandler RespondJson(HttpStatusCode status, string json, int times = 1) => - Enqueue(times, _ => new HttpResponseMessage(status) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }); + 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) => - Enqueue(times, _ => new HttpResponseMessage(status) + Respond(() => new HttpResponseMessage(status) { Content = new StringContent(html, Encoding.UTF8, "text/html") - }); + }, times); - public StubHttpMessageHandler RespondTooManyRequests(TimeSpan retryAfter) => - Enqueue(_ => + public StubHttpMessageHandler RespondWithRetryAfter(HttpStatusCode status, RetryConditionHeaderValue retryAfter) => + Respond(() => { - var response = new HttpResponseMessage(HttpStatusCode.TooManyRequests) - { - Content = new StringContent("""{"succeeded":false,"message":"Demasiadas solicitudes"}""", Encoding.UTF8, "application/json") - }; - response.Headers.RetryAfter = new RetryConditionHeaderValue(retryAfter); + var response = Json(status, """{"succeeded":false,"message":"Intenta más tarde"}"""); + response.Headers.RetryAfter = retryAfter; return response; }); - public StubHttpMessageHandler FailWithNetworkError(int times = 1) => - Enqueue(times, _ => throw new HttpRequestException("Conexión rechazada.")); + public StubHttpMessageHandler Respond(Func createResponse, int times = 1) => + Enqueue(times, _ => Task.FromResult(createResponse())); - private StubHttpMessageHandler Enqueue(Func response) => Enqueue(1, response); + 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 StubHttpMessageHandler Enqueue(int times, Func response) + 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++) { @@ -56,11 +67,8 @@ private StubHttpMessageHandler Enqueue(int times, Func SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Requests.Add(request); - if (!_responses.TryDequeue(out var respond)) - { - throw new InvalidOperationException($"Request no esperado: {request.Method} {request.RequestUri}."); - } - - return Task.FromResult(respond(request)); + return _responses.TryDequeue(out var respond) + ? respond(cancellationToken) + : throw new InvalidOperationException($"Request no esperado: {request.Method} {request.RequestUri}."); } } diff --git a/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs index 94b460f..7b53133 100644 --- a/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs @@ -1,36 +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. +/// --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() { - using var diagnostics = new StringWriter(); - var stub = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, """{"succeeded":true}"""); - using var client = new HttpClient(new VerboseLoggingHandler(diagnostics) { InnerHandler = stub }) + var stub = new StubHttpMessageHandler().Respond(() => { - BaseAddress = FiscalApiClientBuilder.BaseAddress - }; + 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 _ = await client.GetAsync("api/v4/probe"); + 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")); - var log = diagnostics.ToString(); - Assert.Contains("GET https://api.example.test/api/v4/probe", log); - Assert.Contains("Accept: application/json", log); - Assert.Contains("200", log); - Assert.DoesNotContain("secreto", log); - Assert.DoesNotContain("X-API-KEY", log, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("X-TENANT-KEY", log, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("Authorization", log, StringComparison.OrdinalIgnoreCase); + 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 + }; } From c9944318158719570d14e827135707885a09def9 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 14:30:17 -0600 Subject: [PATCH 03/19] =?UTF-8?q?CLI-002:=20login/logout=20y=20almac=C3=A9?= =?UTF-8?q?n=20de=20credenciales=20del=20SO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login: API key y tenant key sin eco o por stdin, GET /api/v4/me/features, una entrada por host; logout local, siempre 0. - Almacenes con LibraryImport: advapi32 CredWriteW/CredReadW/CredDeleteW, llavero SecItem*, libsecret *v_sync; sin archivo plano. - FISCALAPI_API_KEY/FISCALAPI_TENANT_KEY ganan; sin almacén: texto aprobado, código 2. Credenciales fuera de --verbose y errores. - Rojo visto: 35 pruebas nuevas por NotImplementedException; StoredCredentialsFormatTests por mutación (3 en rojo). - dotnet test Release y Debug: Passed 106, Failed 0. Build Release: 0 avisos. - Windows: ida y vuelta real en Credential Manager; cmdkey /list sin entradas fiscalapi-cli al terminar. - Docker sdk:10.0-noble-aot: publish AOT linux-x64 normal y enraizado sin IL/warning; libsecret con gnome-keyring: 5/5. - CI: las pruebas corren en dbus-run-session con gnome-keyring; actionlint sin hallazgos. macOS solo compilado. --- .github/workflows/build.yml | 17 +- src/FiscalApi.Cli/ApiEnvironment.cs | 6 + src/FiscalApi.Cli/Commands/LoginCommand.cs | 83 +++++++ src/FiscalApi.Cli/Commands/LogoutCommand.cs | 48 ++++ .../Credentials/ApiCredentials.cs | 50 ++++ .../Credentials/CredentialResolver.cs | 35 +++ .../Credentials/CredentialStoreException.cs | 12 + .../CredentialStoreUnavailableException.cs | 23 ++ .../Credentials/CredentialStores.cs | 19 ++ .../Credentials/ICredentialStore.cs | 18 ++ .../Linux/LibSecretCredentialStore.cs | 135 +++++++++++ .../Credentials/Linux/LinuxNative.cs | 118 ++++++++++ .../MacOS/KeychainCredentialStore.cs | 109 +++++++++ .../Credentials/MacOS/MacNative.cs | 135 +++++++++++ .../Credentials/StoredCredentialsFormat.cs | 44 ++++ .../Credentials/Windows/AdvApi32.cs | 49 ++++ .../Windows/WindowsCredentialStore.cs | 100 ++++++++ .../Errors/InvalidCredentialsException.cs | 11 + src/FiscalApi.Cli/FiscalApi.Cli.csproj | 7 + .../Http/FiscalApiClientFactory.cs | 27 ++- .../Http/FiscalApiJsonContext.cs | 1 + src/FiscalApi.Cli/Http/PersonFeatures.cs | 10 + .../Input/ConsoleSecretReader.cs | 43 ++++ src/FiscalApi.Cli/Input/ISecretReader.cs | 11 + .../Input/RedirectedSecretReader.cs | 11 + src/FiscalApi.Cli/Program.cs | 24 +- .../Commands/LoginCommandTests.cs | 198 ++++++++++++++++ .../Commands/LogoutCommandTests.cs | 67 ++++++ .../Credentials/CredentialResolutionTests.cs | 222 ++++++++++++++++++ .../CredentialStoreFactAttribute.cs | 19 ++ .../Credentials/CredentialStoreTests.cs | 72 ++++++ .../Credentials/InMemoryCredentialStore.cs | 29 +++ .../StoredCredentialsFormatTests.cs | 40 ++++ .../Http/FiscalApiClientBuilder.cs | 21 +- .../Http/FiscalApiClientFactoryTests.cs | 14 +- .../Http/PersonFeaturesJson.cs | 29 +++ .../FiscalApi.Cli.Tests/Http/RedirectTests.cs | 4 +- 37 files changed, 1836 insertions(+), 25 deletions(-) create mode 100644 src/FiscalApi.Cli/Commands/LoginCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/LogoutCommand.cs create mode 100644 src/FiscalApi.Cli/Credentials/ApiCredentials.cs create mode 100644 src/FiscalApi.Cli/Credentials/CredentialResolver.cs create mode 100644 src/FiscalApi.Cli/Credentials/CredentialStoreException.cs create mode 100644 src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs create mode 100644 src/FiscalApi.Cli/Credentials/CredentialStores.cs create mode 100644 src/FiscalApi.Cli/Credentials/ICredentialStore.cs create mode 100644 src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs create mode 100644 src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs create mode 100644 src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs create mode 100644 src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs create mode 100644 src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs create mode 100644 src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs create mode 100644 src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs create mode 100644 src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs create mode 100644 src/FiscalApi.Cli/Http/PersonFeatures.cs create mode 100644 src/FiscalApi.Cli/Input/ConsoleSecretReader.cs create mode 100644 src/FiscalApi.Cli/Input/ISecretReader.cs create mode 100644 src/FiscalApi.Cli/Input/RedirectedSecretReader.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs create mode 100644 tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs create mode 100644 tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d0381c4..616ea2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,11 +27,12 @@ jobs: with: global-json-file: global.json - # Prerrequisitos de Native AOT en Linux: el linker (clang) y zlib. - - name: Install Native AOT prerequisites + # 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). + - name: Install Native AOT and Secret Service prerequisites run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends clang zlib1g-dev + sudo apt-get install -y --no-install-recommends clang zlib1g-dev libsecret-1-0 gnome-keyring dbus dbus-x11 - name: Restore run: dotnet restore FiscalApi.Cli.slnx @@ -39,8 +40,16 @@ jobs: - name: Build run: dotnet build FiscalApi.Cli.slnx -c Release --no-restore + # gnome-keyring en una sesión de D-Bus propia, desbloqueado con una contraseña de prueba: así corren contra + # libsecret las pruebas de CredentialStoreTests, que sin FISCALAPI_CLI_SECRET_SERVICE_TESTS se omiten en Linux. - name: Test - run: dotnet test FiscalApi.Cli.slnx -c Release --no-build + env: + FISCALAPI_CLI_SECRET_SERVICE_TESTS: "1" + run: | + dbus-run-session -- bash -c ' + printf "%s" "llavero-del-ci" | gnome-keyring-daemon --unlock --components=secrets > /dev/null + sleep 1 + 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) diff --git a/src/FiscalApi.Cli/ApiEnvironment.cs b/src/FiscalApi.Cli/ApiEnvironment.cs index 7eec3a5..ee3f118 100644 --- a/src/FiscalApi.Cli/ApiEnvironment.cs +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -14,6 +14,12 @@ public sealed record ApiEnvironment /// 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 (D-25). + /// + 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))); diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs new file mode 100644 index 0000000..7c0d624 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -0,0 +1,83 @@ +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 (CLI-002, D-25): 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 webhook-endpoints (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; + + // Se valida cada valor antes de llamar a la API: un valor vacío es un error de uso, no un 401. + var credentials = ApiCredentials.Create( + await _input.ReadAsync("API key: ", cancellationToken), + 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 webhook-endpoints 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..d3e06ef --- /dev/null +++ b/src/FiscalApi.Cli/Commands/LogoutCommand.cs @@ -0,0 +1,48 @@ +using System.CommandLine; +using FiscalApi.Cli.Credentials; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi logout (CLI-002): borra la entrada del host del ambiente en el almacén del SO. Es local (no +/// llama a la API) y siempre termina con éxito, aunque no hubiera nada guardado. +/// +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; + } + + // Sin almacén en el SO, login nunca pudo guardar nada: no hay nada que borrar y no es un error. + private bool DeleteSavedCredentials(string host) + { + try + { + return _store.Delete(host); + } + catch (CredentialStoreUnavailableException) + { + return false; + } + } +} diff --git a/src/FiscalApi.Cli/Credentials/ApiCredentials.cs b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs new file mode 100644 index 0000000..a343682 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs @@ -0,0 +1,50 @@ +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 muy por debajo del + // máximo de un secreto en Credential Manager (CRED_MAX_CREDENTIAL_BLOB_SIZE, 2560 bytes). + private const int MaxLength = 256; + + 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( + Validated(apiKey, "La API key"), + 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..a76d295 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs @@ -0,0 +1,35 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// De dónde salen las credenciales de una invocación (D-25): 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. + 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..6d5cedd --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs @@ -0,0 +1,12 @@ +using FiscalApi.Cli.Errors; + +namespace FiscalApi.Cli.Credentials; + +/// +/// El almacén del SO existe pero falló de una forma inesperada (un código que no es "no existe" ni "no hay +/// almacén"), o la entrada guardada no se pudo leer. El mensaje lleva el código del SO, nunca el secreto. +/// +public sealed class CredentialStoreException(string message) : CliException(message) +{ + public override int ExitCode => ExitCodes.Failure; +} diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs new file mode 100644 index 0000000..47d38fd --- /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 (D-25); 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..614940f --- /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 (D-25, CLI-002). +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 (D-24). + 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..08342e4 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs @@ -0,0 +1,18 @@ +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 (D-25). Nunca hay un archivo plano ni otro respaldo: +/// si el SO no tiene almacén, cada operación lanza . +/// +public interface ICredentialStore +{ + /// 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..3b1bd35 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +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 D-Bus, sin Secret Service o sin libsecret no hay almacén. +/// +[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"; + + public ApiCredentials? Read(string host) + { + 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 null; + } + + try + { + return StoredCredentialsFormat.Decode(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(password), host); + } + finally + { + LibSecret.PasswordFree(password); + } + } + + 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; + } + + private Query Open(string host) + { + try + { + return new Query(service, host); + } + catch (DllNotFoundException) + { + throw new CredentialStoreUnavailableException("No se encontró libsecret-1.so.0 (paquete libsecret-1-0 o libsecret)."); + } + } + + /// + /// Cualquier error de libsecret es de D-Bus o del Secret Service (sin sesión de D-Bus, sin servicio, llavero + /// bloqueado sin poder pedir la contraseña): no hay almacén utilizable. El mensaje nunca trae el secreto. + /// + private static void ThrowIfFailed(GLib.GError* error) + { + if (error == null) + { + return; + } + + var message = Utf8StringMarshaller.ConvertToManaged(error->Message); + GLib.ErrorFree(error); + throw new CredentialStoreUnavailableException($"Secret Service: {message}"); + } + + /// 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..d98ac2b --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs @@ -0,0 +1,118 @@ +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); + + /// + /// 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. + /// + internal static (nint Hash, nint Equal) StringHashFunctions() + { + var library = NativeLibrary.Load(Library); + return (NativeLibrary.GetExport(library, "g_str_hash"), NativeLibrary.GetExport(library, "g_str_equal")); + } +} + +/// +/// 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..b79f3b9 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs @@ -0,0 +1,109 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using FiscalApi.Cli.Errors; + +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 +{ + 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; + } + + // Sin llavero de sesión o sin poder pedir permiso (p. ej. por SSH con el llavero bloqueado): no hay almacén. + var unavailable = status is SecurityFramework.NotAvailable or SecurityFramework.NoSuchKeychain + or SecurityFramework.NoDefaultKeychain or SecurityFramework.InteractionNotAllowed; + throw unavailable + ? new CredentialStoreUnavailableException($"El llavero de macOS no está disponible (OSStatus {status}).") + : new CredentialStoreException($"El llavero de macOS falló al {operation} la entrada (OSStatus {status})."); + } +} 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..2a5b166 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs @@ -0,0 +1,44 @@ +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 new CredentialStoreException( + $"La entrada guardada para {host} no se puede leer. Ejecuta `fiscalapi logout` y luego `fiscalapi login`."); + } + } +} + +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..b8bbb07 --- /dev/null +++ b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs @@ -0,0 +1,100 @@ +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; + + public ApiCredentials? Read(string host) + { + AdvApi32.CREDENTIALW* credential; + if (!AdvApi32.CredReadW(TargetName(host), CredTypeGeneric, flags: 0, &credential)) + { + var error = Marshal.GetLastPInvokeError(); + return error == ErrorNotFound ? null : throw Failure(error, "leer"); + } + + 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 StoredCredentialsFormat.Decode(secret, host); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + finally + { + AdvApi32.CredFree(credential); + } + } + + 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"); + } + + 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}).") + : new CredentialStoreException($"Credential Manager falló al {operation} la entrada (Win32 {error})."); +} 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/FiscalApi.Cli.csproj b/src/FiscalApi.Cli/FiscalApi.Cli.csproj index 01e8b8e..ad383b2 100644 --- a/src/FiscalApi.Cli/FiscalApi.Cli.csproj +++ b/src/FiscalApi.Cli/FiscalApi.Cli.csproj @@ -8,10 +8,17 @@ 0.1.0 FiscalAPI CLI FiscalAPI + + true + + + + + diff --git a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs index 9538b47..0a5e241 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs @@ -1,17 +1,23 @@ 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 y el timeout -/// dependen de las opciones globales y del comando. Llega a los comandos por constructor para que sus -/// pruebas inyecten un handler falso. +/// 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, TextWriter diagnostics, RetryPolicy retryPolicy) +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 @@ -19,7 +25,15 @@ public sealed class FiscalApiClientFactory(HttpMessageHandler handler, TextWrite /// public static SocketsHttpHandler CreateHandler() => new() { AllowAutoRedirect = false }; - public FiscalApiClient Create(GlobalSettings settings, TimeSpan timeout) + /// + /// 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; @@ -29,6 +43,9 @@ public FiscalApiClient Create(GlobalSettings settings, TimeSpan timeout) 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 index 6b22704..dd11982 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -9,4 +9,5 @@ namespace FiscalApi.Cli.Http; /// [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ApiResponse))] public sealed partial class FiscalApiJsonContext : JsonSerializerContext; 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/Input/ConsoleSecretReader.cs b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs new file mode 100644 index 0000000..5a0616b --- /dev/null +++ b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs @@ -0,0 +1,43 @@ +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. +/// +public sealed class ConsoleSecretReader(TextWriter prompts) : 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); + var value = new StringBuilder(); + while (true) + { + while (!Console.KeyAvailable) + { + await Task.Delay(KeyPollInterval, cancellationToken); + } + + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + await prompts.WriteLineAsync(); + 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); + } + } + } +} 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..1841f9a --- /dev/null +++ b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs @@ -0,0 +1,11 @@ +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 +{ + public async Task ReadAsync(string prompt, CancellationToken cancellationToken) => + await input.ReadLineAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs index 493641c..071a3f6 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -2,7 +2,10 @@ using System.Runtime.InteropServices; using System.Text; using FiscalApi.Cli; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Credentials; using FiscalApi.Cli.Http; +using FiscalApi.Cli.Input; // 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 @@ -24,14 +27,29 @@ }); 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 (D-25). + 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, console.Error, RetryPolicy.Default); - IReadOnlyList commands = []; + 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); + + IReadOnlyList commands = + [ + new LoginCommand(globalOptions, apiClients, credentialStore, secretReader, console), + new LogoutCommand(globalOptions, credentialStore, console) + ]; - var application = new CliApplication(new GlobalOptions(), commands, console); + var application = new CliApplication(globalOptions, commands, console); return await application.RunAsync(args, cancellation.Token); } finally diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs new file mode 100644 index 0000000..400c71b --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -0,0 +1,198 @@ +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; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// CLI-002: fiscalapi login lee la API key y la tenant key (aquí por stdin redirigido), 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 webhook-endpoints 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_shows_the_approved_text_and_exits_with_a_usage_error() + { + var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + + var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n", 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("Listo.", _error.ToString()); + } + + [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 Task RunAsync( + StubHttpMessageHandler handler, + string input, + ICredentialStore? store = null, + bool verbose = false, + bool json = false, + string? apiBase = null) + { + 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, new RedirectedSecretReader(new StringReader(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).RunAsync(args, CancellationToken.None); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs new file mode 100644 index 0000000..32a5b14 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -0,0 +1,67 @@ +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Tests.Credentials; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// CLI-002: fiscalapi logout es local (no recibe cliente HTTP) y siempre termina con éxito. +/// +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()); + } + + 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).RunAsync(args, CancellationToken.None); + } +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs new file mode 100644 index 0000000..c8b7c21 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -0,0 +1,222 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Credentials; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Credentials; + +/// +/// D-25: 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 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).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..fd888c9 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs @@ -0,0 +1,72 @@ +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)); + } + + public void Dispose() + { + _store.Delete(_host); + _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..fce5f70 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs @@ -0,0 +1,29 @@ +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 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 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); +} diff --git a/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs new file mode 100644 index 0000000..4dfe05a --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs @@ -0,0 +1,40 @@ +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)); + } + + [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/Http/FiscalApiClientBuilder.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs index 15bb2f0..a17c109 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs @@ -1,4 +1,6 @@ +using FiscalApi.Cli.Credentials; using FiscalApi.Cli.Http; +using FiscalApi.Cli.Tests.Credentials; namespace FiscalApi.Cli.Tests.Http; @@ -7,14 +9,25 @@ 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) => - new FiscalApiClientFactory(handler, TextWriter.Null, retryPolicy ?? NoDelayRetryPolicy) - .Create( - new GlobalSettings(ApiEnvironment.Resolve(BaseAddress, live: false), Json: false, Verbose: false), - timeout ?? FiscalApiClientFactory.DefaultTimeout); + 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/FiscalApiClientFactoryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs index 92a0fb8..ac41e24 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientFactoryTests.cs @@ -11,9 +11,9 @@ public sealed class FiscalApiClientFactoryTests public async Task Client_targets_the_resolved_environment_with_the_cli_user_agent() { var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success); - var factory = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy); + var factory = FiscalApiClientBuilder.Factory(handler); - await factory.Create(Settings(live: true), FiscalApiClientFactory.DefaultTimeout) + 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); @@ -25,11 +25,11 @@ await factory.Create(Settings(live: true), FiscalApiClientFactory.DefaultTimeout public async Task Clients_share_the_handler_without_disposing_it() { var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, Success, times: 2); - var factory = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy); + var factory = FiscalApiClientBuilder.Factory(handler); - await factory.Create(Settings(live: false), FiscalApiClientFactory.DefaultTimeout) + 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), FiscalApiClientFactory.DefaultTimeout) + 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); @@ -40,9 +40,9 @@ 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 = new FiscalApiClientFactory(handler, diagnostics, FiscalApiClientBuilder.NoDelayRetryPolicy); + var factory = FiscalApiClientBuilder.Factory(handler, diagnostics); - await factory.Create(Settings(live: false, verbose: true), FiscalApiClientFactory.DefaultTimeout) + 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()); 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 index 4f95a54..517effb 100644 --- a/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs @@ -37,8 +37,8 @@ public async Task Redirect_from_the_api_ends_as_an_api_exception_without_a_secon { using var handler = FiscalApiClientFactory.CreateHandler(); var settings = new GlobalSettings(ApiEnvironment.Resolve(_baseAddress, live: false), Json: false, Verbose: false); - var client = new FiscalApiClientFactory(handler, TextWriter.Null, FiscalApiClientBuilder.NoDelayRetryPolicy) - .Create(settings, TimeSpan.FromSeconds(10)); + 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)); From 932ed3bd1e4b9eee31485c996d328a3a7af2614f Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 14:50:17 -0600 Subject: [PATCH 04/19] =?UTF-8?q?CLI-002:=20correcciones=20de=20la=20revis?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: "no hay almacén" (2) separado de "bloqueado o no respondió" (1, con detalle); logout ya no lo oculta. - M2: --api-base con http solo para loopback; otro host es error de uso (2). B1: login revisa el almacén antes. - B5/B6: ConsoleSecretReader con teclas inyectables; sin BOM en stdin. API key inválida antes de la tenant key. - MaxLength 200: el blob nunca pasa de 2560 bytes ni con escapes JSON. GLib se carga una vez (Lazy). - Rojo visto: 21 pruebas nuevas; 7 más ya en verde cubren comportamiento previo. Verde: Passed 133, Failed 0. - Build Release: 0 avisos. Windows: CredentialStoreTests 6/6 reales; cmdkey /list sin entradas fiscalapi-cli. - Docker AOT linux-x64 normal y enraizado: 0 IL/warning; gnome-keyring 6/6; sin D-Bus o sin servicio: 2; bloqueado: 1. - CI: espera a org.freedesktop.secrets con tope (script), sin dbus-x11; actionlint y shellcheck sin hallazgos. --- .github/scripts/with-test-secret-service.sh | 20 ++++ .github/workflows/build.yml | 16 ++- src/FiscalApi.Cli/Commands/LoginCommand.cs | 11 +- src/FiscalApi.Cli/Commands/LogoutCommand.cs | 10 +- .../Credentials/ApiCredentials.cs | 20 ++-- .../Credentials/CredentialResolver.cs | 1 + .../Credentials/CredentialStoreException.cs | 34 +++++- .../Credentials/ICredentialStore.cs | 9 +- .../Linux/LibSecretCredentialStore.cs | 82 +++++++++----- .../Credentials/Linux/LinuxNative.cs | 50 ++++++++- .../MacOS/KeychainCredentialStore.cs | 33 ++++-- .../Credentials/StoredCredentialsFormat.cs | 3 +- .../Windows/WindowsCredentialStore.cs | 63 ++++++----- src/FiscalApi.Cli/GlobalOptions.cs | 16 ++- .../Input/ConsoleSecretReader.cs | 31 ++++-- .../Input/RedirectedSecretReader.cs | 18 +++- src/FiscalApi.Cli/Program.cs | 2 +- .../CliApplicationTests.cs | 9 ++ .../Commands/LoginCommandTests.cs | 100 ++++++++++++++++-- .../Commands/LogoutCommandTests.cs | 15 ++- .../Credentials/CredentialResolutionTests.cs | 22 ++++ .../Credentials/CredentialStoreTests.cs | 20 +++- .../Credentials/InMemoryCredentialStore.cs | 23 ++++ .../StoredCredentialsFormatTests.cs | 28 +++++ .../FiscalApi.Cli.Tests/GlobalOptionsTests.cs | 25 +++++ .../Input/ConsoleSecretReaderTests.cs | 53 ++++++++++ .../Input/RedirectedSecretReaderTests.cs | 35 ++++++ .../FiscalApi.Cli.Tests/Input/ScriptedKeys.cs | 30 ++++++ 28 files changed, 660 insertions(+), 119 deletions(-) create mode 100644 .github/scripts/with-test-secret-service.sh create mode 100644 tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs 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 index 616ea2c..2a21ee4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,11 +28,11 @@ jobs: 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). + # 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 dbus-x11 + 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 @@ -40,16 +40,14 @@ jobs: - name: Build run: dotnet build FiscalApi.Cli.slnx -c Release --no-restore - # gnome-keyring en una sesión de D-Bus propia, desbloqueado con una contraseña de prueba: así corren contra - # libsecret las pruebas de CredentialStoreTests, que sin FISCALAPI_CLI_SECRET_SERVICE_TESTS se omiten en Linux. + # 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 -c ' - printf "%s" "llavero-del-ci" | gnome-keyring-daemon --unlock --components=secrets > /dev/null - sleep 1 - dotnet test FiscalApi.Cli.slnx -c Release --no-build' + 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) diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs index 7c0d624..620fde3 100644 --- a/src/FiscalApi.Cli/Commands/LoginCommand.cs +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -46,10 +46,13 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc var settings = _globalOptions.Read(parseResult); var host = settings.Environment.Host; - // Se valida cada valor antes de llamar a la API: un valor vacío es un error de uso, no un 401. - var credentials = ApiCredentials.Create( - await _input.ReadAsync("API key: ", cancellationToken), - await _input.ReadAsync("Tenant key: ", cancellationToken)); + // 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); diff --git a/src/FiscalApi.Cli/Commands/LogoutCommand.cs b/src/FiscalApi.Cli/Commands/LogoutCommand.cs index d3e06ef..ab1f213 100644 --- a/src/FiscalApi.Cli/Commands/LogoutCommand.cs +++ b/src/FiscalApi.Cli/Commands/LogoutCommand.cs @@ -5,7 +5,8 @@ namespace FiscalApi.Cli.Commands; /// /// fiscalapi logout (CLI-002): borra la entrada del host del ambiente en el almacén del SO. Es local (no -/// llama a la API) y siempre termina con éxito, aunque no hubiera nada guardado. +/// 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 { @@ -33,16 +34,21 @@ await _console.Error.WriteLineAsync(DeleteSavedCredentials(host) return ExitCodes.Success; } - // Sin almacén en el SO, login nunca pudo guardar nada: no hay nada que borrar y no es un error. 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/Credentials/ApiCredentials.cs b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs index a343682..83b4684 100644 --- a/src/FiscalApi.Cli/Credentials/ApiCredentials.cs +++ b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs @@ -9,9 +9,13 @@ namespace FiscalApi.Cli.Credentials; /// public sealed record ApiCredentials { - // Holgado frente a lo que emite fapi-back (API key de 51 caracteres, tenant key GUID) y muy por debajo del - // máximo de un secreto en Credential Manager (CRED_MAX_CREDENTIAL_BLOB_SIZE, 2560 bytes). - private const int MaxLength = 256; + /// + /// 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) { @@ -28,9 +32,13 @@ private ApiCredentials(string apiKey, string tenantKey) /// 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( - Validated(apiKey, "La API key"), - Validated(tenantKey, "La tenant key")); + 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 = *** }"; diff --git a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs index a76d295..114bfd6 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs @@ -13,6 +13,7 @@ public sealed class CredentialResolver(ICredentialStore store, FuncSin 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); diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs index 6d5cedd..50bafb8 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs @@ -3,10 +3,38 @@ namespace FiscalApi.Cli.Credentials; /// -/// El almacén del SO existe pero falló de una forma inesperada (un código que no es "no existe" ni "no hay -/// almacén"), o la entrada guardada no se pudo leer. El mensaje lleva el código del SO, nunca el secreto. +/// 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(string message) : CliException(message) +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/ICredentialStore.cs b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs index 08342e4..4fe90a7 100644 --- a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs @@ -3,10 +3,17 @@ 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 (D-25). Nunca hay un archivo plano ni otro respaldo: -/// si el SO no tiene almacén, cada operación lanza . +/// 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); diff --git a/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs index 3b1bd35..1904700 100644 --- a/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs @@ -1,5 +1,4 @@ using System.Runtime.InteropServices; -using System.Runtime.InteropServices.Marshalling; using System.Runtime.Versioning; using System.Security.Cryptography; @@ -8,7 +7,9 @@ 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 D-Bus, sin Secret Service o sin libsecret no hay almacén. +/// 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 @@ -17,26 +18,13 @@ internal sealed unsafe class LibSecretCredentialStore(string service) : ICredent private const string ServiceAttribute = "service"; private const string HostAttribute = "host"; - public ApiCredentials? Read(string host) - { - 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 null; - } + private delegate T SecretDecoder(ReadOnlySpan secret, string host); - try - { - return StoredCredentialsFormat.Decode(MemoryMarshal.CreateReadOnlySpanFromNullTerminated(password), host); - } - finally - { - LibSecret.PasswordFree(password); - } - } + // 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) { @@ -73,10 +61,34 @@ public bool Delete(string host) 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) @@ -86,8 +98,25 @@ private Query Open(string host) } /// - /// Cualquier error de libsecret es de D-Bus o del Secret Service (sin sesión de D-Bus, sin servicio, llavero - /// bloqueado sin poder pedir la contraseña): no hay almacén utilizable. El mensaje nunca trae el secreto. + /// 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) { @@ -96,9 +125,10 @@ private static void ThrowIfFailed(GLib.GError* error) return; } - var message = Utf8StringMarshaller.ConvertToManaged(error->Message); - GLib.ErrorFree(error); - throw new CredentialStoreUnavailableException($"Secret Service: {message}"); + 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. diff --git a/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs index d98ac2b..accb75c 100644 --- a/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs +++ b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs @@ -34,17 +34,57 @@ internal struct GError [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. + /// 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() + internal static (nint Hash, nint Equal) StringHashFunctions => StringHashFunctionPointers.Value; + + /// Copia el mensaje del error y lo libera. + internal static string TakeMessage(GError* error) { - var library = NativeLibrary.Load(Library); - return (NativeLibrary.GetExport(library, "g_str_hash"), NativeLibrary.GetExport(library, "g_str_equal")); + 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. @@ -90,7 +130,7 @@ internal sealed unsafe class StringHashTable : IDisposable public StringHashTable(IEnumerable> entries) { - var (hash, equal) = GLib.StringHashFunctions(); + var (hash, equal) = GLib.StringHashFunctions; Handle = GLib.HashTableNew(hash, equal); foreach (var (key, value) in entries) { diff --git a/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs index b79f3b9..f6194eb 100644 --- a/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs @@ -1,6 +1,5 @@ using System.Runtime.Versioning; using System.Security.Cryptography; -using FiscalApi.Cli.Errors; namespace FiscalApi.Cli.Credentials.MacOS; @@ -13,6 +12,22 @@ namespace FiscalApi.Cli.Credentials.MacOS; [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); @@ -99,11 +114,15 @@ private static void ThrowIfFailed(int status, string operation) return; } - // Sin llavero de sesión o sin poder pedir permiso (p. ej. por SSH con el llavero bloqueado): no hay almacén. - var unavailable = status is SecurityFramework.NotAvailable or SecurityFramework.NoSuchKeychain - or SecurityFramework.NoDefaultKeychain or SecurityFramework.InteractionNotAllowed; - throw unavailable - ? new CredentialStoreUnavailableException($"El llavero de macOS no está disponible (OSStatus {status}).") - : new CredentialStoreException($"El llavero de macOS falló al {operation} la entrada (OSStatus {status})."); + 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/StoredCredentialsFormat.cs b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs index 2a5b166..bc89269 100644 --- a/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs +++ b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs @@ -26,8 +26,7 @@ public static ApiCredentials Decode(ReadOnlySpan secret, string host) } catch (Exception exception) when (exception is JsonException or UsageException) { - throw new CredentialStoreException( - $"La entrada guardada para {host} no se puede leer. Ejecuta `fiscalapi logout` y luego `fiscalapi login`."); + throw CredentialStoreException.Unreadable(host); } } } diff --git a/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs index b8bbb07..566649e 100644 --- a/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs @@ -23,33 +23,13 @@ internal sealed unsafe class WindowsCredentialStore(string service) : ICredentia // Sesiones de red o de servicio sin conjunto de credenciales: no hay almacén que usar. private const int ErrorNoSuchLogonSession = 1312; - public ApiCredentials? Read(string host) - { - AdvApi32.CREDENTIALW* credential; - if (!AdvApi32.CredReadW(TargetName(host), CredTypeGeneric, flags: 0, &credential)) - { - var error = Marshal.GetLastPInvokeError(); - return error == ErrorNotFound ? null : throw Failure(error, "leer"); - } + private delegate T SecretDecoder(ReadOnlySpan secret, string host); - 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 StoredCredentialsFormat.Decode(secret, host); - } - finally - { - CryptographicOperations.ZeroMemory(secret); - } - } - finally - { - AdvApi32.CredFree(credential); - } - } + // 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) { @@ -92,9 +72,38 @@ public bool Delete(string host) 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}).") - : new CredentialStoreException($"Credential Manager falló al {operation} la entrada (Win32 {error})."); + : CredentialStoreException.Failed(operation, $"Credential Manager, Win32 {error}"); } diff --git a/src/FiscalApi.Cli/GlobalOptions.cs b/src/FiscalApi.Cli/GlobalOptions.cs index d6d92ff..2c923c9 100644 --- a/src/FiscalApi.Cli/GlobalOptions.cs +++ b/src/FiscalApi.Cli/GlobalOptions.cs @@ -51,12 +51,20 @@ public void AddTo(Command command) 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)) + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) { - return uri; + result.AddError($"--api-base debe ser una URL absoluta http o https; se recibió '{value}'."); + return null; } - 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/Input/ConsoleSecretReader.cs b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs index 5a0616b..53181f6 100644 --- a/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs +++ b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs @@ -6,7 +6,12 @@ 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. /// -public sealed class ConsoleSecretReader(TextWriter prompts) : ISecretReader +/// 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. @@ -14,19 +19,14 @@ public sealed class ConsoleSecretReader(TextWriter prompts) : ISecretReader public async Task ReadAsync(string prompt, CancellationToken cancellationToken) { - await prompts.WriteAsync(prompt); + await prompts.WriteAsync(prompt).ConfigureAwait(false); var value = new StringBuilder(); while (true) { - while (!Console.KeyAvailable) - { - await Task.Delay(KeyPollInterval, cancellationToken); - } - - var key = Console.ReadKey(intercept: true); + var key = await NextKeyAsync(cancellationToken).ConfigureAwait(false); if (key.Key == ConsoleKey.Enter) { - await prompts.WriteLineAsync(); + await prompts.WriteLineAsync().ConfigureAwait(false); return value.ToString(); } @@ -40,4 +40,17 @@ public sealed class ConsoleSecretReader(TextWriter prompts) : ISecretReader } } } + + 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/RedirectedSecretReader.cs b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs index 1841f9a..3eb6683 100644 --- a/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs +++ b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs @@ -6,6 +6,20 @@ namespace FiscalApi.Cli.Input; /// public sealed class RedirectedSecretReader(TextReader input) : ISecretReader { - public async Task ReadAsync(string prompt, CancellationToken cancellationToken) => - await input.ReadLineAsync(cancellationToken).ConfigureAwait(false); + 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/Program.cs b/src/FiscalApi.Cli/Program.cs index 071a3f6..ca9b3d2 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -41,7 +41,7 @@ // 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); + : new ConsoleSecretReader(console.Error, () => Console.KeyAvailable ? Console.ReadKey(intercept: true) : null); IReadOnlyList commands = [ diff --git a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs index 85d55f8..33d6944 100644 --- a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -83,6 +83,15 @@ public async Task Invalid_api_base_is_a_usage_error() 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() { diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs index 400c71b..964973f 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -5,11 +5,12 @@ using FiscalApi.Cli.Input; using FiscalApi.Cli.Tests.Credentials; using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Input; namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-002: fiscalapi login lee la API key y la tenant key (aquí por stdin redirigido), las valida con +/// CLI-002: 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 @@ -92,17 +93,88 @@ public async Task Without_webhooks_manage_it_warns_and_still_saves() } [Fact] - public async Task Without_a_store_shows_the_approved_text_and_exits_with_a_usage_error() + public async Task Without_a_store_fails_with_the_approved_text_before_asking_for_keys_or_calling_the_api() { - var handler = new StubHttpMessageHandler().RespondJson(HttpStatusCode.OK, PersonFeaturesJson.WithWebhooksManage); + var handler = new StubHttpMessageHandler(); + var keys = new ScriptedKeys($"{ApiKey}\r{TenantKey}\r"); - var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n", new UnavailableCredentialStore()); + 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("Listo.", _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] @@ -166,13 +238,25 @@ public async Task Credentials_are_saved_per_environment_host() 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) + 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); @@ -180,7 +264,7 @@ private Task RunAsync( // 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, new RedirectedSecretReader(new StringReader(input)), console); + var login = new LoginCommand(globalOptions, apiClients, credentialStore, input, console); List args = ["login", "--api-base", apiBase ?? FiscalApiClientBuilder.BaseAddress.ToString()]; if (verbose) @@ -193,6 +277,6 @@ private Task RunAsync( args.Add("--json"); } - return new CliApplication(globalOptions, [login], console).RunAsync(args, CancellationToken.None); + return new CliApplication(globalOptions, [login], console).RunAsync(args, cancellationToken); } } diff --git a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs index 32a5b14..fc1c39d 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -5,7 +5,8 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-002: fiscalapi logout es local (no recibe cliente HTTP) y siempre termina con éxito. +/// CLI-002: 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 { @@ -57,6 +58,18 @@ public async Task Without_a_store_still_succeeds_because_nothing_could_be_saved( 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(); diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs index c8b7c21..b35533c 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -97,6 +97,28 @@ public async Task Without_store_and_without_environment_variables_shows_the_appr 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() { diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs index fd888c9..e3a236e 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs @@ -62,10 +62,26 @@ 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() { - _store.Delete(_host); - _store.Delete(_otherHost); + 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 index fce5f70..ddb6203 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs @@ -9,6 +9,10 @@ internal sealed class InMemoryCredentialStore : ICredentialStore 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; @@ -21,9 +25,28 @@ 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 index 4dfe05a..f2d21e3 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs @@ -22,6 +22,34 @@ public void Encoded_secret_is_json_with_both_keys_and_decodes_back() 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"}""")] diff --git a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs index 648eab8..b3e6c90 100644 --- a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -63,6 +63,31 @@ public void Api_base_must_be_an_absolute_http_url(string 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() { 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) + }; + } +} From b1b29aa5e2a0166de5a744819a2fa286874ac390 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 15:34:49 -0600 Subject: [PATCH 05/19] =?UTF-8?q?CLI-004:=20fiscalapi=20listen=20(long-pol?= =?UTF-8?q?ling,=20reenv=C3=ADo=20firmado,=20acuse=20y=20reconexi=C3=B3n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listen abre la escucha de CLI, hace long-polling, reenvía a --forward-to el cuerpo y los headers firmados byte a byte y acusa todo (statusCode o error). - Reenvíos con concurrencia acotada (16) y ordenados por evento; un 410 reabre la sesión, red/5xx reconectan con backoff y jitter (tope 30 s), un 409 sale con 1. - --events, --headers, --skip-verify (solo hacia --forward-to), --print-secret y --json (JSON Lines). - ExponentialBackoff y TransientHttpStatus se extraen de RetryPolicy; CliException.IsTransient. - Evidencia: 195 pruebas en verde (ListenCommandTests con Kestrel en proceso: API falsa y receptor); dotnet build -c Release sin avisos. - Publish AOT win-x64 y linux-x64 (Docker sdk:10.0-noble-aot, normal y con TrimmerRootAssembly) sin IL ni warning. --- src/FiscalApi.Cli/Commands/ListenCommand.cs | 186 +++++ src/FiscalApi.Cli/Errors/ApiException.cs | 3 + src/FiscalApi.Cli/Errors/CliException.cs | 3 + src/FiscalApi.Cli/Errors/NetworkException.cs | 3 + src/FiscalApi.Cli/Http/ExponentialBackoff.cs | 32 + .../Http/FiscalApiJsonContext.cs | 8 + src/FiscalApi.Cli/Http/RetryPolicy.cs | 32 +- src/FiscalApi.Cli/Http/TransientHttpStatus.cs | 10 + src/FiscalApi.Cli/Listen/ClaimedDelivery.cs | 7 + .../Listen/DeliveryDispatcher.cs | 105 +++ src/FiscalApi.Cli/Listen/DeliveryOutcome.cs | 27 + src/FiscalApi.Cli/Listen/DeliveryProcessor.cs | 67 ++ src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs | 14 + src/FiscalApi.Cli/Listen/HttpForwardTarget.cs | 110 +++ src/FiscalApi.Cli/Listen/IDeliveryTarget.cs | 8 + src/FiscalApi.Cli/Listen/JsonListenOutput.cs | 47 ++ src/FiscalApi.Cli/Listen/ListenLoop.cs | 87 +++ src/FiscalApi.Cli/Listen/ListenOutput.cs | 67 ++ src/FiscalApi.Cli/Listen/ListenTimings.cs | 36 + src/FiscalApi.Cli/Listen/ListenerApi.cs | 44 ++ src/FiscalApi.Cli/Listen/ListenerContracts.cs | 46 ++ src/FiscalApi.Cli/Listen/TextListenOutput.cs | 25 + .../Listen/WebhookEventSummary.cs | 23 + .../Listen/WebhookHeaderNames.cs | 17 + src/FiscalApi.Cli/Program.cs | 4 +- .../FiscalApi.Cli.Tests.csproj | 5 + .../Http/ExponentialBackoffTests.cs | 56 ++ .../Listen/DeliveryDispatcherTests.cs | 158 +++++ .../Listen/FakeListenerApi.cs | 259 +++++++ .../Listen/FakeReceiver.cs | 81 +++ .../Listen/ListenCommandTests.cs | 642 ++++++++++++++++++ .../Listen/ListenTimingsTests.cs | 30 + .../Listen/SelfSignedCertificate.cs | 22 + .../Listen/StandardWebhooksSignature.cs | 19 + 34 files changed, 2256 insertions(+), 27 deletions(-) create mode 100644 src/FiscalApi.Cli/Commands/ListenCommand.cs create mode 100644 src/FiscalApi.Cli/Http/ExponentialBackoff.cs create mode 100644 src/FiscalApi.Cli/Http/TransientHttpStatus.cs create mode 100644 src/FiscalApi.Cli/Listen/ClaimedDelivery.cs create mode 100644 src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs create mode 100644 src/FiscalApi.Cli/Listen/DeliveryOutcome.cs create mode 100644 src/FiscalApi.Cli/Listen/DeliveryProcessor.cs create mode 100644 src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs create mode 100644 src/FiscalApi.Cli/Listen/HttpForwardTarget.cs create mode 100644 src/FiscalApi.Cli/Listen/IDeliveryTarget.cs create mode 100644 src/FiscalApi.Cli/Listen/JsonListenOutput.cs create mode 100644 src/FiscalApi.Cli/Listen/ListenLoop.cs create mode 100644 src/FiscalApi.Cli/Listen/ListenOutput.cs create mode 100644 src/FiscalApi.Cli/Listen/ListenTimings.cs create mode 100644 src/FiscalApi.Cli/Listen/ListenerApi.cs create mode 100644 src/FiscalApi.Cli/Listen/ListenerContracts.cs create mode 100644 src/FiscalApi.Cli/Listen/TextListenOutput.cs create mode 100644 src/FiscalApi.Cli/Listen/WebhookEventSummary.cs create mode 100644 src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs create mode 100644 tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs diff --git a/src/FiscalApi.Cli/Commands/ListenCommand.cs b/src/FiscalApi.Cli/Commands/ListenCommand.cs new file mode 100644 index 0000000..9c04081 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -0,0 +1,186 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Diagnostics; +using FiscalApi.Cli.Http; +using FiscalApi.Cli.Listen; + +namespace FiscalApi.Cli.Commands; + +/// +/// fiscalapi listen (CLI-004, D-26): 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,invoice.created). Por omisión, todos.", + HelpName = "tipos", + CustomParser = ParseEvents, + DefaultValueFactory = _ => ["*"] + }; + + private readonly Option>> _headers = new("--headers") + { + Description = "Headers extra para cada reenvío, como \"Nombre:Valor,Nombre:Valor\".", + HelpName = "headers", + 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." + }; + + 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); + 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 (D-29), y abrirla reemplaza la de otro listen en curso (D-26). + 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.Reconnect, _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."); + } + } + } + + // Los tipos los valida la API, con sus textos (un tipo que no existe, '*' junto a otros o ninguno). + private static IReadOnlyList ParseEvents(ArgumentResult result) => + result.Tokens.Single().Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + // "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/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs index 0c0bc67..086a62d 100644 --- a/src/FiscalApi.Cli/Errors/ApiException.cs +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -28,6 +28,9 @@ private ApiException(HttpStatusCode statusCode, string message, string? details, 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; } diff --git a/src/FiscalApi.Cli/Errors/CliException.cs b/src/FiscalApi.Cli/Errors/CliException.cs index 41771a9..115f1a9 100644 --- a/src/FiscalApi.Cli/Errors/CliException.cs +++ b/src/FiscalApi.Cli/Errors/CliException.cs @@ -9,5 +9,8 @@ public abstract class CliException(string message, Exception? innerException = n { 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/NetworkException.cs b/src/FiscalApi.Cli/Errors/NetworkException.cs index 0ba985d..84d3125 100644 --- a/src/FiscalApi.Cli/Errors/NetworkException.cs +++ b/src/FiscalApi.Cli/Errors/NetworkException.cs @@ -4,4 +4,7 @@ namespace FiscalApi.Cli.Errors; 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/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/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs index dd11982..a5e9f44 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using FiscalApi.Cli.Listen; namespace FiscalApi.Cli.Http; @@ -10,4 +11,11 @@ namespace FiscalApi.Cli.Http; [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(OpenCliListenerRequest))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ApiResponse>))] +[JsonSerializable(typeof(CliDeliveryAck))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ListenSecretRecord))] +[JsonSerializable(typeof(ListenEventRecord))] public sealed partial class FiscalApiJsonContext : JsonSerializerContext; diff --git a/src/FiscalApi.Cli/Http/RetryPolicy.cs b/src/FiscalApi.Cli/Http/RetryPolicy.cs index 3ae6935..2e41b3e 100644 --- a/src/FiscalApi.Cli/Http/RetryPolicy.cs +++ b/src/FiscalApi.Cli/Http/RetryPolicy.cs @@ -1,5 +1,3 @@ -using System.Net; - namespace FiscalApi.Cli.Http; /// @@ -9,21 +7,14 @@ namespace FiscalApi.Cli.Http; /// public sealed class RetryPolicy { - // 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 MaxBackoffExponent = 30; - - private readonly TimeSpan _baseDelay; - private readonly TimeSpan _maxDelay; + private readonly ExponentialBackoff _backoff; private readonly TimeProvider _timeProvider; public RetryPolicy(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay, TimeProvider timeProvider) { ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 1); - ArgumentOutOfRangeException.ThrowIfLessThan(baseDelay, TimeSpan.Zero); - ArgumentOutOfRangeException.ThrowIfLessThan(maxDelay, baseDelay); MaxAttempts = maxAttempts; - _baseDelay = baseDelay; - _maxDelay = maxDelay; + _backoff = new ExponentialBackoff(baseDelay, maxDelay); _timeProvider = timeProvider; } @@ -47,11 +38,11 @@ public async Task SendAsync( } catch (HttpRequestException exception) when (attempt < MaxAttempts && IsTransient(exception.HttpRequestError)) { - await Task.Delay(Backoff(attempt), _timeProvider, cancellationToken).ConfigureAwait(false); + await Task.Delay(_backoff.Delay(attempt), _timeProvider, cancellationToken).ConfigureAwait(false); continue; } - if (attempt == MaxAttempts || !IsTransient(response.StatusCode) || DelayBeforeRetry(attempt, response) is not { } delay) + if (attempt == MaxAttempts || !TransientHttpStatus.Is(response.StatusCode) || DelayBeforeRetry(attempt, response) is not { } delay) { return response; } @@ -61,9 +52,6 @@ public async Task SendAsync( } } - private static bool IsTransient(HttpStatusCode statusCode) => - statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests || (int)statusCode >= 500; - // 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) => @@ -75,8 +63,8 @@ private static bool IsTransient(HttpRequestError error) => /// private TimeSpan? DelayBeforeRetry(int attempt, HttpResponseMessage response) { - var delay = RetryAfter(response) ?? Backoff(attempt); - return delay <= _maxDelay ? delay : null; + var delay = RetryAfter(response) ?? _backoff.Delay(attempt); + return delay <= _backoff.MaxDelay ? delay : null; } private TimeSpan? RetryAfter(HttpResponseMessage response) => response.Headers.RetryAfter switch @@ -86,13 +74,5 @@ private static bool IsTransient(HttpRequestError error) => _ => null }; - // "Equal jitter": la mitad fija y la otra mitad aleatoria, para que varios CLI no reintenten al unísono. - private TimeSpan Backoff(int attempt) - { - var exponent = Math.Min(attempt - 1, MaxBackoffExponent); - var exponential = TimeSpan.FromTicks((long)Math.Min(_baseDelay.Ticks * Math.Pow(2, exponent), _maxDelay.Ticks)); - return exponential / 2 + exponential / 2 * Random.Shared.NextDouble(); - } - 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/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..826021c --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs @@ -0,0 +1,105 @@ +using System.Runtime.ExceptionServices; + +namespace FiscalApi.Cli.Listen; + +/// +/// Corre el trabajo de cada entrega de listen fuera del long-polling, con dos reglas: +/// +/// Concurrencia acotada: como mucho maxInFlight entregas a la vez (en espera de su turno o corriendo). +/// Con el cupo lleno, espera a que se libere uno: el long-polling deja de reclamar entregas +/// que no podría reenviar antes de que venza su lease, en lugar de acumularlas en memoria. +/// 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. Una entrega lenta no frena a las demás. +/// +/// 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 int _maxInFlight; + 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. + private readonly Dictionary _lastByEvent = new(StringComparer.Ordinal); + + private ExceptionDispatchInfo? _fault; + + /// Entregas que pueden estar a la vez en el despachador. + /// Ctrl+C: cancela el trabajo en curso. + public DeliveryDispatcher(int maxInFlight, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxInFlight, 1); + _maxInFlight = maxInFlight; + _slots = new SemaphoreSlim(maxInFlight, maxInFlight); + _stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + } + + /// Acepta la entrega en cuanto hay cupo y vuelve sin esperar a que su trabajo termine. + /// El evento de la entrega: ordena las del mismo evento. + /// El trabajo de la entrega; recibe el token que cancela . + /// Cancela la espera de cupo. + public async Task EnqueueAsync(string eventId, Func work, CancellationToken cancellationToken) + { + await _slots.WaitAsync(cancellationToken).ConfigureAwait(false); + 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 y espera a que termine: después no queda nada escribiendo en la consola. + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync().ConfigureAwait(false); + // Con todo el cupo de vuelta no queda ninguna entrega en curso: cada una devuelve el suyo al final. + for (var slot = 0; slot < _maxInFlight; slot++) + { + await _slots.WaitAsync(CancellationToken.None).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 EnqueueAsync, que así vuelve de inmediato al long-polling. + await previous.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + await work(_stopping.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // La escucha terminó: la entrega se queda sin acuse y la API la 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(); + _slots.Release(); + } + } +} diff --git a/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs b/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs new file mode 100644 index 0000000..47326a1 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryOutcome.cs @@ -0,0 +1,27 @@ +namespace FiscalApi.Cli.Listen; + +/// +/// Lo que pasó con una entrega: el receptor de --forward-to respondió un código, o no respondió y queda el motivo, +/// o no hubo reenvío porque listen corre sin --forward-to. Es lo que lleva su acuse. +/// +/// El código que respondió el receptor. +/// Por qué el receptor no respondió, con los nombres de WebhookAttemptError de fapi-back. +/// Lo que tardó el reenvío; nulo si no hubo. +public sealed record DeliveryOutcome(int? StatusCode, string? Error, int? LatencyMs) +{ + // Asumido (CLI-004): sin --forward-to la terminal es el destino y la entrega llegó, como en stripe listen. Se acusa + // como un 200 inmediato para que el log de entregas no la muestre fallida. + private const int DisplayedStatusCode = 200; + + public static DeliveryOutcome NotForwarded { get; } = new(null, null, null); + + public bool Forwarded => LatencyMs is not null; + + public static DeliveryOutcome Responded(int statusCode, int latencyMs) => new(statusCode, null, latencyMs); + + public static DeliveryOutcome Failed(string error, int latencyMs) => new(null, error, latencyMs); + + public CliDeliveryAck ToAck(string sessionId) => Forwarded + ? new CliDeliveryAck(sessionId, StatusCode, LatencyMs!.Value, Error) + : new CliDeliveryAck(sessionId, DisplayedStatusCode, LatencyMs: 0, Error: null); +} diff --git a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs new file mode 100644 index 0000000..d864e57 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs @@ -0,0 +1,67 @@ +using System.Net; +using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Listen; + +/// +/// El trabajo de una entrega: reenviarla (o solo mostrarla), escribir su línea y acusarla. Todo lo que llega 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 (D-26). +/// +public sealed class DeliveryProcessor( + IDeliveryTarget target, + ListenerApi api, + ListenOutput output, + ExponentialBackoff ackBackoff, + TimeProvider timeProvider) +{ + /// La entrega, su sesión y el plazo de su acuse. + /// Ctrl+C o el fin de la escucha. + public async Task ProcessAsync(ClaimedDelivery claimed, CancellationToken cancellationToken) + { + var outcome = await target.DeliverAsync(claimed.Delivery, cancellationToken).ConfigureAwait(false); + output.WriteDelivery(claimed.Delivery, outcome); + await AcknowledgeAsync(claimed, outcome.ToAck(claimed.Session.SessionId), cancellationToken).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; + } + } + } + + // 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 = ackBackoff.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..8f73e8e --- /dev/null +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -0,0 +1,14 @@ +namespace FiscalApi.Cli.Listen; + +/// Sin --forward-to: la entrega solo se muestra en la terminal. +public sealed class DisplayOnlyTarget : IDeliveryTarget +{ + public static DisplayOnlyTarget Instance { get; } = new(); + + private DisplayOnlyTarget() + { + } + + public Task DeliverAsync(CliDelivery delivery, CancellationToken cancellationToken) => + Task.FromResult(DeliveryOutcome.NotForwarded); +} diff --git a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs new file mode 100644 index 0000000..8ff4173 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs @@ -0,0 +1,110 @@ +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 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 DeliveryOutcome.Responded((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; + } + + // 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 + => "InvalidResponse", + _ => "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..208288e --- /dev/null +++ b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs @@ -0,0 +1,47 @@ +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.Parse(delivery.Body); + var record = new ListenEventRecord( + TimeProvider.GetLocalNow(), + delivery.DeliveryId, + summary.Id, + summary.Type, + outcome.StatusCode, + outcome.LatencyMs, + outcome.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 sobre del evento tal como llegó (D-04). +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, + JsonElement Event); diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs new file mode 100644 index 0000000..c1bd24e --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -0,0 +1,87 @@ +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); + var failedAttempts = 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 (D-26) y se recupera con events resend. Un 409 (otra sesión la reemplazó) no es + // transitorio y termina la escucha con el texto de la API. + catch (ApiException expired) when (expired.StatusCode == HttpStatusCode.Gone) + { + output.WriteSessionExpired(); + session = null; + 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(++failedAttempts); + output.WriteConnectionLost(failure, retryIn); + await Task.Delay(retryIn, timeProvider, cancellationToken).ConfigureAwait(false); + continue; + } + + if (failedAttempts > 0) + { + output.WriteReconnected(); + failedAttempts = 0; + } + + await DispatchAsync(dispatcher, session, deliveries, cancellationToken).ConfigureAwait(false); + } + } + + private async Task DispatchAsync( + DeliveryDispatcher dispatcher, + CliListenerSession session, + IReadOnlyList deliveries, + CancellationToken cancellationToken) + { + 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); + await dispatcher.EnqueueAsync( + delivery.EventId, + token => processor.ProcessAsync(claimed, token), + cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/FiscalApi.Cli/Listen/ListenOutput.cs b/src/FiscalApi.Cli/Listen/ListenOutput.cs new file mode 100644 index 0000000..37422ca --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenOutput.cs @@ -0,0 +1,67 @@ +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 (D-21) 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 ({failure.Message.TrimEnd('.')}); reintentando en " + + $"{retryIn.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} 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."); + + /// 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 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..7b6a55c --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenTimings.cs @@ -0,0 +1,36 @@ +using FiscalApi.Cli.Http; + +namespace FiscalApi.Cli.Listen; + +/// +/// Los plazos de fiscalapi listen. son los del contrato (CLI-004, WH-015); 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, con jitter. +/// Entregas que se reenvían (o esperan su turno) a la vez. +/// +/// 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 reintentar un acuse más allá no sirve. +/// +public sealed record ListenTimings( + TimeSpan ApiTimeout, + int PollWaitSeconds, + TimeSpan ForwardTimeout, + ExponentialBackoff Reconnect, + int MaxInFlightDeliveries, + TimeSpan DeliveryLease) +{ + 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)), + // Un lote del long-polling trae hasta 25 entregas (WebhookConsts.ListenerBatchSize). Con 16 a la vez, aun si + // todas agotan los 15 s del reenvío, el lote se despacha en dos rondas y el siguiente long-polling sale mucho + // antes de que la sesión caduque (60 s sin long-polling). + MaxInFlightDeliveries: 16, + DeliveryLease: TimeSpan.FromMinutes(2)); +} diff --git a/src/FiscalApi.Cli/Listen/ListenerApi.cs b/src/FiscalApi.Cli/Listen/ListenerApi.cs new file mode 100644 index 0000000..43c0015 --- /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 (D-28) sobre . +public sealed class ListenerApi(FiscalApiClient client, int pollWaitSeconds) +{ + private const string EndpointsPath = "api/v4/webhook-endpoints"; + + /// Abre una sesión nueva, que reemplaza la anterior de la persona, y responde el secreto (D-29). + public async Task OpenAsync(IReadOnlyList enabledEvents, CancellationToken cancellationToken) => + await client.PostAsync( + $"{EndpointsPath}/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) => $"{EndpointsPath}/{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..4c6199f --- /dev/null +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Listen; + +// Los contratos de la escucha de CLI de fapi-back (WebhookCliListenerController, D-28), con sus mismos nombres. + +/// El cuerpo de POST /api/v4/webhook-endpoints/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 (D-02): ordena las entregas del mismo evento. + public string EventId => Headers.TryGetValue(WebhookHeaderNames.Id, out var eventId) + ? eventId + : throw new InvalidOperationException($"La entrega {DeliveryId} llegó sin el header {WebhookHeaderNames.Id}."); +} + +/// +/// 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..abfff0b --- /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.Parse(delivery.Body); + 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 + { + { StatusCode: { } statusCode } => $" [{statusCode}]", + { Error: { } error } => $" [error: {error}]", + _ => "" + }; +} diff --git a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs new file mode 100644 index 0000000..50ab52e --- /dev/null +++ b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +namespace FiscalApi.Cli.Listen; + +/// +/// Lo que la terminal muestra de una entrega: el id, el tipo y el sobre del evento (D-04). Se lee del cuerpo sin tocarlo: +/// lo que se reenvía es siempre el texto original. +/// +/// El sobre completo, para --json; no depende del documento que lo leyó. +public sealed record WebhookEventSummary(string Id, string Type, JsonElement Envelope) +{ + /// El cuerpo no es el sobre de D-04: un defecto del contrato con la API. + public static WebhookEventSummary Parse(string body) + { + using var envelope = JsonDocument.Parse(body); + var root = envelope.RootElement; + return new WebhookEventSummary(RequiredString(root, "id"), RequiredString(root, "type"), root.Clone()); + } + + private static string RequiredString(JsonElement envelope, string property) => + envelope.GetProperty(property).GetString() + ?? throw new JsonException($"El sobre del evento trae '{property}' nulo."); +} diff --git a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs new file mode 100644 index 0000000..1a9e157 --- /dev/null +++ b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs @@ -0,0 +1,17 @@ +namespace FiscalApi.Cli.Listen; + +/// Los headers de Standard Webhooks (D-02) 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/Program.cs b/src/FiscalApi.Cli/Program.cs index ca9b3d2..01d0899 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -6,6 +6,7 @@ using FiscalApi.Cli.Credentials; using FiscalApi.Cli.Http; using FiscalApi.Cli.Input; +using FiscalApi.Cli.Listen; // 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 @@ -46,7 +47,8 @@ IReadOnlyList commands = [ new LoginCommand(globalOptions, apiClients, credentialStore, secretReader, console), - new LogoutCommand(globalOptions, credentialStore, console) + new LogoutCommand(globalOptions, credentialStore, console), + new ListenCommand(globalOptions, apiClients, console, TimeProvider.System, ListenTimings.Default) ]; var application = new CliApplication(globalOptions, commands, console); diff --git a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj index 450472f..6eccdd4 100644 --- a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj +++ b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj @@ -16,6 +16,11 @@ + + + + + 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/Listen/DeliveryDispatcherTests.cs b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs new file mode 100644 index 0000000..d190834 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs @@ -0,0 +1,158 @@ +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(); + + await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); + await dispatcher.EnqueueAsync("evt_2", second.RunAsync, CancellationToken.None); + + 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(); + + await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); + await dispatcher.EnqueueAsync("evt_1", second.RunAsync, CancellationToken.None); + 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(); + + await dispatcher.EnqueueAsync("evt_1", _ => Task.FromException(new InvalidOperationException("defecto")), + CancellationToken.None); + await dispatcher.EnqueueAsync("evt_1", next.RunAsync, CancellationToken.None); + + await next.Started.WaitAsync(WaitLimit); + next.Release(); + } + + [Fact] + public async Task Enqueue_waits_only_while_every_slot_is_busy() + { + await using var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var first = new BlockingWork(); + var second = new BlockingWork(); + var third = new BlockingWork(); + await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); + await dispatcher.EnqueueAsync("evt_2", second.RunAsync, CancellationToken.None); + + var enqueueThird = dispatcher.EnqueueAsync("evt_3", third.RunAsync, CancellationToken.None); + await Task.Delay(100); + + Assert.False(enqueueThird.IsCompleted, "Se aceptó una entrega con el cupo lleno."); + first.Release(); + await enqueueThird.WaitAsync(WaitLimit); + await third.Started.WaitAsync(WaitLimit); + second.Release(); + third.Release(); + } + + [Fact] + public async Task Dispose_cancels_the_work_in_flight_and_waits_for_it() + { + var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); + var observedCancellation = new TaskCompletionSource(); + await dispatcher.EnqueueAsync("evt_1", async cancellationToken => + { + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + finally + { + observedCancellation.SetResult(); + } + }, CancellationToken.None); + + 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(); + await dispatcher.EnqueueAsync("evt_1", _ => + { + failed.SetResult(); + throw new InvalidOperationException("defecto del CLI"); + }, CancellationToken.None); + 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..f5ce5a3 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -0,0 +1,259 @@ +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 fapi-back (WH-014/WH-015) 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/webhook-endpoints"; + 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 _opens = 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; + + 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; + } + + /// 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()); + 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 (_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) +{ + public const string Timestamp = "1791900000"; + + /// La firma real de Standard Webhooks (D-02) 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 de D-04 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 => new Dictionary + { + ["webhook-id"] = EventId, + ["webhook-timestamp"] = Timestamp, + ["webhook-signature"] = Signature + }; + + 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..d183c34 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -0,0 +1,642 @@ +using System.Net; +using System.Net.Sockets; +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 Microsoft.AspNetCore.Http; + +namespace FiscalApi.Cli.Tests.Listen; + +/// +/// CLI-004: fiscalapi listen contra la escucha de CLI de fapi-back 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() + { + // D-02: 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")); + + StartListen("--forward-to", $"http://127.0.0.1:{FreePort()}/webhooks"); + 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 (como en stripe listen). + 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")); + + StartListen("--forward-to", $"http://127.0.0.1:{FreePort()}/webhooks"); + 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."; + _api.RejectAck("dlv_0001", StatusCodes.Status409Conflict, notAwaiting) + .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")) + .Deliver(new FakeDelivery("dlv_0002", "evt_0002", "invoice.created")); + + StartListen(verbose ? ["--verbose"] : []); + await ExpectAsync(_api.WaitForAcksAsync(2)); + await ExpectAsync(_api.WaitForPollsAsync(3)); + + 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")); + + StartListen("--forward-to", $"http://127.0.0.1:{FreePort()}/webhooks", "--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() + { + var unreachable = new Uri($"http://127.0.0.1:{FreePort()}/"); + + 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); + } + + [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.")] + 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 (CLI-004): 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 static int FreePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + 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); + + 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..6bb97f0 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs @@ -0,0 +1,30 @@ +using FiscalApi.Cli.Listen; + +namespace FiscalApi.Cli.Tests.Listen; + +/// Los plazos de listen son los del contrato (CLI-004, WH-015). +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); + } + + [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/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..4d330e7 --- /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 (D-02): 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))}"; + } +} From 76d12fa5a1edda7431413dd1a2526a8d9a4cb55d Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 16:02:51 -0600 Subject: [PATCH 06/19] =?UTF-8?q?CLI-004:=20correcciones=20de=20la=20revis?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1: el cupo se toma al llegar el turno del evento; Enqueue nunca espera; lease vencido = no se reenvía ni se acusa. - M2: 409 al reabrir (candado) se reintenta con backoff; el 409 del long-polling sigue terminando. B4: 410 seguidos con backoff. - M3/B2: --events y --headers repetidos y --print-secret con opciones de reenvío son error de uso (2). - B1: Ctrl+C acusa lo que ya tenía resultado, un intento con 2 s. B5: código fuera de 100..599 = InvalidResponse. - B6: cuerpo o webhook-id inesperados se muestran con "?" y se acusan. Q1: cada destino decide su acuse. - Pruebas: P1 determinista, orden por evento, corte del acuse por lease, 401/403/404 = 1, ClosedPort sin TOCTOU. - Evidencia: 218 pruebas en verde 3 veces (Linux: 212 + 6 omitidas); build Release sin avisos. - AOT win-x64 y linux-x64 (normal y TrimmerRootAssembly) sin IL ni warning. --- src/FiscalApi.Cli/Commands/ListenCommand.cs | 26 +- .../Listen/DeliveryDispatcher.cs | 50 ++-- src/FiscalApi.Cli/Listen/DeliveryOutcome.cs | 27 +- src/FiscalApi.Cli/Listen/DeliveryProcessor.cs | 52 +++- src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs | 8 +- src/FiscalApi.Cli/Listen/HttpForwardTarget.cs | 12 +- src/FiscalApi.Cli/Listen/JsonListenOutput.cs | 16 +- src/FiscalApi.Cli/Listen/ListenLoop.cs | 48 ++-- src/FiscalApi.Cli/Listen/ListenOutput.cs | 17 +- src/FiscalApi.Cli/Listen/ListenTimings.cs | 23 +- src/FiscalApi.Cli/Listen/ListenerContracts.cs | 12 +- src/FiscalApi.Cli/Listen/TextListenOutput.cs | 6 +- .../Listen/WebhookEventSummary.cs | 42 ++- .../FiscalApi.Cli.Tests/Listen/ClosedPort.cs | 21 ++ .../Listen/DeliveryDispatcherTests.cs | 88 ++++-- .../Listen/FakeListenerApi.cs | 70 ++++- .../Listen/ListenCommandTests.cs | 252 ++++++++++++++++-- .../Listen/ListenTimingsTests.cs | 2 + .../Listen/RawHttpReceiver.cs | 97 +++++++ 19 files changed, 719 insertions(+), 150 deletions(-) create mode 100644 tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs create mode 100644 tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs diff --git a/src/FiscalApi.Cli/Commands/ListenCommand.cs b/src/FiscalApi.Cli/Commands/ListenCommand.cs index 9c04081..bbc743f 100644 --- a/src/FiscalApi.Cli/Commands/ListenCommand.cs +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -24,6 +24,7 @@ public sealed class ListenCommand : Command { Description = "Tipos de evento separados por coma (p. ej. invoice.canceled,invoice.created). Por omisión, todos.", HelpName = "tipos", + Arity = ArgumentArity.ExactlyOne, CustomParser = ParseEvents, DefaultValueFactory = _ => ["*"] }; @@ -32,6 +33,7 @@ public sealed class ListenCommand : Command { Description = "Headers extra para cada reenvío, como \"Nombre:Valor,Nombre:Valor\".", HelpName = "headers", + Arity = ArgumentArity.ExactlyOne, CustomParser = ParseHeaders, DefaultValueFactory = _ => [] }; @@ -43,7 +45,8 @@ public sealed class ListenCommand : Command private readonly Option _printSecret = new("--print-secret") { - Description = "Imprime solo el secreto de firma de la escucha y termina." + 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; @@ -71,6 +74,7 @@ public ListenCommand( Options.Add(_skipVerify); Options.Add(_printSecret); Validators.Add(RequireForwardToFor); + Validators.Add(RejectForwardingWithPrintSecret); SetAction(RunAsync); } @@ -91,7 +95,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc using var forwarder = CreateForwarder(parseResult); var target = forwarder ?? (IDeliveryTarget)DisplayOnlyTarget.Instance; - var processor = new DeliveryProcessor(target, api, output, _timings.Reconnect, _timeProvider); + 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."); } @@ -120,6 +124,24 @@ private void RequireForwardToFor(CommandResult result) } } + // --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}."); + } + } + } + // Los tipos los valida la API, con sus textos (un tipo que no existe, '*' junto a otros o ninguno). private static IReadOnlyList ParseEvents(ArgumentResult result) => result.Tokens.Single().Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); diff --git a/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs index 826021c..7c0e466 100644 --- a/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs +++ b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs @@ -5,45 +5,44 @@ namespace FiscalApi.Cli.Listen; /// /// Corre el trabajo de cada entrega de listen fuera del long-polling, con dos reglas: /// -/// Concurrencia acotada: como mucho maxInFlight entregas a la vez (en espera de su turno o corriendo). -/// Con el cupo lleno, espera a que se libere uno: el long-polling deja de reclamar entregas -/// que no podría reenviar antes de que venza su lease, en lugar de acumularlas en memoria. /// 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. Una entrega lenta no frena a las demás. +/// 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 int _maxInFlight; 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. + // 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 estar a la vez en el despachador. + /// Entregas que pueden correr a la vez. /// Ctrl+C: cancela el trabajo en curso. public DeliveryDispatcher(int maxInFlight, CancellationToken cancellationToken) { ArgumentOutOfRangeException.ThrowIfLessThan(maxInFlight, 1); - _maxInFlight = maxInFlight; _slots = new SemaphoreSlim(maxInFlight, maxInFlight); _stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); } - /// Acepta la entrega en cuanto hay cupo y vuelve sin esperar a que su trabajo termine. + /// 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 . - /// Cancela la espera de cupo. - public async Task EnqueueAsync(string eventId, Func work, CancellationToken cancellationToken) + public void Enqueue(string eventId, Func work) { - await _slots.WaitAsync(cancellationToken).ConfigureAwait(false); var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Task previous; lock (_gate) @@ -58,16 +57,20 @@ public async Task EnqueueAsync(string eventId, Func wor /// 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 y espera a que termine: después no queda nada escribiendo en la consola. + /// + /// 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); - // Con todo el cupo de vuelta no queda ninguna entrega en curso: cada una devuelve el suyo al final. - for (var slot = 0; slot < _maxInFlight; slot++) + Task[] pending; + lock (_gate) { - await _slots.WaitAsync(CancellationToken.None).ConfigureAwait(false); + pending = [.. _lastByEvent.Values]; } + await Task.WhenAll(pending).ConfigureAwait(false); _stopping.Dispose(); _slots.Dispose(); } @@ -76,13 +79,21 @@ private async Task RunAsync(string eventId, Task previous, Func -/// Lo que pasó con una entrega: el receptor de --forward-to respondió un código, o no respondió y queda el motivo, -/// o no hubo reenvío porque listen corre sin --forward-to. Es lo que lleva su acuse. +/// 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 respondió el receptor. +/// 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ó el reenvío; nulo si no hubo. -public sealed record DeliveryOutcome(int? StatusCode, string? Error, int? LatencyMs) +/// 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) { - // Asumido (CLI-004): sin --forward-to la terminal es el destino y la entrega llegó, como en stripe listen. Se acusa - // como un 200 inmediato para que el log de entregas no la muestre fallida. - private const int DisplayedStatusCode = 200; + public static DeliveryOutcome Responded(int statusCode, int latencyMs) => new(statusCode, null, latencyMs, Forwarded: true); - public static DeliveryOutcome NotForwarded { get; } = new(null, null, null); + public static DeliveryOutcome Failed(string error, int latencyMs) => new(null, error, latencyMs, Forwarded: true); - public bool Forwarded => LatencyMs is not null; - - public static DeliveryOutcome Responded(int statusCode, int latencyMs) => new(statusCode, null, latencyMs); - - public static DeliveryOutcome Failed(string error, int latencyMs) => new(null, error, latencyMs); - - public CliDeliveryAck ToAck(string sessionId) => Forwarded - ? new CliDeliveryAck(sessionId, StatusCode, LatencyMs!.Value, Error) - : new CliDeliveryAck(sessionId, DisplayedStatusCode, LatencyMs: 0, Error: null); + 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 index d864e57..d9eff11 100644 --- a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs +++ b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs @@ -1,28 +1,43 @@ using System.Net; using FiscalApi.Cli.Errors; -using FiscalApi.Cli.Http; namespace FiscalApi.Cli.Listen; /// -/// El trabajo de una entrega: reenviarla (o solo mostrarla), escribir su línea y acusarla. Todo lo que llega 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 (D-26). +/// 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 (D-26). /// public sealed class DeliveryProcessor( IDeliveryTarget target, ListenerApi api, ListenOutput output, - ExponentialBackoff ackBackoff, + ListenTimings timings, TimeProvider timeProvider) { /// La entrega, su sesión y el plazo de su acuse. - /// Ctrl+C o el fin de la escucha. + /// 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); - await AcknowledgeAsync(claimed, outcome.ToAck(claimed.Session.SessionId), cancellationToken).ConfigureAwait(false); + 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); + } } /// @@ -58,10 +73,31 @@ private async Task AcknowledgeAsync(ClaimedDelivery claimed, CliDeliveryAck ack, } } + /// + /// 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 = ackBackoff.Delay(attempt); + 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 index 8f73e8e..99f91de 100644 --- a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -3,12 +3,16 @@ namespace FiscalApi.Cli.Listen; /// Sin --forward-to: la entrega solo se muestra en la terminal. public sealed class DisplayOnlyTarget : IDeliveryTarget { + // Asumido (CLI-004): la terminal es el destino y la entrega llegó, como en stripe listen. 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) => - Task.FromResult(DeliveryOutcome.NotForwarded); + public Task DeliverAsync(CliDelivery delivery, CancellationToken cancellationToken) => Displayed; } diff --git a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs index 8ff4173..e170e70 100644 --- a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs +++ b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs @@ -14,6 +14,7 @@ public sealed class HttpForwardTarget : IDeliveryTarget, IDisposable 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; @@ -66,7 +67,7 @@ public async Task DeliverAsync(CliDelivery delivery, Cancellati using var request = CreateRequest(delivery); using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - return DeliveryOutcome.Responded((int)response.StatusCode, ElapsedMs(startedAt)); + 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) @@ -95,13 +96,20 @@ private HttpRequestMessage CreateRequest(CliDelivery delivery) 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 - => "InvalidResponse", + => InvalidResponseError, _ => "ConnectionFailed" }; diff --git a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs index 208288e..ad8c65e 100644 --- a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs @@ -13,15 +13,17 @@ public override void WriteSecret(string secret) => public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome) { - var summary = WebhookEventSummary.Parse(delivery.Body); + 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, - outcome.StatusCode, - outcome.LatencyMs, - outcome.Error, + forwarded?.StatusCode, + forwarded?.LatencyMs, + forwarded?.Error, summary.Envelope); WriteData(JsonSerializer.Serialize(record, FiscalApiJsonContext.Default.ListenEventRecord)); } @@ -35,7 +37,9 @@ public sealed record ListenSecretRecord(string Secret); /// statusCode o error, como su acuse. /// /// Cuando terminó el reenvío, en la hora local con su desfase. -/// El sobre del evento tal como llegó (D-04). +/// El de webhook-id, o "?" si no vino. +/// El del sobre, o "?" si el cuerpo no es el sobre de D-04. +/// El sobre del evento tal como llegó (D-04); se omite si el cuerpo no es JSON. public sealed record ListenEventRecord( DateTimeOffset Time, string DeliveryId, @@ -44,4 +48,4 @@ public sealed record ListenEventRecord( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? StatusCode, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? LatencyMs, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error, - JsonElement Event); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] JsonElement? Event); diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs index c1bd24e..d3e0d8a 100644 --- a/src/FiscalApi.Cli/Listen/ListenLoop.cs +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -27,7 +27,10 @@ public async Task RunAsync(CancellationToken cancellationToken) output.WriteReady(session); await using var dispatcher = new DeliveryDispatcher(timings.MaxInFlightDeliveries, cancellationToken); - var failedAttempts = 0; + // Cada cuenta se reinicia con el primer long-polling exitoso. + var connectionFailures = 0; + var busyReopens = 0; + var expirationsInARow = 0; while (true) { IReadOnlyList deliveries; @@ -38,39 +41,53 @@ public async Task RunAsync(CancellationToken cancellationToken) 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 (D-26) y se recupera con events resend. Un 409 (otra sesión la reemplazó) no es - // transitorio y termina la escucha con el texto de la API. + // pasó mientras tanto no llega (D-26) 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 es el candado de otra apertura de la misma + // persona en curso (WebhookEndpointWriteLock de fapi-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(++failedAttempts); + var retryIn = timings.Reconnect.Delay(++connectionFailures); output.WriteConnectionLost(failure, retryIn); - await Task.Delay(retryIn, timeProvider, cancellationToken).ConfigureAwait(false); + await WaitAsync(retryIn, cancellationToken).ConfigureAwait(false); continue; } - if (failedAttempts > 0) + if (connectionFailures > 0) { output.WriteReconnected(); - failedAttempts = 0; } - await DispatchAsync(dispatcher, session, deliveries, cancellationToken).ConfigureAwait(false); + connectionFailures = busyReopens = expirationsInARow = 0; + Dispatch(dispatcher, session, deliveries); } } - private async Task DispatchAsync( - DeliveryDispatcher dispatcher, - CliListenerSession session, - IReadOnlyList deliveries, - CancellationToken cancellationToken) + 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. @@ -78,10 +95,7 @@ private async Task DispatchAsync( foreach (var delivery in deliveries) { var claimed = new ClaimedDelivery(session, delivery, ackDeadline); - await dispatcher.EnqueueAsync( - delivery.EventId, - token => processor.ProcessAsync(claimed, token), - cancellationToken).ConfigureAwait(false); + 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 index 37422ca..150ecd1 100644 --- a/src/FiscalApi.Cli/Listen/ListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/ListenOutput.cs @@ -34,14 +34,21 @@ 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 ({failure.Message.TrimEnd('.')}); reintentando en " + - $"{retryIn.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)} s."); + 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. @@ -55,6 +62,10 @@ public void WriteAckNotRecorded(string deliveryId, CliException failure, bool ve 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) diff --git a/src/FiscalApi.Cli/Listen/ListenTimings.cs b/src/FiscalApi.Cli/Listen/ListenTimings.cs index 7b6a55c..cdeeb9e 100644 --- a/src/FiscalApi.Cli/Listen/ListenTimings.cs +++ b/src/FiscalApi.Cli/Listen/ListenTimings.cs @@ -9,11 +9,16 @@ namespace FiscalApi.Cli.Listen; /// 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, con jitter. -/// Entregas que se reenvían (o esperan su turno) a la vez. +/// +/// 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 reintentar un acuse más allá no sirve. +/// 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, @@ -21,16 +26,18 @@ public sealed record ListenTimings( TimeSpan ForwardTimeout, ExponentialBackoff Reconnect, int MaxInFlightDeliveries, - TimeSpan DeliveryLease) + 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)), - // Un lote del long-polling trae hasta 25 entregas (WebhookConsts.ListenerBatchSize). Con 16 a la vez, aun si - // todas agotan los 15 s del reenvío, el lote se despacha en dos rondas y el siguiente long-polling sale mucho - // antes de que la sesión caduque (60 s sin long-polling). + // 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)); + 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/ListenerContracts.cs b/src/FiscalApi.Cli/Listen/ListenerContracts.cs index 4c6199f..9e93a35 100644 --- a/src/FiscalApi.Cli/Listen/ListenerContracts.cs +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -29,10 +29,14 @@ public sealed class CliListenerSession /// 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 (D-02): ordena las entregas del mismo evento. - public string EventId => Headers.TryGetValue(WebhookHeaderNames.Id, out var eventId) - ? eventId - : throw new InvalidOperationException($"La entrega {DeliveryId} llegó sin el header {WebhookHeaderNames.Id}."); + /// El id del evento que la API manda en webhook-id (D-02); 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; } /// diff --git a/src/FiscalApi.Cli/Listen/TextListenOutput.cs b/src/FiscalApi.Cli/Listen/TextListenOutput.cs index abfff0b..4d0e8c5 100644 --- a/src/FiscalApi.Cli/Listen/TextListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/TextListenOutput.cs @@ -10,7 +10,7 @@ public sealed class TextListenOutput(CliConsole console, bool verbose, TimeProvi public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome) { - var summary = WebhookEventSummary.Parse(delivery.Body); + 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)}"); } @@ -18,8 +18,8 @@ public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome 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: { } error } => $" [error: {error}]", - _ => "" + _ => $" [error: {outcome.Error}]" }; } diff --git a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs index 50ab52e..d7a1956 100644 --- a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs +++ b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs @@ -3,21 +3,39 @@ namespace FiscalApi.Cli.Listen; /// -/// Lo que la terminal muestra de una entrega: el id, el tipo y el sobre del evento (D-04). Se lee del cuerpo sin tocarlo: -/// lo que se reenvía es siempre el texto original. +/// Lo que la terminal muestra de una entrega: el id del evento (de webhook-id), su tipo y el sobre (D-04). 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; no depende del documento que lo leyó. -public sealed record WebhookEventSummary(string Id, string Type, JsonElement Envelope) +/// El sobre completo para --json; nulo si el cuerpo no es JSON. +public sealed record WebhookEventSummary(string Id, string Type, JsonElement? Envelope) { - /// El cuerpo no es el sobre de D-04: un defecto del contrato con la API. - public static WebhookEventSummary Parse(string body) + public const string Unknown = "?"; + + public static WebhookEventSummary Of(CliDelivery delivery) { - using var envelope = JsonDocument.Parse(body); - var root = envelope.RootElement; - return new WebhookEventSummary(RequiredString(root, "id"), RequiredString(root, "type"), root.Clone()); + var envelope = TryParse(delivery.Body); + return new WebhookEventSummary(delivery.EventId ?? Unknown, TypeOf(envelope), envelope); } - private static string RequiredString(JsonElement envelope, string property) => - envelope.GetProperty(property).GetString() - ?? throw new JsonException($"El sobre del evento trae '{property}' nulo."); + 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/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 index d190834..204a9c9 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs @@ -18,8 +18,8 @@ public async Task Deliveries_of_different_events_run_concurrently() var first = new BlockingWork(); var second = new BlockingWork(); - await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); - await dispatcher.EnqueueAsync("evt_2", second.RunAsync, CancellationToken.None); + dispatcher.Enqueue("evt_1", first.RunAsync); + dispatcher.Enqueue("evt_2", second.RunAsync); await first.Started.WaitAsync(WaitLimit); await second.Started.WaitAsync(WaitLimit); @@ -34,8 +34,8 @@ public async Task Deliveries_of_the_same_event_run_in_arrival_order() var first = new BlockingWork(); var second = new BlockingWork(); - await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); - await dispatcher.EnqueueAsync("evt_1", second.RunAsync, CancellationToken.None); + dispatcher.Enqueue("evt_1", first.RunAsync); + dispatcher.Enqueue("evt_1", second.RunAsync); await first.Started.WaitAsync(WaitLimit); await Task.Delay(100); @@ -51,42 +51,81 @@ 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(); - await dispatcher.EnqueueAsync("evt_1", _ => Task.FromException(new InvalidOperationException("defecto")), - CancellationToken.None); - await dispatcher.EnqueueAsync("evt_1", next.RunAsync, CancellationToken.None); + 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 Enqueue_waits_only_while_every_slot_is_busy() + public async Task At_most_max_in_flight_deliveries_run_and_enqueue_never_waits() { await using var dispatcher = new DeliveryDispatcher(maxInFlight: 2, CancellationToken.None); - var first = new BlockingWork(); - var second = new BlockingWork(); - var third = new BlockingWork(); - await dispatcher.EnqueueAsync("evt_1", first.RunAsync, CancellationToken.None); - await dispatcher.EnqueueAsync("evt_2", second.RunAsync, CancellationToken.None); + BlockingWork[] works = [new(), new(), new()]; + + for (var i = 0; i < works.Length; i++) + { + dispatcher.Enqueue($"evt_{i}", works[i].RunAsync); + } - var enqueueThird = dispatcher.EnqueueAsync("evt_3", third.RunAsync, CancellationToken.None); + // 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(); + } + } - Assert.False(enqueueThird.IsCompleted, "Se aceptó una entrega con el cupo lleno."); - first.Release(); - await enqueueThird.WaitAsync(WaitLimit); - await third.Started.WaitAsync(WaitLimit); - second.Release(); - third.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(); - await dispatcher.EnqueueAsync("evt_1", async cancellationToken => + dispatcher.Enqueue("evt_1", async cancellationToken => { + started.SetResult(); try { await Task.Delay(Timeout.Infinite, cancellationToken); @@ -95,7 +134,8 @@ await dispatcher.EnqueueAsync("evt_1", async cancellationToken => { observedCancellation.SetResult(); } - }, CancellationToken.None); + }); + await started.Task.WaitAsync(WaitLimit); await dispatcher.DisposeAsync().AsTask().WaitAsync(WaitLimit); @@ -107,11 +147,11 @@ 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(); - await dispatcher.EnqueueAsync("evt_1", _ => + dispatcher.Enqueue("evt_1", _ => { failed.SetResult(); throw new InvalidOperationException("defecto del CLI"); - }, CancellationToken.None); + }); await failed.Task.WaitAsync(WaitLimit); var error = await WaitForFaultAsync(dispatcher); diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs index f5ce5a3..0ba8bd7 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -29,7 +29,10 @@ internal sealed class FakeListenerApi : IAsyncDisposable 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; @@ -40,6 +43,9 @@ internal sealed class FakeListenerApi : IAsyncDisposable public IReadOnlyCollection Opens => _opens; + /// Cuándo llegó cada apertura, incluidas las rechazadas. + public IReadOnlyCollection OpenTimes => _openTimes; + public IReadOnlyCollection PollsReceived => _pollsReceived; public IReadOnlyCollection Acks => _acks; @@ -98,6 +104,20 @@ public FakeListenerApi DropPoll(int times = 1) 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) { @@ -146,6 +166,14 @@ private void MapRoutes() 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 { @@ -186,6 +214,19 @@ 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); @@ -228,7 +269,12 @@ public sealed record RecordedAck(string ListenerId, string DeliveryId, JsonObjec /// 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) + string DeliveryId, + string EventId, + string Type, + string? Body = null, + string? SignatureHeader = null, + bool WithoutEventIdHeader = false) { public const string Timestamp = "1791900000"; @@ -242,12 +288,24 @@ internal sealed record FakeDelivery( "data":{"relatedObject":{"id":"inv_1","type":"invoice","url":"/api/v4/invoices/inv_1"},"status":"X","previousStatus":"V"}} """; - public IReadOnlyDictionary Headers => new Dictionary + public IReadOnlyDictionary Headers { - ["webhook-id"] = EventId, - ["webhook-timestamp"] = Timestamp, - ["webhook-signature"] = Signature - }; + 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() { diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index d183c34..0d734c9 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -1,5 +1,4 @@ -using System.Net; -using System.Net.Sockets; +using System.Diagnostics; using System.Text; using System.Text.Json.Nodes; using System.Text.RegularExpressions; @@ -125,8 +124,9 @@ public async Task Ack_carries_the_receiver_status_code_and_the_session(int recei 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", $"http://127.0.0.1:{FreePort()}/webhooks"); + StartListen("--forward-to", closed.Url("webhooks").ToString()); await ExpectAsync(_api.WaitForAcksAsync(1)); var ack = Assert.Single(_api.Acks); @@ -193,8 +193,9 @@ public async Task Each_forwarded_event_prints_a_line_with_the_receiver_result() 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", $"http://127.0.0.1:{FreePort()}/webhooks"); + StartListen("--forward-to", closed.Url("webhooks").ToString()); await ExpectAsync(_api.WaitForAcksAsync(1)); Assert.Matches(EventLine("invoice.canceled", "evt_0001", suffix: " [error: ConnectionFailed]"), Out); @@ -340,13 +341,14 @@ public async Task Ctrl_c_during_a_forward_exits_130_without_waiting_for_the_rece 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_0002", "invoice.created")); + .Deliver(new FakeDelivery("dlv_0002", "evt_0001", "invoice.canceled")); StartListen(verbose ? ["--verbose"] : []); await ExpectAsync(_api.WaitForAcksAsync(2)); - await ExpectAsync(_api.WaitForPollsAsync(3)); Assert.Equal(verbose, Error.Contains($"No se registró el acuse de la entrega dlv_0001: {notAwaiting}")); Assert.Equal(2, _api.Acks.Count); @@ -441,8 +443,9 @@ private static void AssertEventRecord(JsonObject record, FakeDelivery delivery, 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", $"http://127.0.0.1:{FreePort()}/webhooks", "--json"); + StartListen("--forward-to", closed.Url("webhooks").ToString(), "--json"); await ExpectAsync(_api.WaitForAcksAsync(1)); var record = JsonNode.Parse(Out)!.AsObject(); @@ -510,7 +513,8 @@ public async Task Skip_verify_never_relaxes_tls_toward_the_api() [Fact] public async Task An_unreachable_api_at_start_exits_1_without_retrying() { - var unreachable = new Uri($"http://127.0.0.1:{FreePort()}/"); + using var closed = new ClosedPort(); + var unreachable = closed.Url(); StartListen(apiBase: unreachable); @@ -562,6 +566,225 @@ public async Task The_secret_appears_only_on_the_ready_line_even_with_verbose() 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 endpoints de webhook 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 endpoints de webhook 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")] @@ -572,6 +795,12 @@ public async Task The_secret_appears_only_on_the_ready_line_even_with_verbose() [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('|')); @@ -612,13 +841,6 @@ private async Task PressCtrlCAsync() return await ExitCodeAsync(); } - private static int FreePort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } - private void StartListen(params string[] options) => StartListen(_api.BaseAddress, options); private void StartListen(Uri apiBase, params string[] options) diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs index 6bb97f0..27785e1 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs @@ -17,6 +17,8 @@ public void Defaults_follow_the_listen_contract() 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] 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); +} From 5baf74c7b9d6798c0c1ee3049adaa5f2e591ae12 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 16:16:44 -0600 Subject: [PATCH 07/19] CLI: rutas /api/v4/webhooks listen usa /api/v4/webhooks/cli-listener, /{id}/cli-deliveries y /{id}/cli-deliveries/{deliveryId}/ack (antes /api/v4/webhook-endpoints/...), por el cambio de contrato decidido por el usuario. La API falsa de ListenCommandTests sirve la ruta nueva. Evidencia: dotnet test, 218 correctas, 0 con error. --- src/FiscalApi.Cli/Listen/ListenerApi.cs | 6 +++--- src/FiscalApi.Cli/Listen/ListenerContracts.cs | 2 +- tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FiscalApi.Cli/Listen/ListenerApi.cs b/src/FiscalApi.Cli/Listen/ListenerApi.cs index 43c0015..6c77ec7 100644 --- a/src/FiscalApi.Cli/Listen/ListenerApi.cs +++ b/src/FiscalApi.Cli/Listen/ListenerApi.cs @@ -5,12 +5,12 @@ namespace FiscalApi.Cli.Listen; /// Las rutas de la escucha de CLI (D-28) sobre . public sealed class ListenerApi(FiscalApiClient client, int pollWaitSeconds) { - private const string EndpointsPath = "api/v4/webhook-endpoints"; + private const string WebhooksPath = "api/v4/webhooks"; /// Abre una sesión nueva, que reemplaza la anterior de la persona, y responde el secreto (D-29). public async Task OpenAsync(IReadOnlyList enabledEvents, CancellationToken cancellationToken) => await client.PostAsync( - $"{EndpointsPath}/cli-listener", + $"{WebhooksPath}/cli-listener", new OpenCliListenerRequest(enabledEvents), FiscalApiJsonContext.Default.OpenCliListenerRequest, FiscalApiJsonContext.Default.ApiResponseCliListenerSession, @@ -40,5 +40,5 @@ public Task AckAsync(CliListenerSession session, string deliveryId, CliDeliveryA FiscalApiJsonContext.Default.ApiResponseBoolean, cancellationToken); - private static string ListenerPath(CliListenerSession session) => $"{EndpointsPath}/{Uri.EscapeDataString(session.Id)}"; + 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 index 9e93a35..0596c74 100644 --- a/src/FiscalApi.Cli/Listen/ListenerContracts.cs +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -4,7 +4,7 @@ namespace FiscalApi.Cli.Listen; // Los contratos de la escucha de CLI de fapi-back (WebhookCliListenerController, D-28), con sus mismos nombres. -/// El cuerpo de POST /api/v4/webhook-endpoints/cli-listener (OpenCliListenerCommand). +/// El cuerpo de POST /api/v4/webhooks/cli-listener (OpenCliListenerCommand). public sealed record OpenCliListenerRequest(IReadOnlyList EnabledEvents); /// diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs index 0ba8bd7..1e96e55 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -23,7 +23,7 @@ internal sealed class FakeListenerApi : IAsyncDisposable public const string ListenerId = "wh_listener_0001"; public const string Secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; - private const string Route = "/api/v4/webhook-endpoints"; + private const string Route = "/api/v4/webhooks"; private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(10); private readonly WebApplication _app; From 3cc712b2d4e0555b1bc875f129cdb9289f9f5d67 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 16:28:09 -0600 Subject: [PATCH 08/19] CLI-003: fiscalapi webhooks y events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit webhooks list|retrieve|create|update|delete|rotate-secret sobre /api/v4/webhooks y events list|retrieve|resend sobre /api/v4/events (D-28, D-33). --json deja el data tal cual (events retrieve, byte a byte); create y rotate-secret imprimen el secreto una vez; update lee y reemplaza solo lo pedido; delete pide confirmación o --yes. Los 400 de validación muestran el texto de cada falla. Evidencia: 63 pruebas nuevas (rojo 62/63 antes del código); dotnet test 281 correctas x3; build Release sin avisos; AOT win-x64 y linux-x64 (normal y con TrimmerRootAssembly) sin IL ni warning; binario nativo contra API falsa. --- .../Commands/ApiCommandContext.cs | 50 ++ .../Commands/CommaSeparatedValues.cs | 14 + .../Commands/Events/EventsCommand.cs | 20 + .../Commands/Events/ListEventsCommand.cs | 103 ++++ .../Commands/Events/ResendEventCommand.cs | 41 ++ .../Commands/Events/RetrieveEventCommand.cs | 29 + src/FiscalApi.Cli/Commands/ListenCommand.cs | 6 +- src/FiscalApi.Cli/Commands/LoginCommand.cs | 4 +- src/FiscalApi.Cli/Commands/PageOptions.cs | 28 + .../Commands/Webhooks/CreateWebhookCommand.cs | 39 ++ .../Commands/Webhooks/DeleteWebhookCommand.cs | 67 +++ .../Commands/Webhooks/ListWebhooksCommand.cs | 28 + .../Webhooks/RetrieveWebhookCommand.cs | 28 + .../Webhooks/RotateWebhookSecretCommand.cs | 33 ++ .../Commands/Webhooks/UpdateWebhookCommand.cs | 92 +++ .../Commands/Webhooks/WebhookOptions.cs | 58 ++ .../Commands/Webhooks/WebhooksCommand.cs | 25 + src/FiscalApi.Cli/Errors/ApiException.cs | 25 +- src/FiscalApi.Cli/Http/ApiData.cs | 16 + src/FiscalApi.Cli/Http/FiscalApiClient.cs | 49 +- .../Http/FiscalApiJsonContext.cs | 10 + src/FiscalApi.Cli/Http/PagedList.cs | 30 + src/FiscalApi.Cli/Http/QueryString.cs | 15 + src/FiscalApi.Cli/Http/ValidationFailures.cs | 34 ++ .../Input/ConsoleConfirmationPrompt.cs | 24 + .../Input/IConfirmationPrompt.cs | 11 + src/FiscalApi.Cli/Output/LabeledValues.cs | 20 + src/FiscalApi.Cli/Output/PagedListView.cs | 26 + src/FiscalApi.Cli/Output/TextTable.cs | 37 ++ src/FiscalApi.Cli/Program.cs | 9 +- src/FiscalApi.Cli/Webhooks/EventsApi.cs | 62 ++ .../Webhooks/WebhookContracts.cs | 112 ++++ .../Webhooks/WebhookEventContracts.cs | 46 ++ .../Webhooks/WebhookEventView.cs | 17 + src/FiscalApi.Cli/Webhooks/WebhookView.cs | 63 ++ src/FiscalApi.Cli/Webhooks/WebhooksApi.cs | 57 ++ .../Commands/ApiCommandHarness.cs | 87 +++ .../Commands/EventsCommandTests.cs | 237 ++++++++ .../Commands/LoginCommandTests.cs | 2 +- .../Commands/WebhooksCommandTests.cs | 548 ++++++++++++++++++ .../Http/StubHttpMessageHandler.cs | 11 +- 41 files changed, 2188 insertions(+), 25 deletions(-) create mode 100644 src/FiscalApi.Cli/Commands/ApiCommandContext.cs create mode 100644 src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs create mode 100644 src/FiscalApi.Cli/Commands/Events/EventsCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/PageOptions.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs create mode 100644 src/FiscalApi.Cli/Http/ApiData.cs create mode 100644 src/FiscalApi.Cli/Http/PagedList.cs create mode 100644 src/FiscalApi.Cli/Http/QueryString.cs create mode 100644 src/FiscalApi.Cli/Http/ValidationFailures.cs create mode 100644 src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs create mode 100644 src/FiscalApi.Cli/Input/IConfirmationPrompt.cs create mode 100644 src/FiscalApi.Cli/Output/LabeledValues.cs create mode 100644 src/FiscalApi.Cli/Output/PagedListView.cs create mode 100644 src/FiscalApi.Cli/Output/TextTable.cs create mode 100644 src/FiscalApi.Cli/Webhooks/EventsApi.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhookContracts.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhookEventView.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhookView.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhooksApi.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs diff --git a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs new file mode 100644 index 0000000..12857b8 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs @@ -0,0 +1,50 @@ +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 (webhooks y events): 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)); + } + + /// Como la otra sobrecarga, cuando el comando ya leyó el recurso porque necesita algo más de él. + public void WriteData(GlobalSettings settings, JsonElement data, T value, Action writeText) + { + if (settings.Json) + { + WriteRaw(data); + return; + } + + writeText(Console.Out, value); + } + + /// El JSON del data sin re-serializarlo: los mismos caracteres que mandó la API. + public void WriteRaw(JsonElement data) => Console.Out.WriteLine(data.GetRawText()); + + public static T Read(JsonElement data, JsonTypeInfo type) => + data.Deserialize(type) ?? throw new InvalidOperationException("La API respondió con éxito pero sin data."); +} diff --git a/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs new file mode 100644 index 0000000..9be17ba --- /dev/null +++ b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs @@ -0,0 +1,14 @@ +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. Una lista vacía es válida (p. ej. --person ""); lo que contiene lo valida la API, con sus textos. +/// +public static class CommaSeparatedValues +{ + public static IReadOnlyList Parse(ArgumentResult result) => + [.. result.Tokens.SelectMany(token => + token.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..2a0d310 --- /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 (CLI-003): 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..475789a --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs @@ -0,0 +1,103 @@ +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: fecha, o fecha y hora con o sin segundos y fracción, con o sin zona (Z o ±hh:mm). + private static readonly string[] Iso8601Formats = + [ + "yyyy-MM-dd", + "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 = ParseInstant + }; + + private readonly Option _to = new("--to") + { + Description = "Hasta este instante, incluido, en ISO 8601; sin zona, UTC. Por omisión, ahora.", + HelpName = "fecha", + CustomParser = ParseInstant + }; + + 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: una fecha sin zona se toma como UTC y una con zona se convierte. + private static DateTimeOffset? ParseInstant(ArgumentResult result) + { + var value = result.Tokens.Single().Value; + if (DateTimeOffset.TryParseExact( + value, + Iso8601Formats, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + 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; + } +} diff --git a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs new file mode 100644 index 0000000..35d5d8c --- /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 (D-28): 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 index bbc743f..3a4118f 100644 --- a/src/FiscalApi.Cli/Commands/ListenCommand.cs +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -25,7 +25,7 @@ public sealed class ListenCommand : Command Description = "Tipos de evento separados por coma (p. ej. invoice.canceled,invoice.created). Por omisión, todos.", HelpName = "tipos", Arity = ArgumentArity.ExactlyOne, - CustomParser = ParseEvents, + CustomParser = CommaSeparatedValues.Parse, DefaultValueFactory = _ => ["*"] }; @@ -142,10 +142,6 @@ private void RejectForwardingWithPrintSecret(CommandResult result) } } - // Los tipos los valida la API, con sus textos (un tipo que no existe, '*' junto a otros o ninguno). - private static IReadOnlyList ParseEvents(ArgumentResult result) => - result.Tokens.Single().Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - // "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. diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs index 620fde3..422b005 100644 --- a/src/FiscalApi.Cli/Commands/LoginCommand.cs +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -16,7 +16,7 @@ public sealed class LoginCommand : Command { private const string FeaturesPath = "api/v4/me/features"; - // El permiso que exigen listen, trigger y webhook-endpoints (Permissions.Webhooks.Manage de fapi-back). + // El permiso que exigen listen, trigger y webhooks (Permissions.Webhooks.Manage de fapi-back). private const string WebhooksManagePermission = "Webhooks.Manage"; private readonly GlobalOptions _globalOptions; @@ -62,7 +62,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc if (features?.Permissions.Contains(WebhooksManagePermission, StringComparer.Ordinal) != true) { await _console.Error.WriteLineAsync( - "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhook-endpoints responderán 403."); + "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403."); } return ExitCodes.Success; 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/Webhooks/CreateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs new file mode 100644 index 0000000..c1c8406 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs @@ -0,0 +1,39 @@ +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)!, + WebhookOptions.GivenValue(parseResult, _webhook.Persons)); + + var data = await new WebhooksApi(_context.Client(settings)).CreateAsync(request, cancellationToken); + var webhook = ApiCommandContext.Read(data, FiscalApiJsonContext.Default.Webhook); + _context.WriteData(settings, data, webhook, WebhookView.WriteDetail); + WebhookView.WriteSigningSecret(_context.Console, webhook, settings.Json, 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..3cdaff9 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs @@ -0,0 +1,67 @@ +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."); + } + + if (!await _confirmation.ConfirmAsync( + $"¿Borrar el webhook {id}? Sus entregas pendientes se cerrarán.", cancellationToken)) + { + // 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..5c288ba --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs @@ -0,0 +1,33 @@ +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); + var webhook = ApiCommandContext.Read(data, FiscalApiJsonContext.Default.Webhook); + _context.WriteData(settings, data, webhook, WebhookView.WriteDetail); + WebhookView.WriteSigningSecret(_context.Console, webhook, settings.Json, 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..7f44cbc --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs @@ -0,0 +1,92 @@ +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 fapi-back (WebhookMessages.CliListenerNotEditable, WH-011, D-21). + 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. + if (current.IsCliListener) + { + throw new UsageException(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), + 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, _enabled, _disabled]; + if (!changes.Any(option => result.GetResult(option) is { Implicit: false })) + { + result.AddError("Indica qué cambiar: --url, --description, --events, --person, --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..1cbbb21 --- /dev/null +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs @@ -0,0 +1,58 @@ +using System.CommandLine; + +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. Sin personas, recibe los de todas.", + HelpName = "id", + Arity = ArgumentArity.OneOrMore, + CustomParser = CommaSeparatedValues.Parse + }; + + public void AddTo(Command command) + { + command.Options.Add(Url); + command.Options.Add(Description); + command.Options.Add(Events); + command.Options.Add(Persons); + } + + /// 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; +} diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs new file mode 100644 index 0000000..d0280ae --- /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 (CLI-003): 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/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs index 086a62d..6c4c1c8 100644 --- a/src/FiscalApi.Cli/Errors/ApiException.cs +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -18,12 +18,18 @@ public sealed class ApiException : CliException "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) + 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; @@ -40,15 +46,21 @@ private ApiException(HttpStatusCode statusCode, string message, string? details, public string? Hint => Hints.GetValueOrDefault(StatusCode); - public static ApiException FromEnvelope(HttpStatusCode statusCode, ApiResponse envelope) => new( + /// 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)); + 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); + new(statusCode, $"Respuesta inesperada ({(int)statusCode}).", details: null, traceIdentifier: null, validationErrors: []); public override void WriteTo(TextWriter error) { @@ -58,6 +70,11 @@ public override void WriteTo(TextWriter error) error.WriteLine($" Detalle: {Details}"); } + foreach (var validationError in ValidationErrors) + { + error.WriteLine($" - {validationError}"); + } + error.WriteLine($" Código HTTP: {(int)StatusCode}"); if (TraceIdentifier is not null) { 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/FiscalApiClient.cs b/src/FiscalApi.Cli/Http/FiscalApiClient.cs index 802ff8e..d5887db 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClient.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClient.cs @@ -8,7 +8,8 @@ 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. +/// 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) { @@ -23,17 +24,44 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli } /// Un POST no se reintenta: repetirlo podría crear el recurso dos veces. - public async Task PostAsync( + 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(HttpMethod.Post, path) - { - Content = JsonContent.Create(body, requestType) - }; + 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); @@ -74,18 +102,19 @@ private async Task SendAsync( } /// - /// Un error se lee sin data, cuya forma 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. + /// 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.ApiResponse, cancellationToken).ConfigureAwait(false); + .ReadFromJsonAsync(FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false); return envelope is null ? ApiException.UnexpectedResponse(response.StatusCode) - : ApiException.FromEnvelope(response.StatusCode, envelope); + : ApiException.FromEnvelope(response.StatusCode, envelope, ValidationFailures.MessagesIn(envelope.Data)); } catch (JsonException) { diff --git a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs index a5e9f44..a42ac85 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using FiscalApi.Cli.Listen; +using FiscalApi.Cli.Webhooks; namespace FiscalApi.Cli.Http; @@ -18,4 +19,13 @@ namespace FiscalApi.Cli.Http; [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(ListenSecretRecord))] [JsonSerializable(typeof(ListenEventRecord))] +// CLI-003: 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))] 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/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/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/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/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/Output/LabeledValues.cs b/src/FiscalApi.Cli/Output/LabeledValues.cs new file mode 100644 index 0000000..a0eeced --- /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 endpoint 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 index 01d0899..8c700ee 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -3,6 +3,8 @@ 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; @@ -44,11 +46,16 @@ ? 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 ListenCommand(globalOptions, apiClients, console, TimeProvider.System, ListenTimings.Default), + new WebhooksCommand(globalOptions, apiClients, confirmation, console), + new EventsCommand(globalOptions, apiClients, console) ]; var application = new CliApplication(globalOptions, commands, console); diff --git a/src/FiscalApi.Cli/Webhooks/EventsApi.cs b/src/FiscalApi.Cli/Webhooks/EventsApi.cs new file mode 100644 index 0000000..1a9ae11 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/EventsApi.cs @@ -0,0 +1,62 @@ +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, D-28) 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 (D-28), 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); + } + + 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..aa745c8 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs @@ -0,0 +1,112 @@ +using System.Text.Json.Serialization; + +namespace FiscalApi.Cli.Webhooks; + +// Los contratos de los webhooks de fapi-back (WH-011), 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, y WebhookWithSecretDto en el +/// alta y la rotación). Las fechas se guardan como texto: la API las escribe en la zona de la petición y el CLI las muestra +/// tal cual. Clase y no record: su ToString no debe escribir el secreto si algún día termina en un mensaje. +/// +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; } + + /// El secreto whsec_…: solo lo traen el alta y la rotación, y nunca durante una suplantación. + public string? Secret { 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 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) +{ + public bool IsEmpty => Url is null && Description is null && EnabledEvents is null && PersonIds is null && Status is null; + + /// + /// 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..b35062d --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs @@ -0,0 +1,46 @@ +namespace FiscalApi.Cli.Webhooks; + +// Los contratos de los eventos de webhook de fapi-back (WH-013, D-28), con sus mismos nombres. + +/// +/// Lo que la tabla de events list muestra del sobre de D-04 ({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); diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs new file mode 100644 index 0000000..1fc69c7 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs @@ -0,0 +1,17 @@ +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}."); +} diff --git a/src/FiscalApi.Cli/Webhooks/WebhookView.cs b/src/FiscalApi.Cli/Webhooks/WebhookView.cs new file mode 100644 index 0000000..223c279 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookView.cs @@ -0,0 +1,63 @@ +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; el aviso de guardarlo (texto aprobado de FE-001, D-21) 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. + /// + /// Si viene de rotate-secret: el anterior sigue firmando 24 horas. + public static void WriteSigningSecret(CliConsole console, Webhook webhook, bool json, bool rotated) + { + if (webhook.Secret is null) + { + return; + } + + if (!json) + { + console.Out.WriteLine(); + console.Out.WriteLine($"Secreto de firma: {webhook.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/Commands/ApiCommandHarness.cs b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs new file mode 100644 index 0000000..c609721 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -0,0 +1,87 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Nodes; +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; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// Corre fiscalapi webhooks … y fiscalapi events … 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. + public Task RunAsync(IEnumerable args, string confirmationInput = "", bool interactive = false) + { + 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); + var confirmation = new ConsoleConfirmationPrompt(console.Error, new StringReader(confirmationInput), interactive); + Command[] commands = + [ + new WebhooksCommand(globalOptions, apiClients, confirmation, console), + new EventsCommand(globalOptions, apiClients, console) + ]; + + return new CliApplication(globalOptions, commands, console) + .RunAsync([.. args, "--api-base", FiscalApiClientBuilder.BaseAddress.ToString()], CancellationToken.None); + } + + 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..a7e1093 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Text; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// CLI-003: fiscalapi events list|retrieve|resend contra las rutas de EventsController de fapi-back (WH-013, +/// D-28), 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 de D-04 como lo guarda y entrega fapi-back (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); + } + + [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 index 964973f..05c9925 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -86,7 +86,7 @@ public async Task Without_webhooks_manage_it_warns_and_still_saves() Assert.Equal(ExitCodes.Success, exitCode); Assert.Contains( - "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhook-endpoints responderán 403.", + "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)); diff --git a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs new file mode 100644 index 0000000..10eb28c --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs @@ -0,0 +1,548 @@ +using System.Net; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// CLI-003: fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret contra las rutas de +/// WebhooksController de fapi-back (WH-011), 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_an_empty_person_list_receives_the_events_of_every_person() + { + _cli.Api + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)) + .RespondJson(HttpStatusCode.OK, ApiCommandHarness.Success(UrlWebhook)); + + await _cli.RunAsync("webhooks", "update", WebhookId, "--person", "", "--enabled"); + + var body = _cli.Body(1)!; + Assert.Empty(body["personIds"]!.AsArray()); + Assert.Equal("enabled", (string?)body["status"]); + } + + [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"); + + Assert.Equal(ExitCodes.Usage, 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 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); + } + + // 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/Http/StubHttpMessageHandler.cs b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs index d08c69b..60c0ab9 100644 --- a/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs +++ b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs @@ -15,6 +15,12 @@ public sealed class StubHttpMessageHandler : HttpMessageHandler 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); @@ -64,11 +70,12 @@ private StubHttpMessageHandler Enqueue(int times, Func SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + 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) - ? respond(cancellationToken) + ? await respond(cancellationToken) : throw new InvalidOperationException($"Request no esperado: {request.Method} {request.RequestUri}."); } } From 4fd9a7fe6795971439fb907c2661123bbe1c7022 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 16:41:46 -0600 Subject: [PATCH 09/19] =?UTF-8?q?CLI-003:=20correcciones=20de=20la=20revis?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --person vacío es error de uso y --all-persons quita el filtro; Ctrl+C en la confirmación de delete sale con 130; --to con solo fecha incluye el día (UTC); el secreto de create/rotate-secret sale del JSON crudo aunque el modelo falle; update de una escucha de CLI sale con 1; sin IsEmpty, ApiResponse no genérico ni la guarda duplicada de data. Evidencia: 20 pruebas nuevas o ajustadas (rojo 18 antes del código); dotnet test 301 correctas x3; build Release sin avisos; AOT linux-x64 (normal y raíz) sin IL. --- .../Commands/ApiCommandContext.cs | 19 +- .../Commands/CommaSeparatedValues.cs | 9 +- .../Commands/Events/ListEventsCommand.cs | 45 +++-- .../Commands/Webhooks/CreateWebhookCommand.cs | 5 +- .../Commands/Webhooks/DeleteWebhookCommand.cs | 8 +- .../Webhooks/RotateWebhookSecretCommand.cs | 4 +- .../Commands/Webhooks/UpdateWebhookCommand.cs | 12 +- .../Commands/Webhooks/WebhookOptions.cs | 35 +++- .../Webhooks/WebhookWithSecretOutput.cs | 34 ++++ .../Errors/ResourceStateException.cs | 11 ++ .../Http/FiscalApiJsonContext.cs | 1 - src/FiscalApi.Cli/Listen/ListenLoop.cs | 4 +- src/FiscalApi.Cli/Output/LabeledValues.cs | 2 +- .../Webhooks/WebhookContracts.cs | 15 +- src/FiscalApi.Cli/Webhooks/WebhookView.cs | 12 +- .../Commands/ApiCommandHarness.cs | 13 +- .../Commands/EventsCommandTests.cs | 15 ++ .../Commands/WebhooksCommandTests.cs | 162 +++++++++++++++++- 18 files changed, 337 insertions(+), 69 deletions(-) create mode 100644 src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs create mode 100644 src/FiscalApi.Cli/Errors/ResourceStateException.cs diff --git a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs index 12857b8..cafc47f 100644 --- a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs +++ b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs @@ -30,21 +30,12 @@ public void WriteData(GlobalSettings settings, JsonElement data, JsonTypeInfo writeText(Console.Out, Read(data, type)); } - /// Como la otra sobrecarga, cuando el comando ya leyó el recurso porque necesita algo más de él. - public void WriteData(GlobalSettings settings, JsonElement data, T value, Action writeText) - { - if (settings.Json) - { - WriteRaw(data); - return; - } - - writeText(Console.Out, value); - } - /// El JSON del data sin re-serializarlo: los mismos caracteres que mandó la API. public void WriteRaw(JsonElement data) => Console.Out.WriteLine(data.GetRawText()); - public static T Read(JsonElement data, JsonTypeInfo type) => - data.Deserialize(type) ?? throw new InvalidOperationException("La API respondió con éxito pero sin data."); + /// + /// 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 index 9be17ba..f4c7b41 100644 --- a/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs +++ b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs @@ -4,11 +4,14 @@ 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. Una lista vacía es válida (p. ej. --person ""); lo que contiene lo valida la API, con sus textos. +/// 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 => - token.Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))]; + [.. 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/ListEventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs index 475789a..d3084c9 100644 --- a/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs @@ -13,10 +13,11 @@ namespace FiscalApi.Cli.Commands.Events; /// public sealed class ListEventsCommand : Command { - // ISO 8601: fecha, o fecha y hora con o sin segundos y fracción, con o sin zona (Z o ±hh:mm). - private static readonly string[] Iso8601Formats = + // 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", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mmK", "yyyy-MM-dd'T'HH:mm:ss", @@ -41,14 +42,15 @@ public sealed class ListEventsCommand : Command { 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 = ParseInstant + CustomParser = result => ParseInstant(result, dateIncludesWholeDay: false) }; private readonly Option _to = new("--to") { - Description = "Hasta este instante, incluido, en ISO 8601; sin zona, UTC. Por omisión, ahora.", + 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 = ParseInstant + CustomParser = result => ParseInstant(result, dateIncludesWholeDay: true) }; private readonly PageOptions _page = new(); @@ -81,16 +83,23 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc return ExitCodes.Success; } - // La API compara instantes UTC: una fecha sin zona se toma como UTC y una con zona se convierte. - private static DateTimeOffset? ParseInstant(ArgumentResult result) + /// + /// 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 (DateTimeOffset.TryParseExact( - value, - Iso8601Formats, - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var instant)) + if (TryParseUtc(value, [DateOnlyFormat], out var date)) + { + return dateIncludesWholeDay ? date.AddDays(1).AddTicks(-1) : date; + } + + if (TryParseUtc(value, DateTimeFormats, out var instant)) { return instant; } @@ -100,4 +109,12 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc $"{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/Webhooks/CreateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs index c1c8406..b02ec5b 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs @@ -28,12 +28,11 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc 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); - var webhook = ApiCommandContext.Read(data, FiscalApiJsonContext.Default.Webhook); - _context.WriteData(settings, data, webhook, WebhookView.WriteDetail); - WebhookView.WriteSigningSecret(_context.Console, webhook, settings.Json, rotated: false); + 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 index 3cdaff9..8ac765f 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs @@ -42,8 +42,12 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc throw new UsageException("Para borrar sin confirmación interactiva usa --yes."); } - if (!await _confirmation.ConfirmAsync( - $"¿Borrar el webhook {id}? Sus entregas pendientes se cerrarán.", cancellationToken)) + 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}."); diff --git a/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs index 5c288ba..ca2e437 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs @@ -25,9 +25,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { var settings = _context.Settings(parseResult); var data = await new WebhooksApi(_context.Client(settings)).RotateSecretAsync(parseResult.GetValue(_id)!, cancellationToken); - var webhook = ApiCommandContext.Read(data, FiscalApiJsonContext.Default.Webhook); - _context.WriteData(settings, data, webhook, WebhookView.WriteDetail); - WebhookView.WriteSigningSecret(_context.Console, webhook, settings.Json, rotated: true); + 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 index 7f44cbc..c45e141 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs @@ -53,9 +53,10 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc 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 UsageException(CliListenerNotEditable); + throw new ResourceStateException(CliListenerNotEditable); } var data = await api.ReplaceAsync(Changes(parseResult).ApplyTo(current), cancellationToken); @@ -67,7 +68,8 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc WebhookOptions.GivenValue(parseResult, _webhook.Url), WebhookOptions.GivenValue(parseResult, _webhook.Description), WebhookOptions.GivenValue(parseResult, _webhook.Events), - WebhookOptions.GivenValue(parseResult, _webhook.Persons), + // --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); @@ -75,10 +77,12 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc // 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, _enabled, _disabled]; + 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, --enabled o --disabled."); + result.AddError( + "Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled."); } } diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs index 1cbbb21..5532d9f 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.CommandLine.Parsing; namespace FiscalApi.Cli.Commands.Webhooks; @@ -38,10 +39,15 @@ public WebhookOptions(bool required) public Option> Persons { get; } = new("--person") { - Description = "Id de una persona cuyos eventos recibe; se repite o va separado por coma. Sin personas, recibe los de todas.", + Description = "Id de una persona cuyos eventos recibe; se repite o va separado por coma.", HelpName = "id", Arity = ArgumentArity.OneOrMore, - CustomParser = CommaSeparatedValues.Parse + 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) @@ -50,9 +56,34 @@ public void AddTo(Command command) 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/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/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs index a42ac85..61fb348 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -10,7 +10,6 @@ namespace FiscalApi.Cli.Http; /// Los comandos agregan aquí los tipos de sus requests y respuestas (los enums con JsonStringEnumConverter<TEnum>). /// [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] -[JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(OpenCliListenerRequest))] [JsonSerializable(typeof(ApiResponse))] diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs index d3e0d8a..a2aaf0c 100644 --- a/src/FiscalApi.Cli/Listen/ListenLoop.cs +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -54,8 +54,8 @@ public async Task RunAsync(CancellationToken cancellationToken) continue; } - // Con la sesión nula falló la reapertura, no el long-polling: su 409 es el candado de otra apertura de la misma - // persona en curso (WebhookEndpointWriteLock de fapi-back) y otro intento lo resuelve. El 409 del long-polling + // 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) { diff --git a/src/FiscalApi.Cli/Output/LabeledValues.cs b/src/FiscalApi.Cli/Output/LabeledValues.cs index a0eeced..2d1b2a8 100644 --- a/src/FiscalApi.Cli/Output/LabeledValues.cs +++ b/src/FiscalApi.Cli/Output/LabeledValues.cs @@ -2,7 +2,7 @@ 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 endpoint habilitado) solo estorbaría. +/// no aplica (p. ej. el motivo de un webhook habilitado) solo estorbaría. /// public static class LabeledValues { diff --git a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs index aa745c8..ff7ae34 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs @@ -20,9 +20,9 @@ public static class WebhookStatuses } /// -/// Un webhook como lo responde la API (WebhookDto, y WebhookWithSecretDto en el -/// alta y la rotación). Las fechas se guardan como texto: la API las escribe en la zona de la petición y el CLI las muestra -/// tal cual. Clase y no record: su ToString no debe escribir el secreto si algún día termina en un mensaje. +/// 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 { @@ -57,9 +57,6 @@ public sealed class Webhook public string? UpdatedAt { get; init; } - /// El secreto whsec_…: solo lo traen el alta y la rotación, y nunca durante una suplantación. - public string? Secret { get; init; } - [JsonIgnore] public bool IsCliListener => Kind == WebhookKinds.CliListener; } @@ -87,7 +84,9 @@ public sealed record UpdateWebhookRequest( /// 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 recibe los eventos de todas las personas, igual que en la API. +/// +/// 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, @@ -96,8 +95,6 @@ public sealed record WebhookChanges( IReadOnlyList? PersonIds, string? Status) { - public bool IsEmpty => Url is null && Description is null && EnabledEvents is null && PersonIds is null && Status is null; - /// /// 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. diff --git a/src/FiscalApi.Cli/Webhooks/WebhookView.cs b/src/FiscalApi.Cli/Webhooks/WebhookView.cs index 223c279..2d1ad8c 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookView.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookView.cs @@ -36,13 +36,15 @@ public static void WriteDetail(TextWriter output, Webhook webhook) => ]); /// - /// El secreto va a stdout, con el webhook; el aviso de guardarlo (texto aprobado de FE-001, D-21) 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 secreto va a stdout, con el webhook (con --json ya va dentro del data); el aviso de guardarlo + /// (texto aprobado de FE-001, D-21) 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, Webhook webhook, bool json, bool rotated) + public static void WriteSigningSecret(CliConsole console, string? secret, bool json, bool rotated) { - if (webhook.Secret is null) + if (secret is null) { return; } @@ -50,7 +52,7 @@ public static void WriteSigningSecret(CliConsole console, Webhook webhook, bool if (!json) { console.Out.WriteLine(); - console.Out.WriteLine($"Secreto de firma: {webhook.Secret}"); + 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."); diff --git a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs index c609721..204cc2a 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -32,7 +32,14 @@ internal sealed class ApiCommandHarness /// 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. - public Task RunAsync(IEnumerable args, string confirmationInput = "", bool interactive = false) + /// 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); @@ -43,7 +50,7 @@ public Task RunAsync(IEnumerable args, string confirmationInput = " _ => null }); var apiClients = new FiscalApiClientFactory(Api, credentials, console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); - var confirmation = new ConsoleConfirmationPrompt(console.Error, new StringReader(confirmationInput), interactive); + confirmation ??= new ConsoleConfirmationPrompt(console.Error, new StringReader(confirmationInput), interactive); Command[] commands = [ new WebhooksCommand(globalOptions, apiClients, confirmation, console), @@ -51,7 +58,7 @@ public Task RunAsync(IEnumerable args, string confirmationInput = " ]; return new CliApplication(globalOptions, commands, console) - .RunAsync([.. args, "--api-base", FiscalApiClientBuilder.BaseAddress.ToString()], CancellationToken.None); + .RunAsync([.. args, "--api-base", FiscalApiClientBuilder.BaseAddress.ToString()], cancellationToken); } public Task RunAsync(params string[] args) => RunAsync((IEnumerable)args); diff --git a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs index a7e1093..9cc41fc 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs @@ -61,6 +61,21 @@ await _cli.RunAsync( _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")] diff --git a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs index 10eb28c..df0445c 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs @@ -1,4 +1,5 @@ using System.Net; +using FiscalApi.Cli.Input; using FiscalApi.Cli.Tests.Http; namespace FiscalApi.Cli.Tests.Commands; @@ -326,19 +327,93 @@ await _cli.RunAsync( } [Fact] - public async Task Update_with_an_empty_person_list_receives_the_events_of_every_person() + 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)); - await _cli.RunAsync("webhooks", "update", WebhookId, "--person", "", "--enabled"); + 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() { @@ -379,7 +454,8 @@ public async Task Update_of_a_cli_listener_stops_before_replacing_it() var exitCode = await _cli.RunAsync("webhooks", "update", "escucha-1", "--events", "invoice.canceled"); - Assert.Equal(ExitCodes.Usage, exitCode); + // 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); } @@ -531,6 +607,71 @@ public async Task Rotate_secret_with_json_prints_the_data_unchanged() 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() { @@ -542,6 +683,21 @@ public async Task Unauthorized_shows_the_login_hint() 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"}"""; From eabf4b20eeb30728e77ec723c58a3076d4746c6b Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 17:10:55 -0600 Subject: [PATCH 10/19] =?UTF-8?q?CLI-006:=20release=20multiplataforma,=20a?= =?UTF-8?q?viso=20de=20versi=C3=B3n=20nueva=20y=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml: en cada tag v* (o workflow_dispatch) compila AOT win-x64, linux-x64 (AlmaLinux 8, glibc 2.28), osx-arm64 y osx-x64 en runners nativos, humo de --version, CredentialStoreTests en macOS con llavero de prueba, zip/tar.gz + SHA256SUMS y release en borrador. - Aviso de versión nueva (UpdateCheck): releases/latest como mucho cada 24 h, en paralelo al comando, tope de 2 s, nunca falla; apagado con --json, stderr redirigido, CI o FISCALAPI_NO_UPDATE_CHECK. - README: instalación con verificación de SHA-256, login, listen + trigger, webhooks/events, variables, códigos de salida y ConPTY. - Evidencia: rojo 85/86 antes del código; 387 pruebas en verde x3; build Release sin avisos. - AOT win-x64 y linux-x64 (noble-aot normal y con TrimmerRootAssembly; AlmaLinux 8) sin IL ni warning. - Linux: enlazado en AlmaLinux 8 pide GLIBC_2.17 (en Ubuntu 24.04, 2.34); corre en Ubuntu 18.04 y Debian 10. - Empaque y SHA256SUMS simulados en Docker: sha256sum -c OK y alteración detectada; actionlint y shellcheck sin hallazgos. --- .github/scripts/publish-linux.sh | 49 +++ .github/scripts/with-test-keychain.sh | 31 ++ .github/workflows/release.yml | 248 +++++++++++ README.md | 229 +++++++++- src/FiscalApi.Cli/CliApplication.cs | 21 +- src/FiscalApi.Cli/Program.cs | 12 +- src/FiscalApi.Cli/Updates/IUpdateCheck.cs | 11 + src/FiscalApi.Cli/Updates/SemanticVersion.cs | 127 ++++++ src/FiscalApi.Cli/Updates/UpdateCheck.cs | 120 ++++++ .../Updates/UpdateCheckContracts.cs | 18 + .../Updates/UpdateCheckStateFile.cs | 104 +++++ src/FiscalApi.Cli/Updates/UpdateNotice.cs | 19 + .../CliApplicationTests.cs | 4 +- .../Commands/ApiCommandHarness.cs | 3 +- .../Commands/LoginCommandTests.cs | 3 +- .../Commands/LogoutCommandTests.cs | 3 +- .../Credentials/CredentialResolutionTests.cs | 3 +- .../Listen/ListenCommandTests.cs | 3 +- .../Updates/CliApplicationUpdateCheckTests.cs | 135 ++++++ .../Updates/NoUpdateCheck.cs | 16 + .../Updates/SemanticVersionTests.cs | 85 ++++ .../Updates/UpdateCheckTests.cs | 398 ++++++++++++++++++ 22 files changed, 1632 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/publish-linux.sh create mode 100644 .github/scripts/with-test-keychain.sh create mode 100644 .github/workflows/release.yml create mode 100644 src/FiscalApi.Cli/Updates/IUpdateCheck.cs create mode 100644 src/FiscalApi.Cli/Updates/SemanticVersion.cs create mode 100644 src/FiscalApi.Cli/Updates/UpdateCheck.cs create mode 100644 src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs create mode 100644 src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs create mode 100644 src/FiscalApi.Cli/Updates/UpdateNotice.cs create mode 100644 tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs create mode 100644 tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs create mode 100644 tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs diff --git a/.github/scripts/publish-linux.sh b/.github/scripts/publish-linux.sh new file mode 100644 index 0000000..edccdbd --- /dev/null +++ b/.github/scripts/publish-linux.sh @@ -0,0 +1,49 @@ +#!/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). +dnf install -y --setopt=install_weak_deps=False clang zlib-devel binutils libicu findutils > /dev/null + +# El SDK exacto de global.json, con el script oficial (https://dot.net/v1/dotnet-install.sh). +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 +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/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/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d297a15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,248 @@ +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. + +# Dos corridas del mismo tag o rama no compiten por el mismo borrador; una corrida en curso nunca se cancela. +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 + 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::'$version' no es una versión semántica (p. ej. 1.2.3 o 1.2.3-rc.1)." + 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)." + + 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 + + # 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 + + # El almacén de macOS (CLI-002) no se ha ejecutado fuera de aquí: el release no sigue si la ida y vuelta real en el + # llavero falla. Se exige que todas las pruebas hayan corrido y pasado (ninguna omitida). + - name: Credential store tests (macOS Keychain) + if: runner.os == 'macOS' + run: | + bash .github/scripts/with-test-keychain.sh \ + dotnet test FiscalApi.Cli.slnx -c Release \ + --filter "FullyQualifiedName~CredentialStoreTests" \ + --logger "trx;LogFileName=credential-store.trx" --results-directory TestResults + 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 del empaque. + - 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" + + - name: Package (tar.gz) + if: runner.os != 'Windows' + run: | + mkdir -p dist + tar -czf "dist/$PACKAGE" -C "$PUBLISH_DIR" "$BINARY" + tar -tzvf "dist/$PACKAGE" + + - 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/README.md b/README.md index 33a3b06..09f0e56 100644 --- a/README.md +++ b/README.md @@ -1 +1,228 @@ -# 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. + +Está inspirado en la Stripe CLI: [`stripe listen`](https://docs.stripe.com/cli/listen), +[`stripe trigger`](https://docs.stripe.com/cli/trigger) y [`stripe events resend`](https://docs.stripe.com/cli/events/resend) +tienen aquí su equivalente. [`stripe logs tail`](https://docs.stripe.com/cli/logs/tail) no lo tiene, porque FiscalAPI no +guarda un log de peticiones por cuenta. + +Es un solo binario nativo (.NET Native AOT): no necesitas instalar .NET. + +## Instalación + +Cada [release](https://github.com/FiscalAPI/fiscalapi-cli/releases) trae un empaque por sistema y un archivo +`SHA256SUMS` con el SHA-256 de cada uno. **Verifica el SHA-256 antes de ejecutar el binario.** + +| Sistema | Empaque | +|---|---| +| Windows x64 | `fiscalapi-win-x64.zip` | +| Linux x64 (glibc 2.28 o más nueva: RHEL/AlmaLinux/Rocky 8+, Debian 10+, Ubuntu 20.04+) | `fiscalapi-linux-x64.tar.gz` | +| macOS Apple Silicon | `fiscalapi-osx-arm64.tar.gz` | +| macOS Intel | `fiscalapi-osx-x64.tar.gz` | + +Los comandos de abajo descargan la última versión. Para una versión fija, cambia `releases/latest/download` por +`releases/download/v1.2.3`. + +### 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 +``` + +### macOS + +En una Mac con Apple Silicon usa `fiscalapi-osx-arm64.tar.gz`; en una Intel, `fiscalapi-osx-x64.tar.gz`. + +```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, macOS lo bloquea la primera vez: quítale la marca de cuarentena con +`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 +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if (($userPath -split ';') -notcontains $dir) { + [Environment]::SetEnvironmentVariable('Path', "$userPath;$dir", 'User') +} +``` + +Abre una terminal nueva y corre `fiscalapi --version`. El binario no lleva firma Authenticode: si lo bajaste con el +navegador, SmartScreen puede advertirlo; `Unblock-File "$dir\fiscalapi.exe"` quita la marca de descarga. + +## Primeros pasos + +### `login` y `logout` + +```bash +fiscalapi login # ambiente de pruebas (https://test.fiscalapi.com) +fiscalapi login --live # producción (https://live.fiscalapi.com) +``` + +`login` pide tu API key y tu tenant key sin mostrarlas, las valida contra la API y las guarda en el almacén de +credenciales del sistema (Credential Manager en Windows, el llavero en macOS, Secret Service/libsecret en Linux), una +entrada por ambiente. Nunca se aceptan como argumento. `fiscalapi logout` las borra. + +Sin terminal (un script o un CI), pásalas por stdin, una por línea, o usa las variables de entorno: + +```bash +printf '%s\n%s\n' "$API_KEY" "$TENANT_KEY" | fiscalapi login +``` + +En Linux sin Secret Service (WSL, un servidor o un contenedor) no hay dónde guardarlas: define `FISCALAPI_API_KEY` y +`FISCALAPI_TENANT_KEY`. No hay respaldo en un archivo de texto. + +> **Terminales sin ConPTY.** En mintty (la terminal de Git Bash en Windows) y en `docker run -i` sin `-t`, la entrada +> no llega como una 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. + +### Recibir webhooks en tu equipo: `listen` + `trigger` + +```bash +fiscalapi listen --forward-to http://localhost:3000/webhooks +``` + +`listen` abre una escucha y muestra `Listo. Tu secreto de firma es whsec_… (escucha …)`. Usa ese secreto en tu +servidor para verificar la firma: cada reenvío lleva el cuerpo exacto y los headers `webhook-id`, `webhook-timestamp` y +`webhook-signature` que firmó FiscalAPI (Standard Webhooks). Por cada evento verás una línea como: + +``` +2026-10-12 09:47:46 --> invoice.canceled [evt_…] [200] +``` + +En otra terminal, dispara un evento de prueba hacia tu escucha: + +```bash +fiscalapi trigger invoice.canceled +``` + +Los tipos son `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, +`download_request.completed`, `download_request.abandoned` y `download_rule.suspended`. `trigger` solo funciona en el +ambiente de pruebas; en producción la API responde 403. + +Opciones de `listen`: + +| Opción | Uso | +|---|---| +| `--forward-to ` | URL de tu servidor local. Sin ella, `listen` solo muestra los eventos. | +| `--events ` | Tipos separados por coma; por omisión, todos. | +| `--headers "K:V,K:V"` | Headers extra en cada reenvío. | +| `--skip-verify` | No verifica el certificado TLS de `--forward-to` (p. ej. uno autofirmado). Nunca afecta a la API. | +| `--print-secret` | Imprime solo el secreto de firma y termina. | + +Ctrl+C cierra la escucha. + +### Webhooks y eventos + +```bash +fiscalapi webhooks list +fiscalapi webhooks create --url https://mi-servidor.com/webhooks --events invoice.canceled,invoice.cancellation_rejected +fiscalapi webhooks retrieve +fiscalapi webhooks update --disabled +fiscalapi webhooks rotate-secret +fiscalapi webhooks delete + +fiscalapi events list --type invoice.canceled --from 2026-10-01 +fiscalapi events retrieve +fiscalapi events resend # a tu escucha de fiscalapi listen +fiscalapi events resend --webhook # a un webhook +``` + +`create` y `rotate-secret` muestran el secreto de firma **una sola vez**: guárdalo. `fiscalapi --help` +describe cada opción. + +## Opciones globales + +| Opción | Uso | +|---|---| +| `--live` | Usa producción (`https://live.fiscalapi.com`) en lugar de pruebas. | +| `--api-base ` | URL base de la API (p. ej. un back local). Gana sobre `--live`. Con `http` solo se acepta localhost. | +| `--json` | Solo JSON en stdout, para scripts. Los mensajes y errores van a stderr. | +| `--verbose` | Detalle HTTP en stderr, sin los headers de credenciales. | +| `--version` | Versión del CLI. | + +## Variables de entorno + +| Variable | Efecto | +|---|---| +| `FISCALAPI_API_KEY` y `FISCALAPI_TENANT_KEY` | Credenciales. Se definen juntas y ganan sobre las que guardó `login`. | +| `FISCALAPI_NO_UPDATE_CHECK` | Con `1` (o `true`) apaga el aviso de versión nueva. | +| `CI` | Con `true` (o `1`), como en GitHub Actions y la mayoría de los CI, también apaga el aviso. | + +## Aviso de versión nueva + +Una vez cada 24 horas como mucho, `fiscalapi` pregunta a GitHub por el último release publicado (sin enviar tus +credenciales) mientras corre tu comando. Si hay uno más nuevo, al final escribe en stderr la versión y la liga de +descarga. La consulta tarda como mucho 2 segundos y, si falla, no muestra nada ni cambia el resultado del comando. + +No corre con `--json`, con stderr redirigido, con `CI=true` ni con `FISCALAPI_NO_UPDATE_CHECK=1`. 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/…`) | + +## Códigos de salida + +| Código | Caso | +|---|---| +| 0 | Éxito. | +| 1 | Error de la API o de la red (el mensaje incluye el `traceIdentifier` para soporte), o almacén de credenciales bloqueado. | +| 2 | Error de uso: opción o argumento inválido, o falta de credenciales o de almacén. | +| 130 | Cancelado con Ctrl+C. | + +## 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. +Prueba cada binario con `--version`, corre en macOS las pruebas del llavero 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/src/FiscalApi.Cli/CliApplication.cs b/src/FiscalApi.Cli/CliApplication.cs index 8e5750c..611d7fe 100644 --- a/src/FiscalApi.Cli/CliApplication.cs +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -2,6 +2,7 @@ using System.CommandLine.Help; using System.CommandLine.Invocation; using FiscalApi.Cli.Errors; +using FiscalApi.Cli.Updates; namespace FiscalApi.Cli; @@ -17,11 +18,13 @@ public sealed class CliApplication private readonly CliConsole _console; private readonly InvocationConfiguration _invocation; private readonly InvocationConfiguration _jsonInvocation; + private readonly IUpdateCheck _updateCheck; - public CliApplication(GlobalOptions globalOptions, IReadOnlyList commands, CliConsole console) + 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) @@ -52,9 +55,23 @@ public async Task RunAsync(IReadOnlyList args, CancellationToken ca 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 (exitCode != ExitCodes.Cancelled && await updateNotice is { } notice) + { + await notice.WriteToAsync(_console.Error); + } + + return exitCode; + } + + private async Task InvokeAsync(ParseResult parseResult, GlobalSettings settings, CancellationToken cancellationToken) + { try { - var invocation = _globalOptions.Read(parseResult).Json ? _jsonInvocation : _invocation; + var invocation = settings.Json ? _jsonInvocation : _invocation; return await parseResult.InvokeAsync(invocation, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs index 8c700ee..300cce5 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -9,6 +9,7 @@ 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 @@ -58,7 +59,16 @@ new EventsCommand(globalOptions, apiClients, console) ]; - var application = new CliApplication(globalOptions, commands, 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 diff --git a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs new file mode 100644 index 0000000..72c33e5 --- /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 (CLI-006). +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..02369fe --- /dev/null +++ b/src/FiscalApi.Cli/Updates/UpdateCheck.cs @@ -0,0 +1,120 @@ +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"; + + // releases/latest ya descarta borradores y prereleases (docs.github.com/rest/releases/releases#get-the-latest-release). + // 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 static readonly Uri LatestReleaseUri = new("https://api.github.com/repos/FiscalAPI/fiscalapi-cli/releases/latest"); + 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, LatestReleaseUri); + 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..228146d --- /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 => new($"https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v{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/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs index 33d6944..5d72f60 100644 --- a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -3,6 +3,7 @@ using FiscalApi.Cli.Errors; using FiscalApi.Cli.Http; using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; namespace FiscalApi.Cli.Tests; @@ -219,7 +220,8 @@ private Task RunAsync(string[] args, Command? command = null, CancellationT var application = new CliApplication( new GlobalOptions(), command is null ? [] : [command], - new CliConsole(_out, _error)); + 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 index 204cc2a..ccfd6fe 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -8,6 +8,7 @@ using FiscalApi.Cli.Input; using FiscalApi.Cli.Tests.Credentials; using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; namespace FiscalApi.Cli.Tests.Commands; @@ -57,7 +58,7 @@ public Task RunAsync( new EventsCommand(globalOptions, apiClients, console) ]; - return new CliApplication(globalOptions, commands, console) + return new CliApplication(globalOptions, commands, console, NoUpdateCheck.Instance) .RunAsync([.. args, "--api-base", FiscalApiClientBuilder.BaseAddress.ToString()], cancellationToken); } diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs index 05c9925..6aed8f6 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -6,6 +6,7 @@ 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; @@ -277,6 +278,6 @@ private Task RunAsync( args.Add("--json"); } - return new CliApplication(globalOptions, [login], console).RunAsync(args, cancellationToken); + 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 index fc1c39d..82253b0 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -1,6 +1,7 @@ using FiscalApi.Cli.Commands; using FiscalApi.Cli.Credentials; using FiscalApi.Cli.Tests.Credentials; +using FiscalApi.Cli.Tests.Updates; namespace FiscalApi.Cli.Tests.Commands; @@ -75,6 +76,6 @@ 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).RunAsync(args, CancellationToken.None); + return new CliApplication(globalOptions, [logout], console, NoUpdateCheck.Instance).RunAsync(args, CancellationToken.None); } } diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs index b35533c..07cd39e 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -3,6 +3,7 @@ using FiscalApi.Cli.Credentials; using FiscalApi.Cli.Http; using FiscalApi.Cli.Tests.Http; +using FiscalApi.Cli.Tests.Updates; namespace FiscalApi.Cli.Tests.Credentials; @@ -239,6 +240,6 @@ private Task RunProbeAsync(StubHttpMessageHandler? handler = null, ICredent }); string[] args = ["probe", "--api-base", FiscalApiClientBuilder.BaseAddress.ToString(), .. verbose ? new[] { "--verbose" } : []]; - return new CliApplication(globalOptions, [probe], console).RunAsync(args, CancellationToken.None); + return new CliApplication(globalOptions, [probe], console, NoUpdateCheck.Instance).RunAsync(args, CancellationToken.None); } } diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index 0d734c9..efa2348 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -8,6 +8,7 @@ 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; @@ -856,7 +857,7 @@ private void StartListen(Uri apiBase, params string[] options) 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); + 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/Updates/CliApplicationUpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs new file mode 100644 index 0000000..6a6cf03 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs @@ -0,0 +1,135 @@ +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_exits_130_without_the_notice() + { + using var cancellation = new CancellationTokenSource(); + var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); + 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, check, cancellation.Token); + + Assert.Equal(ExitCodes.Cancelled, exitCode); + Assert.DoesNotContain("0.2.0", _error.ToString()); + } + + [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..12ec5e7 --- /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 (CLI-006): 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}"""; +} From d47d869050525cca2673c07e1b89670e69d180f0 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 17:50:54 -0600 Subject: [PATCH 11/19] =?UTF-8?q?CLI-006:=20correcciones=20de=20la=20revis?= =?UTF-8?q?i=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M2/B4: workflow_dispatch falla si el tag ya existe (gh api, 404 = libre) o si la versión no coincide con el tag; ::error:: sin el valor sin validar. - B1: con Ctrl+C (130) se muestra el aviso solo si ya llegó, sin esperar la consulta. - B2: dotnet-install.sh se ejecuta solo con firma GPG válida de la llave 2B930AB1…FC7D3ACF (versionada, huella de Microsoft Learn). - B3/B5/menores: persist-credentials: false; PATH de Windows sin aplanar (REG_EXPAND_SZ); COPYFILE_DISABLE y una sola entrada por empaque; URL del repo en CliRepository; comentario de concurrency. - CredentialStoreTests también en Windows (Credential Manager) antes de empaquetar. - Evidencia: rojo 1/8 en CliApplicationUpdateCheckTests; 389 en verde x3; build sin avisos; AOT win-x64 y linux-x64 (AlmaLinux 8, GPG ok) sin IL. - GPG en Docker: firma, script, firma vacía, otra llave y llave del repo cambiada fallan; actionlint y shellcheck limpios. --- .github/scripts/dotnet-install.asc | 19 +++++ .github/scripts/publish-linux.sh | 9 ++- .github/scripts/verify-dotnet-install.sh | 38 ++++++++++ .github/workflows/build.yml | 3 + .github/workflows/release.yml | 70 +++++++++++++++---- README.md | 13 +++- src/FiscalApi.Cli/CliApplication.cs | 16 ++++- src/FiscalApi.Cli/Updates/CliRepository.cs | 18 +++++ src/FiscalApi.Cli/Updates/UpdateCheck.cs | 4 +- src/FiscalApi.Cli/Updates/UpdateNotice.cs | 2 +- .../Updates/CliApplicationUpdateCheckTests.cs | 45 ++++++++++-- 11 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/dotnet-install.asc create mode 100644 .github/scripts/verify-dotnet-install.sh create mode 100644 src/FiscalApi.Cli/Updates/CliRepository.cs 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 index edccdbd..6a86189 100644 --- a/.github/scripts/publish-linux.sh +++ b/.github/scripts/publish-linux.sh @@ -18,15 +18,18 @@ output="$2" 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). -dnf install -y --setopt=install_weak_deps=False clang zlib-devel binutils libicu findutils > /dev/null +# 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). +# 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. 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/workflows/build.yml b/.github/workflows/build.yml index 2a21ee4..881d3f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,9 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d297a15..b0e6e64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,8 @@ on: permissions: contents: read # Required for actions/checkout; solo el job release escribe. -# Dos corridas del mismo tag o rama no compiten por el mismo borrador; una corrida en curso nunca se cancela. +# 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 @@ -50,10 +51,16 @@ jobs: 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::'$version' no es una versión semántica (p. ej. 1.2.3 o 1.2.3-rc.1)." + 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%%+*}" @@ -65,6 +72,24 @@ jobs: } >> "$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 @@ -105,6 +130,9 @@ jobs: 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 @@ -125,15 +153,20 @@ jobs: Write-Host "vswhere.exe antes del cambio: $(if ($vswhere) { $vswhere.Source } else { 'no está en PATH' })" Add-Content -Path $env:GITHUB_PATH -Value $installer - # El almacén de macOS (CLI-002) no se ha ejecutado fuera de aquí: el release no sigue si la ida y vuelta real en el - # llavero falla. Se exige que todas las pruebas hayan corrido y pasado (ninguna omitida). - - name: Credential store tests (macOS Keychain) - if: runner.os == 'macOS' + # 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: | - bash .github/scripts/with-test-keychain.sh \ - dotnet test FiscalApi.Cli.slnx -c Release \ - --filter "FullyQualifiedName~CredentialStoreTests" \ - --logger "trx;LogFileName=credential-store.trx" --results-directory TestResults + 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") @@ -164,20 +197,33 @@ jobs: "./$PUBLISH_DIR/$BINARY" --help > /dev/null echo "$RID: fiscalapi $actual" - # Solo el binario: los símbolos (.pdb, .dbg, .dSYM) se quedan fuera del empaque. + # 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 - tar -czf "dist/$PACKAGE" -C "$PUBLISH_DIR" "$BINARY" + 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 diff --git a/README.md b/README.md index 09f0e56..53bed41 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,21 @@ if ($actual -ne $expected) { throw "El SHA-256 no coincide: $actual" } else { 'S $dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' Expand-Archive fiscalapi-win-x64.zip -DestinationPath $dir -Force -$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + +# Agrega la carpeta al PATH de tu usuario sin expandir las variables que ya tenga (%USERPROFILE%…). +$environment = Get-Item 'HKCU:\Environment' +$userPath = $environment.GetValue('Path', '', 'DoNotExpandEnvironmentNames') if (($userPath -split ';') -notcontains $dir) { - [Environment]::SetEnvironmentVariable('Path', "$userPath;$dir", 'User') + $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') } ``` +Si prefieres hacerlo a mano: "Editar las variables de entorno de esta cuenta" → `Path` → Nuevo → la carpeta +`%LOCALAPPDATA%\Programs\fiscalapi`. + Abre una terminal nueva y corre `fiscalapi --version`. El binario no lleva firma Authenticode: si lo bajaste con el navegador, SmartScreen puede advertirlo; `Unblock-File "$dir\fiscalapi.exe"` quita la marca de descarga. diff --git a/src/FiscalApi.Cli/CliApplication.cs b/src/FiscalApi.Cli/CliApplication.cs index 611d7fe..edc990f 100644 --- a/src/FiscalApi.Cli/CliApplication.cs +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -59,7 +59,7 @@ public async Task RunAsync(IReadOnlyList args, CancellationToken ca // 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 (exitCode != ExitCodes.Cancelled && await updateNotice is { } notice) + if (await NoticeToShowAsync(updateNotice, exitCode) is { } notice) { await notice.WriteToAsync(_console.Error); } @@ -67,6 +67,20 @@ public async Task RunAsync(IReadOnlyList args, CancellationToken ca 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 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/UpdateCheck.cs b/src/FiscalApi.Cli/Updates/UpdateCheck.cs index 02369fe..31e3ac4 100644 --- a/src/FiscalApi.Cli/Updates/UpdateCheck.cs +++ b/src/FiscalApi.Cli/Updates/UpdateCheck.cs @@ -24,9 +24,7 @@ public sealed class UpdateCheck( public const string OptOutVariable = "FISCALAPI_NO_UPDATE_CHECK"; private const string CiVariable = "CI"; - // releases/latest ya descarta borradores y prereleases (docs.github.com/rest/releases/releases#get-the-latest-release). // 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 static readonly Uri LatestReleaseUri = new("https://api.github.com/repos/FiscalAPI/fiscalapi-cli/releases/latest"); 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. @@ -94,7 +92,7 @@ private bool IsOn(string variable) => // 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, LatestReleaseUri); + 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. diff --git a/src/FiscalApi.Cli/Updates/UpdateNotice.cs b/src/FiscalApi.Cli/Updates/UpdateNotice.cs index 228146d..4924cb1 100644 --- a/src/FiscalApi.Cli/Updates/UpdateNotice.cs +++ b/src/FiscalApi.Cli/Updates/UpdateNotice.cs @@ -7,7 +7,7 @@ public sealed record UpdateNotice(SemanticVersion Current, SemanticVersion Lates /// 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 => new($"https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v{Latest}"); + public Uri DownloadUrl => CliRepository.ReleasePage(Latest); /// Va a stderr: stdout es de los datos del comando. public async Task WriteToAsync(TextWriter writer) diff --git a/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs index 6a6cf03..64008af 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs @@ -70,10 +70,47 @@ public async Task Command_runs_while_the_check_is_still_pending() } [Fact] - public async Task Ctrl_c_exits_130_without_the_notice() + 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) => { @@ -81,11 +118,7 @@ public async Task Ctrl_c_exits_130_without_the_notice() await Task.Delay(Timeout.Infinite, cancellationToken); return ExitCodes.Success; }); - - var exitCode = await RunAsync(["probe"], probe, check, cancellation.Token); - - Assert.Equal(ExitCodes.Cancelled, exitCode); - Assert.DoesNotContain("0.2.0", _error.ToString()); + return probe; } [Fact] From 4b368e0cbfcb84059ea26c959a2062d9da62d564 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:02:49 -0600 Subject: [PATCH 12/19] =?UTF-8?q?CLI:=20documentaci=C3=B3n=20de=20uso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README como portada: qué es, instalación con verificación de SHA-256, inicio rápido (login → listen → trigger) y enlaces a docs/. - docs/: instalacion, autenticacion, listen, webhooks-y-eventos, trigger (esqueleto de CLI-005, marcado), salida-y-codigos y solucion-de-problemas. - Ejemplos de salida capturados del binario nativo contra una API falsa con los datos de las pruebas; textos de error del código y de fapi-back. - Evidencia: cada comando y opción documentados existen en --help (salvo trigger y su --person-id); 32 enlaces relativos y anclas resuelven. - markdownlint-cli2 0.18.1 en Docker: 0 hallazgos (MD013 a 120 columnas, como el repo); bloques de PATH de PowerShell probados sobre una variable de prueba. --- .markdownlint-cli2.jsonc | 12 ++ README.md | 192 +++++-------------------- docs/autenticacion.md | 172 +++++++++++++++++++++++ docs/instalacion.md | 195 ++++++++++++++++++++++++++ docs/listen.md | 237 +++++++++++++++++++++++++++++++ docs/salida-y-codigos.md | 99 +++++++++++++ docs/solucion-de-problemas.md | 187 +++++++++++++++++++++++++ docs/trigger.md | 39 ++++++ docs/webhooks-y-eventos.md | 257 ++++++++++++++++++++++++++++++++++ 9 files changed, 1230 insertions(+), 160 deletions(-) create mode 100644 .markdownlint-cli2.jsonc create mode 100644 docs/autenticacion.md create mode 100644 docs/instalacion.md create mode 100644 docs/listen.md create mode 100644 docs/salida-y-codigos.md create mode 100644 docs/solucion-de-problemas.md create mode 100644 docs/trigger.md create mode 100644 docs/webhooks-y-eventos.md 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/README.md b/README.md index 53bed41..2ce89bf 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,21 @@ Está inspirado en la Stripe CLI: [`stripe listen`](https://docs.stripe.com/cli/ tienen aquí su equivalente. [`stripe logs tail`](https://docs.stripe.com/cli/logs/tail) no lo tiene, porque FiscalAPI no guarda un log de peticiones por cuenta. -Es un solo binario nativo (.NET Native AOT): no necesitas instalar .NET. +Es un solo binario nativo para Windows, Linux y macOS: no necesitas instalar .NET. ## Instalación -Cada [release](https://github.com/FiscalAPI/fiscalapi-cli/releases) trae un empaque por sistema y un archivo -`SHA256SUMS` con el SHA-256 de cada uno. **Verifica el SHA-256 antes de ejecutar el binario.** +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 o más nueva: RHEL/AlmaLinux/Rocky 8+, Debian 10+, Ubuntu 20.04+) | `fiscalapi-linux-x64.tar.gz` | +| 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` | -Los comandos de abajo descargan la última versión. Para una versión fija, cambia `releases/latest/download` por -`releases/download/v1.2.3`. - -### Linux +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 @@ -34,30 +31,9 @@ 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 ``` -### macOS - -En una Mac con Apple Silicon usa `fiscalapi-osx-arm64.tar.gz`; en una Intel, `fiscalapi-osx-x64.tar.gz`. - -```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, macOS lo bloquea la primera vez: quítale la marca de cuarentena con -`xattr -d com.apple.quarantine /usr/local/bin/fiscalapi`. - -### Windows (PowerShell) +Windows (PowerShell): ```powershell $base = 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download' @@ -66,149 +42,45 @@ 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 - -# Agrega la carpeta al PATH de tu usuario sin expandir las variables que ya tenga (%USERPROFILE%…). -$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') -} +Expand-Archive fiscalapi-win-x64.zip -DestinationPath (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi') -Force ``` -Si prefieres hacerlo a mano: "Editar las variables de entorno de esta cuenta" → `Path` → Nuevo → la carpeta -`%LOCALAPPDATA%\Programs\fiscalapi`. - -Abre una terminal nueva y corre `fiscalapi --version`. El binario no lleva firma Authenticode: si lo bajaste con el -navegador, SmartScreen puede advertirlo; `Unblock-File "$dir\fiscalapi.exe"` quita la marca de descarga. - -## Primeros pasos +Cómo agregarlo al `PATH`, Gatekeeper y SmartScreen, actualizar, el aviso de versión nueva y desinstalar: +[docs/instalacion.md](docs/instalacion.md). -### `login` y `logout` +## Inicio rápido ```bash -fiscalapi login # ambiente de pruebas (https://test.fiscalapi.com) -fiscalapi login --live # producción (https://live.fiscalapi.com) -``` - -`login` pide tu API key y tu tenant key sin mostrarlas, las valida contra la API y las guarda en el almacén de -credenciales del sistema (Credential Manager en Windows, el llavero en macOS, Secret Service/libsecret en Linux), una -entrada por ambiente. Nunca se aceptan como argumento. `fiscalapi logout` las borra. - -Sin terminal (un script o un CI), pásalas por stdin, una por línea, o usa las variables de entorno: - -```bash -printf '%s\n%s\n' "$API_KEY" "$TENANT_KEY" | fiscalapi login -``` +# 1. Guarda tu API key y tu tenant key en el almacén de credenciales del sistema (ambiente de pruebas) +fiscalapi login -En Linux sin Secret Service (WSL, un servidor o un contenedor) no hay dónde guardarlas: define `FISCALAPI_API_KEY` y -`FISCALAPI_TENANT_KEY`. No hay respaldo en un archivo de texto. - -> **Terminales sin ConPTY.** En mintty (la terminal de Git Bash en Windows) y en `docker run -i` sin `-t`, la entrada -> no llega como una 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. - -### Recibir webhooks en tu equipo: `listen` + `trigger` - -```bash +# 2. Recibe los eventos de tu cuenta y reenvíalos a tu servidor local fiscalapi listen --forward-to http://localhost:3000/webhooks -``` - -`listen` abre una escucha y muestra `Listo. Tu secreto de firma es whsec_… (escucha …)`. Usa ese secreto en tu -servidor para verificar la firma: cada reenvío lleva el cuerpo exacto y los headers `webhook-id`, `webhook-timestamp` y -`webhook-signature` que firmó FiscalAPI (Standard Webhooks). Por cada evento verás una línea como: +# Listo. Tu secreto de firma es whsec_… (escucha …) +# 2026-10-12 09:47:46 --> invoice.canceled [evt_0001] [200] -``` -2026-10-12 09:47:46 --> invoice.canceled [evt_…] [200] -``` - -En otra terminal, dispara un evento de prueba hacia tu escucha: - -```bash +# 3. En otra terminal, dispara un evento de prueba hacia tu escucha fiscalapi trigger invoice.canceled ``` -Los tipos son `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, -`download_request.completed`, `download_request.abandoned` y `download_rule.suspended`. `trigger` solo funciona en el -ambiente de pruebas; en producción la API responde 403. - -Opciones de `listen`: - -| Opción | Uso | -|---|---| -| `--forward-to ` | URL de tu servidor local. Sin ella, `listen` solo muestra los eventos. | -| `--events ` | Tipos separados por coma; por omisión, todos. | -| `--headers "K:V,K:V"` | Headers extra en cada reenvío. | -| `--skip-verify` | No verifica el certificado TLS de `--forward-to` (p. ej. uno autofirmado). Nunca afecta a la API. | -| `--print-secret` | Imprime solo el secreto de firma y termina. | - -Ctrl+C cierra la escucha. - -### Webhooks y eventos - -```bash -fiscalapi webhooks list -fiscalapi webhooks create --url https://mi-servidor.com/webhooks --events invoice.canceled,invoice.cancellation_rejected -fiscalapi webhooks retrieve -fiscalapi webhooks update --disabled -fiscalapi webhooks rotate-secret -fiscalapi webhooks delete - -fiscalapi events list --type invoice.canceled --from 2026-10-01 -fiscalapi events retrieve -fiscalapi events resend # a tu escucha de fiscalapi listen -fiscalapi events resend --webhook # a un webhook -``` - -`create` y `rotate-secret` muestran el secreto de firma **una sola vez**: guárdalo. `fiscalapi --help` -describe cada opción. - -## Opciones globales - -| Opción | Uso | -|---|---| -| `--live` | Usa producción (`https://live.fiscalapi.com`) en lugar de pruebas. | -| `--api-base ` | URL base de la API (p. ej. un back local). Gana sobre `--live`. Con `http` solo se acepta localhost. | -| `--json` | Solo JSON en stdout, para scripts. Los mensajes y errores van a stderr. | -| `--verbose` | Detalle HTTP en stderr, sin los headers de credenciales. | -| `--version` | Versión del CLI. | - -## Variables de entorno +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-en-tu-servidor-local)). `fiscalapi trigger` llega en una +versión próxima ([detalles](docs/trigger.md)). -| Variable | Efecto | -|---|---| -| `FISCALAPI_API_KEY` y `FISCALAPI_TENANT_KEY` | Credenciales. Se definen juntas y ganan sobre las que guardó `login`. | -| `FISCALAPI_NO_UPDATE_CHECK` | Con `1` (o `true`) apaga el aviso de versión nueva. | -| `CI` | Con `true` (o `1`), como en GitHub Actions y la mayoría de los CI, también apaga el aviso. | - -## Aviso de versión nueva - -Una vez cada 24 horas como mucho, `fiscalapi` pregunta a GitHub por el último release publicado (sin enviar tus -credenciales) mientras corre tu comando. Si hay uno más nuevo, al final escribe en stderr la versión y la liga de -descarga. La consulta tarda como mucho 2 segundos y, si falla, no muestra nada ni cambia el resultado del comando. +## Documentación -No corre con `--json`, con stderr redirigido, con `CI=true` ni con `FISCALAPI_NO_UPDATE_CHECK=1`. La fecha de la última -consulta, sin ningún secreto, se guarda en: - -| Sistema | Archivo | +| Tema | Contenido | |---|---| -| 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/…`) | - -## Códigos de salida +| [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. | +| [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. | -| Código | Caso | -|---|---| -| 0 | Éxito. | -| 1 | Error de la API o de la red (el mensaje incluye el `traceIdentifier` para soporte), o almacén de credenciales bloqueado. | -| 2 | Error de uso: opción o argumento inválido, o falta de credenciales o de almacén. | -| 130 | Cancelado con Ctrl+C. | +`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 @@ -229,8 +101,8 @@ Con Visual Studio 2026, si el publish de Windows falla con "'vswhere.exe' is not `.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. -Prueba cada binario con `--version`, corre en macOS las pruebas del llavero y deja un release **en borrador** con los -4 empaques y `SHA256SUMS`. Publicarlo es una decisión manual. +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 diff --git a/docs/autenticacion.md b/docs/autenticacion.md new file mode 100644 index 0000000..65ae1ad --- /dev/null +++ b/docs/autenticacion.md @@ -0,0 +1,172 @@ +# 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`. Nunca se aceptan como +argumento de un comando, porque un argumento queda 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`, y `http` solo hacia el propio equipo (`localhost`, +`127.0.0.1` o `::1`), porque las llaves viajan en headers: + +```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`, las de `live.fiscalapi.com` y las de `localhost:5001` son independientes. + +## `fiscalapi login` + +```text +$ fiscalapi login +API key: +Tenant key: +Listo. Credenciales guardadas para test.fiscalapi.com. +``` + +`login`: + +1. pide la API key y la tenant key sin mostrar lo que escribes; +2. las valida contra la API con `GET /api/v4/me/features`; +3. las guarda en el almacén de credenciales del sistema, en la entrada del ambiente. + +Para producción usa `fiscalapi login --live`. Los prompts y los mensajes van a stderr. + +Si las llaves no son válidas para ese ambiente, no se guarda nada y sale con 1: + +```text +Error: Las credenciales no son válidas para test.fiscalapi.com. +``` + +Si la API key es válida pero su persona no tiene el permiso `Webhooks.Manage`, `login` las guarda y avisa: + +```text +Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403. +``` + +Cada llave admite hasta 200 caracteres ASCII visibles; los espacios alrededor se ignoran. Una llave vacía o con otros +caracteres es un error de uso (salida 2): `La API key está vacía, es demasiado larga o tiene caracteres no válidos.` + +### 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 una 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 y siempre 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. +``` + +`logout` es local: no llama a la API ni revoca la API key. Para revocarla, hazlo desde 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 un 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. | + +**Precedencia:** si las dos están definidas, ganan sobre lo que guardó `login`, para cualquier ambiente. Se definen +**juntas**; con una sola, el CLI no adivina 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: + +```text +Error: No hay credenciales para test.fiscalapi.com. Ejecuta `fiscalapi login` o define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +``` + +Las variables valen para **cualquier** ambiente al que apunte el comando. Si usas llaves de pruebas en las variables, +no agregues `--live`: la API las rechazaría con 401. + +## Casos especiales + +### CI + +En un CI no hay almacén ni persona 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)). +El aviso de versión nueva no corre con `CI=true`. + +### 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). Define 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 +vuelve a intentarlo, o usa las variables de entorno. + +### Entrada guardada ilegible + +Si la entrada guardada se dañó: + +```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..1f14f91 --- /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 y un archivo +`SHA256SUMS` con el SHA-256 de cada empaque. Cada empaque contiene solo el binario. + +| 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.** El comando de 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: `install -D -m 0755 fiscalapi ~/.local/bin/fiscalapi` y +verifica que `~/.local/bin` esté en tu `PATH`. + +## macOS + +En una Mac con Apple Silicon usa `fiscalapi-osx-arm64.tar.gz`; en una Intel, `fiscalapi-osx-x64.tar.gz` +(`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 + +El bloque siguiente agrega la carpeta al `Path` de tu usuario. Lo lee y lo escribe en el registro como +`REG_EXPAND_SZ`, sin expandir las variables que ya tenga (`%USERPROFILE%`…), y no lo 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') +} +``` + +Si prefieres hacerlo 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 con: + +```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 guardadas con +`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 publicado, **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 segundos. Si GitHub no responde, falla o devuelve algo inesperado, no se muestra nada + y el comando termina igual, 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`). +- Con Ctrl+C solo se muestra si el aviso 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 `CI` definida (`true` o `1`, 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` (o `true`) | Para apagarlo siempre. | + +Cualquier valor de `CI` o de `FISCALAPI_NO_UPDATE_CHECK` lo apaga, salvo vacío, `0` o `false`. Para apagarlo 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 https://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..e6c128a --- /dev/null +++ b/docs/listen.md @@ -0,0 +1,237 @@ +# `fiscalapi listen` + +`listen` recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu servidor local, sin exponerlo a +internet. Es el equivalente de [`stripe listen`](https://docs.stripe.com/cli/listen). + +```bash +fiscalapi listen --forward-to http://localhost:3000/webhooks +``` + +Requiere una API key cuya persona tenga el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). + +## Cómo funciona + +1. `listen` abre (o renueva) **tu escucha de CLI**: un webhook sin URL, uno por persona, con su propio secreto de firma + (`POST /api/v4/webhooks/cli-listener`). Escribe en stderr: + + ```text + Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha wh_listener_0001) + ``` + + El secreto es estable entre reinicios de `listen`: configúralo una vez en tu servidor local. + +2. Mientras la sesión está abierta, 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`, espera de hasta 25 s). + +3. Cada entrega se reenvía a `--forward-to` como un `POST` con: + - el cuerpo **exacto** que firmó FiscalAPI, byte a byte, con `Content-Type: application/json; charset=utf-8`; + - los headers firmados `webhook-id`, `webhook-timestamp` y `webhook-signature` (Standard Webhooks); + - los headers extra de `--headers`. + +4. `listen` manda a la API un acuse con lo que respondió tu servidor (el código HTTP o el error), así la entrega queda en + el historial igual que la de un webhook con URL. + +5. Por cada entrega escribe una línea en stdout: + + ```text + 2026-10-12 09:47:46 --> invoice.canceled [evt_0001] [200] + ``` + + Es la hora local, el tipo, el id del evento (el header `webhook-id`) y el resultado del reenvío. + +Una entrega lenta no frena a las demás: se reenvían hasta 16 a la vez, y las de un mismo evento conservan su orden. +**No hay reintentos**: si tu servidor responde un error, la entrega queda con ese resultado; vuelve a pedirla con +[`fiscalapi events resend`](webhooks-y-eventos.md#reenviar-un-evento). + +Para disparar un evento de prueba hacia tu escucha, usa [`fiscalapi trigger`](trigger.md) en otra terminal. + +## Opciones + +| Opción | Uso | +|---|---| +| `--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. | + +Más las opciones globales `--live`, `--api-base`, `--json` y `--verbose` ([Salida y códigos](salida-y-codigos.md)). + +Ejemplos: + +```bash +# Solo ver los eventos de cancelación, sin reenviarlos +fiscalapi listen --events invoice.canceled,invoice.cancellation_rejected + +# Reenviar a un 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" + +# Guardar el secreto para la configuración del servidor local +export FISCALAPI_WEBHOOK_SECRET=$(fiscalapi listen --print-secret) +``` + +Reglas de las opciones (son errores de uso, salida 2): + +- `--headers` y `--skip-verify` solo aplican con `--forward-to`: `Error: --skip-verify solo aplica con --forward-to.` +- `--headers` no puede cambiar un header firmado: `Error: --headers no puede cambiar webhook-id: lo firma FiscalAPI.` +- `--print-secret` no se combina con las opciones del reenvío: `Error: --print-secret no se combina con --forward-to.` + +> **Atención con `--print-secret`:** abre una sesión, igual que `listen`. Si ya tienes un `fiscalapi listen` corriendo +> con la misma API key, esa sesión queda reemplazada y aquel `listen` termina con el 409 de abajo. Usa `--print-secret` +> antes de arrancar `listen`, no mientras corre. + +## El resultado de cada reenvío + +| Línea | Significado | +|---|---| +| `... [200]` | Tu servidor respondió ese código. Cualquier código de 100 a 599 se muestra y se acusa tal cual; las redirecciones no se siguen. | +| `... [error: Timeout]` | Tu servidor no respondió en 15 segundos. | +| `... [error: ConnectionFailed]` | No se pudo conectar (nada escucha en ese puerto, DNS, 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ó. | +| `...` sin resultado | Sin `--forward-to`: el evento solo se mostró (se acusa como entregado). | + +Los nombres de los errores son los mismos del historial de entregas de la API. + +## Verificar la firma en tu servidor local + +Cada reenvío lleva la firma de FiscalAPI con el secreto de tu escucha (`whsec_…`), según +[Standard Webhooks](https://www.standardwebhooks.com/). Tu servidor la verifica igual que la de un webhook con URL: +con el **cuerpo crudo** tal como llegó, antes de deserializarlo. Un cuerpo deserializado y vuelto a serializar cambia +sus bytes (espacios, orden, escapes) y la firma deja de coincidir. + +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)` | + +Las cinco comprueban la firma en tiempo constante, rechazan un `webhook-timestamp` con más de 5 minutos de diferencia y +devuelven el evento ya verificado. Por ejemplo, en ASP.NET Core (del README del SDK de .NET): + +```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 estos valores la firma verifica +si pasas como hora actual el mismo timestamp, y falla si cambias un carácter del cuerpo, de la firma o del secreto. + +| Dato | Valor | +|---|---| +| Secreto | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` | +| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` | +| `webhook-timestamp` | `1614265330` | +| Cuerpo | `{"test": 2432232314}` | +| `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` | + +La guía completa (forma del evento, verificación a mano en cada lenguaje, reintentos) está en la +[guía de webhooks de FiscalAPI](https://docs.fiscalapi.com/webhooks). + +## Conexión, sesiones y salida + +`listen` no termina por sí solo. Mientras corre: + +- **Red caída o error 5xx de la API:** reintenta con espera creciente (de 1 s hasta 30 s, con variación aleatoria) y lo + avisa en stderr: + + ```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. + ``` + +- **410, sesión caducada:** la API cierra la sesión si pasan 60 s sin long-polling (por ejemplo, tras un corte largo o + si la computadora se suspendió). `listen` abre una nueva sin terminar: + + ```text + La sesión de fiscalapi listen caducó; se abre una nueva. + ``` + + Los eventos que ocurrieron mientras no había sesión **no** llegan después. Búscalos con + `fiscalapi events list` y reenvíalos con `fiscalapi events resend `. + +- **409, sesión reemplazada:** otra sesión con tu misma persona (otro `listen` o un `--print-secret`) reemplazó a esta. + Solo puede haber una; esta termina con código 1: + + ```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): … + ``` + +- **401, 403 o 404:** no se resuelven reintentando; `listen` termina con código 1 y el error de la API. + +- **Ctrl+C:** cierra la escucha. 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. + +Avisos que puedes ver en stderr: + +| Aviso | Qué pasó | +|---|---| +| `La entrega () venció antes de reenviarse; la API la cerró sin acuse.` | La entrega esperó demasiado su turno y la API ya la había cerrado: no se reenvía. | +| `No se registró el acuse de la entrega : …` | La API no aceptó el acuse. Un 409 en el acuse (una entrega que ya no espera acuse) solo se muestra con `--verbose`. | +| `No se pudo reabrir la sesión de escucha (…); reintentando en s.` | Falló la reapertura tras un 410; se reintenta con espera creciente. | + +## Salida con `--json` + +Con `--json`, stdout lleva **un objeto JSON por línea** (JSON Lines), uno por entrega, para procesarlo con otra +herramienta. Los avisos y errores siguen en stderr como texto, incluida la línea `Listo. Tu secreto de firma es …`. + +```json +{"time":"2026-10-12T09:47:46.1234567-06:00","deliveryId":"dlv_0001","eventId":"evt_0001","type":"invoice.canceled","statusCode":200,"latencyMs":12,"event":{"id":"evt_0001","type":"invoice.canceled","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"}}} +``` + +| 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 solo si respondió. | +| `latencyMs` | Lo que tardó el reenvío. Solo con `--forward-to`. | +| `error` | `Timeout`, `ConnectionFailed`, `TlsFailed` o `InvalidResponse`. Solo con `--forward-to`, si no hubo respuesta. | +| `event` | El evento tal como llegó. Se omite si el cuerpo no es JSON. | + +Sin `--forward-to` cada línea trae solo `time`, `deliveryId`, `eventId`, `type` y `event`. Con +`--print-secret --json` la única línea es `{"secret":"whsec_…"}`. + +```bash +# Solo los tipos y códigos de cada reenvío +fiscalapi listen --forward-to http://localhost:3000/webhooks --json | jq -r '"\(.type) \(.statusCode // .error)"' +``` + +## Terminales sin ConPTY y Docker + +`listen` funciona en cualquier terminal. Solo `login` depende de una terminal real para leer las llaves sin mostrarlas: +en mintty (Git Bash) o en `docker run -i` sin `-t`, `login` lee la entrada como redirigida, **sin prompt y con eco** +([Autenticación](autenticacion.md#sin-terminal-stdin)). + +Dentro de un contenedor, `--forward-to http://localhost:…` apunta al 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`. + +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..1435863 --- /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` | 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` | 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); 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): 0HN7:00000042 + 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 errores de red, +408, 429 (respetando `Retry-After`) y 5xx. Un POST, PUT o DELETE nunca se reintenta, para no duplicar una operación, y +un 4xx tampoco. + +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..562e255 --- /dev/null +++ b/docs/solucion-de-problemas.md @@ -0,0 +1,187 @@ +# 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: The authenticated credentials do not have permission to perform this operation. + Código HTTP: 403 +``` + +**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 [evt_0001] [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-en-tu-servidor-local)). + +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. +``` + +**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 `. + +## 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..4b26dca --- /dev/null +++ b/docs/trigger.md @@ -0,0 +1,39 @@ +# `fiscalapi trigger` + +> **Disponible desde la versión que lo incluya.** Esta página describe la sintaxis prevista; el comando todavía no está +> en el CLI. Revisa `fiscalapi --help` en tu versión. + +`trigger` le pide a FiscalAPI que genere un evento de prueba y lo entregue a un solo destino: tu escucha de +[`fiscalapi listen`](listen.md) o el webhook que indiques. Es el equivalente de +[`stripe trigger`](https://docs.stripe.com/cli/trigger). + +```bash +# Terminal 1 +fiscalapi listen --forward-to http://localhost:3000/webhooks + +# Terminal 2 +fiscalapi trigger invoice.canceled +``` + +## Sintaxis prevista + +```text +fiscalapi trigger [--related-object-id ] [--person-id ] [--webhook ] +``` + +| Argumento u opción | Uso | +|---|---| +| `` | Tipo de evento: `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, `download_request.completed`, `download_request.abandoned` o `download_rule.suspended`. Quien valida es la API. | +| `--related-object-id ` | Id del recurso que irá en `data.relatedObject.id`. Sin él, la API usa un id sintético cuyo `GET` responde 404. | +| `--person-id ` | Persona del evento (`personId`). | +| `--webhook ` | Entrega el evento a ese webhook. Sin esta opción, va a tu escucha de CLI: `fiscalapi listen` tiene que estar abierto. | + +Llama a `POST /api/v4/events/trigger` e imprime el id del evento creado. El evento existe después en +`fiscalapi events list` y se puede reenviar con `fiscalapi events resend`. + +## Solo en pruebas + +La API solo genera eventos de prueba en el ambiente de pruebas (`https://test.fiscalapi.com`). Con `--live` el CLI no +lo bloquea por su cuenta: muestra el 403 que responde la API y sale con 1. + +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..48979d3 --- /dev/null +++ b/docs/webhooks-y-eventos.md @@ -0,0 +1,257 @@ +# Webhooks y eventos + +`fiscalapi webhooks` administra los webhooks de tu cuenta (`/api/v4/webhooks`) y `fiscalapi events` consulta y reenvía +los eventos de los últimos 30 días (`/api/v4/events`). Requieren una API key cuya persona tenga el permiso +`Webhooks.Manage`. + +Los ejemplos de salida de esta página salen del CLI real con los datos de sus pruebas automatizadas. + +## `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. | + +### 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 +escucha-1 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 dice qué opción pedir para la página siguiente. + +### Paginación + +`webhooks list` y `events list` aceptan: + +| Opción | Uso | +|---|---| +| `--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 persona-1, persona-2 +Creado 2026-10-13T09:30:00.000 +``` + +Solo se muestran los campos con valor. Según el caso aparecen también `Motivo` y `Deshabilitado` (un webhook +deshabilitado), `Fallando desde`, `Persona de la escucha` y `Sesión vence` (una escucha de CLI) y `Actualizado`. + +### Crear + +| Opción | Uso | +|---|---| +| `--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. Se repite (`--person a --person b`) o va separado por coma. | +| `--all-persons` | Los eventos de todas las personas. Es lo que pasa si no indicas `--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: cópialo a la configuración segura de tu +receptor en ese momento. 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 como comodín de archivos. + +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 el detalle de cada regla. + +### `--person` y `--all-persons` + +Un webhook recibe los eventos de todas las personas de tu cuenta o solo los de las personas que indiques: + +```bash +fiscalapi webhooks create --url https://example.com/webhooks --events invoice.canceled --person persona-1,persona-2 +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. +``` + +`--all-persons` y `--person` no se combinan. + +### Actualizar + +`update` lee el webhook, cambia solo lo que indiques y lo guarda. Acepta las opciones de `create` (todas opcionales) +y además: + +| Opción | Uso | +|---|---| +| `--enabled` | Habilita el webhook; 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 ya actualizado. Sin ninguna opción es un error de uso: + +```text +Error: Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled. +``` + +Tu escucha de CLI no se edita: `update` sobre ella sale con 1 y +`Error: Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger.` + +### Borrar + +En una terminal, `delete` pregunta antes de borrar. 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. +``` + +Si respondes otra cosa: `No se borró el webhook .` (sale con 0). En un script o con `--json` no hay a quién +preguntar: usa `--yes`. + +```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. `rotate-secret` también funciona sobre 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 | Uso | +|---|---| +| `--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` | [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 factura-1 persona-1 +evt-2 webhook.test 2026-10-12T15:40:00Z we-1 - +Página 1 de 1 (2 en total). +``` + +### 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 con su zona horaria. | + +Sin zona, la hora se toma como UTC. Otro formato es un error de uso: + +```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'. +``` + +```bash +# Las cancelaciones de una factura en octubre +fiscalapi events list --type invoice.canceled --related-object-id factura-1 --from 2026-10-01 --to 2026-10-31 +``` + +### Ver un evento + +`events retrieve` imprime el evento exactamente como lo entrega la API, con o sin `--json`: + +```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":"persona-1","data":{"relatedObject":{"id":"factura-1","type":"invoice","url":"/api/v4/invoices/factura-1"},"status":"X","previousStatus":"C"}} +``` + +El `id` del evento es el mismo del header `webhook-id` de sus entregas. + +### Reenviar un evento + +```text +$ fiscalapi events resend 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f --webhook we-1 +Reenvío creado: entrega entrega-1 al webhook we-1. +``` + +- Con `--webhook `, el evento se reenvía a ese webhook (cuerpo `{"webhookId":""}`). +- Sin `--webhook`, va a **tu escucha de CLI**, que tiene que tener un `fiscalapi listen` abierto. Es la forma de recibir + en local un evento que ocurrió mientras `listen` no corría. + +El reenvío crea una entrega nueva con el mismo evento: mismo `webhook-id` y 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, para procesarlo con otra +herramienta: + +```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`. Los mensajes +para la persona (el aviso del secreto, `Webhook … borrado.`) y los errores siguen en stderr como texto. Ver +[Salida y códigos](salida-y-codigos.md). + +Siguiente: [`fiscalapi trigger`](trigger.md). From aa2f578d685d01902849fe47cbeca98516768e98 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:20:23 -0600 Subject: [PATCH 13/19] CLI-005: fiscalapi trigger --- README.md | 6 +- docs/listen.md | 3 +- docs/salida-y-codigos.md | 5 +- docs/solucion-de-problemas.md | 30 +++ docs/trigger.md | 111 +++++++++-- src/FiscalApi.Cli/Commands/TriggerCommand.cs | 69 +++++++ .../Http/FiscalApiJsonContext.cs | 2 + src/FiscalApi.Cli/Program.cs | 1 + src/FiscalApi.Cli/Webhooks/EventsApi.cs | 12 ++ .../Webhooks/WebhookEventContracts.cs | 16 ++ .../Webhooks/WebhookEventTypes.cs | 31 +++ .../Webhooks/WebhookEventView.cs | 3 + .../Commands/ApiCommandHarness.cs | 6 +- .../Commands/TriggerCommandTests.cs | 183 ++++++++++++++++++ 14 files changed, 449 insertions(+), 29 deletions(-) create mode 100644 src/FiscalApi.Cli/Commands/TriggerCommand.cs create mode 100644 src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs create mode 100644 tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs diff --git a/README.md b/README.md index 2ce89bf..ce33731 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ 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-en-tu-servidor-local)). `fiscalapi trigger` llega en una -versión próxima ([detalles](docs/trigger.md)). +tu lenguaje ([cómo](docs/listen.md#verificar-la-firma-en-tu-servidor-local)). `fiscalapi trigger` solo funciona en el +ambiente de pruebas ([detalles](docs/trigger.md)). ## Documentación @@ -75,7 +75,7 @@ versión próxima ([detalles](docs/trigger.md)). | [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. | +| [`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. | diff --git a/docs/listen.md b/docs/listen.md index e6c128a..e2ddf6b 100644 --- a/docs/listen.md +++ b/docs/listen.md @@ -43,7 +43,8 @@ Una entrega lenta no frena a las demás: se reenvían hasta 16 a la vez, y las d **No hay reintentos**: si tu servidor responde un error, la entrega queda con ese resultado; vuelve a pedirla con [`fiscalapi events resend`](webhooks-y-eventos.md#reenviar-un-evento). -Para disparar un evento de prueba hacia tu escucha, usa [`fiscalapi trigger`](trigger.md) en otra terminal. +Para disparar un evento de prueba hacia tu escucha, usa [`fiscalapi trigger`](trigger.md) en otra terminal. Ese +evento llega aunque `--events` no incluya su tipo. ## Opciones diff --git a/docs/salida-y-codigos.md b/docs/salida-y-codigos.md index 1435863..83aa8e4 100644 --- a/docs/salida-y-codigos.md +++ b/docs/salida-y-codigos.md @@ -11,6 +11,7 @@ | `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` | La confirmación de `webhooks delete` y `Webhook … borrado.` | +| `Evento disparado: …` de `trigger` | | | `--version` y `--help` (sin `--json`) | El detalle HTTP de `--verbose` y el aviso de versión nueva | ```bash @@ -24,7 +25,7 @@ Con `--json`, **stdout trae solo JSON** y todo lo demás va a stderr: | Comando | stdout con `--json` | |---|---| -| `webhooks …`, `events list`, `events resend` | El `data` de la respuesta de la API, sin cambios. | +| `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_…"}` | @@ -73,7 +74,7 @@ reenvíos de `listen` a tu servidor. | 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); el almacén de credenciales está bloqueado; editar tu escucha de CLI. | +| 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. | diff --git a/docs/solucion-de-problemas.md b/docs/solucion-de-problemas.md index 562e255..497162b 100644 --- a/docs/solucion-de-problemas.md +++ b/docs/solucion-de-problemas.md @@ -157,6 +157,36 @@ Error: Sorry, it's not me, it's you. **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 +``` + +**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 `. + ## Instalación ### macOS bloquea el binario (Gatekeeper) diff --git a/docs/trigger.md b/docs/trigger.md index 4b26dca..eabd9ac 100644 --- a/docs/trigger.md +++ b/docs/trigger.md @@ -1,39 +1,108 @@ # `fiscalapi trigger` -> **Disponible desde la versión que lo incluya.** Esta página describe la sintaxis prevista; el comando todavía no está -> en el CLI. Revisa `fiscalapi --help` en tu versión. +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. -`trigger` le pide a FiscalAPI que genere un evento de prueba y lo entregue a un solo destino: tu escucha de -[`fiscalapi listen`](listen.md) o el webhook que indiques. Es el equivalente de -[`stripe trigger`](https://docs.stripe.com/cli/trigger). +## Uso -```bash +```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 +$ fiscalapi listen --forward-to http://localhost:3000/webhooks +Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha wh_listener_0001) +2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] # Terminal 2 -fiscalapi trigger invoice.canceled +$ fiscalapi trigger invoice.canceled +Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook wh_listener_0001. ``` -## Sintaxis prevista +Dispara un evento hacia un webhook con URL, sin `listen`: ```text -fiscalapi trigger [--related-object-id ] [--person-id ] [--webhook ] +$ fiscalapi trigger invoice.canceled --webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. ``` -| Argumento u opción | Uso | -|---|---| -| `` | Tipo de evento: `invoice.cancellation_requested`, `invoice.canceled`, `invoice.cancellation_rejected`, `download_request.completed`, `download_request.abandoned` o `download_rule.suspended`. Quien valida es la API. | -| `--related-object-id ` | Id del recurso que irá en `data.relatedObject.id`. Sin él, la API usa un id sintético cuyo `GET` responde 404. | -| `--person-id ` | Persona del evento (`personId`). | -| `--webhook ` | Entrega el evento a ese webhook. Sin esta opción, va a tu escucha de CLI: `fiscalapi listen` tiene que estar abierto. | +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":"wh_listener_0001"} +``` + +## 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 `](webhooks-y-eventos.md#reenviar-un-evento). +- Queda en `fiscalapi events list` como cualquier otro evento. +- Con `--live`, la API responde 403. El CLI no lo bloquea por su cuenta: muestra el error y sale con 1. -Llama a `POST /api/v4/events/trigger` e imprime el id del evento creado. El evento existe después en -`fiscalapi events list` y se puede reenviar con `fiscalapi events resend`. +## 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): 0HN7KQ2L0F3MA:00000002 +``` -## Solo en pruebas +| 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 endpoint de 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`. | +| 404 | `No such resource for the given Id: …` | Revisa `--webhook`: no existe, está dado de baja o no es tuyo. | +| 429 | — | Demasiadas peticiones con la misma API key: espera y repite. | -La API solo genera eventos de prueba en el ambiente de pruebas (`https://test.fiscalapi.com`). Con `--live` el CLI no -lo bloquea por su cuenta: muestra el 403 que responde la API y sale con 1. +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/src/FiscalApi.Cli/Commands/TriggerCommand.cs b/src/FiscalApi.Cli/Commands/TriggerCommand.cs new file mode 100644 index 0000000..40e3642 --- /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>] +/// (CLI-005): POST /api/v4/events/trigger. La API genera un evento sintético del tipo y lo entrega a un solo +/// destino (D-27): 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/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs index 61fb348..46e1836 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -27,4 +27,6 @@ namespace FiscalApi.Cli.Http; [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/Program.cs b/src/FiscalApi.Cli/Program.cs index 300cce5..c03b4f9 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -55,6 +55,7 @@ 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) ]; diff --git a/src/FiscalApi.Cli/Webhooks/EventsApi.cs b/src/FiscalApi.Cli/Webhooks/EventsApi.cs index 1a9ae11..d556049 100644 --- a/src/FiscalApi.Cli/Webhooks/EventsApi.cs +++ b/src/FiscalApi.Cli/Webhooks/EventsApi.cs @@ -55,6 +55,18 @@ public async Task ResendAsync(string id, string? webhookId, Cancell return ApiData.Required(data); } + /// + /// POST /events/trigger: 202 con el evento sintético y su único destino (D-27). 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) => diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs index b35062d..797abcb 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace FiscalApi.Cli.Webhooks; // Los contratos de los eventos de webhook de fapi-back (WH-013, D-28), con sus mismos nombres. @@ -44,3 +46,17 @@ 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, D-32 con el webhookId de +/// D-33). 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..9be69d5 --- /dev/null +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs @@ -0,0 +1,31 @@ +namespace FiscalApi.Cli.Webhooks; + +/// +/// Los tipos de evento que se suscriben (D-01), con los nombres de WebhookEventTypes de los SDK (SDK-001). 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 index 1fc69c7..636a5ad 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs @@ -14,4 +14,7 @@ public static void WriteList(TextWriter output, PagedList 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/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs index ccfd6fe..0f4ff3d 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -1,6 +1,7 @@ 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; @@ -13,7 +14,7 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// Corre fiscalapi webhooks … y fiscalapi events … con la misma composición que Program, sobre +/// 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 @@ -55,7 +56,8 @@ public Task RunAsync( Command[] commands = [ new WebhooksCommand(globalOptions, apiClients, confirmation, console), - new EventsCommand(globalOptions, apiClients, console) + new EventsCommand(globalOptions, apiClients, console), + new TriggerCommand(globalOptions, apiClients, console) ]; return new CliApplication(globalOptions, commands, console, NoUpdateCheck.Instance) diff --git a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs new file mode 100644 index 0000000..6929367 --- /dev/null +++ b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs @@ -0,0 +1,183 @@ +using System.CommandLine; +using System.Net; +using FiscalApi.Cli.Commands; +using FiscalApi.Cli.Tests.Http; + +namespace FiscalApi.Cli.Tests.Commands; + +/// +/// CLI-005: fiscalapi trigger contra POST /api/v4/events/trigger de fapi-back (WH-016, D-27, D-32, con el +/// webhookId de D-33), 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 (D-32). + 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. + [Fact] + public async Task Trigger_with_live_reaches_the_api_and_shows_its_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(); + } +} From 0cae3338a38115e0226d6b8f695519855a05dd80 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:20:23 -0600 Subject: [PATCH 14/19] CLI: quita las referencias a Stripe --- README.md | 5 ----- docs/listen.md | 2 +- src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs | 4 ++-- tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs | 2 +- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ce33731..b3cfbb3 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,6 @@ `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. -Está inspirado en la Stripe CLI: [`stripe listen`](https://docs.stripe.com/cli/listen), -[`stripe trigger`](https://docs.stripe.com/cli/trigger) y [`stripe events resend`](https://docs.stripe.com/cli/events/resend) -tienen aquí su equivalente. [`stripe logs tail`](https://docs.stripe.com/cli/logs/tail) no lo tiene, porque FiscalAPI no -guarda un log de peticiones por cuenta. - Es un solo binario nativo para Windows, Linux y macOS: no necesitas instalar .NET. ## Instalación diff --git a/docs/listen.md b/docs/listen.md index e2ddf6b..a9f9f02 100644 --- a/docs/listen.md +++ b/docs/listen.md @@ -1,7 +1,7 @@ # `fiscalapi listen` `listen` recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu servidor local, sin exponerlo a -internet. Es el equivalente de [`stripe listen`](https://docs.stripe.com/cli/listen). +internet. ```bash fiscalapi listen --forward-to http://localhost:3000/webhooks diff --git a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs index 99f91de..0423107 100644 --- a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -3,8 +3,8 @@ namespace FiscalApi.Cli.Listen; /// Sin --forward-to: la entrega solo se muestra en la terminal. public sealed class DisplayOnlyTarget : IDeliveryTarget { - // Asumido (CLI-004): la terminal es el destino y la entrega llegó, como en stripe listen. Se acusa como un 200 - // inmediato para que el log de entregas no la muestre fallida. + // Asumido (CLI-004): 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)); diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index efa2348..2957b4c 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -168,7 +168,7 @@ public async Task Without_forward_to_events_are_only_shown_and_acked() 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 (como en stripe listen). + // 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); From 8e78262a121ef9d29e8e4bb622f351a75cc84b4f Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:30:36 -0600 Subject: [PATCH 15/19] CLI: comentarios sin referencias internas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quita de comentarios y XML docs los IDs de planeación (CLI-, WH-, SDK-, FE-, D-), que apuntan a documentos privados. La ayuda de --events usa tipos reales, ApiCommandContext ya no enumera comandos y la prueba de trigger --live se renombra. --- src/FiscalApi.Cli/ApiEnvironment.cs | 4 ++-- src/FiscalApi.Cli/Commands/ApiCommandContext.cs | 2 +- src/FiscalApi.Cli/Commands/Events/EventsCommand.cs | 2 +- .../Commands/Events/ResendEventCommand.cs | 2 +- src/FiscalApi.Cli/Commands/ListenCommand.cs | 6 +++--- src/FiscalApi.Cli/Commands/LoginCommand.cs | 2 +- src/FiscalApi.Cli/Commands/LogoutCommand.cs | 2 +- src/FiscalApi.Cli/Commands/TriggerCommand.cs | 6 +++--- .../Commands/Webhooks/UpdateWebhookCommand.cs | 2 +- .../Commands/Webhooks/WebhooksCommand.cs | 2 +- src/FiscalApi.Cli/Credentials/CredentialResolver.cs | 2 +- .../CredentialStoreUnavailableException.cs | 2 +- src/FiscalApi.Cli/Credentials/CredentialStores.cs | 4 ++-- src/FiscalApi.Cli/Credentials/ICredentialStore.cs | 2 +- src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs | 2 +- src/FiscalApi.Cli/Listen/DeliveryProcessor.cs | 2 +- src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs | 2 +- src/FiscalApi.Cli/Listen/JsonListenOutput.cs | 4 ++-- src/FiscalApi.Cli/Listen/ListenLoop.cs | 2 +- src/FiscalApi.Cli/Listen/ListenOutput.cs | 2 +- src/FiscalApi.Cli/Listen/ListenTimings.cs | 2 +- src/FiscalApi.Cli/Listen/ListenerApi.cs | 4 ++-- src/FiscalApi.Cli/Listen/ListenerContracts.cs | 4 ++-- src/FiscalApi.Cli/Listen/WebhookEventSummary.cs | 2 +- src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs | 2 +- src/FiscalApi.Cli/Program.cs | 2 +- src/FiscalApi.Cli/Updates/IUpdateCheck.cs | 2 +- src/FiscalApi.Cli/Webhooks/EventsApi.cs | 6 +++--- src/FiscalApi.Cli/Webhooks/WebhookContracts.cs | 2 +- src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs | 8 ++++---- src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs | 2 +- src/FiscalApi.Cli/Webhooks/WebhookView.cs | 2 +- .../Commands/EventsCommandTests.cs | 6 +++--- .../FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs | 2 +- .../Commands/LogoutCommandTests.cs | 2 +- .../Commands/TriggerCommandTests.cs | 11 ++++++----- .../Commands/WebhooksCommandTests.cs | 4 ++-- .../Credentials/CredentialResolutionTests.cs | 2 +- tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs | 2 +- tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs | 6 +++--- .../FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs | 6 +++--- .../FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs | 2 +- .../Listen/StandardWebhooksSignature.cs | 2 +- tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs | 2 +- 44 files changed, 70 insertions(+), 69 deletions(-) diff --git a/src/FiscalApi.Cli/ApiEnvironment.cs b/src/FiscalApi.Cli/ApiEnvironment.cs index ee3f118..b907d3d 100644 --- a/src/FiscalApi.Cli/ApiEnvironment.cs +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -1,7 +1,7 @@ namespace FiscalApi.Cli; /// -/// Ambiente de la API contra el que trabaja una invocación (D-25). Se resuelve una sola vez por invocación +/// 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 @@ -16,7 +16,7 @@ public sealed record ApiEnvironment /// /// 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 (D-25). + /// lo que ven los mensajes. Así test, live y cada back local guardan credenciales separadas. /// public string Host => BaseAddress.Authority; diff --git a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs index cafc47f..586c561 100644 --- a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs +++ b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs @@ -6,7 +6,7 @@ namespace FiscalApi.Cli.Commands; /// -/// Lo que comparten los comandos que llaman a la API (webhooks y events): las opciones globales, la fábrica +/// 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) diff --git a/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs index 2a0d310..2504902 100644 --- a/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs @@ -4,7 +4,7 @@ namespace FiscalApi.Cli.Commands.Events; /// -/// fiscalapi events list|retrieve|resend (CLI-003): los eventos de webhook del tenant de los últimos 30 días sobre +/// 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 diff --git a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs index 35d5d8c..69cbb0d 100644 --- a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs @@ -6,7 +6,7 @@ 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 (D-28): fiscalapi listen tiene +/// 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 diff --git a/src/FiscalApi.Cli/Commands/ListenCommand.cs b/src/FiscalApi.Cli/Commands/ListenCommand.cs index 3a4118f..647c14a 100644 --- a/src/FiscalApi.Cli/Commands/ListenCommand.cs +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -7,7 +7,7 @@ namespace FiscalApi.Cli.Commands; /// -/// fiscalapi listen (CLI-004, D-26): abre la escucha de CLI, hace long-polling y reenvía cada entrega a +/// 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 @@ -22,7 +22,7 @@ public sealed class ListenCommand : Command // 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,invoice.created). Por omisión, todos.", + 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, @@ -87,7 +87,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc if (parseResult.GetValue(_printSecret)) { - // El secreto solo llega al abrir una sesión (D-29), y abrirla reemplaza la de otro listen en curso (D-26). + // 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; diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs index 422b005..5d4c5db 100644 --- a/src/FiscalApi.Cli/Commands/LoginCommand.cs +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -8,7 +8,7 @@ namespace FiscalApi.Cli.Commands; /// -/// fiscalapi login (CLI-002, D-25): pide la API key y la tenant key, las valida con +/// 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. /// diff --git a/src/FiscalApi.Cli/Commands/LogoutCommand.cs b/src/FiscalApi.Cli/Commands/LogoutCommand.cs index ab1f213..f2e7b9b 100644 --- a/src/FiscalApi.Cli/Commands/LogoutCommand.cs +++ b/src/FiscalApi.Cli/Commands/LogoutCommand.cs @@ -4,7 +4,7 @@ namespace FiscalApi.Cli.Commands; /// -/// fiscalapi logout (CLI-002): borra la entrada del host del ambiente en el almacén del SO. Es local (no +/// 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. /// diff --git a/src/FiscalApi.Cli/Commands/TriggerCommand.cs b/src/FiscalApi.Cli/Commands/TriggerCommand.cs index 40e3642..3b10378 100644 --- a/src/FiscalApi.Cli/Commands/TriggerCommand.cs +++ b/src/FiscalApi.Cli/Commands/TriggerCommand.cs @@ -5,9 +5,9 @@ namespace FiscalApi.Cli.Commands; /// -/// fiscalapi trigger <type> [--related-object-id <id>] [--person-id <id>] [--webhook <id>] -/// (CLI-005): POST /api/v4/events/trigger. La API genera un evento sintético del tipo y lo entrega a un solo -/// destino (D-27): sin --webhook, la escucha de CLI de la persona, así que fiscalapi listen tiene que estar +/// 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. /// diff --git a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs index c45e141..1475a0b 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs @@ -13,7 +13,7 @@ namespace FiscalApi.Cli.Commands.Webhooks; /// public sealed class UpdateWebhookCommand : Command { - // El texto de fapi-back (WebhookMessages.CliListenerNotEditable, WH-011, D-21). + // 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."; diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs index d0280ae..d5c0738 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs @@ -5,7 +5,7 @@ namespace FiscalApi.Cli.Commands.Webhooks; /// -/// fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret (CLI-003): los webhooks del +/// 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 diff --git a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs index 114bfd6..c645e6b 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs @@ -3,7 +3,7 @@ namespace FiscalApi.Cli.Credentials; /// -/// De dónde salen las credenciales de una invocación (D-25): las variables de entorno ganan sobre lo guardado en +/// 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) diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs index 47d38fd..8590169 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs @@ -4,7 +4,7 @@ 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 (D-25); la salida es usar las variables de entorno, por eso es un +/// 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) diff --git a/src/FiscalApi.Cli/Credentials/CredentialStores.cs b/src/FiscalApi.Cli/Credentials/CredentialStores.cs index 614940f..7701292 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStores.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStores.cs @@ -4,13 +4,13 @@ namespace FiscalApi.Cli.Credentials; -/// Elige el almacén de credenciales del SO en que corre el binario (D-25, CLI-002). +/// 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 (D-24). + /// 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) diff --git a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs index 4fe90a7..ee2d799 100644 --- a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs @@ -2,7 +2,7 @@ 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 (D-25). Nunca hay un archivo plano ni otro respaldo: +/// 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, . /// diff --git a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs index 46e1836..bc28581 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -18,7 +18,7 @@ namespace FiscalApi.Cli.Http; [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(ListenSecretRecord))] [JsonSerializable(typeof(ListenEventRecord))] -// CLI-003: el data se lee como JsonElement para imprimirlo sin cambios; los modelos solo alimentan la salida de texto. +// 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))] diff --git a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs index d9eff11..6a9ec11 100644 --- a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs +++ b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs @@ -6,7 +6,7 @@ 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 (D-26). +/// vencer su lease, sin reintentos. /// public sealed class DeliveryProcessor( IDeliveryTarget target, diff --git a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs index 0423107..cfc36d3 100644 --- a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -3,7 +3,7 @@ namespace FiscalApi.Cli.Listen; /// Sin --forward-to: la entrega solo se muestra en la terminal. public sealed class DisplayOnlyTarget : IDeliveryTarget { - // Asumido (CLI-004): la terminal es el destino y la entrega llegó. Se acusa como un 200 inmediato para que el log de + // 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)); diff --git a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs index ad8c65e..ff6834d 100644 --- a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs @@ -38,8 +38,8 @@ public sealed record ListenSecretRecord(string Secret); /// /// 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 de D-04. -/// El sobre del evento tal como llegó (D-04); se omite si el cuerpo no es JSON. +/// 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, diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs index a2aaf0c..fc1ac2c 100644 --- a/src/FiscalApi.Cli/Listen/ListenLoop.cs +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -41,7 +41,7 @@ public async Task RunAsync(CancellationToken cancellationToken) 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 (D-26) y se recupera con events resend. El primero reabre de inmediato; otro + // 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) { diff --git a/src/FiscalApi.Cli/Listen/ListenOutput.cs b/src/FiscalApi.Cli/Listen/ListenOutput.cs index 150ecd1..0fc542d 100644 --- a/src/FiscalApi.Cli/Listen/ListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/ListenOutput.cs @@ -27,7 +27,7 @@ public static ListenOutput For(GlobalSettings settings, CliConsole console, Time public abstract void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome); /// - /// El texto aprobado (D-21) con el secreto. Es el único lugar, junto con --print-secret, donde se escribe el + /// 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) => diff --git a/src/FiscalApi.Cli/Listen/ListenTimings.cs b/src/FiscalApi.Cli/Listen/ListenTimings.cs index cdeeb9e..e4ada41 100644 --- a/src/FiscalApi.Cli/Listen/ListenTimings.cs +++ b/src/FiscalApi.Cli/Listen/ListenTimings.cs @@ -3,7 +3,7 @@ namespace FiscalApi.Cli.Listen; /// -/// Los plazos de fiscalapi listen. son los del contrato (CLI-004, WH-015); las pruebas los +/// 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 . diff --git a/src/FiscalApi.Cli/Listen/ListenerApi.cs b/src/FiscalApi.Cli/Listen/ListenerApi.cs index 6c77ec7..091dc79 100644 --- a/src/FiscalApi.Cli/Listen/ListenerApi.cs +++ b/src/FiscalApi.Cli/Listen/ListenerApi.cs @@ -2,12 +2,12 @@ namespace FiscalApi.Cli.Listen; -/// Las rutas de la escucha de CLI (D-28) sobre . +/// 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 (D-29). + /// 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", diff --git a/src/FiscalApi.Cli/Listen/ListenerContracts.cs b/src/FiscalApi.Cli/Listen/ListenerContracts.cs index 0596c74..0f9299d 100644 --- a/src/FiscalApi.Cli/Listen/ListenerContracts.cs +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -2,7 +2,7 @@ namespace FiscalApi.Cli.Listen; -// Los contratos de la escucha de CLI de fapi-back (WebhookCliListenerController, D-28), con sus mismos nombres. +// 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); @@ -29,7 +29,7 @@ public sealed class CliListenerSession /// 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 (D-02); nulo si no vino. + /// El id del evento que la API manda en webhook-id; nulo si no vino. public string? EventId => Headers.GetValueOrDefault(WebhookHeaderNames.Id); /// diff --git a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs index d7a1956..a891d35 100644 --- a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs +++ b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs @@ -3,7 +3,7 @@ namespace FiscalApi.Cli.Listen; /// -/// Lo que la terminal muestra de una entrega: el id del evento (de webhook-id), su tipo y el sobre (D-04). Se lee +/// 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. /// diff --git a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs index 1a9e157..4f01291 100644 --- a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs +++ b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs @@ -1,6 +1,6 @@ namespace FiscalApi.Cli.Listen; -/// Los headers de Standard Webhooks (D-02) que la API firma y el CLI reenvía sin cambios. +/// Los headers de Standard Webhooks que la API firma y el CLI reenvía sin cambios. public static class WebhookHeaderNames { /// El id del evento. diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs index c03b4f9..1717f50 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -33,7 +33,7 @@ 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 (D-25). + // 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); diff --git a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs index 72c33e5..4dd9f25 100644 --- a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs +++ b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs @@ -1,6 +1,6 @@ namespace FiscalApi.Cli.Updates; -/// Aviso de versión nueva que corre en paralelo al comando (CLI-006). +/// Aviso de versión nueva que corre en paralelo al comando. public interface IUpdateCheck { /// diff --git a/src/FiscalApi.Cli/Webhooks/EventsApi.cs b/src/FiscalApi.Cli/Webhooks/EventsApi.cs index d556049..a13a6aa 100644 --- a/src/FiscalApi.Cli/Webhooks/EventsApi.cs +++ b/src/FiscalApi.Cli/Webhooks/EventsApi.cs @@ -5,7 +5,7 @@ namespace FiscalApi.Cli.Webhooks; /// -/// Las rutas de los eventos de webhook (/api/v4/events, D-28) sobre . Cada una responde +/// 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) @@ -38,7 +38,7 @@ public async Task RetrieveAsync(string id, CancellationToken cancel /// /// 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 (D-28), que tiene que tener un listen abierto. + /// 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) { @@ -56,7 +56,7 @@ public async Task ResendAsync(string id, string? webhookId, Cancell } /// - /// POST /events/trigger: 202 con el evento sintético y su único destino (D-27). La API decide si el ambiente lo + /// 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) => diff --git a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs index ff7ae34..b88ceba 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs @@ -2,7 +2,7 @@ namespace FiscalApi.Cli.Webhooks; -// Los contratos de los webhooks de fapi-back (WH-011), con sus mismos nombres. Los enums de la API (kind, +// 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. diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs index 797abcb..c582891 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs @@ -2,10 +2,10 @@ namespace FiscalApi.Cli.Webhooks; -// Los contratos de los eventos de webhook de fapi-back (WH-013, D-28), con sus mismos nombres. +// Los contratos de los eventos de webhook de la API, con sus mismos nombres. /// -/// Lo que la tabla de events list muestra del sobre de D-04 ({id, type, createdAt, personId, +/// 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. /// @@ -48,8 +48,8 @@ public sealed record ResendWebhookEventRequest(string WebhookId); public sealed record WebhookEventResend(string DeliveryId, string WebhookId); /// -/// El cuerpo de POST /api/v4/events/trigger (TriggerWebhookEventCommand, D-32 con el webhookId de -/// D-33). 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 +/// 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( diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs index 9be69d5..ca0e1d5 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs @@ -1,7 +1,7 @@ namespace FiscalApi.Cli.Webhooks; /// -/// Los tipos de evento que se suscriben (D-01), con los nombres de WebhookEventTypes de los SDK (SDK-001). Son la +/// 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. /// diff --git a/src/FiscalApi.Cli/Webhooks/WebhookView.cs b/src/FiscalApi.Cli/Webhooks/WebhookView.cs index 2d1ad8c..0a71643 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookView.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookView.cs @@ -37,7 +37,7 @@ public static void WriteDetail(TextWriter output, Webhook webhook) => /// /// El secreto va a stdout, con el webhook (con --json ya va dentro del data); el aviso de guardarlo - /// (texto aprobado de FE-001, D-21) es para la persona y va a stderr, también con --json. Durante una + /// (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. diff --git a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs index 9cc41fc..5d2963a 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs @@ -5,8 +5,8 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-003: fiscalapi events list|retrieve|resend contra las rutas de EventsController de fapi-back (WH-013, -/// D-28), simuladas con . retrieve imprime el sobre exactamente como lo entregó la +/// 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 @@ -14,7 +14,7 @@ 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 de D-04 como lo guarda y entrega fapi-back (WebhookEnvelopeSerializer): sin espacios y con las nulas. + // 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"}}"""; diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs index 6aed8f6..2cd823d 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -11,7 +11,7 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-002: fiscalapi login lee la API key y la tenant key (por stdin redirigido o tecla por tecla), las valida con +/// 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 diff --git a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs index 82253b0..3bde647 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -6,7 +6,7 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-002: fiscalapi logout es local (no recibe cliente HTTP). Termina con éxito si borró o si no había +/// 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 diff --git a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs index 6929367..9895515 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs @@ -6,8 +6,8 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-005: fiscalapi trigger contra POST /api/v4/events/trigger de fapi-back (WH-016, D-27, D-32, con el -/// webhookId de D-33), simulada con . Quien valida el tipo y el ambiente es la +/// 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 @@ -29,7 +29,7 @@ public async Task Trigger_posts_only_the_type_so_the_api_sends_it_to_the_cli_lis 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 (D-32). + // 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()); } @@ -73,9 +73,10 @@ public async Task Trigger_with_json_prints_the_data_unchanged() _cli.AssertJsonOut(accepted); } - // El CLI no bloquea --live: la regla vive en la API, que responde 403 fuera de Development y Test. + // 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_reaches_the_api_and_shows_its_403() + 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"}"""); diff --git a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs index df0445c..4bfa47a 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs @@ -5,8 +5,8 @@ namespace FiscalApi.Cli.Tests.Commands; /// -/// CLI-003: fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret contra las rutas de -/// WebhooksController de fapi-back (WH-011), simuladas con . Cada +/// 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. /// diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs index 07cd39e..f1bbfa0 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -8,7 +8,7 @@ namespace FiscalApi.Cli.Tests.Credentials; /// -/// D-25: FISCALAPI_API_KEY/FISCALAPI_TENANT_KEY ganan sobre lo guardado, y las credenciales nunca +/// 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 diff --git a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs index b3e6c90..885de92 100644 --- a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -3,7 +3,7 @@ namespace FiscalApi.Cli.Tests; /// -/// D-25: --api-base gana sobre --live, y este sobre test. +/// --api-base gana sobre --live, y este sobre test. /// public sealed class GlobalOptionsTests { diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs index 1e96e55..32dff86 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -14,7 +14,7 @@ namespace FiscalApi.Cli.Tests.Listen; /// -/// La escucha de CLI de fapi-back (WH-014/WH-015) en Kestrel, en proceso: abre sesiones, responde el long-polling con lo +/// 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. /// @@ -278,11 +278,11 @@ internal sealed record FakeDelivery( { public const string Timestamp = "1791900000"; - /// La firma real de Standard Webhooks (D-02) con el secreto de la escucha: el receptor la puede verificar. + /// 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 de D-04 con un formato que un re-serializador cambiaría (sangría y espacios). + /// 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"}} diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index 2957b4c..00c2517 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -14,7 +14,7 @@ namespace FiscalApi.Cli.Tests.Listen; /// -/// CLI-004: fiscalapi listen contra la escucha de CLI de fapi-back simulada en Kestrel () +/// 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. /// @@ -90,7 +90,7 @@ public async Task Receiver_gets_the_body_and_signed_headers_byte_for_byte() [Fact] public async Task During_a_secret_rotation_both_signatures_reach_the_receiver_unchanged() { - // D-02: varias firmas separadas por un espacio mientras rota el secreto. + // 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(); @@ -812,7 +812,7 @@ public async Task Invalid_options_are_usage_errors_without_calling_the_api(strin Assert.Empty(Out); } - // La línea de cada evento en la hora local (CLI-004): fecha, hora, flecha, tipo, [eventId] y el resultado. + // 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?$"; diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs index 27785e1..3e4a61b 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs @@ -2,7 +2,7 @@ namespace FiscalApi.Cli.Tests.Listen; -/// Los plazos de listen son los del contrato (CLI-004, WH-015). +/// Los plazos de listen son los del contrato con la API. public sealed class ListenTimingsTests { [Fact] diff --git a/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs b/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs index 4d330e7..7012ce0 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs @@ -4,7 +4,7 @@ namespace FiscalApi.Cli.Tests.Listen; /// -/// La firma de Standard Webhooks (D-02): v1, + HMAC-SHA256 en base64 sobre {id}.{timestamp}.{body}, con la +/// 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. /// diff --git a/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs index 12ec5e7..c375f64 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs @@ -8,7 +8,7 @@ namespace FiscalApi.Cli.Tests.Updates; /// -/// Aviso de versión nueva (CLI-006): consulta releases/latest como mucho una vez cada 24 h, con 2 s de tope, y nunca +/// 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 From 28631de2d4f9b8381d66a97ae82375f7ad5bd3a6 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:30:43 -0600 Subject: [PATCH 16/19] =?UTF-8?q?CLI:=20documentaci=C3=B3n=20simple=20y=20?= =?UTF-8?q?directa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listen, webhooks y eventos, autenticación e instalación reescritos con formato de referencia (uso, opciones, ejemplos, errores). Corrige hechos: 403 sin Webhooks.Manage, logout con almacén bloqueado, reintentos de GET, --json de delete, reconexión de listen. trigger: 403 por permiso, texto del 429, resend con --webhook; ids de ejemplo como GUID coherentes en todos los docs. --- README.md | 4 +- docs/autenticacion.md | 81 ++++++------ docs/instalacion.md | 52 ++++---- docs/listen.md | 232 ++++++++++++++++------------------ docs/salida-y-codigos.md | 9 +- docs/solucion-de-problemas.md | 9 +- docs/trigger.md | 13 +- docs/webhooks-y-eventos.md | 145 +++++++++++---------- 8 files changed, 260 insertions(+), 285 deletions(-) diff --git a/README.md b/README.md index b3cfbb3..5eb1854 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,14 @@ 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 [evt_0001] [200] +# 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-en-tu-servidor-local)). `fiscalapi trigger` solo funciona en el +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 diff --git a/docs/autenticacion.md b/docs/autenticacion.md index 65ae1ad..30da0ac 100644 --- a/docs/autenticacion.md +++ b/docs/autenticacion.md @@ -1,8 +1,8 @@ # 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`. Nunca se aceptan como -argumento de un comando, porque un argumento queda en el historial del shell y en la lista de procesos. +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 @@ -12,8 +12,8 @@ argumento de un comando, porque un argumento queda en el historial del shell y e | `--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`, y `http` solo hacia el propio equipo (`localhost`, -`127.0.0.1` o `::1`), porque las llaves viajan en headers: +`--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 @@ -21,8 +21,8 @@ Error: --api-base con http solo se permite para localhost, 127.0.0.1 o ::1; usa 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`, las de `live.fiscalapi.com` y las de `localhost:5001` son independientes. +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` @@ -33,28 +33,18 @@ Tenant key: Listo. Credenciales guardadas para test.fiscalapi.com. ``` -`login`: +`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. -1. pide la API key y la tenant key sin mostrar lo que escribes; -2. las valida contra la API con `GET /api/v4/me/features`; -3. las guarda en el almacén de credenciales del sistema, en la entrada del ambiente. - -Para producción usa `fiscalapi login --live`. Los prompts y los mensajes van a stderr. - -Si las llaves no son válidas para ese ambiente, no se guarda nada y sale con 1: - -```text -Error: Las credenciales no son válidas para test.fiscalapi.com. -``` - -Si la API key es válida pero su persona no tiene el permiso `Webhooks.Manage`, `login` las guarda y avisa: - -```text -Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403. -``` +| 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.` | -Cada llave admite hasta 200 caracteres ASCII visibles; los espacios alrededor se ignoran. Una llave vacía o con otros -caracteres es un 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 @@ -69,12 +59,12 @@ printf '%s\n%s\n' "$API_KEY" "$TENANT_KEY" | fiscalapi login 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 una terminal: `login` la trata como redirigida, sin prompt y **mostrando lo que escribes**. Usa Windows +> 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 y siempre termina con 0, aunque no hubiera nada guardado: +Borra la entrada del ambiente. Termina con 0 aunque no hubiera nada guardado: ```text $ fiscalapi logout @@ -83,7 +73,9 @@ $ fiscalapi logout --live No había credenciales guardadas para live.fiscalapi.com. ``` -`logout` es local: no llama a la API ni revoca la API key. Para revocarla, hazlo desde el dashboard de FiscalAPI. +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 @@ -93,7 +85,7 @@ No había credenciales guardadas para live.fiscalapi.com. | 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 un archivo: si el sistema no tiene almacén, `login` no guarda nada en texto plano. +No hay respaldo en archivo: si el sistema no tiene almacén, `login` no guarda nada en texto plano. ## Variables de entorno @@ -102,27 +94,26 @@ No hay respaldo en un archivo: si el sistema no tiene almacén, `login` no guard | `FISCALAPI_API_KEY` | Tu API key. | | `FISCALAPI_TENANT_KEY` | Tu tenant key. | -**Precedencia:** si las dos están definidas, ganan sobre lo que guardó `login`, para cualquier ambiente. Se definen -**juntas**; con una sola, el CLI no adivina y sale con 2: +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: +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. ``` -Las variables valen para **cualquier** ambiente al que apunte el comando. Si usas llaves de pruebas en las variables, -no agregues `--live`: la API las rechazaría con 401. - ## Casos especiales ### CI -En un CI no hay almacén ni persona que escriba: define las dos variables como secretos del CI y no uses `login`. +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 @@ -131,12 +122,12 @@ env: 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)). -El aviso de versión nueva no corre con `CI=true`. +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 +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 @@ -144,9 +135,9 @@ Error: No hay un almacén de credenciales disponible en este sistema. Define FIS 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). Define 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. +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 @@ -159,11 +150,11 @@ Error: El almacén de credenciales del sistema existe, pero está bloqueado o no ``` Desbloquéalo (en macOS, abre "Acceso a Llaveros"; en Linux, inicia sesión en el escritorio o desbloquea el llavero) y -vuelve a intentarlo, o usa las variables de entorno. +repite el comando, o usa las variables de entorno. ### Entrada guardada ilegible -Si la entrada guardada se dañó: +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`. diff --git a/docs/instalacion.md b/docs/instalacion.md index 1f14f91..582b9cc 100644 --- a/docs/instalacion.md +++ b/docs/instalacion.md @@ -4,8 +4,8 @@ ## Sistemas y empaques -Cada [release](https://github.com/FiscalAPI/fiscalapi-cli/releases) trae un empaque por sistema y un archivo -`SHA256SUMS` con el SHA-256 de cada empaque. Cada empaque contiene solo el binario. +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 | |---|---|---| @@ -20,8 +20,8 @@ Los requisitos de Windows y macOS son los de .NET 10 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.** El comando de verificación debe decir `OK`; si dice -`FAILED`, borra lo que descargaste y vuelve a bajarlo. +**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 @@ -35,13 +35,13 @@ sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi fiscalapi --version ``` -Sin permisos de administrador, instálalo en tu usuario: `install -D -m 0755 fiscalapi ~/.local/bin/fiscalapi` y +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 -En una Mac con Apple Silicon usa `fiscalapi-osx-arm64.tar.gz`; en una Intel, `fiscalapi-osx-x64.tar.gz` -(`uname -m` dice `arm64` o `x86_64`). +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 @@ -55,8 +55,8 @@ 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: +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 @@ -78,8 +78,8 @@ Expand-Archive fiscalapi-win-x64.zip -DestinationPath $dir -Force ### Agregar `fiscalapi` al PATH -El bloque siguiente agrega la carpeta al `Path` de tu usuario. Lo lee y lo escribe en el registro como -`REG_EXPAND_SZ`, sin expandir las variables que ya tenga (`%USERPROFILE%`…), y no lo duplica si ya está: +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' @@ -93,13 +93,13 @@ if (($userPath -split ';') -notcontains $dir) { } ``` -Si prefieres hacerlo a mano: busca "Editar las variables de entorno de esta cuenta" en el menú Inicio, abre `Path`, -elige **Nuevo** y escribe `%LOCALAPPDATA%\Programs\fiscalapi`. +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 con: +("Windows protegió su PC"). Quita la marca de descarga: ```powershell Unblock-File (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi\fiscalapi.exe') @@ -107,13 +107,13 @@ 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 guardadas con -`fiscalapi login` se conservan, porque viven en el almacén del sistema y no junto al binario. +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 publicado, **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: +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 @@ -121,10 +121,10 @@ 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 segundos. Si GitHub no responde, falla o devuelve algo inesperado, no se muestra nada - y el comando termina igual, 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`). -- Con Ctrl+C solo se muestra si el aviso ya había llegado (por ejemplo, durante un `fiscalapi listen` largo). +- 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**: @@ -132,11 +132,11 @@ El aviso **no corre**: |---|---| | Con `--json` | La salida es para otra herramienta. | | Con stderr redirigido (`2> log.txt`) | No ensucia el log de un script. | -| Con `CI` definida (`true` o `1`, 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` (o `true`) | Para apagarlo siempre. | +| 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. | -Cualquier valor de `CI` o de `FISCALAPI_NO_UPDATE_CHECK` lo apaga, salvo vacío, `0` o `false`. Para apagarlo de forma -permanente: +`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 diff --git a/docs/listen.md b/docs/listen.md index a9f9f02..fa3026f 100644 --- a/docs/listen.md +++ b/docs/listen.md @@ -1,105 +1,97 @@ # `fiscalapi listen` -`listen` recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu servidor local, sin exponerlo a -internet. +Recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu servidor local, sin exponerlo a internet. -```bash -fiscalapi listen --forward-to http://localhost:3000/webhooks -``` - -Requiere una API key cuya persona tenga el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). - -## Cómo funciona +## Uso -1. `listen` abre (o renueva) **tu escucha de CLI**: un webhook sin URL, uno por persona, con su propio secreto de firma - (`POST /api/v4/webhooks/cli-listener`). Escribe en stderr: - - ```text - Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha wh_listener_0001) - ``` - - El secreto es estable entre reinicios de `listen`: configúralo una vez en tu servidor local. +```text +fiscalapi listen [--forward-to ] [--events ] [--headers "K:V,K:V"] [--skip-verify] [--print-secret] +``` -2. Mientras la sesión está abierta, 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`, espera de hasta 25 s). +Requiere una API key con el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). -3. Cada entrega se reenvía a `--forward-to` como un `POST` con: - - el cuerpo **exacto** que firmó FiscalAPI, byte a byte, con `Content-Type: application/json; charset=utf-8`; - - los headers firmados `webhook-id`, `webhook-timestamp` y `webhook-signature` (Standard Webhooks); - - los headers extra de `--headers`. +## Opciones -4. `listen` manda a la API un acuse con lo que respondió tu servidor (el código HTTP o el error), así la entrega queda en - el historial igual que la de un webhook con URL. +| 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. | -5. Por cada entrega escribe una línea en stdout: +También acepta las opciones globales `--live`, `--api-base`, `--json` y `--verbose` +([Salida y códigos](salida-y-codigos.md)). - ```text - 2026-10-12 09:47:46 --> invoice.canceled [evt_0001] [200] - ``` +Estas combinaciones son errores de uso (salida 2): - Es la hora local, el tipo, el id del evento (el header `webhook-id`) y el resultado del reenvío. +| 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.` | -Una entrega lenta no frena a las demás: se reenvían hasta 16 a la vez, y las de un mismo evento conservan su orden. -**No hay reintentos**: si tu servidor responde un error, la entrega queda con ese resultado; vuelve a pedirla con -[`fiscalapi events resend`](webhooks-y-eventos.md#reenviar-un-evento). +> **`--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`. -Para disparar un evento de prueba hacia tu escucha, usa [`fiscalapi trigger`](trigger.md) en otra terminal. Ese -evento llega aunque `--events` no incluya su tipo. +## Ejemplos -## Opciones +Reenvía los eventos a tu servidor local: -| Opción | Uso | -|---|---| -| `--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. | - -Más las opciones globales `--live`, `--api-base`, `--json` y `--verbose` ([Salida y códigos](salida-y-codigos.md)). +```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] +``` -Ejemplos: +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 ver los eventos de cancelación, sin reenviarlos +# Solo muestra las cancelaciones, sin reenviarlas fiscalapi listen --events invoice.canceled,invoice.cancellation_rejected -# Reenviar a un servidor con TLS autofirmado y un header de autorización propio +# 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" -# Guardar el secreto para la configuración del servidor local +# Guarda el secreto para configurar tu servidor export FISCALAPI_WEBHOOK_SECRET=$(fiscalapi listen --print-secret) ``` -Reglas de las opciones (son errores de uso, salida 2): +## Cómo funciona -- `--headers` y `--skip-verify` solo aplican con `--forward-to`: `Error: --skip-verify solo aplica con --forward-to.` -- `--headers` no puede cambiar un header firmado: `Error: --headers no puede cambiar webhook-id: lo firma FiscalAPI.` -- `--print-secret` no se combina con las opciones del reenvío: `Error: --print-secret no se combina con --forward-to.` +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. -> **Atención con `--print-secret`:** abre una sesión, igual que `listen`. Si ya tienes un `fiscalapi listen` corriendo -> con la misma API key, esa sesión queda reemplazada y aquel `listen` termina con el 409 de abajo. Usa `--print-secret` -> antes de arrancar `listen`, no mientras corre. +Se reenvían hasta 16 entregas a la vez; las de un mismo evento conservan su orden. Las redirecciones no se siguen. -## El resultado de cada reenvío +**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 -| Línea | Significado | +| 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; las redirecciones no se siguen. | -| `... [error: Timeout]` | Tu servidor no respondió en 15 segundos. | -| `... [error: ConnectionFailed]` | No se pudo conectar (nada escucha en ese puerto, DNS, 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ó. | -| `...` sin resultado | Sin `--forward-to`: el evento solo se mostró (se acusa como entregado). | +| `[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. | -Los nombres de los errores son los mismos del historial de entregas de la API. +Son los mismos nombres de error del historial de entregas de la API. -## Verificar la firma en tu servidor local +## 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/). Tu servidor la verifica igual que la de un webhook con URL: -con el **cuerpo crudo** tal como llegó, antes de deserializarlo. Un cuerpo deserializado y vuelto a serializar cambia -sus bytes (espacios, orden, escapes) y la firma deja de coincidir. +[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: @@ -111,8 +103,8 @@ Los SDK oficiales traen la verificación: | PHP | `Fiscalapi\Webhooks\Webhook::verify(...)` | | Java | `com.fiscalapi.webhooks.Webhook.verify(headers, body, secret)` | -Las cinco comprueban la firma en tiempo constante, rechazan un `webhook-timestamp` con más de 5 minutos de diferencia y -devuelven el evento ya verificado. Por ejemplo, en ASP.NET Core (del README del SDK de .NET): +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")] @@ -138,8 +130,8 @@ public async Task Receive() } ``` -Para probar tu verificación sin la API, usa el vector oficial de Standard Webhooks: con estos valores la firma verifica -si pasas como hora actual el mismo timestamp, y falla si cambias un carácter del cuerpo, de la firma o del secreto. +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 | |---|---| @@ -149,61 +141,55 @@ si pasas como hora actual el mismo timestamp, y falla si cambias un carácter de | Cuerpo | `{"test": 2432232314}` | | `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` | -La guía completa (forma del evento, verificación a mano en cada lenguaje, reintentos) está en la +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, sesiones y salida - -`listen` no termina por sí solo. Mientras corre: - -- **Red caída o error 5xx de la API:** reintenta con espera creciente (de 1 s hasta 30 s, con variación aleatoria) y lo - avisa en stderr: +## Conexión y sesiones - ```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. - ``` +`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: -- **410, sesión caducada:** la API cierra la sesión si pasan 60 s sin long-polling (por ejemplo, tras un corte largo o - si la computadora se suspendió). `listen` abre una nueva sin terminar: - - ```text - La sesión de fiscalapi listen caducó; se abre una nueva. - ``` - - Los eventos que ocurrieron mientras no había sesión **no** llegan después. Búscalos con - `fiscalapi events list` y reenvíalos con `fiscalapi events resend `. - -- **409, sesión reemplazada:** otra sesión con tu misma persona (otro `listen` o un `--print-secret`) reemplazó a esta. - Solo puede haber una; esta termina con código 1: +| 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. +``` - ```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): … - ``` +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 `. -- **401, 403 o 404:** no se resuelven reintentando; `listen` termina con código 1 y el error de la API. +Una sesión reemplazada termina así: -- **Ctrl+C:** cierra la escucha. 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 +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): … +``` -Avisos que puedes ver en stderr: +Otros avisos en stderr: | Aviso | Qué pasó | |---|---| -| `La entrega () venció antes de reenviarse; la API la cerró sin acuse.` | La entrega esperó demasiado su turno y la API ya la había cerrado: no se reenvía. | -| `No se registró el acuse de la entrega : …` | La API no aceptó el acuse. Un 409 en el acuse (una entrega que ya no espera acuse) solo se muestra con `--verbose`. | -| `No se pudo reabrir la sesión de escucha (…); reintentando en s.` | Falló la reapertura tras un 410; se reintenta con espera creciente. | +| `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 línea** (JSON Lines), uno por entrega, para procesarlo con otra -herramienta. Los avisos y errores siguen en stderr como texto, incluida la línea `Listo. Tu secreto de firma es …`. +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":"dlv_0001","eventId":"evt_0001","type":"invoice.canceled","statusCode":200,"latencyMs":12,"event":{"id":"evt_0001","type":"invoice.canceled","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"}}} +{"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 | @@ -212,27 +198,25 @@ herramienta. Los avisos y errores siguen en stderr como texto, incluida la líne | `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 solo si respondió. | +| `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`, si no hubo respuesta. | +| `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. | -Sin `--forward-to` cada línea trae solo `time`, `deliveryId`, `eventId`, `type` y `event`. Con -`--print-secret --json` la única línea es `{"secret":"whsec_…"}`. +Con `--print-secret --json`, la única línea es `{"secret":"whsec_…"}`. ```bash -# Solo los tipos y códigos de cada reenvío +# El tipo y el resultado de cada reenvío fiscalapi listen --forward-to http://localhost:3000/webhooks --json | jq -r '"\(.type) \(.statusCode // .error)"' ``` -## Terminales sin ConPTY y Docker +## Docker y terminales sin ConPTY -`listen` funciona en cualquier terminal. Solo `login` depende de una terminal real para leer las llaves sin mostrarlas: -en mintty (Git Bash) o en `docker run -i` sin `-t`, `login` lee la entrada como redirigida, **sin prompt y con eco** -([Autenticación](autenticacion.md#sin-terminal-stdin)). +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`. -Dentro de un contenedor, `--forward-to http://localhost:…` apunta al 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 index 83aa8e4..40ba2f9 100644 --- a/docs/salida-y-codigos.md +++ b/docs/salida-y-codigos.md @@ -10,8 +10,7 @@ | 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` | La confirmación de `webhooks delete` y `Webhook … borrado.` | -| `Evento disparado: …` de `trigger` | | +| `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 @@ -93,8 +92,8 @@ Error: Sorry, it's not me, it's you. 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 errores de red, -408, 429 (respetando `Retry-After`) y 5xx. Un POST, PUT o DELETE nunca se reintenta, para no duplicar una operación, y -un 4xx tampoco. +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 index 497162b..11bdf63 100644 --- a/docs/solucion-de-problemas.md +++ b/docs/solucion-de-problemas.md @@ -30,7 +30,7 @@ pruebas no sirven con `--live` y al revés). ```text Error: Sorry, it's not me, it's you. - Detalle: The authenticated credentials do not have permission to perform this operation. + Detalle: No tienes permiso para administrar webhooks. Código HTTP: 403 ``` @@ -114,7 +114,7 @@ terminal, otra máquina o un script). Solo puede haber una sesión por persona; ### El receptor no responde: `[error: Timeout]`, `[error: ConnectionFailed]` ```text -2026-10-12 09:47:46 --> invoice.canceled [evt_0001] [error: ConnectionFailed] +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 @@ -129,7 +129,7 @@ autofirmado verás `[error: TlsFailed]`: agrega `--skip-verify`. La entrega no s `[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-en-tu-servidor-local)). +([listen](listen.md#verificar-la-firma)). Otras causas: @@ -185,7 +185,8 @@ dispara. **Remedio:** usa uno de los tipos de `fiscalapi trigger --help`. **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 `. +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 diff --git a/docs/trigger.md b/docs/trigger.md index eabd9ac..4cf96a7 100644 --- a/docs/trigger.md +++ b/docs/trigger.md @@ -40,12 +40,12 @@ 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 wh_listener_0001) +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 wh_listener_0001. +Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a. ``` Dispara un evento hacia un webhook con URL, sin `listen`: @@ -67,7 +67,7 @@ Con `--json`, imprime el `data` de la respuesta sin cambios: ```text $ fiscalapi trigger invoice.canceled --json -{"eventId":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","webhookId":"wh_listener_0001"} +{"eventId":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","webhookId":"9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a"} ``` ## Cómo se entrega @@ -77,9 +77,9 @@ $ fiscalapi trigger invoice.canceled --json - 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 `](webhooks-y-eventos.md#reenviar-un-evento). + [`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. -- Con `--live`, la API responde 403. El CLI no lo bloquea por su cuenta: muestra el error y sale con 1. ## Errores @@ -100,8 +100,9 @@ Error: Sorry, it's not me, it's you. | 400 | `El endpoint de 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 | — | Demasiadas peticiones con la misma API key: espera y repite. | +| 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). diff --git a/docs/webhooks-y-eventos.md b/docs/webhooks-y-eventos.md index 48979d3..6cdb0e4 100644 --- a/docs/webhooks-y-eventos.md +++ b/docs/webhooks-y-eventos.md @@ -1,10 +1,7 @@ # Webhooks y eventos -`fiscalapi webhooks` administra los webhooks de tu cuenta (`/api/v4/webhooks`) y `fiscalapi events` consulta y reenvía -los eventos de los últimos 30 días (`/api/v4/events`). Requieren una API key cuya persona tenga el permiso -`Webhooks.Manage`. - -Los ejemplos de salida de esta página salen del CLI real con los datos de sus pruebas automatizadas. +`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` @@ -23,21 +20,21 @@ Los ejemplos de salida de esta página salen del CLI real con los datos de sus p $ 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 -escucha-1 cliListener enabled - * - +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 dice qué opción pedir para la página siguiente. +`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 | Uso | +| 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). | +| `--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 @@ -49,22 +46,22 @@ Estado enabled URL https://example.com/webhooks Descripción Producción Eventos invoice.canceled, invoice.cancellation_requested -Personas persona-1, persona-2 +Personas 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f, 7c3b2a1d-0e9f-4d8c-a7b6-5f4e3d2c1b0a Creado 2026-10-13T09:30:00.000 ``` -Solo se muestran los campos con valor. Según el caso aparecen también `Motivo` y `Deshabilitado` (un webhook -deshabilitado), `Fallando desde`, `Persona de la escucha` y `Sesión vence` (una escucha de CLI) y `Actualizado`. +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 +### Crear un webhook -| Opción | Uso | +| 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. Se repite (`--person a --person b`) o va separado por coma. | -| `--all-persons` | Los eventos de todas las personas. Es lo que pasa si no indicas `--person`. | +| `--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 '*' @@ -80,22 +77,22 @@ 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: cópialo a la configuración segura de tu -receptor en ese momento. Si lo pierdes, genera otro con `rotate-secret`. La línea `Secreto de firma: …` va a stdout y el -aviso, a stderr. +**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 como comodín de archivos. +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 el detalle de cada regla. +tipos; si algo no es válido, responde 400 con una línea por cada regla que falló. -### `--person` y `--all-persons` +### Filtrar por persona -Un webhook recibe los eventos de todas las personas de tu cuenta o solo los de las personas que indiques: +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 persona-1,persona-2 +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 ``` @@ -106,16 +103,16 @@ personas sin querer: Error: --person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons. ``` -`--all-persons` y `--person` no se combinan. +`--person` y `--all-persons` no se combinan. -### Actualizar +### Actualizar un webhook -`update` lee el webhook, cambia solo lo que indiques y lo guarda. Acepta las opciones de `create` (todas opcionales) -y además: +`update` lee el webhook, cambia solo lo que indicas y lo guarda. Acepta las opciones de `create`, todas opcionales, y +además: -| Opción | Uso | +| Opción | Descripción | |---|---| -| `--enabled` | Habilita el webhook; limpia el motivo y la fecha de la deshabilitació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 @@ -123,18 +120,17 @@ fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --events invoice. fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --disabled ``` -Imprime el webhook ya actualizado. Sin ninguna opción es un error de uso: +Imprime el webhook actualizado. Estos casos fallan: -```text -Error: Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled. -``` - -Tu escucha de CLI no se edita: `update` sobre ella sale con 1 y -`Error: Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger.` +| 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 +### Borrar un webhook -En una terminal, `delete` pregunta antes de borrar. Solo `s`, `si`, `sí`, `y` o `yes` confirman: +En una terminal, `delete` pide confirmación. Solo `s`, `si`, `sí`, `y` o `yes` confirman: ```text $ fiscalapi webhooks delete 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d @@ -142,8 +138,8 @@ $ fiscalapi webhooks delete 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d Webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d borrado. ``` -Si respondes otra cosa: `No se borró el webhook .` (sale con 0). En un script o con `--json` no hay a quién -preguntar: usa `--yes`. +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 @@ -162,7 +158,10 @@ El secreto anterior seguirá firmando durante 24 horas para que actualices tu re ``` 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. `rotate-secret` también funciona sobre el id de tu escucha de CLI. +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` @@ -174,22 +173,27 @@ una lista) y quita el anterior al terminar. `rotate-secret` también funciona so ### Listar eventos -| Opción | Uso | +| Opción | Descripción | |---|---| -| `--type ` | Solo los eventos de este tipo (p. ej. `invoice.canceled` o `webhook.test`). | +| `--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` | [Paginación](#paginación). | +| `--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 factura-1 persona-1 -evt-2 webhook.test 2026-10-12T15:40:00Z we-1 - +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: @@ -198,49 +202,45 @@ Página 1 de 1 (2 en total). |---|---| | `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 con su zona horaria. | +| `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: +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'. ``` -```bash -# Las cancelaciones de una factura en octubre -fiscalapi events list --type invoice.canceled --related-object-id factura-1 --from 2026-10-01 --to 2026-10-31 -``` - ### Ver un evento -`events retrieve` imprime el evento exactamente como lo entrega la API, con o sin `--json`: +`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":"persona-1","data":{"relatedObject":{"id":"factura-1","type":"invoice","url":"/api/v4/invoices/factura-1"},"status":"X","previousStatus":"C"}} +{"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"}} ``` -El `id` del evento es el mismo del header `webhook-id` de sus entregas. - ### Reenviar un evento ```text -$ fiscalapi events resend 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f --webhook we-1 -Reenvío creado: entrega entrega-1 al webhook we-1. +$ 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. ``` -- Con `--webhook `, el evento se reenvía a ese webhook (cuerpo `{"webhookId":""}`). -- Sin `--webhook`, va a **tu escucha de CLI**, que tiene que tener un `fiscalapi listen` abierto. Es la forma de recibir - en local un evento que ocurrió mientras `listen` no corría. +| 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 con el mismo evento: mismo `webhook-id` y mismo cuerpo. Tu receptor debe descartar -los ids que ya procesó. +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, para procesarlo con otra -herramienta: +Con `--json`, cada comando imprime en stdout el `data` de la respuesta de la API, sin cambios: ```bash # Los ids de los webhooks habilitados @@ -250,8 +250,7 @@ fiscalapi webhooks list --page-size 50 --json | jq -r '.items[] | select(.status 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`. Los mensajes -para la persona (el aviso del secreto, `Webhook … borrado.`) y los errores siguen en stderr como texto. Ver -[Salida y códigos](salida-y-codigos.md). +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). From 993a2c6a474746e42ab2649e28402d638fcf9a16 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 18:42:28 -0600 Subject: [PATCH 17/19] CLI: el back local usa http en los ejemplos --- docs/instalacion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/instalacion.md b/docs/instalacion.md index 582b9cc..511afff 100644 --- a/docs/instalacion.md +++ b/docs/instalacion.md @@ -163,7 +163,7 @@ Borrarlo solo adelanta la siguiente consulta. Si la carpeta no se puede escribir ```bash fiscalapi logout fiscalapi logout --live - fiscalapi logout --api-base https://localhost:5001 # si usaste otra URL base + fiscalapi logout --api-base http://localhost:5001 # si usaste otra URL base ``` 2. Borra el binario y la carpeta de estado: From 16cea1283d87fd2dc1bae57c26059feb2e8ca984 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 21:28:05 -0600 Subject: [PATCH 18/19] =?UTF-8?q?CLI:=20textos=20del=20back=20con=20el=20v?= =?UTF-8?q?ocabulario=20=C3=BAnico=20(D-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/trigger.md | 2 +- docs/webhooks-y-eventos.md | 3 +++ tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/trigger.md b/docs/trigger.md index 4cf96a7..8ac4a7a 100644 --- a/docs/trigger.md +++ b/docs/trigger.md @@ -97,7 +97,7 @@ Error: Sorry, it's not me, it's you. |---|---|---| | 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 endpoint de webhook está deshabilitado: habilítalo antes de probarlo.` | `fiscalapi webhooks update --enabled`. | +| 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)). | diff --git a/docs/webhooks-y-eventos.md b/docs/webhooks-y-eventos.md index 6cdb0e4..3e584c6 100644 --- a/docs/webhooks-y-eventos.md +++ b/docs/webhooks-y-eventos.md @@ -14,6 +14,9 @@ | `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 diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index 00c2517..8faa5d3 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -642,7 +642,7 @@ public async Task Deliveries_of_the_same_event_reach_the_receiver_in_arrival_ord 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 endpoints de webhook está en curso; intenta de nuevo.") + .FailOpen(StatusCodes.Status409Conflict, "Otro cambio a los webhooks está en curso; intenta de nuevo.") .Deliver(new FakeDelivery("dlv_0001", "evt_0001", "invoice.canceled")); StartListen(); @@ -650,7 +650,7 @@ public async Task A_409_while_reopening_is_retried_with_backoff_instead_of_exiti 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 endpoints de webhook está en curso; " + + 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."); } From de4c1420d1ae284328e131da171b694c9a712323 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Tue, 13 Oct 2026 21:35:56 -0600 Subject: [PATCH 19/19] CLI: errores de la API como los imprime el CLI --- docs/salida-y-codigos.md | 2 +- docs/solucion-de-problemas.md | 4 ++++ docs/trigger.md | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/salida-y-codigos.md b/docs/salida-y-codigos.md index 40ba2f9..d16fce1 100644 --- a/docs/salida-y-codigos.md +++ b/docs/salida-y-codigos.md @@ -85,7 +85,7 @@ $ fiscalapi webhooks list Error: Sorry, it's not me, it's you. Detalle: Invalid tenant key Código HTTP: 401 - traceIdentifier (para soporte): 0HN7:00000042 + traceIdentifier (para soporte): 4C2E9A7B1D3F5E608B9A0C1D2E3F4A5B Sugerencia: Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. ``` diff --git a/docs/solucion-de-problemas.md b/docs/solucion-de-problemas.md index 11bdf63..889135f 100644 --- a/docs/solucion-de-problemas.md +++ b/docs/solucion-de-problemas.md @@ -32,6 +32,7 @@ pruebas no sirven con `--live` y al revés). 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 @@ -152,6 +153,8 @@ con `fiscalapi events resend `. ```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 @@ -166,6 +169,7 @@ $ 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. diff --git a/docs/trigger.md b/docs/trigger.md index 8ac4a7a..d2c6801 100644 --- a/docs/trigger.md +++ b/docs/trigger.md @@ -90,7 +90,7 @@ $ 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): 0HN7KQ2L0F3MA:00000002 + traceIdentifier (para soporte): 8F3A2C1D9B7E4F60A5C3D2E1F0B9A877 ``` | Código | Texto | Qué hacer |