From 4508b491cbfb7e79327b954b1030282b5f0679e5 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 23:58:36 -0600 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20quitar=20comentarios=20y=20trad?= =?UTF-8?q?ucir=20mensajes=20del=20CLI=20al=20ingl=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se quitan los comentarios de src/ y tests/. Las razones de los cambios viven en este historial. - Mensajes, descripciones de ayuda, etiquetas de tablas y prompts pasan a inglés y se acortan. Comandos, opciones, códigos de salida, forma de --json, variables de entorno y almacenes de credenciales no cambian. - GetValue(x)! pasa a GetRequiredValue(x) cuando la opción es obligatoria o tiene default. Las opcionales quedan nullable y se manejan explícitamente. Se eliminan los ! de src/. - El catch del aviso de versión se limita a HttpRequestException y JsonException. Las pruebas de UpdateCheck cubren cada falla; se agregan dos pruebas de rutas de estado inválidas que fallan sin el cambio. - webhooks delete acepta solo y/yes en la confirmación. - La línea de entrega de listen no lleva flecha. - Las pruebas se actualizan al texto nuevo. Resultado: 404 de 404 en verde, 0 warnings. --- src/FiscalApi.Cli/ApiEnvironment.cs | 12 -- src/FiscalApi.Cli/CliApplication.cs | 33 +---- src/FiscalApi.Cli/CliConsole.cs | 4 - src/FiscalApi.Cli/CliVersion.cs | 6 +- .../Commands/ApiCommandContext.cs | 16 +-- .../Commands/CommaSeparatedValues.cs | 5 - .../Commands/Events/EventsCommand.cs | 6 +- .../Commands/Events/ListEventsCommand.cs | 40 +++--- .../Commands/Events/ResendEventCommand.cs | 13 +- .../Commands/Events/RetrieveEventCommand.cs | 10 +- src/FiscalApi.Cli/Commands/ListenCommand.cs | 48 +++---- src/FiscalApi.Cli/Commands/LoginCommand.cs | 18 +-- src/FiscalApi.Cli/Commands/LogoutCommand.cs | 16 +-- src/FiscalApi.Cli/Commands/PageOptions.cs | 9 +- src/FiscalApi.Cli/Commands/TriggerCommand.cs | 21 +-- .../Commands/Webhooks/CreateWebhookCommand.cs | 11 +- .../Commands/Webhooks/DeleteWebhookCommand.cs | 24 ++-- .../Commands/Webhooks/ListWebhooksCommand.cs | 3 +- .../Webhooks/RetrieveWebhookCommand.cs | 7 +- .../Webhooks/RotateWebhookSecretCommand.cs | 10 +- .../Commands/Webhooks/UpdateWebhookCommand.cs | 26 ++-- .../Commands/Webhooks/WebhookOptions.cs | 30 ++--- .../Webhooks/WebhookWithSecretOutput.cs | 6 - .../Commands/Webhooks/WebhooksCommand.cs | 6 +- .../Credentials/ApiCredentials.cs | 25 +--- .../Credentials/CredentialResolver.cs | 13 +- .../Credentials/CredentialStoreException.cs | 15 +-- .../CredentialStoreUnavailableException.cs | 10 +- .../Credentials/CredentialStores.cs | 5 +- .../Credentials/ICredentialStore.cs | 13 -- .../Linux/LibSecretCredentialStore.cs | 31 +---- .../Credentials/Linux/LinuxNative.cs | 27 ---- .../MacOS/KeychainCredentialStore.cs | 24 +--- .../Credentials/MacOS/MacNative.cs | 17 +-- .../Credentials/StoredCredentialsFormat.cs | 6 - .../Credentials/Windows/AdvApi32.cs | 6 - .../Windows/WindowsCredentialStore.cs | 26 ++-- src/FiscalApi.Cli/Errors/ApiException.cs | 25 +--- src/FiscalApi.Cli/Errors/CliException.cs | 5 - .../Errors/InvalidCredentialsException.cs | 6 +- src/FiscalApi.Cli/Errors/NetworkException.cs | 2 - .../Errors/ResourceStateException.cs | 5 - src/FiscalApi.Cli/Errors/UsageException.cs | 3 - src/FiscalApi.Cli/ExitCodes.cs | 6 - src/FiscalApi.Cli/GlobalOptions.cs | 18 +-- src/FiscalApi.Cli/GlobalSettings.cs | 1 - src/FiscalApi.Cli/Http/ApiData.cs | 7 +- src/FiscalApi.Cli/Http/ApiResponse.cs | 4 - src/FiscalApi.Cli/Http/ExponentialBackoff.cs | 6 - src/FiscalApi.Cli/Http/FiscalApiClient.cs | 26 +--- .../Http/FiscalApiClientFactory.cs | 19 --- .../Http/FiscalApiJsonContext.cs | 5 - src/FiscalApi.Cli/Http/PagedList.cs | 7 - src/FiscalApi.Cli/Http/PersonFeatures.cs | 4 - src/FiscalApi.Cli/Http/QueryString.cs | 6 +- src/FiscalApi.Cli/Http/RetryPolicy.cs | 13 -- src/FiscalApi.Cli/Http/TransientHttpStatus.cs | 1 - src/FiscalApi.Cli/Http/ValidationFailures.cs | 6 - .../Http/VerboseLoggingHandler.cs | 10 +- .../Input/ConsoleConfirmationPrompt.cs | 13 +- .../Input/ConsoleSecretReader.cs | 11 -- .../Input/IConfirmationPrompt.cs | 3 - src/FiscalApi.Cli/Input/ISecretReader.cs | 5 - .../Input/RedirectedSecretReader.cs | 5 - src/FiscalApi.Cli/Listen/ClaimedDelivery.cs | 4 - .../Listen/DeliveryDispatcher.cs | 29 ----- src/FiscalApi.Cli/Listen/DeliveryOutcome.cs | 8 -- src/FiscalApi.Cli/Listen/DeliveryProcessor.cs | 21 --- src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs | 3 - src/FiscalApi.Cli/Listen/HttpForwardTarget.cs | 23 ---- src/FiscalApi.Cli/Listen/IDeliveryTarget.cs | 2 - src/FiscalApi.Cli/Listen/JsonListenOutput.cs | 11 -- src/FiscalApi.Cli/Listen/ListenLoop.cs | 21 --- src/FiscalApi.Cli/Listen/ListenOutput.cs | 32 ++--- src/FiscalApi.Cli/Listen/ListenTimings.cs | 21 --- src/FiscalApi.Cli/Listen/ListenerApi.cs | 12 +- src/FiscalApi.Cli/Listen/ListenerContracts.cs | 23 ---- src/FiscalApi.Cli/Listen/TextListenOutput.cs | 4 +- .../Listen/WebhookEventSummary.cs | 12 +- .../Listen/WebhookHeaderNames.cs | 2 - src/FiscalApi.Cli/Output/LabeledValues.cs | 4 - src/FiscalApi.Cli/Output/PagedListView.cs | 10 +- src/FiscalApi.Cli/Output/TextTable.cs | 8 -- src/FiscalApi.Cli/Program.cs | 13 -- src/FiscalApi.Cli/Updates/CliRepository.cs | 8 -- src/FiscalApi.Cli/Updates/IUpdateCheck.cs | 5 - src/FiscalApi.Cli/Updates/SemanticVersion.cs | 17 --- src/FiscalApi.Cli/Updates/UpdateCheck.cs | 23 +--- .../Updates/UpdateCheckContracts.cs | 6 - .../Updates/UpdateCheckStateFile.cs | 31 ++--- src/FiscalApi.Cli/Updates/UpdateNotice.cs | 10 +- src/FiscalApi.Cli/Webhooks/EventsApi.cs | 15 --- .../Webhooks/WebhookContracts.cs | 31 ----- .../Webhooks/WebhookEventContracts.cs | 17 --- .../Webhooks/WebhookEventTypes.cs | 5 - .../Webhooks/WebhookEventView.cs | 7 +- src/FiscalApi.Cli/Webhooks/WebhookView.cs | 45 +++---- src/FiscalApi.Cli/Webhooks/WebhooksApi.cs | 10 -- .../CliApplicationTests.cs | 18 +-- .../Commands/ApiCommandHarness.cs | 12 -- .../Commands/EventsCommandTests.cs | 30 ++--- .../Commands/LoginCommandTests.cs | 21 ++- .../Commands/LogoutCommandTests.cs | 16 +-- .../Commands/TriggerCommandTests.cs | 23 +--- .../Commands/WebhooksCommandTests.cs | 120 +++++++----------- .../Credentials/CredentialResolutionTests.cs | 18 +-- .../CredentialStoreFactAttribute.cs | 5 - .../Credentials/CredentialStoreTests.cs | 6 - .../Credentials/InMemoryCredentialStore.cs | 6 - .../StoredCredentialsFormatTests.cs | 8 -- .../FiscalApi.Cli.Tests.csproj | 3 - .../FiscalApi.Cli.Tests/GlobalOptionsTests.cs | 3 - .../Http/ExponentialBackoffTests.cs | 4 - .../Http/FiscalApiClientBuilder.cs | 3 - .../Http/FiscalApiClientErrorTests.cs | 9 +- .../Http/FiscalApiClientRetryTests.cs | 11 +- .../Http/PersonFeaturesJson.cs | 5 - .../FiscalApi.Cli.Tests/Http/RedirectTests.cs | 8 +- .../Http/SteppedTimeProvider.cs | 9 -- .../Http/StubHttpMessageHandler.cs | 10 -- .../Http/TestJsonContext.cs | 1 - .../Http/VerboseLoggingHandlerTests.cs | 7 +- .../Input/ConsoleSecretReaderTests.cs | 1 - .../Input/RedirectedSecretReaderTests.cs | 2 - .../FiscalApi.Cli.Tests/Input/ScriptedKeys.cs | 5 - .../FiscalApi.Cli.Tests/Listen/ClosedPort.cs | 4 - .../Listen/DeliveryDispatcherTests.cs | 7 - .../Listen/FakeListenerApi.cs | 18 --- .../Listen/FakeReceiver.cs | 7 - .../Listen/ListenCommandTests.cs | 79 +++++------- .../Listen/ListenTimingsTests.cs | 4 - .../Listen/RawHttpReceiver.cs | 5 - .../Listen/SelfSignedCertificate.cs | 2 - .../Listen/StandardWebhooksSignature.cs | 5 - .../Updates/CliApplicationUpdateCheckTests.cs | 7 - .../Updates/NoUpdateCheck.cs | 1 - .../Updates/SemanticVersionTests.cs | 1 - .../Updates/UpdateCheckTests.cs | 46 ++++--- 138 files changed, 346 insertions(+), 1517 deletions(-) diff --git a/src/FiscalApi.Cli/ApiEnvironment.cs b/src/FiscalApi.Cli/ApiEnvironment.cs index b907d3d..57f2f22 100644 --- a/src/FiscalApi.Cli/ApiEnvironment.cs +++ b/src/FiscalApi.Cli/ApiEnvironment.cs @@ -1,9 +1,5 @@ namespace FiscalApi.Cli; -/// -/// Ambiente de la API contra el que trabaja una invocación. Se resuelve una sola vez por invocación -/// y viaja como valor inmutable, sin estáticos mutables. -/// public sealed record ApiEnvironment { public static readonly Uri TestBaseAddress = new("https://test.fiscalapi.com/"); @@ -11,21 +7,13 @@ public sealed record ApiEnvironment private ApiEnvironment(Uri baseAddress) => BaseAddress = baseAddress; - /// Siempre termina en /, para que las rutas relativas conserven su prefijo. public Uri BaseAddress { get; } - /// - /// Host (y puerto, si no es el de omisión) de la API: identifica la entrada del almacén de credenciales y es - /// lo que ven los mensajes. Así test, live y cada back local guardan credenciales separadas. - /// public string Host => BaseAddress.Authority; - /// Precedencia: --api-base gana sobre --live, y este sobre test. public static ApiEnvironment Resolve(Uri? apiBase, bool live) => new(WithTrailingSlash(apiBase ?? (live ? LiveBaseAddress : TestBaseAddress))); - // Sin la "/" final, Uri resuelve "api/v4/..." contra el padre y descarta el último segmento - // (https://host/fiscalapi + api/v4 = https://host/api/v4). private static Uri WithTrailingSlash(Uri baseAddress) => baseAddress.AbsolutePath.EndsWith('/') ? baseAddress diff --git a/src/FiscalApi.Cli/CliApplication.cs b/src/FiscalApi.Cli/CliApplication.cs index edc990f..962a98a 100644 --- a/src/FiscalApi.Cli/CliApplication.cs +++ b/src/FiscalApi.Cli/CliApplication.cs @@ -6,11 +6,6 @@ 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; @@ -25,25 +20,21 @@ public CliApplication(GlobalOptions globalOptions, IReadOnlyList comman _globalOptions = globalOptions; _console = console; _updateCheck = updateCheck; - _rootCommand = new RootCommand("CLI de FiscalAPI: webhooks y eventos desde la terminal."); + _rootCommand = new RootCommand("FiscalAPI CLI. Manage webhooks and events from your 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."; + versionOption.Description = "Show the CLI version."; + _rootCommand.Options.OfType().Single().Description = "Show help and usage."; - // Sin subcomando, la ayuda: es lo que la persona necesita y no es un error. _rootCommand.SetAction((parseResult, _) => Task.FromResult(new HelpAction().Invoke(parseResult))); _invocation = CreateInvocation(output: console.Out, console.Error); - // Con --json, el texto que genera System.CommandLine (la ayuda) va a stderr: stdout es solo para JSON. - // Los comandos escriben sus datos en CliConsole.Out, que no cambia. _jsonInvocation = CreateInvocation(output: console.Error, console.Error); } @@ -56,7 +47,6 @@ public async Task RunAsync(IReadOnlyList args, CancellationToken ca } var settings = _globalOptions.Read(parseResult); - // El aviso de versión nueva corre mientras corre el comando y se muestra al final, después de su salida. var updateNotice = _updateCheck.Start(settings, cancellationToken); var exitCode = await InvokeAsync(parseResult, settings, cancellationToken); if (await NoticeToShowAsync(updateNotice, exitCode) is { } notice) @@ -67,10 +57,6 @@ 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) @@ -99,17 +85,11 @@ private async Task InvokeAsync(ParseResult parseResult, GlobalSettings sett } 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}"); + await _console.Error.WriteLineAsync($"Unexpected CLI error: {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) @@ -117,7 +97,7 @@ private async Task ReportUsageErrorsAsync(ParseResult parseResult) await _console.Error.WriteLineAsync($"Error: {error.Message}"); } - await _console.Error.WriteLineAsync("Usa `fiscalapi --help` para ver los comandos y opciones."); + await _console.Error.WriteLineAsync("Run `fiscalapi --help` to see commands and options."); return ExitCodes.Usage; } @@ -125,13 +105,10 @@ private async Task ReportUsageErrorsAsync(ParseResult parseResult) { Output = output, Error = error, - // Los errores los atrapa RunAsync, con los códigos de salida del contrato. EnableDefaultExceptionHandler = false, - // Ctrl+C lo maneja Program y llega aquí como el token de RunAsync. ProcessTerminationTimeout = null }; - // Asíncrona como el resto de las acciones: System.CommandLine pide no mezclar acciones sync y async. private sealed class PrintVersionAction : AsynchronousCommandLineAction { public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) diff --git a/src/FiscalApi.Cli/CliConsole.cs b/src/FiscalApi.Cli/CliConsole.cs index d74301b..34a7f46 100644 --- a/src/FiscalApi.Cli/CliConsole.cs +++ b/src/FiscalApi.Cli/CliConsole.cs @@ -1,7 +1,3 @@ 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 index cf79d97..2913c7a 100644 --- a/src/FiscalApi.Cli/CliVersion.cs +++ b/src/FiscalApi.Cli/CliVersion.cs @@ -4,11 +4,7 @@ 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 ."); + ?? throw new InvalidOperationException("The assembly has no AssemblyInformationalVersion. The SDK generates it from ."); } diff --git a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs index 586c561..32b2016 100644 --- a/src/FiscalApi.Cli/Commands/ApiCommandContext.cs +++ b/src/FiscalApi.Cli/Commands/ApiCommandContext.cs @@ -5,20 +5,12 @@ namespace FiscalApi.Cli.Commands; -/// -/// Lo que comparten los comandos que llaman a la API: las opciones globales, la fábrica -/// de clientes y la salida, más la única regla de salida de sus datos (). -/// public sealed record ApiCommandContext(GlobalOptions GlobalOptions, FiscalApiClientFactory ApiClients, CliConsole Console) { public GlobalSettings Settings(ParseResult parseResult) => GlobalOptions.Read(parseResult); public FiscalApiClient Client(GlobalSettings settings) => ApiClients.Create(settings, FiscalApiClientFactory.DefaultTimeout); - /// - /// Con --json, el data de la respuesta exactamente como lo escribió la API (contrato común); sin él, la - /// vista de texto del recurso leído con . - /// public void WriteData(GlobalSettings settings, JsonElement data, JsonTypeInfo type, Action writeText) { if (settings.Json) @@ -30,12 +22,8 @@ public void WriteData(GlobalSettings settings, JsonElement data, JsonTypeInfo writeText(Console.Out, Read(data, type)); } - /// El JSON del data sin re-serializarlo: los mismos caracteres que mandó la API. public void WriteRaw(JsonElement data) => Console.Out.WriteLine(data.GetRawText()); - /// - /// El modelo de texto del data. ya descarta el que falta o es null, así que - /// la lectura no devuelve null. - /// - public static T Read(JsonElement data, JsonTypeInfo type) => ApiData.Required(data).Deserialize(type)!; + public static T Read(JsonElement data, JsonTypeInfo type) => + ApiData.Required(data).Deserialize(type) ?? throw new InvalidOperationException("The API returned no data."); } diff --git a/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs index f4c7b41..c15df9f 100644 --- a/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs +++ b/src/FiscalApi.Cli/Commands/CommaSeparatedValues.cs @@ -2,16 +2,11 @@ namespace FiscalApi.Cli.Commands; -/// -/// Las opciones de lista (--events a,b, --person a --person b,c): cada valor se parte por comas y se recortan -/// los espacios. Lo que contiene la lista lo valida la API, con sus textos. -/// public static class CommaSeparatedValues { public static IReadOnlyList Parse(ArgumentResult result) => [.. result.Tokens.SelectMany(token => Split(token.Value))]; - /// Los valores no vacíos de , sin espacios alrededor. public static string[] Split(string value) => value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); } diff --git a/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs index 2504902..f6800e3 100644 --- a/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/EventsCommand.cs @@ -3,14 +3,10 @@ namespace FiscalApi.Cli.Commands.Events; -/// -/// fiscalapi events list|retrieve|resend: los eventos de webhook del tenant de los últimos 30 días sobre -/// /api/v4/events. Sin subcomando muestra la ayuda. -/// public sealed class EventsCommand : Command { public EventsCommand(GlobalOptions globalOptions, FiscalApiClientFactory apiClients, CliConsole console) - : base("events", "Consulta y reenvía los eventos de webhook de los últimos 30 días.") + : base("events", "List, retrieve and resend webhook events from the last 30 days.") { var context = new ApiCommandContext(globalOptions, apiClients, console); Subcommands.Add(new ListEventsCommand(context)); diff --git a/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs index d3084c9..20aa2f7 100644 --- a/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/ListEventsCommand.cs @@ -1,19 +1,14 @@ using System.CommandLine; using System.CommandLine.Parsing; +using System.Diagnostics; using System.Globalization; using FiscalApi.Cli.Http; using FiscalApi.Cli.Webhooks; namespace FiscalApi.Cli.Commands.Events; -/// -/// fiscalapi events list [--type] [--related-object-id] [--from] [--to] [--page] [--page-size]: -/// GET /api/v4/events. Los filtros los valida la API (tipo que existe, rango de 30 días); el CLI solo convierte las -/// fechas a UTC. -/// public sealed class ListEventsCommand : Command { - // ISO 8601: solo la fecha, o fecha y hora con o sin segundos y fracción, con o sin zona (Z o ±hh:mm). private const string DateOnlyFormat = "yyyy-MM-dd"; private static readonly string[] DateTimeFormats = @@ -28,28 +23,27 @@ public sealed class ListEventsCommand : Command private readonly Option _type = new("--type") { - Description = "Solo los eventos de este tipo (p. ej. invoice.canceled o webhook.test).", - HelpName = "tipo" + Description = "Only events of this type, e.g. invoice.canceled or webhook.test.", + HelpName = "type" }; private readonly Option _relatedObjectId = new("--related-object-id") { - Description = "Solo los eventos de este recurso (data.relatedObject.id).", + Description = "Only events for this resource (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", + Description = "Start time, inclusive, in ISO 8601, e.g. 2026-10-01 or 2026-10-01T08:00:00-06:00. UTC if no offset.", + HelpName = "date", CustomParser = result => ParseInstant(result, dateIncludesWholeDay: false) }; private readonly Option _to = new("--to") { - Description = "Hasta este instante, incluido, en ISO 8601; sin zona, UTC. Con solo la fecha, incluye ese día completo " + - "(hasta las 23:59:59.9999999 UTC). Por omisión, ahora.", - HelpName = "fecha", + Description = "End time, inclusive, in ISO 8601. UTC if no offset. A date alone includes the whole day. Defaults to now.", + HelpName = "date", CustomParser = result => ParseInstant(result, dateIncludesWholeDay: true) }; @@ -57,7 +51,7 @@ public sealed class ListEventsCommand : Command 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.") + : base("list", "List events from the last 30 days, newest first.") { _context = context; Options.Add(_type); @@ -83,14 +77,6 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc return ExitCodes.Success; } - /// - /// La API compara instantes UTC, ambos extremos incluidos: una fecha sin zona se toma como UTC y una con zona se - /// convierte. - /// - /// - /// Para --to: "hasta el 13" incluye todo el 13, así que solo la fecha es su último instante (un tick antes del - /// día siguiente; la API guarda createdAt en datetime2). - /// private static DateTimeOffset? ParseInstant(ArgumentResult result, bool dateIncludesWholeDay) { var value = result.Tokens.Single().Value; @@ -104,9 +90,13 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc return instant; } - var option = ((OptionResult)result.Parent!).Option.Name; + if (result.Parent is not OptionResult option) + { + throw new UnreachableException("Dates are parsed only as option values."); + } + 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}'."); + $"{option.Option.Name} must be an ISO 8601 date, e.g. 2026-10-01 or 2026-10-01T08:00:00-06:00. Got '{value}'."); return null; } diff --git a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs index 69cbb0d..ebd26ff 100644 --- a/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/ResendEventCommand.cs @@ -4,25 +4,20 @@ namespace FiscalApi.Cli.Commands.Events; -/// -/// fiscalapi events resend <eventId> [--webhook <id>]: POST /api/v4/events/{id}/resend. -/// Sin --webhook, la API lo entrega a la escucha de CLI de la persona: fiscalapi listen tiene -/// que estar abierto, o responde 409. -/// public sealed class ResendEventCommand : Command { - private readonly Argument _eventId = new("eventId") { Description = "Id del evento que se reenvía." }; + private readonly Argument _eventId = new("eventId") { Description = "ID of the event to resend." }; private readonly Option _webhook = new("--webhook") { - Description = "Id del webhook que recibe el reenvío. Por omisión, tu escucha de fiscalapi listen.", + Description = "ID of the webhook that receives the event. Defaults to your listen session.", 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.") + : base("resend", "Resend an event to a webhook or to your listen session.") { _context = context; Arguments.Add(_eventId); @@ -34,7 +29,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { var settings = _context.Settings(parseResult); var data = await new EventsApi(_context.Client(settings)).ResendAsync( - parseResult.GetValue(_eventId)!, parseResult.GetValue(_webhook), cancellationToken); + parseResult.GetRequiredValue(_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 index 234cbfe..9b8883d 100644 --- a/src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs +++ b/src/FiscalApi.Cli/Commands/Events/RetrieveEventCommand.cs @@ -3,17 +3,13 @@ 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 Argument _id = new("id") { Description = "Event ID: the webhook-id it was delivered with." }; private readonly ApiCommandContext _context; public RetrieveEventCommand(ApiCommandContext context) - : base("retrieve", "Imprime el sobre de un evento tal como se entregó.") + : base("retrieve", "Print the event as the API returns it.") { _context = context; Arguments.Add(_id); @@ -23,7 +19,7 @@ public RetrieveEventCommand(ApiCommandContext context) 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)); + _context.WriteRaw(await new EventsApi(_context.Client(settings)).RetrieveAsync(parseResult.GetRequiredValue(_id), cancellationToken)); return ExitCodes.Success; } } diff --git a/src/FiscalApi.Cli/Commands/ListenCommand.cs b/src/FiscalApi.Cli/Commands/ListenCommand.cs index 647c14a..d300cea 100644 --- a/src/FiscalApi.Cli/Commands/ListenCommand.cs +++ b/src/FiscalApi.Cli/Commands/ListenCommand.cs @@ -6,24 +6,19 @@ namespace FiscalApi.Cli.Commands; -/// -/// fiscalapi listen: abre la escucha de CLI, hace long-polling y reenvía cada entrega a -/// --forward-to con el cuerpo y los headers firmados intactos. -/// public sealed class ListenCommand : Command { private readonly Option _forwardTo = new("--forward-to") { - Description = "URL a la que se reenvía cada evento (p. ej. http://localhost:3000/webhooks).", + Description = "URL that receives each event, e.g. http://localhost:3000/webhooks.", HelpName = "url", CustomParser = ParseForwardTo }; - // Sin --events, todos los tipos: el comodín de fapi-back (WebhookEventTypes.Wildcard). private readonly Option> _events = new("--events") { - Description = "Tipos de evento separados por coma (p. ej. invoice.canceled,download_request.completed). Por omisión, todos.", - HelpName = "tipos", + Description = "Comma-separated event types, e.g. invoice.canceled,download_request.completed. Defaults to all.", + HelpName = "types", Arity = ArgumentArity.ExactlyOne, CustomParser = CommaSeparatedValues.Parse, DefaultValueFactory = _ => ["*"] @@ -31,7 +26,7 @@ public sealed class ListenCommand : Command private readonly Option>> _headers = new("--headers") { - Description = "Headers extra para cada reenvío, como \"Nombre:Valor,Nombre:Valor\".", + Description = "Extra headers for each forwarded request, as \"Name:Value,Name:Value\".", HelpName = "headers", Arity = ArgumentArity.ExactlyOne, CustomParser = ParseHeaders, @@ -40,13 +35,12 @@ public sealed class ListenCommand : Command 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." + Description = "Skip the TLS certificate check for --forward-to, e.g. self-signed. Never applies to the API." }; private readonly Option _printSecret = new("--print-secret") { - Description = "Imprime solo el secreto de firma de la escucha y termina. Abre una sesión: un listen en curso " + - "con la misma API key termina." + Description = "Print the signing secret of a new listen session and exit. Ends any running listen with the same API key." }; private readonly GlobalOptions _globalOptions; @@ -61,7 +55,7 @@ public ListenCommand( CliConsole console, TimeProvider timeProvider, ListenTimings timings) - : base("listen", "Recibe los eventos de webhooks en la terminal y los reenvía a tu servidor local.") + : base("listen", "Receive webhook events in your terminal and forward them to your local server.") { _globalOptions = globalOptions; _apiClients = apiClients; @@ -82,12 +76,11 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { var settings = _globalOptions.Read(parseResult); var api = new ListenerApi(_apiClients.Create(settings, _timings.ApiTimeout), _timings.PollWaitSeconds); - var enabledEvents = parseResult.GetValue(_events)!; + var enabledEvents = parseResult.GetRequiredValue(_events); var output = ListenOutput.For(settings, _console, _timeProvider); if (parseResult.GetValue(_printSecret)) { - // El secreto solo llega al abrir una sesión, y abrirla reemplaza la de otro listen en curso. var session = await api.OpenAsync(enabledEvents, cancellationToken); output.WriteSecret(session.Secret); return ExitCodes.Success; @@ -97,17 +90,15 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc var target = forwarder ?? (IDeliveryTarget)DisplayOnlyTarget.Instance; var processor = new DeliveryProcessor(target, api, output, _timings, _timeProvider); await new ListenLoop(api, enabledEvents, processor, output, _timings, _timeProvider).RunAsync(cancellationToken); - throw new UnreachableException("El bucle de listen solo termina con una excepción."); + throw new UnreachableException("The listen loop ends only with an exception."); } private HttpForwardTarget? CreateForwarder(ParseResult parseResult) => parseResult.GetValue(_forwardTo) is { } forwardTo ? HttpForwardTarget.Create( - forwardTo, parseResult.GetValue(_headers)!, parseResult.GetValue(_skipVerify), _timings.ForwardTimeout, _timeProvider) + forwardTo, parseResult.GetRequiredValue(_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) @@ -119,13 +110,11 @@ private void RequireForwardToFor(CommandResult result) { if (result.GetResult(option) is { Implicit: false }) { - result.AddError($"{option.Name} solo aplica con --forward-to."); + result.AddError($"{option.Name} requires --forward-to."); } } } - // --print-secret abre la sesión, imprime el secreto y termina: nunca reenvía, así que las opciones del reenvío no - // tendrían efecto. private void RejectForwardingWithPrintSecret(CommandResult result) { if (result.GetResult(_printSecret) is not { Implicit: false }) @@ -137,14 +126,11 @@ private void RejectForwardingWithPrintSecret(CommandResult result) { if (result.GetResult(option) is { Implicit: false }) { - result.AddError($"--print-secret no se combina con {option.Name}."); + result.AddError($"--print-secret cannot be used with {option.Name}."); } } } - // "Nombre:Valor,Nombre:Valor": el valor llega hasta la coma siguiente y puede llevar ':'. Cada header se prueba aquí - // con la misma validación de HttpClient que usará el reenvío, así un header inválido es un error de uso y no una - // falla en cada entrega. private static IReadOnlyList> ParseHeaders(ArgumentResult result) { var headers = new List>(); @@ -155,15 +141,15 @@ private static IReadOnlyList> ParseHeaders(Argument 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}'."); + result.AddError($"--headers expects comma-separated Name:Value pairs. Got '{entry}'."); } else if (WebhookHeaderNames.IsSigned(name)) { - result.AddError($"--headers no puede cambiar {name}: lo firma FiscalAPI."); + result.AddError($"--headers cannot set {name}. FiscalAPI signs it."); } else if (!IsValidRequestHeader(name, value)) { - result.AddError($"--headers trae un header que HTTP no admite: '{name}'."); + result.AddError($"--headers has a header that HTTP does not allow: '{name}'."); } else { @@ -174,7 +160,6 @@ private static IReadOnlyList> ParseHeaders(Argument 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(); @@ -189,7 +174,6 @@ private static bool IsValidRequestHeader(string name, string value) } } - // 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; @@ -198,7 +182,7 @@ private static bool IsValidRequestHeader(string name, string value) return uri; } - result.AddError($"--forward-to debe ser una URL absoluta http o https; se recibió '{value}'."); + result.AddError($"--forward-to must be an absolute http or https URL. Got '{value}'."); return null; } } diff --git a/src/FiscalApi.Cli/Commands/LoginCommand.cs b/src/FiscalApi.Cli/Commands/LoginCommand.cs index 5d4c5db..46c9a76 100644 --- a/src/FiscalApi.Cli/Commands/LoginCommand.cs +++ b/src/FiscalApi.Cli/Commands/LoginCommand.cs @@ -7,16 +7,10 @@ namespace FiscalApi.Cli.Commands; -/// -/// fiscalapi login: pide la API key y la tenant key, las valida con -/// GET /api/v4/me/features y las guarda en el almacén del SO, en la entrada del host del ambiente. -/// No usa las variables de entorno: guarda lo que la persona acaba de dar. -/// public sealed class LoginCommand : Command { private const string FeaturesPath = "api/v4/me/features"; - // El permiso que exigen listen, trigger y webhooks (Permissions.Webhooks.Manage de fapi-back). private const string WebhooksManagePermission = "Webhooks.Manage"; private readonly GlobalOptions _globalOptions; @@ -31,7 +25,7 @@ public LoginCommand( 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.") + : base("login", "Validate your API key and tenant key and save them in the system credential store.") { _globalOptions = globalOptions; _apiClients = apiClients; @@ -46,23 +40,19 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc var settings = _globalOptions.Read(parseResult); var host = settings.Environment.Host; - // Sin almacén no se piden las llaves ni se llama a la API: no se podrían guardar. _store.EnsureAvailable(host); - // Cada valor se valida en cuanto se lee: un valor inválido es un error de uso, no un 401, y una API key - // inválida se rechaza antes de pedir la tenant key. var apiKey = ApiCredentials.ValidApiKey(await _input.ReadAsync("API key: ", cancellationToken)); var credentials = ApiCredentials.Create(apiKey, await _input.ReadAsync("Tenant key: ", cancellationToken)); var features = await FetchFeaturesAsync(settings, credentials, cancellationToken); _store.Save(host, credentials); - // Los mensajes van a stderr: login no produce datos, y con --json stdout queda vacío. - await _console.Error.WriteLineAsync($"Listo. Credenciales guardadas para {host}."); + await _console.Error.WriteLineAsync($"Done. Credentials saved for {host}."); if (features?.Permissions.Contains(WebhooksManagePermission, StringComparer.Ordinal) != true) { await _console.Error.WriteLineAsync( - "Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403."); + "This API key lacks the Webhooks.Manage permission. listen, trigger and webhooks will return 403."); } return ExitCodes.Success; @@ -76,8 +66,6 @@ await _console.Error.WriteLineAsync( { 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 index f2e7b9b..611f38c 100644 --- a/src/FiscalApi.Cli/Commands/LogoutCommand.cs +++ b/src/FiscalApi.Cli/Commands/LogoutCommand.cs @@ -3,11 +3,6 @@ namespace FiscalApi.Cli.Commands; -/// -/// fiscalapi logout: borra la entrada del host del ambiente en el almacén del SO. Es local (no -/// llama a la API). Termina con éxito si borró o si no había nada guardado; si el almacén existe pero falla -/// (bloqueado, sin respuesta), lo dice y sale con 1. -/// public sealed class LogoutCommand : Command { private readonly GlobalOptions _globalOptions; @@ -15,7 +10,7 @@ public sealed class LogoutCommand : Command private readonly CliConsole _console; public LogoutCommand(GlobalOptions globalOptions, ICredentialStore store, CliConsole console) - : base("logout", "Borra las credenciales guardadas para el ambiente.") + : base("logout", "Delete the saved credentials for the environment.") { _globalOptions = globalOptions; _store = store; @@ -27,10 +22,9 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { 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}."); + ? $"Credentials deleted for {host}." + : $"No saved credentials for {host}."); return ExitCodes.Success; } @@ -40,15 +34,13 @@ private bool DeleteSavedCredentials(string host) { 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}."); + throw exception.During($"Could not delete the saved credentials for {host}."); } } } diff --git a/src/FiscalApi.Cli/Commands/PageOptions.cs b/src/FiscalApi.Cli/Commands/PageOptions.cs index 12fd9eb..e8f20d3 100644 --- a/src/FiscalApi.Cli/Commands/PageOptions.cs +++ b/src/FiscalApi.Cli/Commands/PageOptions.cs @@ -3,19 +3,18 @@ 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" + Description = "Page number, from 1. Defaults to 1.", + HelpName = "number" }; private readonly Option _pageSize = new("--page-size") { - Description = "Elementos por página, de 1 a 50 (por omisión 10).", - HelpName = "tamaño" + Description = "Items per page, from 1 to 50. Defaults to 10.", + HelpName = "size" }; public void AddTo(Command command) diff --git a/src/FiscalApi.Cli/Commands/TriggerCommand.cs b/src/FiscalApi.Cli/Commands/TriggerCommand.cs index 3b10378..d403853 100644 --- a/src/FiscalApi.Cli/Commands/TriggerCommand.cs +++ b/src/FiscalApi.Cli/Commands/TriggerCommand.cs @@ -4,47 +4,38 @@ namespace FiscalApi.Cli.Commands; -/// -/// fiscalapi trigger <type> [--related-object-id <id>] [--person-id <id>] [--webhook <id>]: -/// POST /api/v4/events/trigger. La API genera un evento sintético del tipo y lo entrega a un solo -/// destino: sin --webhook, la escucha de CLI de la persona, así que fiscalapi listen tiene que estar -/// abierto o responde 409. El CLI no valida el tipo ni bloquea --live: esas reglas viven solo en la API, que -/// responde 400 o 403 con su texto. -/// public sealed class TriggerCommand : Command { - // Con HelpName el uso dice ; sin él, System.CommandLine lo reemplaza por la lista entera de sugerencias. private readonly Argument _type = new("type") { - Description = $"Tipo de evento: {string.Join(", ", WebhookEventTypes.Subscribable)}. Quien lo valida es la API.", + Description = $"Event type: {string.Join(", ", WebhookEventTypes.Subscribable)}. The API validates it.", 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.", + Description = "Resource ID for the event (data.relatedObject.id). Defaults to a synthetic ID.", 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.", + Description = "Tenant person that owns the resource (event personId). Defaults to none.", HelpName = "id" }; private readonly Option _webhook = new("--webhook") { - Description = "Id del webhook que recibe el evento. Por omisión, tu escucha de fiscalapi listen.", + Description = "ID of the webhook that receives the event. Defaults to your listen session.", 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).") + : base("trigger", "Send a test event to your listen session or to a webhook. Test environment only.") { _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); @@ -57,7 +48,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { var settings = _context.Settings(parseResult); var request = new TriggerWebhookEventRequest( - parseResult.GetValue(_type)!, + parseResult.GetRequiredValue(_type), parseResult.GetValue(_relatedObjectId), parseResult.GetValue(_personId), parseResult.GetValue(_webhook)); diff --git a/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs index b02ec5b..c6a9216 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/CreateWebhookCommand.cs @@ -4,17 +4,13 @@ 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.") + : base("create", "Create a webhook and print its signing secret once.") { _context = context; _webhook.AddTo(this); @@ -25,10 +21,9 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc { var settings = _context.Settings(parseResult); var request = new CreateWebhookRequest( - parseResult.GetValue(_webhook.Url)!, + parseResult.GetRequiredValue(_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. + parseResult.GetRequiredValue(_webhook.Events), WebhookOptions.GivenValue(parseResult, _webhook.Persons)); var data = await new WebhooksApi(_context.Client(settings)).CreateAsync(request, cancellationToken); diff --git a/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs index 8ac765f..5c8c8d7 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/DeleteWebhookCommand.cs @@ -5,24 +5,20 @@ 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 Argument _id = new("id") { Description = "ID of the webhook or listen session." }; private readonly Option _yes = new("--yes") { - Description = "Borra sin pedir confirmación (necesario en scripts y con --json)." + Description = "Delete without confirmation. Required in scripts and with --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.") + : base("delete", "Delete a webhook and close its pending deliveries. Its history is kept.") { _context = context; _confirmation = confirmation; @@ -34,23 +30,20 @@ public DeleteWebhookCommand(ApiCommandContext context, IConfirmationPrompt confi private async Task RunAsync(ParseResult parseResult, CancellationToken cancellationToken) { var settings = _context.Settings(parseResult); - var id = parseResult.GetValue(_id)!; + var id = parseResult.GetRequiredValue(_id); if (!parseResult.GetValue(_yes)) { if (settings.Json || !_confirmation.IsInteractive) { - throw new UsageException("Para borrar sin confirmación interactiva usa --yes."); + throw new UsageException("Use --yes to delete without an interactive confirmation."); } 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. + $"Delete webhook {id}? Its pending deliveries will be closed.", cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (!confirmed) { - // La persona decidió no borrar: no es un error. - await _context.Console.Error.WriteLineAsync($"No se borró el webhook {id}."); + await _context.Console.Error.WriteLineAsync($"Webhook {id} was not deleted."); return ExitCodes.Success; } } @@ -62,8 +55,7 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc } else { - // Como logout: el resultado es un aviso para la persona, no un dato. - await _context.Console.Error.WriteLineAsync($"Webhook {id} borrado."); + await _context.Console.Error.WriteLineAsync($"Webhook {id} deleted."); } return ExitCodes.Success; diff --git a/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs index 8fe126b..848b53c 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/ListWebhooksCommand.cs @@ -4,14 +4,13 @@ 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.") + : base("list", "List the tenant's webhooks, without their signing secrets.") { _context = context; _page.AddTo(this); diff --git a/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs index a58f8be..de07a94 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/RetrieveWebhookCommand.cs @@ -4,14 +4,13 @@ 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 Argument _id = new("id") { Description = "Webhook ID." }; private readonly ApiCommandContext _context; public RetrieveWebhookCommand(ApiCommandContext context) - : base("retrieve", "Muestra un webhook, sin su secreto.") + : base("retrieve", "Retrieve a webhook, without its signing secret.") { _context = context; Arguments.Add(_id); @@ -21,7 +20,7 @@ public RetrieveWebhookCommand(ApiCommandContext context) 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); + var data = await new WebhooksApi(_context.Client(settings)).RetrieveAsync(parseResult.GetRequiredValue(_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 index ca2e437..2b3f829 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/RotateWebhookSecretCommand.cs @@ -4,17 +4,13 @@ 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 Argument _id = new("id") { Description = "ID of the webhook or listen session." }; 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.") + : base("rotate-secret", "Rotate the signing secret and print the new one once. The old one also signs events for 24 hours.") { _context = context; Arguments.Add(_id); @@ -24,7 +20,7 @@ public RotateWebhookSecretCommand(ApiCommandContext context) 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 data = await new WebhooksApi(_context.Client(settings)).RotateSecretAsync(parseResult.GetRequiredValue(_id), cancellationToken); WebhookWithSecretOutput.Write(_context, settings, data, rotated: true); return ExitCodes.Success; } diff --git a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs index 1475a0b..f522bfd 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/UpdateWebhookCommand.cs @@ -6,34 +6,28 @@ namespace FiscalApi.Cli.Commands.Webhooks; -/// -/// fiscalapi webhooks update <id> [--url] [--description] [--events] [--person…] [--enabled|--disabled]. El -/// PUT /api/v4/webhooks/{id} reemplaza todo el webhook: primero se lee con GET y solo cambia lo que la persona -/// escribió, para no borrar lo demás. -/// public sealed class UpdateWebhookCommand : Command { - // El texto de la API (WebhookMessages.CliListenerNotEditable). private const string CliListenerNotEditable = - "Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger."; + "A listen session cannot be updated. Use `fiscalapi listen` and `fiscalapi trigger` instead."; - private readonly Argument _id = new("id") { Description = "Id del webhook." }; + private readonly Argument _id = new("id") { Description = "Webhook ID." }; 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." + Description = "Enable the webhook and clear its disabled reason and date." }; private readonly Option _disabled = new("--disabled") { - Description = "Deshabilita el webhook y cierra sus entregas pendientes." + Description = "Disable the webhook and close its pending deliveries." }; 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.") + : base("update", "Update the URL, description, event types, persons or status of a webhook.") { _context = context; Arguments.Add(_id); @@ -50,10 +44,8 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc 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); + await api.RetrieveAsync(parseResult.GetRequiredValue(_id), cancellationToken), FiscalApiJsonContext.Default.Webhook); - // La API también lo rechaza, pero su validación de la URL (vacía en una escucha) respondería antes y confundiría. - // Es el estado del recurso, no el uso del comando: sale con 1, como el 400 de la API. if (current.IsCliListener) { throw new ResourceStateException(CliListenerNotEditable); @@ -68,13 +60,11 @@ private async Task RunAsync(ParseResult parseResult, CancellationToken canc WebhookOptions.GivenValue(parseResult, _webhook.Url), WebhookOptions.GivenValue(parseResult, _webhook.Description), WebhookOptions.GivenValue(parseResult, _webhook.Events), - // --all-persons es la lista vacía, que la API lee como "todas las personas". parseResult.GetValue(_webhook.AllPersons) ? [] : WebhookOptions.GivenValue(parseResult, _webhook.Persons), parseResult.GetValue(_enabled) ? WebhookStatuses.Enabled : parseResult.GetValue(_disabled) ? WebhookStatuses.Disabled : null); - // Sin cambios, el PUT reescribiría lo mismo: casi siempre es una opción olvidada. private void RequireOneChange(CommandResult result) { Option[] changes = @@ -82,7 +72,7 @@ private void RequireOneChange(CommandResult result) if (!changes.Any(option => result.GetResult(option) is { Implicit: false })) { result.AddError( - "Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled."); + "Specify what to update: --url, --description, --events, --person, --all-persons, --enabled or --disabled."); } } @@ -90,7 +80,7 @@ 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."); + result.AddError("--enabled cannot be used with --disabled."); } } } diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs index 5532d9f..c8de548 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookOptions.cs @@ -3,14 +3,8 @@ 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; @@ -19,27 +13,27 @@ public WebhookOptions(bool required) public Option Url { get; } = new("--url") { - Description = "URL https que recibe las entregas.", + Description = "HTTPS URL that receives the deliveries.", HelpName = "url" }; public Option Description { get; } = new("--description") { - Description = "Descripción libre, hasta 256 caracteres.", - HelpName = "texto" + Description = "Free-text description, up to 256 characters.", + HelpName = "text" }; 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", + Description = "Comma-separated event types, e.g. invoice.canceled,invoice.cancellation_rejected, or * for all.", + HelpName = "types", 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.", + Description = "ID of a person whose events the webhook receives. Repeat it or separate IDs with commas.", HelpName = "id", Arity = ArgumentArity.OneOrMore, CustomParser = ParsePersons @@ -47,7 +41,7 @@ public WebhookOptions(bool required) public Option AllPersons { get; } = new("--all-persons") { - Description = "Recibe los eventos de todas las personas (quita el filtro de --person)." + Description = "Receive events for all persons. Removes the --person filter." }; public void AddTo(Command command) @@ -60,20 +54,14 @@ public void AddTo(Command command) 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."); + result.AddError("--person needs at least one person ID. Use --all-persons to receive events for all persons."); } return CommaSeparatedValues.Parse(result); @@ -83,7 +71,7 @@ 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."); + result.AddError("--all-persons cannot be used with --person."); } } } diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs index ef310b9..e4a9942 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhookWithSecretOutput.cs @@ -4,11 +4,6 @@ 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) @@ -16,7 +11,6 @@ public static void Write(ApiCommandContext context, GlobalSettings settings, Jso 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 diff --git a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs index d5c0738..99795d4 100644 --- a/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs +++ b/src/FiscalApi.Cli/Commands/Webhooks/WebhooksCommand.cs @@ -4,15 +4,11 @@ namespace FiscalApi.Cli.Commands.Webhooks; -/// -/// fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret: los webhooks del -/// tenant sobre /api/v4/webhooks. Sin subcomando muestra la ayuda. -/// public sealed class WebhooksCommand : Command { public WebhooksCommand( GlobalOptions globalOptions, FiscalApiClientFactory apiClients, IConfirmationPrompt confirmation, CliConsole console) - : base("webhooks", "Administra los webhooks que reciben los eventos del tenant.") + : base("webhooks", "Manage the webhooks that receive the tenant's events.") { var context = new ApiCommandContext(globalOptions, apiClients, console); Subcommands.Add(new ListWebhooksCommand(context)); diff --git a/src/FiscalApi.Cli/Credentials/ApiCredentials.cs b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs index 83b4684..8ba79d3 100644 --- a/src/FiscalApi.Cli/Credentials/ApiCredentials.cs +++ b/src/FiscalApi.Cli/Credentials/ApiCredentials.cs @@ -2,19 +2,8 @@ namespace FiscalApi.Cli.Credentials; -/// -/// La API key y la tenant key con las que el CLI llama a la API (headers X-API-KEY y X-TENANT-KEY). -/// Solo se crean validadas, sin importar si vienen del teclado, de stdin, de las variables de entorno o del -/// almacén del SO: así un valor inválido nunca llega a un header ni a un mensaje de error. -/// public sealed record ApiCredentials { - /// - /// Holgado frente a lo que emite fapi-back (API key de 51 caracteres, tenant key GUID) y acotado para que el - /// secreto guardado nunca pase de 2560 bytes (CRED_MAX_CREDENTIAL_BLOB_SIZE, el límite más chico de los tres - /// almacenes): en JSON un carácter puede escaparse en seis bytes (" → \u0022), y 2 × 200 × 6 más - /// las llaves del objeto da 2428. Acotar la entrada da esa garantía sin un error más al guardar. - /// public const int MaxLength = 200; private ApiCredentials(string apiKey, string tenantKey) @@ -27,20 +16,12 @@ private ApiCredentials(string apiKey, string tenantKey) public string TenantKey { get; } - /// - /// Quita los espacios de los extremos y exige ASCII visible. Un salto de línea en un header haría que - /// lanzara una excepción que repite el valor; por eso el mensaje nunca lo incluye. - /// - /// Un valor vacío, demasiado largo o con caracteres no válidos. public static ApiCredentials Create(string? apiKey, string? tenantKey) => new(ValidApiKey(apiKey), ValidTenantKey(tenantKey)); - /// La API key validada sola, para rechazarla antes de pedir la tenant key. - /// Un valor vacío, demasiado largo o con caracteres no válidos. - public static string ValidApiKey(string? apiKey) => Validated(apiKey, "La API key"); + public static string ValidApiKey(string? apiKey) => Validated(apiKey, "The API key"); - private static string ValidTenantKey(string? tenantKey) => Validated(tenantKey, "La tenant key"); + private static string ValidTenantKey(string? tenantKey) => Validated(tenantKey, "The 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) @@ -48,7 +29,7 @@ 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."); + throw new UsageException($"{name} is empty, too long, or has invalid characters."); } return trimmed; diff --git a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs index c645e6b..c6cb1f8 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialResolver.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialResolver.cs @@ -2,18 +2,11 @@ namespace FiscalApi.Cli.Credentials; -/// -/// De dónde salen las credenciales de una invocación: las variables de entorno ganan sobre lo guardado en -/// el almacén del SO, para que el CI no dependa de un llavero. -/// public sealed class CredentialResolver(ICredentialStore store, Func readEnvironmentVariable) { public const string ApiKeyVariable = "FISCALAPI_API_KEY"; public const string TenantKeyVariable = "FISCALAPI_TENANT_KEY"; - /// Sin credenciales, con solo una de las dos variables o con un valor inválido. - /// Sin variables y sin almacén en el SO. - /// Sin variables, y el almacén está bloqueado o falló. public ApiCredentials Resolve(ApiEnvironment environment) { var apiKey = ReadVariable(ApiKeyVariable); @@ -23,14 +16,12 @@ public ApiCredentials Resolve(ApiEnvironment environment) { (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. + $"No credentials for {environment.Host}. Run `fiscalapi login` or set {ApiKeyVariable} and {TenantKeyVariable}."), _ => throw new UsageException( - $"Define {ApiKeyVariable} y {TenantKeyVariable} juntas; solo está definida {(apiKey is null ? TenantKeyVariable : ApiKeyVariable)}.") + $"Set both {ApiKeyVariable} and {TenantKeyVariable}; only {(apiKey is null ? TenantKeyVariable : ApiKeyVariable)} is set.") }; } - // 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 index 50bafb8..2aa5534 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreException.cs @@ -2,31 +2,24 @@ namespace FiscalApi.Cli.Credentials; -/// -/// El almacén del SO existe, pero no se pudo usar: está bloqueado y no se puede pedir la contraseña, no respondió, -/// falló con un código inesperado, o la entrada guardada no se puede leer. Es distinto de "no hay almacén" -/// (): aquí las variables de entorno no son la salida. Código 1. -/// public sealed class CredentialStoreException : CliException { private CredentialStoreException(string message, string? detail) : base(message) => Detail = detail; - /// Lo que respondió el SO (un código o el error de D-Bus), para diagnosticar; nunca trae el secreto. public string? Detail { get; } public override int ExitCode => ExitCodes.Failure; public static CredentialStoreException Locked(string detail) => - new("El almacén de credenciales del sistema existe, pero está bloqueado o no respondió.", detail); + new("The credential store is locked or not responding. Unlock it and try again.", detail); public static CredentialStoreException Failed(string operation, string detail) => - new($"El almacén de credenciales del sistema falló al {operation} la entrada.", detail); + new($"The credential store failed to {operation} the entry.", 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); + new($"Saved credentials for {host} are unreadable. Run `fiscalapi logout`, then `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) @@ -34,7 +27,7 @@ public override void WriteTo(TextWriter error) base.WriteTo(error); if (Detail is not null) { - error.WriteLine($" Detalle: {Detail}"); + error.WriteLine($" Details: {Detail}"); } } } diff --git a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs index 8590169..257988f 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStoreUnavailableException.cs @@ -2,15 +2,9 @@ namespace FiscalApi.Cli.Credentials; -/// -/// El SO no tiene un almacén de credenciales utilizable: Linux sin Secret Service, WSL, un servidor o una sesión -/// sin llavero. No hay archivo plano de respaldo; la salida es usar las variables de entorno, por eso es un -/// error de uso (código 2) y no de la API. -/// public sealed class CredentialStoreUnavailableException(string detail) - : CliException("No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY.") + : CliException($"No credential store is available. Set {CredentialResolver.ApiKeyVariable} and {CredentialResolver.TenantKeyVariable}.") { - /// 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; @@ -18,6 +12,6 @@ public sealed class CredentialStoreUnavailableException(string detail) public override void WriteTo(TextWriter error) { base.WriteTo(error); - error.WriteLine($" Detalle: {Detail}"); + error.WriteLine($" Details: {Detail}"); } } diff --git a/src/FiscalApi.Cli/Credentials/CredentialStores.cs b/src/FiscalApi.Cli/Credentials/CredentialStores.cs index 7701292..5546cfb 100644 --- a/src/FiscalApi.Cli/Credentials/CredentialStores.cs +++ b/src/FiscalApi.Cli/Credentials/CredentialStores.cs @@ -4,16 +4,13 @@ namespace FiscalApi.Cli.Credentials; -/// Elige el almacén de credenciales del SO en que corre el binario. public static class CredentialStores { - /// Nombre de servicio de las entradas del CLI; las pruebas usan otro para no tocarlas. public const string ProductionService = "fiscalapi-cli"; - /// Un SO para el que no se publica el CLI. public static ICredentialStore ForCurrentOs(string service) => OperatingSystem.IsWindows() ? new WindowsCredentialStore(service) : OperatingSystem.IsMacOS() ? new KeychainCredentialStore(service) : OperatingSystem.IsLinux() ? new LibSecretCredentialStore(service) - : throw new PlatformNotSupportedException("El CLI de FiscalAPI solo corre en Windows, macOS y Linux."); + : throw new PlatformNotSupportedException("The FiscalAPI CLI runs only on Windows, macOS, and Linux."); } diff --git a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs index ee2d799..90f044b 100644 --- a/src/FiscalApi.Cli/Credentials/ICredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/ICredentialStore.cs @@ -1,25 +1,12 @@ namespace FiscalApi.Cli.Credentials; -/// -/// Almacén de credenciales del SO, con una entrada por host de API (), así las -/// credenciales de test, live y el back local no se pisan. Nunca hay un archivo plano ni otro respaldo: -/// si el SO no tiene almacén, cada operación lanza ; si lo tiene -/// pero está bloqueado o falla, . -/// public interface ICredentialStore { - /// - /// Comprueba que el almacén se puede usar consultando la entrada del host, sin leer ni decodificar su secreto: - /// login lo hace antes de pedir las llaves, para no pedirlas si no se van a poder guardar. - /// void EnsureAvailable(string host); - /// Las credenciales guardadas para el host, o null si no hay. ApiCredentials? Read(string host); - /// Crea la entrada del host o reemplaza la que ya existía. void Save(string host, ApiCredentials credentials); - /// true si había una entrada y se borró; false si no había nada que borrar. bool Delete(string host); } diff --git a/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs index 1904700..02f3976 100644 --- a/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/Linux/LibSecretCredentialStore.cs @@ -4,24 +4,16 @@ namespace FiscalApi.Cli.Credentials.Linux; -/// -/// Secret Service de freedesktop (gnome-keyring, KWallet) por medio de libsecret: un secreto por host en la -/// colección por omisión, con los atributos service y host del esquema com.fiscalapi.cli. -/// Se ve en Seahorse ("Contraseñas y claves"). Sin libsecret, sin sesión de D-Bus o sin el servicio -/// org.freedesktop.secrets no hay almacén; con ellos, cualquier otro error es un almacén bloqueado o que -/// no respondió. -/// [SupportedOSPlatform("linux")] internal sealed unsafe class LibSecretCredentialStore(string service) : ICredentialStore { + private const string StoreName = "Secret Service"; private const string SchemaName = "com.fiscalapi.cli"; private const string ServiceAttribute = "service"; private const string HostAttribute = "host"; private delegate T SecretDecoder(ReadOnlySpan secret, string host); - // libsecret no tiene una consulta sin secreto: se busca la entrada y se libera sin decodificarla, así una - // entrada ilegible no impide que login la reemplace. public void EnsureAvailable(string host) => ReadSecret(host, static (_, _) => true); public ApiCredentials? Read(string host) => ReadSecret(host, StoredCredentialsFormat.Decode); @@ -29,7 +21,6 @@ internal sealed unsafe class LibSecretCredentialStore(string service) : ICredent 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 { @@ -38,7 +29,6 @@ public void Save(string host, ApiCredentials credentials) 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); } @@ -61,7 +51,6 @@ 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); @@ -79,7 +68,6 @@ public bool Delete(string host) } finally { - // Borra la memoria del secreto antes de liberarla. LibSecret.PasswordFree(password); } } @@ -93,31 +81,22 @@ private Query Open(string host) } catch (DllNotFoundException) { - throw new CredentialStoreUnavailableException("No se encontró libsecret-1.so.0 (paquete libsecret-1-0 o libsecret)."); + throw new CredentialStoreUnavailableException($"{StoreName}: libsecret-1.so.0 not found. Install libsecret-1-0 or libsecret."); } } - /// - /// Sin sesión de D-Bus (WSL, SSH, un contenedor) no hay Secret Service posible. Se comprueba aparte porque el - /// error que deja libsecret en ese caso es genérico (G_IO_ERROR_FAILED) y no se distingue de otras fallas. - /// private static void EnsureSessionBus() { GLib.GError* error = null; var connection = Gio.BusGetSync(Gio.BusTypeSession, cancellable: 0, &error); if (connection == 0) { - throw new CredentialStoreUnavailableException($"D-Bus: {GLib.TakeMessage(error)}"); + throw new CredentialStoreUnavailableException($"{StoreName}: no D-Bus session bus. {GLib.TakeMessage(error)}"); } GObject.Unref(connection); } - /// - /// Con la sesión de D-Bus ya comprobada, "el servicio no existe" (ServiceUnknown, NameHasNoOwner) es que no hay - /// almacén. Cualquier otro error (colección bloqueada, desbloqueo cancelado, sin respuesta) es un almacén que - /// existe y no se pudo usar. El mensaje es de D-Bus o del servicio; nunca trae el secreto. - /// private static void ThrowIfFailed(GLib.GError* error) { if (error == null) @@ -127,11 +106,10 @@ private static void ThrowIfFailed(GLib.GError* error) var serviceMissing = error->Domain == Gio.DBusErrorQuark() && error->Code is Gio.DBusErrorServiceUnknown or Gio.DBusErrorNameHasNoOwner; - var detail = $"Secret Service: {GLib.TakeMessage(error)}"; + var detail = $"{StoreName}: {GLib.TakeMessage(error)}"; throw serviceMissing ? new CredentialStoreUnavailableException(detail) : CredentialStoreException.Locked(detail); } - /// El esquema y los atributos de una entrada; libera lo nativo de ambos al terminar la operación. private sealed class Query : IDisposable { private readonly StringHashTable _attributes; @@ -141,7 +119,6 @@ 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); } diff --git a/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs index accb75c..61ed293 100644 --- a/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs +++ b/src/FiscalApi.Cli/Credentials/Linux/LinuxNative.cs @@ -4,16 +4,11 @@ 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 { @@ -34,21 +29,14 @@ 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. Se cargan una vez. - /// internal static (nint Hash, nint Equal) StringHashFunctions => StringHashFunctionPointers.Value; - /// Copia el mensaje del error y lo libera. internal static string TakeMessage(GError* error) { var message = Utf8StringMarshaller.ConvertToManaged(error->Message) ?? string.Empty; @@ -57,7 +45,6 @@ internal static string TakeMessage(GError* error) } } -/// GIO (gdbusconnection.h, gdbuserror.h): la sesión de D-Bus que usa libsecret. [SupportedOSPlatform("linux")] internal static unsafe partial class Gio { @@ -65,11 +52,9 @@ internal static unsafe partial class Gio 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); @@ -77,7 +62,6 @@ internal static unsafe partial class Gio internal static partial uint DBusErrorQuark(); } -/// GObject: libera la referencia a la conexión de D-Bus. [SupportedOSPlatform("linux")] internal static partial class GObject { @@ -85,10 +69,6 @@ internal static partial class GObject internal static partial void Unref(nint instance); } -/// -/// libsecret (secret-password.h, secret-schema.h): las variantes v, que reciben los atributos en un -/// GHashTable en lugar de argumentos variádicos, que el interop de .NET no puede llamar. -/// [SupportedOSPlatform("linux")] internal static unsafe partial class LibSecret { @@ -106,23 +86,16 @@ internal static unsafe partial class LibSecret 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 { diff --git a/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs index f6194eb..2b5c184 100644 --- a/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/MacOS/KeychainCredentialStore.cs @@ -3,16 +3,11 @@ namespace FiscalApi.Cli.Credentials.MacOS; -/// -/// Llavero de macOS: una contraseña genérica (kSecClassGenericPassword) por host, con servicio -/// fiscalapi-cli y cuenta igual al host. Se ve en "Acceso a Llaveros". -/// Usa el llavero de sesión del usuario y no el de protección de datos (kSecUseDataProtectionKeychain), -/// que exige firmar el binario con el entitlement keychain-access-groups. -/// [SupportedOSPlatform("macos")] internal sealed unsafe class KeychainCredentialStore(string service) : ICredentialStore { - // Sin kSecReturnData la consulta solo dice si la entrada existe: no lee el secreto ni pide permiso para leerlo. + private const string StoreName = "macOS Keychain"; + public void EnsureAvailable(string host) { using var serviceName = CoreFoundation.CreateString(service); @@ -24,7 +19,7 @@ public void EnsureAvailable(string host) var status = SecurityFramework.SecItemCopyMatching(Raw(query), result: null); if (status != SecurityFramework.ItemNotFound) { - ThrowIfFailed(status, "consultar"); + ThrowIfFailed(status, "check"); } } @@ -43,8 +38,7 @@ public void EnsureAvailable(string host) return null; } - ThrowIfFailed(status, "leer"); - // Un CFData inmutable no se puede borrar en su lugar: se libera en cuanto se decodifica. + ThrowIfFailed(status, "read"); using var data = CFHandle.Owned(result); return StoredCredentialsFormat.Decode(CoreFoundation.GetBytes(data), host); } @@ -59,7 +53,6 @@ public void Save(string host, ApiCredentials credentials) [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) { @@ -70,7 +63,7 @@ public void Save(string host, ApiCredentials credentials) status = SecurityFramework.SecItemAdd(Raw(item), result: null); } - ThrowIfFailed(status, "guardar"); + ThrowIfFailed(status, "save"); } public bool Delete(string host) @@ -87,7 +80,7 @@ public bool Delete(string host) return false; } - ThrowIfFailed(status, "borrar"); + ThrowIfFailed(status, "delete"); return true; } @@ -104,7 +97,6 @@ private static CFHandle CreateSecretData(ApiCredentials credentials) } } - // 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) @@ -114,13 +106,11 @@ private static void ThrowIfFailed(int status, string operation) return; } - var detail = $"llavero de macOS, OSStatus {status}"; + var detail = $"{StoreName}: OSStatus {status}"; throw status switch { - // Sin llavero de sesión: no hay almacén y la salida son las variables de entorno. SecurityFramework.NotAvailable or SecurityFramework.NoSuchKeychain or SecurityFramework.NoDefaultKeychain => new CredentialStoreUnavailableException(detail), - // El llavero existe pero está bloqueado y no se puede pedir la contraseña (p. ej. por SSH). SecurityFramework.InteractionNotAllowed => CredentialStoreException.Locked(detail), _ => CredentialStoreException.Failed(operation, detail) }; diff --git a/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs b/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs index bad5281..4fa8abc 100644 --- a/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs +++ b/src/FiscalApi.Cli/Credentials/MacOS/MacNative.cs @@ -3,10 +3,6 @@ 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 { @@ -16,7 +12,6 @@ internal static unsafe partial class CoreFoundation 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"); @@ -26,7 +21,6 @@ internal static unsafe partial class CoreFoundation 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) @@ -35,7 +29,6 @@ public static CFHandle CreateData(ReadOnlySpan bytes) } } - /// Los bytes viven mientras viva . public static ReadOnlySpan GetBytes(CFHandle data) => new(CFDataGetBytePtr(data.DangerousGetHandle()), checked((int)CFDataGetLength(data.DangerousGetHandle()))); @@ -69,10 +62,6 @@ private static partial nint CFDictionaryCreate( 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 { @@ -81,9 +70,8 @@ private CFHandle(nint 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."); + handle != 0 ? new CFHandle(handle) : throw new InvalidOperationException("CoreFoundation returned a null object."); protected override bool ReleaseHandle() { @@ -92,7 +80,6 @@ protected override bool ReleaseHandle() } } -/// Keychain Services de Security.framework (SecItem.h y SecBase.h). [SupportedOSPlatform("macos")] internal static unsafe partial class SecurityFramework { @@ -117,7 +104,6 @@ internal static unsafe partial class SecurityFramework 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); @@ -130,6 +116,5 @@ internal static unsafe partial class SecurityFramework [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 index bc89269..dfc47af 100644 --- a/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs +++ b/src/FiscalApi.Cli/Credentials/StoredCredentialsFormat.cs @@ -5,18 +5,12 @@ 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 diff --git a/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs b/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs index c1898fd..56d7d1f 100644 --- a/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs +++ b/src/FiscalApi.Cli/Credentials/Windows/AdvApi32.cs @@ -4,16 +4,11 @@ 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 { @@ -31,7 +26,6 @@ internal struct CREDENTIALW 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); diff --git a/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs index 566649e..e9a0934 100644 --- a/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs +++ b/src/FiscalApi.Cli/Credentials/Windows/WindowsCredentialStore.cs @@ -5,31 +5,24 @@ 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 string StoreName = "Windows Credential Manager"; + private const uint CredTypeGeneric = 1; - // Solo en este equipo y para esta persona. CRED_PERSIST_ENTERPRISE viajaría con un perfil móvil a otros equipos. private const uint CredPersistLocalMachine = 2; private const int ErrorNotFound = 1168; - // Sesiones de red o de servicio sin conjunto de credenciales: no hay almacén que usar. private const int ErrorNoSuchLogonSession = 1312; private delegate T SecretDecoder(ReadOnlySpan secret, string host); - // Credential Manager no tiene una consulta sin secreto: se lee la entrada y se borra sin decodificarla, así una - // entrada ilegible no impide que login la reemplace. - public void EnsureAvailable(string host) => ReadSecret(host, "consultar", static (_, _) => true); + public void EnsureAvailable(string host) => ReadSecret(host, "check", static (_, _) => true); - public ApiCredentials? Read(string host) => ReadSecret(host, "leer", StoredCredentialsFormat.Decode); + public ApiCredentials? Read(string host) => ReadSecret(host, "read", StoredCredentialsFormat.Decode); public void Save(string host, ApiCredentials credentials) { @@ -48,10 +41,9 @@ public void Save(string host, ApiCredentials credentials) 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"); + throw Failure(Marshal.GetLastPInvokeError(), "save"); } } } @@ -69,10 +61,9 @@ public bool Delete(string host) } var error = Marshal.GetLastPInvokeError(); - return error == ErrorNotFound ? false : throw Failure(error, "borrar"); + return error == ErrorNotFound ? false : throw Failure(error, "delete"); } - /// 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; @@ -84,7 +75,6 @@ public bool Delete(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 { @@ -104,6 +94,6 @@ public bool Delete(string host) private string TargetName(string host) => $"{service}:{host}"; private static CliException Failure(int error, string operation) => error == ErrorNoSuchLogonSession - ? new CredentialStoreUnavailableException($"Credential Manager no tiene credenciales para esta sesión (Win32 {error}).") - : CredentialStoreException.Failed(operation, $"Credential Manager, Win32 {error}"); + ? new CredentialStoreUnavailableException($"{StoreName}: not available in this logon session (Win32 error {error}).") + : CredentialStoreException.Failed(operation, $"{StoreName}: Win32 error {error}"); } diff --git a/src/FiscalApi.Cli/Errors/ApiException.cs b/src/FiscalApi.Cli/Errors/ApiException.cs index 6c4c1c8..f59d3f1 100644 --- a/src/FiscalApi.Cli/Errors/ApiException.cs +++ b/src/FiscalApi.Cli/Errors/ApiException.cs @@ -4,18 +4,12 @@ namespace FiscalApi.Cli.Errors; -/// -/// La API respondió con un error. Se arma desde el envelope de fapi-back y muestra -/// message, details y el traceIdentifier que pide soporte. -/// public sealed class ApiException : CliException { - // Pistas accionables por código HTTP. Viven aquí, en un mapa, y no en condicionales repartidos por los comandos. private static readonly FrozenDictionary Hints = new Dictionary { - // El back responde 401 tanto por la API key como por la tenant key (X-TENANT-KEY). [HttpStatusCode.Unauthorized] = - "Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY." + "Run `fiscalapi login` or set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY." }.ToFrozenDictionary(); private ApiException( @@ -34,10 +28,8 @@ private ApiException( public override int ExitCode => ExitCodes.Failure; - /// 408, 429 y 5xx (incluido el 502/503 de nginx en un deploy); un 4xx distinto nunca. public override bool IsTransient => TransientHttpStatus.Is(StatusCode); - /// El de la respuesta HTTP: el envelope omite httpStatusCode cuando vale el default. public HttpStatusCode StatusCode { get; } public string? Details { get; } @@ -46,28 +38,25 @@ private ApiException( public string? Hint => Hints.GetValueOrDefault(StatusCode); - /// El texto de cada regla que falló en un 400 de validación (). public IReadOnlyList ValidationErrors { get; } - /// Las fallas que trae el data de un 400 de validación, si las hay. public static ApiException FromEnvelope( HttpStatusCode statusCode, ApiResponse envelope, IReadOnlyList? validationErrors = null) => new( statusCode, - string.IsNullOrWhiteSpace(envelope.Message) ? $"La API respondió {(int)statusCode} sin mensaje." : envelope.Message, + string.IsNullOrWhiteSpace(envelope.Message) ? $"The API returned {(int)statusCode} without a message." : envelope.Message, NullIfBlank(envelope.Details), NullIfBlank(envelope.TraceIdentifier), validationErrors ?? []); - /// Un cuerpo que no es el envelope JSON, como el 502/503 en HTML de nginx. public static ApiException UnexpectedResponse(HttpStatusCode statusCode) => - new(statusCode, $"Respuesta inesperada ({(int)statusCode}).", details: null, traceIdentifier: null, validationErrors: []); + new(statusCode, $"Unexpected response ({(int)statusCode}).", details: null, traceIdentifier: null, validationErrors: []); public override void WriteTo(TextWriter error) { base.WriteTo(error); if (Details is not null && Details != Message) { - error.WriteLine($" Detalle: {Details}"); + error.WriteLine($" Details: {Details}"); } foreach (var validationError in ValidationErrors) @@ -75,15 +64,15 @@ public override void WriteTo(TextWriter error) error.WriteLine($" - {validationError}"); } - error.WriteLine($" Código HTTP: {(int)StatusCode}"); + error.WriteLine($" HTTP status: {(int)StatusCode}"); if (TraceIdentifier is not null) { - error.WriteLine($" traceIdentifier (para soporte): {TraceIdentifier}"); + error.WriteLine($" traceIdentifier (for support): {TraceIdentifier}"); } if (Hint is not null) { - error.WriteLine($" Sugerencia: {Hint}"); + error.WriteLine($" Hint: {Hint}"); } } diff --git a/src/FiscalApi.Cli/Errors/CliException.cs b/src/FiscalApi.Cli/Errors/CliException.cs index 115f1a9..7740ff3 100644 --- a/src/FiscalApi.Cli/Errors/CliException.cs +++ b/src/FiscalApi.Cli/Errors/CliException.cs @@ -1,15 +1,10 @@ namespace FiscalApi.Cli.Errors; -/// -/// Error esperado del CLI. Cada tipo sabe su código de salida y cómo mostrarse, así el único punto que atrapa -/// errores () no necesita condicionales por tipo. -/// public abstract class CliException(string message, Exception? innerException = null) : Exception(message, innerException) { public abstract int ExitCode { get; } - /// Si otro intento podría salir bien: lo consulta la reconexión de listen. public virtual bool IsTransient => false; public virtual void WriteTo(TextWriter error) => error.WriteLine($"Error: {Message}"); diff --git a/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs b/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs index f6c9e31..01d8a2f 100644 --- a/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs +++ b/src/FiscalApi.Cli/Errors/InvalidCredentialsException.cs @@ -1,11 +1,7 @@ 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) + : CliException($"Invalid credentials for {host}.", rejection) { public override int ExitCode => ExitCodes.Failure; } diff --git a/src/FiscalApi.Cli/Errors/NetworkException.cs b/src/FiscalApi.Cli/Errors/NetworkException.cs index 84d3125..60bfaf5 100644 --- a/src/FiscalApi.Cli/Errors/NetworkException.cs +++ b/src/FiscalApi.Cli/Errors/NetworkException.cs @@ -1,10 +1,8 @@ namespace FiscalApi.Cli.Errors; -/// La API no respondió: conexión rechazada, DNS, TLS o tiempo de espera agotado. public sealed class NetworkException(string message, Exception innerException) : CliException(message, innerException) { public override int ExitCode => ExitCodes.Failure; - /// Una red caída, un DNS que no responde o un timeout pueden resolverse solos. public override bool IsTransient => true; } diff --git a/src/FiscalApi.Cli/Errors/ResourceStateException.cs b/src/FiscalApi.Cli/Errors/ResourceStateException.cs index 094cb5e..6159396 100644 --- a/src/FiscalApi.Cli/Errors/ResourceStateException.cs +++ b/src/FiscalApi.Cli/Errors/ResourceStateException.cs @@ -1,10 +1,5 @@ namespace FiscalApi.Cli.Errors; -/// -/// El recurso no admite la operación en su estado (p. ej. editar una escucha de CLI). El CLI lo detecta con lo que ya leyó -/// de la API y lo informa con el texto que respondería la API; sale con 1, como un error de la API, porque el comando -/// estaba bien escrito. -/// public sealed class ResourceStateException(string message) : CliException(message) { public override int ExitCode => ExitCodes.Failure; diff --git a/src/FiscalApi.Cli/Errors/UsageException.cs b/src/FiscalApi.Cli/Errors/UsageException.cs index 807b002..427f5b0 100644 --- a/src/FiscalApi.Cli/Errors/UsageException.cs +++ b/src/FiscalApi.Cli/Errors/UsageException.cs @@ -1,8 +1,5 @@ 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 index 2b0d2ab..2dc1649 100644 --- a/src/FiscalApi.Cli/ExitCodes.cs +++ b/src/FiscalApi.Cli/ExitCodes.cs @@ -1,18 +1,12 @@ 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/GlobalOptions.cs b/src/FiscalApi.Cli/GlobalOptions.cs index 2c923c9..d4e8c05 100644 --- a/src/FiscalApi.Cli/GlobalOptions.cs +++ b/src/FiscalApi.Cli/GlobalOptions.cs @@ -3,20 +3,17 @@ 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.", + Description = "Use the live environment (https://live.fiscalapi.com) instead of test.", Recursive = true }; private readonly Option _apiBase = new("--api-base") { - Description = "URL base de la API (p. ej. el back local). Gana sobre --live.", + Description = "API base URL, e.g. a local API. Overrides --live.", HelpName = "url", Recursive = true, CustomParser = ParseApiBase @@ -24,13 +21,13 @@ public sealed class GlobalOptions private readonly Option _json = new("--json") { - Description = "Escribe solo JSON en stdout.", + Description = "Write only JSON to stdout.", Recursive = true }; private readonly Option _verbose = new("--verbose") { - Description = "Muestra el detalle HTTP en stderr, sin headers de credenciales.", + Description = "Show HTTP details on stderr, without credential headers.", Recursive = true }; @@ -47,21 +44,18 @@ public void AddTo(Command command) parseResult.GetValue(_json), parseResult.GetValue(_verbose)); - // Solo URLs absolutas http/https: una URL relativa o de otro esquema terminaría en un error de red confuso. private static Uri? ParseApiBase(ArgumentResult result) { var value = result.Tokens.Single().Value; if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) { - result.AddError($"--api-base debe ser una URL absoluta http o https; se recibió '{value}'."); + result.AddError($"--api-base must be an absolute http or https URL. Got '{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}'."); + result.AddError($"--api-base allows http only for localhost, 127.0.0.1 or ::1. Use https for '{value}'."); return null; } diff --git a/src/FiscalApi.Cli/GlobalSettings.cs b/src/FiscalApi.Cli/GlobalSettings.cs index 1b64a1b..86d3f04 100644 --- a/src/FiscalApi.Cli/GlobalSettings.cs +++ b/src/FiscalApi.Cli/GlobalSettings.cs @@ -1,4 +1,3 @@ namespace FiscalApi.Cli; -/// Valores de las opciones globales ya leídos de una invocación. public sealed record GlobalSettings(ApiEnvironment Environment, bool Json, bool Verbose); diff --git a/src/FiscalApi.Cli/Http/ApiData.cs b/src/FiscalApi.Cli/Http/ApiData.cs index aa7fbb0..99f0596 100644 --- a/src/FiscalApi.Cli/Http/ApiData.cs +++ b/src/FiscalApi.Cli/Http/ApiData.cs @@ -2,15 +2,10 @@ 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.") + ? throw new InvalidOperationException("The API returned a success response without data.") : data; } diff --git a/src/FiscalApi.Cli/Http/ApiResponse.cs b/src/FiscalApi.Cli/Http/ApiResponse.cs index 37ba668..38d9bde 100644 --- a/src/FiscalApi.Cli/Http/ApiResponse.cs +++ b/src/FiscalApi.Cli/Http/ApiResponse.cs @@ -1,9 +1,5 @@ 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; } diff --git a/src/FiscalApi.Cli/Http/ExponentialBackoff.cs b/src/FiscalApi.Cli/Http/ExponentialBackoff.cs index 0539813..fece675 100644 --- a/src/FiscalApi.Cli/Http/ExponentialBackoff.cs +++ b/src/FiscalApi.Cli/Http/ExponentialBackoff.cs @@ -1,12 +1,7 @@ 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; @@ -21,7 +16,6 @@ public ExponentialBackoff(TimeSpan baseDelay, TimeSpan 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); diff --git a/src/FiscalApi.Cli/Http/FiscalApiClient.cs b/src/FiscalApi.Cli/Http/FiscalApiClient.cs index d5887db..e7f3ad0 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClient.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClient.cs @@ -5,15 +5,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. Con como tipo del data, -/// el comando recibe el JSON tal como lo escribió la API (). -/// public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPolicy) { - /// Un GET es idempotente: se reintenta ante errores transitorios. public async Task GetAsync( string path, JsonTypeInfo> responseType, CancellationToken cancellationToken) { @@ -23,7 +16,6 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli return await ReadDataAsync(response, responseType, cancellationToken).ConfigureAwait(false); } - /// Un POST no se reintenta: repetirlo podría crear el recurso dos veces. public Task PostAsync( string path, TRequest body, @@ -32,15 +24,10 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli 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, @@ -49,7 +36,6 @@ public sealed class FiscalApiClient(HttpClient httpClient, RetryPolicy retryPoli 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); @@ -76,14 +62,12 @@ private async Task SendAsync( } catch (HttpRequestException exception) { - throw new NetworkException($"No se pudo conectar con {httpClient.BaseAddress}: {exception.Message}", exception); + throw new NetworkException($"Could not connect to {httpClient.BaseAddress}: {exception.Message}", exception); } - // HttpClient reporta su timeout como TaskCanceledException sin que el token de quien llama se haya - // cancelado. Si el token sí se canceló (Ctrl+C), la excepción sigue su camino hasta el código 130. catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) { throw new NetworkException( - $"{httpClient.BaseAddress} no respondió en {httpClient.Timeout.TotalSeconds:0} s.", exception); + $"{httpClient.BaseAddress} did not respond within {httpClient.Timeout.TotalSeconds:0} s.", exception); } } @@ -95,17 +79,11 @@ private async Task SendAsync( throw await ToApiExceptionAsync(response, cancellationToken).ConfigureAwait(false); } - // En un 2xx el contrato es nuestro: un JsonException aquí es un defecto del CLI y sube tal cual. var envelope = await response.Content.ReadFromJsonAsync(responseType, cancellationToken).ConfigureAwait(false) ?? throw ApiException.UnexpectedResponse(response.StatusCode); return envelope.Succeeded ? envelope.Data : throw ApiException.FromEnvelope(response.StatusCode, envelope); } - /// - /// Un error se lee con su data como JSON sin forma fija: solo el 400 de validación trae ahí las fallas, y la - /// forma de otro error no tiene por qué coincidir con la del éxito. Un cuerpo que no es JSON (el 502/503 en HTML de - /// nginx, un 3xx sin cuerpo) es una respuesta inesperada, no un fallo del CLI. - /// private static async Task ToApiExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken) { try diff --git a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs index 0a5e241..f4cc1a6 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiClientFactory.cs @@ -3,39 +3,21 @@ namespace FiscalApi.Cli.Http; -/// -/// El único lugar donde se fabrican los clientes HTTP del CLI. Comparte un solo handler (pool de conexiones) -/// y crea un por invocación, porque la URL base, --verbose, el timeout y las -/// credenciales dependen de las opciones globales y del comando. Llega a los comandos por constructor para que -/// sus pruebas inyecten un handler falso. -/// public sealed class FiscalApiClientFactory( HttpMessageHandler handler, CredentialResolver credentials, TextWriter diagnostics, RetryPolicy retryPolicy) { public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); - // Los de fapi-back (ApiConstants.ApiKeyHeader y TenantHeader). private const string ApiKeyHeader = "X-API-KEY"; private const string TenantKeyHeader = "X-TENANT-KEY"; - /// - /// El handler de producción, uno por proceso. Usa el proxy del sistema por omisión (UseProxy = true). - /// No sigue redirecciones: un 3xx termina como , así X-API-KEY y - /// X-TENANT-KEY nunca viajan a otro host y un POST no se convierte en GET. - /// public static SocketsHttpHandler CreateHandler() => new() { AllowAutoRedirect = false }; - /// - /// El cliente de los comandos de la API, con las credenciales del ambiente: las variables de entorno o, si no - /// están, las que guardó fiscalapi login (). - /// public FiscalApiClient Create(GlobalSettings settings, TimeSpan timeout) => Create(settings, credentials.Resolve(settings.Environment), timeout); - /// Con credenciales dadas: fiscalapi login valida las que la persona acaba de escribir. public FiscalApiClient Create(GlobalSettings settings, ApiCredentials apiCredentials, TimeSpan timeout) { - // Cada HttpClient envuelve el handler compartido sin adueñarse de él: lo libera Program al salir. var pipeline = settings.Verbose ? new VerboseLoggingHandler(diagnostics) { InnerHandler = handler } : handler; var httpClient = new HttpClient(pipeline, disposeHandler: false) { @@ -43,7 +25,6 @@ public FiscalApiClient Create(GlobalSettings settings, ApiCredentials apiCredent 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 bc28581..055fcf4 100644 --- a/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs +++ b/src/FiscalApi.Cli/Http/FiscalApiJsonContext.cs @@ -5,10 +5,6 @@ namespace FiscalApi.Cli.Http; -/// -/// Contrato JSON del CLI generado en compilación: la serialización por reflexión no funciona en Native AOT. -/// Los comandos agregan aquí los tipos de sus requests y respuestas (los enums con JsonStringEnumConverter<TEnum>). -/// [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(OpenCliListenerRequest))] @@ -18,7 +14,6 @@ namespace FiscalApi.Cli.Http; [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(ListenSecretRecord))] [JsonSerializable(typeof(ListenEventRecord))] -// El data se lee como JsonElement para imprimirlo sin cambios; los modelos solo alimentan la salida de texto. [JsonSerializable(typeof(ApiResponse))] [JsonSerializable(typeof(Webhook))] [JsonSerializable(typeof(PagedList))] diff --git a/src/FiscalApi.Cli/Http/PagedList.cs b/src/FiscalApi.Cli/Http/PagedList.cs index 79113ab..b71de7f 100644 --- a/src/FiscalApi.Cli/Http/PagedList.cs +++ b/src/FiscalApi.Cli/Http/PagedList.cs @@ -1,10 +1,5 @@ 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; } = []; @@ -18,10 +13,8 @@ public sealed class PagedList 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)), diff --git a/src/FiscalApi.Cli/Http/PersonFeatures.cs b/src/FiscalApi.Cli/Http/PersonFeatures.cs index 1de381c..6ce931e 100644 --- a/src/FiscalApi.Cli/Http/PersonFeatures.cs +++ b/src/FiscalApi.Cli/Http/PersonFeatures.cs @@ -1,9 +1,5 @@ namespace FiscalApi.Cli.Http; -/// -/// Lo que el CLI lee de GET /api/v4/me/features (PersonFeaturesDto de fapi-back): solo los permisos -/// concedidos. Un permiso que los features del tenant niegan va en denied, no aquí. -/// public sealed class PersonFeatures { public IReadOnlyList Permissions { get; init; } = []; diff --git a/src/FiscalApi.Cli/Http/QueryString.cs b/src/FiscalApi.Cli/Http/QueryString.cs index 22d0811..50f76b4 100644 --- a/src/FiscalApi.Cli/Http/QueryString.cs +++ b/src/FiscalApi.Cli/Http/QueryString.cs @@ -1,14 +1,12 @@ 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!)}") + .Select(parameter => parameter.Value is { } value ? $"{parameter.Key}={Uri.EscapeDataString(value)}" : null) + .OfType() .ToList(); return pairs.Count == 0 ? "" : "?" + string.Join('&', pairs); } diff --git a/src/FiscalApi.Cli/Http/RetryPolicy.cs b/src/FiscalApi.Cli/Http/RetryPolicy.cs index 2e41b3e..a8acf0c 100644 --- a/src/FiscalApi.Cli/Http/RetryPolicy.cs +++ b/src/FiscalApi.Cli/Http/RetryPolicy.cs @@ -1,10 +1,5 @@ namespace FiscalApi.Cli.Http; -/// -/// Reintentos con backoff exponencial y jitter, solo ante errores transitorios: conexión fallida o cortada, -/// 408, 429 y 5xx. Un 4xx nunca se reintenta, y un timeout tampoco: el plazo que ve la persona es el del -/// mensaje. Quien la usa decide qué requests son seguros de repetir (solo GET). -/// public sealed class RetryPolicy { private readonly ExponentialBackoff _backoff; @@ -18,13 +13,11 @@ public RetryPolicy(int maxAttempts, TimeSpan baseDelay, TimeSpan maxDelay, TimeP _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) { @@ -52,15 +45,9 @@ public async Task SendAsync( } } - // Solo fallas que otro intento puede resolver. Un error de TLS o de DNS (casi siempre un --api-base mal - // escrito) se repetiría igual, y solo retrasaría el mensaje. private static bool IsTransient(HttpRequestError error) => error is HttpRequestError.ConnectionError or HttpRequestError.ResponseEnded; - /// - /// Respeta Retry-After cuando viene. Si pide esperar más que el máximo, no se reintenta y el error - /// llega a la persona en lugar de dejar la terminal colgada. - /// private TimeSpan? DelayBeforeRetry(int attempt, HttpResponseMessage response) { var delay = RetryAfter(response) ?? _backoff.Delay(attempt); diff --git a/src/FiscalApi.Cli/Http/TransientHttpStatus.cs b/src/FiscalApi.Cli/Http/TransientHttpStatus.cs index cf0e8fe..15a8db7 100644 --- a/src/FiscalApi.Cli/Http/TransientHttpStatus.cs +++ b/src/FiscalApi.Cli/Http/TransientHttpStatus.cs @@ -2,7 +2,6 @@ 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) => diff --git a/src/FiscalApi.Cli/Http/ValidationFailures.cs b/src/FiscalApi.Cli/Http/ValidationFailures.cs index 28445b8..cb9c42b 100644 --- a/src/FiscalApi.Cli/Http/ValidationFailures.cs +++ b/src/FiscalApi.Cli/Http/ValidationFailures.cs @@ -2,14 +2,8 @@ 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) diff --git a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs index 47cce8d..1b42ae7 100644 --- a/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs +++ b/src/FiscalApi.Cli/Http/VerboseLoggingHandler.cs @@ -3,11 +3,6 @@ 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) @@ -36,13 +31,12 @@ protected override async Task SendAsync( } catch (HttpRequestException exception) { - diagnostics.WriteLine($"! {line} falló ({exception.HttpRequestError}) tras {Elapsed(startedAt)}: {exception.Message}"); + diagnostics.WriteLine($"! {line} failed ({exception.HttpRequestError}) after {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)"); + diagnostics.WriteLine($"! {line} got no response after {Elapsed(startedAt)} (timed out or canceled)"); throw; } diff --git a/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs b/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs index d1ecd7e..028a412 100644 --- a/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs +++ b/src/FiscalApi.Cli/Input/ConsoleConfirmationPrompt.cs @@ -1,23 +1,14 @@ 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" }; + private static readonly HashSet Yes = new(StringComparer.OrdinalIgnoreCase) { "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. + await prompts.WriteAsync($"{question} [y/N] ").ConfigureAwait(false); var answer = await Task.Run(input.ReadLine, CancellationToken.None).WaitAsync(cancellationToken).ConfigureAwait(false); return answer is not null && Yes.Contains(answer.Trim()); } diff --git a/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs index 53181f6..f30d7a8 100644 --- a/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs +++ b/src/FiscalApi.Cli/Input/ConsoleSecretReader.cs @@ -2,19 +2,8 @@ namespace FiscalApi.Cli.Input; -/// -/// Una persona en la terminal: muestra el prompt en stderr y lee las teclas sin eco, así el secreto no queda en -/// pantalla ni en el scrollback. -/// -/// Donde se escriben el prompt y el salto de línea final; nunca una tecla. -/// -/// La siguiente tecla sin eco, o null si todavía no hay ninguna. En producción, -/// Console.KeyAvailable y Console.ReadKey(intercept: true); en las pruebas, teclas simuladas. -/// public sealed class ConsoleSecretReader(TextWriter prompts, Func tryReadKey) : ISecretReader { - // Console.ReadKey bloquea sin token: se espera a que haya tecla en intervalos cortos para que Ctrl+C (que - // Program convierte en la cancelación del token) termine la lectura con el código 130. private static readonly TimeSpan KeyPollInterval = TimeSpan.FromMilliseconds(50); public async Task ReadAsync(string prompt, CancellationToken cancellationToken) diff --git a/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs b/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs index 22ac391..cc9c4fe 100644 --- a/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs +++ b/src/FiscalApi.Cli/Input/IConfirmationPrompt.cs @@ -1,11 +1,8 @@ namespace FiscalApi.Cli.Input; -/// Una pregunta de sí o no antes de una acción que no se deshace (p. ej. webhooks delete). public interface IConfirmationPrompt { - /// Si hay una persona en la terminal que pueda responder; con stdin redirigido, no. bool IsInteractive { get; } - /// true solo si la persona respondió que sí. Task ConfirmAsync(string question, CancellationToken cancellationToken); } diff --git a/src/FiscalApi.Cli/Input/ISecretReader.cs b/src/FiscalApi.Cli/Input/ISecretReader.cs index ee6a100..b19792e 100644 --- a/src/FiscalApi.Cli/Input/ISecretReader.cs +++ b/src/FiscalApi.Cli/Input/ISecretReader.cs @@ -1,11 +1,6 @@ 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 index 3eb6683..37772ec 100644 --- a/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs +++ b/src/FiscalApi.Cli/Input/RedirectedSecretReader.cs @@ -1,9 +1,5 @@ namespace FiscalApi.Cli.Input; -/// -/// Stdin redirigido (printf '%s\n%s\n' "$KEY" "$TENANT" | fiscalapi login): un valor por línea y sin -/// prompts, porque no hay una persona que los lea. -/// public sealed class RedirectedSecretReader(TextReader input) : ISecretReader { private const char ByteOrderMark = '\uFEFF'; @@ -13,7 +9,6 @@ public sealed class RedirectedSecretReader(TextReader input) : ISecretReader 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); diff --git a/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs b/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs index 087e2bd..d650f13 100644 --- a/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs +++ b/src/FiscalApi.Cli/Listen/ClaimedDelivery.cs @@ -1,7 +1,3 @@ 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 index 7c0e466..ecad60b 100644 --- a/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs +++ b/src/FiscalApi.Cli/Listen/DeliveryDispatcher.cs @@ -2,35 +2,16 @@ namespace FiscalApi.Cli.Listen; -/// -/// Corre el trabajo de cada entrega de listen fuera del long-polling, con dos reglas: -/// -/// Orden por evento: las entregas del mismo evento (un reenvío con events resend) corren una tras otra, -/// en el orden en que llegaron; las de eventos distintos, en paralelo. -/// Concurrencia acotada: como mucho maxInFlight entregas corren a la vez. El cupo se toma cuando le -/// toca su turno a la entrega, así una cadena larga del mismo evento no ocupa cupo mientras espera. -/// -/// nunca espera: el long-polling sigue a tiempo y la sesión no caduca aunque el receptor sea lento. -/// Lo que espera su turno ocupa solo memoria (cada lote trae como mucho 25 entregas), y la que llega a su turno con el lease -/// vencido se descarta sin reenviarse (). -/// El trabajo no debe lanzar: una excepción es un defecto del CLI, se guarda y sale en , que -/// el long-polling revisa en cada vuelta para terminar por el único punto que atrapa errores. -/// public sealed class DeliveryDispatcher : IAsyncDisposable { private readonly SemaphoreSlim _slots; private readonly CancellationTokenSource _stopping; private readonly Lock _gate = new(); - // La última entrega aceptada de cada evento con trabajo pendiente; la siguiente del mismo evento la espera. La entrada - // se borra cuando termina la última, así el diccionario no crece durante una escucha larga. Como cada entrega espera a - // la anterior de su evento, que terminen las últimas significa que terminó todo. private readonly Dictionary _lastByEvent = new(StringComparer.Ordinal); private ExceptionDispatchInfo? _fault; - /// Entregas que pueden correr a la vez. - /// Ctrl+C: cancela el trabajo en curso. public DeliveryDispatcher(int maxInFlight, CancellationToken cancellationToken) { ArgumentOutOfRangeException.ThrowIfLessThan(maxInFlight, 1); @@ -38,9 +19,6 @@ public DeliveryDispatcher(int maxInFlight, CancellationToken cancellationToken) _stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); } - /// Acepta la entrega y vuelve de inmediato; su trabajo corre cuando le toca su turno y hay cupo. - /// El evento de la entrega: ordena las del mismo evento. - /// El trabajo de la entrega; recibe el token que cancela . public void Enqueue(string eventId, Func work) { var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -54,13 +32,8 @@ public void Enqueue(string eventId, Func work) _ = RunAsync(eventId, previous, work, done); } - /// Relanza, una vez, la primera excepción que lanzó el trabajo de una entrega. public void ThrowIfFaulted() => Interlocked.Exchange(ref _fault, null)?.Throw(); - /// - /// Cancela el trabajo en curso, descarta lo que esperaba su turno y espera a que todo termine: después no queda nada - /// escribiendo en la consola. - /// public async ValueTask DisposeAsync() { await _stopping.CancelAsync().ConfigureAwait(false); @@ -79,7 +52,6 @@ private async Task RunAsync(string eventId, Task previous, Func -/// Lo que pasó con una entrega, decidido por su destino (): es lo que lleva su acuse y lo que -/// muestra la terminal. -/// -/// El código que se acusa; nulo si el receptor no respondió. -/// Por qué el receptor no respondió, con los nombres de WebhookAttemptError de fapi-back. -/// Lo que tardó la entrega. -/// Si hubo un reenvío cuyo resultado se muestra; sin --forward-to no lo hay. public sealed record DeliveryOutcome(int? StatusCode, string? Error, int LatencyMs, bool Forwarded) { public static DeliveryOutcome Responded(int statusCode, int latencyMs) => new(statusCode, null, latencyMs, Forwarded: true); diff --git a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs index 6a9ec11..5afe87e 100644 --- a/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs +++ b/src/FiscalApi.Cli/Listen/DeliveryProcessor.cs @@ -3,11 +3,6 @@ namespace FiscalApi.Cli.Listen; -/// -/// El trabajo de una entrega: reenviarla (o solo mostrarla), escribir su línea y acusarla. Todo lo que tiene resultado se -/// acusa, también lo que el receptor no pudo recibir: una entrega sin acuse queda abierta hasta que la API la cierra al -/// vencer su lease, sin reintentos. -/// public sealed class DeliveryProcessor( IDeliveryTarget target, ListenerApi api, @@ -15,12 +10,8 @@ public sealed class DeliveryProcessor( ListenTimings timings, TimeProvider timeProvider) { - /// La entrega, su sesión y el plazo de su acuse. - /// Ctrl+C o el fin de la escucha: cancela el reenvío en curso. public async Task ProcessAsync(ClaimedDelivery claimed, CancellationToken cancellationToken) { - // Con el lease vencido la API ya la cerró por AckTimeout: reenviarla tarde no se registraría y su firma podría - // salir de la tolerancia de 5 min del receptor. if (timeProvider.GetUtcNow() >= claimed.AckDeadline) { output.WriteExpired(claimed.Delivery); @@ -40,10 +31,6 @@ public async Task ProcessAsync(ClaimedDelivery claimed, CancellationToken cancel } } - /// - /// 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; @@ -58,8 +45,6 @@ private async Task AcknowledgeAsync(ClaimedDelivery claimed, CliDeliveryAck ack, { 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); @@ -73,10 +58,6 @@ 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); @@ -90,11 +71,9 @@ private async Task AcknowledgeOnShutdownAsync(ClaimedDelivery claimed, CliDelive } catch (OperationCanceledException) when (deadline.IsCancellationRequested) { - // Sin respuesta en el plazo: la API la cierra por AckTimeout. } } - // Solo si el reintento sale antes de que venza el lease: después la API responde 409 de todos modos. private TimeSpan? RetryIn(int attempt, DateTimeOffset ackDeadline) { var retryIn = timings.Reconnect.Delay(attempt); diff --git a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs index cfc36d3..59e2645 100644 --- a/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs +++ b/src/FiscalApi.Cli/Listen/DisplayOnlyTarget.cs @@ -1,10 +1,7 @@ namespace FiscalApi.Cli.Listen; -/// Sin --forward-to: la entrega solo se muestra en la terminal. public sealed class DisplayOnlyTarget : IDeliveryTarget { - // La terminal es el destino y la entrega llegó. Se acusa como un 200 inmediato para que el log de - // entregas no la muestre fallida. private static readonly Task Displayed = Task.FromResult(new DeliveryOutcome(StatusCode: 200, Error: null, LatencyMs: 0, Forwarded: false)); diff --git a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs index e170e70..17b55e9 100644 --- a/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs +++ b/src/FiscalApi.Cli/Listen/HttpForwardTarget.cs @@ -3,13 +3,8 @@ 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" }; @@ -30,11 +25,6 @@ private HttpForwardTarget( _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, @@ -42,8 +32,6 @@ public static HttpForwardTarget Create( 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) { @@ -54,11 +42,6 @@ public static HttpForwardTarget Create( 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(); @@ -69,7 +52,6 @@ public async Task DeliverAsync(CliDelivery delivery, Cancellati .ConfigureAwait(false); return Outcome((int)response.StatusCode, ElapsedMs(startedAt)); } - // El timeout de HttpClient llega como cancelación sin que nadie haya cancelado el token de quien llama. catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return DeliveryOutcome.Failed(TimeoutError, ElapsedMs(startedAt)); @@ -84,7 +66,6 @@ public async Task DeliverAsync(CliDelivery delivery, Cancellati 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 }; @@ -96,15 +77,11 @@ 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", diff --git a/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs b/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs index 422022e..86dc92b 100644 --- a/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs +++ b/src/FiscalApi.Cli/Listen/IDeliveryTarget.cs @@ -1,8 +1,6 @@ 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 index ff6834d..7011c4d 100644 --- a/src/FiscalApi.Cli/Listen/JsonListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/JsonListenOutput.cs @@ -4,7 +4,6 @@ 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) { @@ -14,7 +13,6 @@ public override void WriteSecret(string secret) => public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome) { var summary = WebhookEventSummary.Of(delivery); - // Sin --forward-to no hay resultado del receptor que mostrar. var forwarded = outcome.Forwarded ? outcome : null; var record = new ListenEventRecord( TimeProvider.GetLocalNow(), @@ -29,17 +27,8 @@ public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome } } -/// La línea de --print-secret --json. public sealed record ListenSecretRecord(string Secret); -/// -/// La línea de una entrega con --json. Sin --forward-to no lleva statusCode, latencyMs ni error; con él, -/// statusCode o error, como su acuse. -/// -/// Cuando terminó el reenvío, en la hora local con su desfase. -/// El de webhook-id, o "?" si no vino. -/// El del sobre, o "?" si el cuerpo no es el sobre del evento. -/// El sobre del evento tal como llegó; se omite si el cuerpo no es JSON. public sealed record ListenEventRecord( DateTimeOffset Time, string DeliveryId, diff --git a/src/FiscalApi.Cli/Listen/ListenLoop.cs b/src/FiscalApi.Cli/Listen/ListenLoop.cs index fc1ac2c..230dc9a 100644 --- a/src/FiscalApi.Cli/Listen/ListenLoop.cs +++ b/src/FiscalApi.Cli/Listen/ListenLoop.cs @@ -3,10 +3,6 @@ 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, @@ -15,19 +11,12 @@ public sealed class ListenLoop( ListenTimings timings, TimeProvider timeProvider) { - /// - /// No termina por sí solo: sale con Ctrl+C (, código 130) o con un error que no - /// se resuelve reintentando (un 409 de sesión reemplazada, un 401, un 404), que atrapa . - /// Al salir, el despachador cancela los reenvíos en curso y espera a que terminen. - /// public async Task RunAsync(CancellationToken cancellationToken) { - // La primera apertura no se reintenta: un --api-base equivocado o una key sin permiso se informan de inmediato. CliListenerSession? session = await api.OpenAsync(enabledEvents, cancellationToken).ConfigureAwait(false); output.WriteReady(session); await using var dispatcher = new DeliveryDispatcher(timings.MaxInFlightDeliveries, cancellationToken); - // Cada cuenta se reinicia con el primer long-polling exitoso. var connectionFailures = 0; var busyReopens = 0; var expirationsInARow = 0; @@ -36,13 +25,9 @@ public async Task RunAsync(CancellationToken cancellationToken) IReadOnlyList deliveries; try { - // Misma escucha y mismo secreto: la sesión nueva solo cambia su sessionId. session ??= await api.OpenAsync(enabledEvents, cancellationToken).ConfigureAwait(false); deliveries = await api.PollAsync(session, cancellationToken).ConfigureAwait(false); } - // 410: la sesión caducó (más de 60 s sin long-polling, p. ej. tras un corte). Se reabre sin terminar; lo que - // pasó mientras tanto no llega y se recupera con events resend. El primero reabre de inmediato; otro - // sin un long-polling exitoso de por medio espera el backoff, para no quedar girando. catch (ApiException expired) when (expired.StatusCode == HttpStatusCode.Gone) { output.WriteSessionExpired(); @@ -54,9 +39,6 @@ public async Task RunAsync(CancellationToken cancellationToken) continue; } - // Con la sesión nula falló la reapertura, no el long-polling: su 409 dice que otra apertura de la misma persona - // tiene tomado el candado de escritura de sus webhooks en el back, y otro intento lo resuelve. El 409 del long-polling - // (otra sesión reemplazó a esta) no entra aquí y termina la escucha con el texto de la API. catch (ApiException busy) when (session is null && busy.StatusCode == HttpStatusCode.Conflict) { var retryIn = timings.Reconnect.Delay(++busyReopens); @@ -64,8 +46,6 @@ public async Task RunAsync(CancellationToken cancellationToken) await WaitAsync(retryIn, cancellationToken).ConfigureAwait(false); continue; } - // Una red caída o un 5xx (un deploy de la API) no terminan la escucha: se reintenta con backoff y jitter. Si - // el corte pasa de 60 s, la sesión caduca y el siguiente long-polling lo dice con un 410. catch (CliException failure) when (failure.IsTransient) { var retryIn = timings.Reconnect.Delay(++connectionFailures); @@ -90,7 +70,6 @@ private Task WaitAsync(TimeSpan delay, CancellationToken cancellationToken) => private void Dispatch(DeliveryDispatcher dispatcher, CliListenerSession session, IReadOnlyList deliveries) { dispatcher.ThrowIfFaulted(); - // El lease corre desde que la API reclamó las entregas, justo antes de responder: se cuenta desde la respuesta. var ackDeadline = timeProvider.GetUtcNow() + timings.DeliveryLease; foreach (var delivery in deliveries) { diff --git a/src/FiscalApi.Cli/Listen/ListenOutput.cs b/src/FiscalApi.Cli/Listen/ListenOutput.cs index 0fc542d..b8f9199 100644 --- a/src/FiscalApi.Cli/Listen/ListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/ListenOutput.cs @@ -3,12 +3,6 @@ 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(); @@ -20,43 +14,33 @@ public static ListenOutput For(GlobalSettings settings, CliConsole console, Time ? new JsonListenOutput(console, settings.Verbose, timeProvider) : new TextListenOutput(console, settings.Verbose, timeProvider); - /// --print-secret: el secreto solo, para usarlo en un script. public abstract void WriteSecret(string secret); - /// Una entrega con lo que pasó al reenviarla. public abstract void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome); - /// - /// El texto aprobado con el secreto. Es el único lugar, junto con --print-secret, donde se escribe el - /// secreto; va a stderr porque es para la persona, no un dato del flujo de eventos. - /// public void WriteReady(CliListenerSession session) => - WriteDiagnostic($"Listo. Tu secreto de firma es {session.Secret} (escucha {session.Id})"); + WriteDiagnostic($"Ready. Your signing secret is {session.Secret} (listen session {session.Id})."); public void WriteConnectionLost(CliException failure, TimeSpan retryIn) => - WriteDiagnostic($"Se perdió la conexión con FiscalAPI ({Detail(failure)}); reintentando en {Seconds(retryIn)} s."); + WriteDiagnostic($"Lost connection to FiscalAPI ({Detail(failure)}). Retrying in {Seconds(retryIn)} s."); - public void WriteReconnected() => WriteDiagnostic("Conexión con FiscalAPI restablecida."); + public void WriteReconnected() => WriteDiagnostic("Reconnected to FiscalAPI."); - public void WriteSessionExpired() => WriteDiagnostic("La sesión de fiscalapi listen caducó; se abre una nueva."); + public void WriteSessionExpired() => WriteDiagnostic("The listen session expired. Opening a new one."); public void WriteReopenRetry(CliException failure, TimeSpan retryIn) => - WriteDiagnostic($"No se pudo reabrir la sesión de escucha ({Detail(failure)}); reintentando en {Seconds(retryIn)} s."); + WriteDiagnostic($"Could not reopen the listen session ({Detail(failure)}). Retrying in {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."); + $"Delivery {delivery.DeliveryId} ({delivery.EventId ?? WebhookEventSummary.Unknown}) expired before " + + "forwarding. The API closed it unacknowledged."); - /// 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}"); + WriteDiagnostic($"Could not acknowledge delivery {deliveryId}: {failure.Message}"); } } diff --git a/src/FiscalApi.Cli/Listen/ListenTimings.cs b/src/FiscalApi.Cli/Listen/ListenTimings.cs index e4ada41..4d265e2 100644 --- a/src/FiscalApi.Cli/Listen/ListenTimings.cs +++ b/src/FiscalApi.Cli/Listen/ListenTimings.cs @@ -2,24 +2,6 @@ namespace FiscalApi.Cli.Listen; -/// -/// Los plazos de fiscalapi listen. son los del contrato con la API; las pruebas los -/// acortan para no esperar 15 s a un receptor ni 30 s a una reconexión. -/// -/// Timeout del cliente de la API: cubre el long-polling de . -/// La espera que se pide a cada long-polling; la API la acota a 25 s. -/// Timeout de cada reenvío a --forward-to. -/// -/// Espera entre reintentos tras un error de red o un 5xx, tras un 409 al reabrir o entre dos 410 seguidos, con jitter. -/// -/// Entregas que se reenvían a la vez; las que esperan su turno no cuentan. -/// -/// Cuánto espera la API el acuse de una entrega que respondió el long-polling (WebhookConsts.DeliveryLease): -/// después la cierra sin acuse, así que ni se reenvía ni se reintenta su acuse más allá. -/// -/// -/// Al salir con Ctrl+C, el plazo del único intento de acusar lo que ya tenía resultado. -/// public sealed record ListenTimings( TimeSpan ApiTimeout, int PollWaitSeconds, @@ -34,10 +16,7 @@ public sealed record ListenTimings( PollWaitSeconds: 25, ForwardTimeout: TimeSpan.FromSeconds(15), Reconnect: new ExponentialBackoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)), - // El long-polling nunca espera a los reenvíos (DeliveryDispatcher), así que este cupo solo acota cuántos receptores - // se atienden a la vez; no influye en que la sesión siga viva (60 s sin long-polling). MaxInFlightDeliveries: 16, DeliveryLease: TimeSpan.FromMinutes(2), - // Corto: la persona pidió salir. Un acuse que no llega en este plazo lo cierra la API por AckTimeout. ShutdownAckTimeout: TimeSpan.FromSeconds(2)); } diff --git a/src/FiscalApi.Cli/Listen/ListenerApi.cs b/src/FiscalApi.Cli/Listen/ListenerApi.cs index 091dc79..cea5a72 100644 --- a/src/FiscalApi.Cli/Listen/ListenerApi.cs +++ b/src/FiscalApi.Cli/Listen/ListenerApi.cs @@ -2,12 +2,10 @@ namespace FiscalApi.Cli.Listen; -/// Las rutas de la escucha de CLI sobre . public sealed class ListenerApi(FiscalApiClient client, int pollWaitSeconds) { private const string WebhooksPath = "api/v4/webhooks"; - /// Abre una sesión nueva, que reemplaza la anterior de la persona, y responde el secreto. public async Task OpenAsync(IReadOnlyList enabledEvents, CancellationToken cancellationToken) => await client.PostAsync( $"{WebhooksPath}/cli-listener", @@ -15,12 +13,8 @@ await client.PostAsync( FiscalApiJsonContext.Default.OpenCliListenerRequest, FiscalApiJsonContext.Default.ApiResponseCliListenerSession, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("La API abrió la escucha de CLI sin responder la sesión."); + ?? throw new InvalidOperationException("The API opened the listen session but returned no session data."); - /// - /// 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}", @@ -28,10 +22,6 @@ await client.GetAsync( 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", diff --git a/src/FiscalApi.Cli/Listen/ListenerContracts.cs b/src/FiscalApi.Cli/Listen/ListenerContracts.cs index 0f9299d..3c77880 100644 --- a/src/FiscalApi.Cli/Listen/ListenerContracts.cs +++ b/src/FiscalApi.Cli/Listen/ListenerContracts.cs @@ -2,47 +2,24 @@ namespace FiscalApi.Cli.Listen; -// Los contratos de la escucha de CLI de la API (WebhookCliListenerController), con sus mismos nombres. - -/// El cuerpo de POST /api/v4/webhooks/cli-listener (OpenCliListenerCommand). public sealed record OpenCliListenerRequest(IReadOnlyList EnabledEvents); -/// -/// La sesión que abre la API (CliListenerSessionDto). Clase y no record: su ToString no debe escribir el -/// secreto si algún día termina en un mensaje. -/// public sealed class CliListenerSession { - /// El id de la escucha: el mismo en cada apertura de la persona. public required string Id { get; init; } - /// La sesión abierta: la llevan el long-polling y cada acuse; otra apertura la reemplaza. public required string SessionId { get; init; } - /// El secreto whsec_… con el que la API firma las entregas; estable entre sesiones. public required string Secret { get; init; } } -/// Una entrega del long-polling (WebhookCliDeliveryDto): se reenvía tal cual. -/// El id que lleva su acuse. -/// webhook-id, webhook-timestamp y webhook-signature, ya firmados. -/// El sobre del evento: los mismos caracteres que se firmaron. public sealed record CliDelivery(string DeliveryId, IReadOnlyDictionary Headers, string Body) { - /// El id del evento que la API manda en webhook-id; nulo si no vino. public string? EventId => Headers.GetValueOrDefault(WebhookHeaderNames.Id); - /// - /// Ordena las entregas del mismo evento. Sin webhook-id (un defecto del contrato) la entrega no espera a ninguna - /// otra, pero igual se muestra y se acusa. - /// public string OrderingKey => EventId ?? DeliveryId; } -/// -/// El cuerpo del acuse (AckWebhookCliDeliveryCommand): lleva o , uno de -/// los dos; el que no aplica no se escribe. -/// public sealed record CliDeliveryAck( string SessionId, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? StatusCode, diff --git a/src/FiscalApi.Cli/Listen/TextListenOutput.cs b/src/FiscalApi.Cli/Listen/TextListenOutput.cs index 4d0e8c5..f0b3b5c 100644 --- a/src/FiscalApi.Cli/Listen/TextListenOutput.cs +++ b/src/FiscalApi.Cli/Listen/TextListenOutput.cs @@ -2,7 +2,6 @@ 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) { @@ -12,10 +11,9 @@ public override void WriteDelivery(CliDelivery delivery, DeliveryOutcome outcome { var summary = WebhookEventSummary.Of(delivery); var time = TimeProvider.GetLocalNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); - WriteData($"{time} --> {summary.Type} [{summary.Id}]{Result(outcome)}"); + WriteData($"{time} {summary.Type} [{summary.Id}]{Result(outcome)}"); } - // Sin --forward-to no hay resultado que mostrar: la terminal es el destino. private static string Result(DeliveryOutcome outcome) => outcome switch { { Forwarded: false } => "", diff --git a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs index a891d35..5c75810 100644 --- a/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs +++ b/src/FiscalApi.Cli/Listen/WebhookEventSummary.cs @@ -2,12 +2,6 @@ namespace FiscalApi.Cli.Listen; -/// -/// Lo que la terminal muestra de una entrega: el id del evento (de webhook-id), su tipo y el sobre. Se lee -/// sin tocar el cuerpo, que se reenvía siempre tal cual, y sin fallar: un cuerpo que no es el sobre o una entrega sin -/// webhook-id se muestran con y se acusan como cualquier otra. -/// -/// El sobre completo para --json; nulo si el cuerpo no es JSON. public sealed record WebhookEventSummary(string Id, string Type, JsonElement? Envelope) { public const string Unknown = "?"; @@ -21,11 +15,11 @@ public static WebhookEventSummary Of(CliDelivery delivery) private static string TypeOf(JsonElement? envelope) => envelope is { ValueKind: JsonValueKind.Object } root && root.TryGetProperty("type", out var type) && - type.ValueKind == JsonValueKind.String - ? type.GetString()! + type.ValueKind == JsonValueKind.String && + type.GetString() is { } value + ? value : Unknown; - // Clone: el elemento sobrevive al documento que lo leyó. private static JsonElement? TryParse(string body) { try diff --git a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs index 4f01291..4b719d0 100644 --- a/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs +++ b/src/FiscalApi.Cli/Listen/WebhookHeaderNames.cs @@ -1,9 +1,7 @@ namespace FiscalApi.Cli.Listen; -/// Los headers de Standard Webhooks que la API firma y el CLI reenvía sin cambios. public static class WebhookHeaderNames { - /// El id del evento. public const string Id = "webhook-id"; public const string Timestamp = "webhook-timestamp"; diff --git a/src/FiscalApi.Cli/Output/LabeledValues.cs b/src/FiscalApi.Cli/Output/LabeledValues.cs index 2d1b2a8..8771431 100644 --- a/src/FiscalApi.Cli/Output/LabeledValues.cs +++ b/src/FiscalApi.Cli/Output/LabeledValues.cs @@ -1,9 +1,5 @@ namespace FiscalApi.Cli.Output; -/// -/// El detalle de un recurso: una línea por dato, con las etiquetas alineadas. Los datos sin valor no se muestran: lo que -/// no aplica (p. ej. el motivo de un webhook habilitado) solo estorbaría. -/// public static class LabeledValues { private const string Separator = " "; diff --git a/src/FiscalApi.Cli/Output/PagedListView.cs b/src/FiscalApi.Cli/Output/PagedListView.cs index 83b92d3..dc0c2ae 100644 --- a/src/FiscalApi.Cli/Output/PagedListView.cs +++ b/src/FiscalApi.Cli/Output/PagedListView.cs @@ -2,25 +2,21 @@ 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."); + output.WriteLine("No data."); } 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); + var footer = $"Page {page.PageNumber} of {Math.Max(page.TotalPages, 1)} ({page.TotalCount} total)."; + output.WriteLine(page.HasNextPage ? $"{footer} Next: --page {page.PageNumber + 1}" : footer); } } diff --git a/src/FiscalApi.Cli/Output/TextTable.cs b/src/FiscalApi.Cli/Output/TextTable.cs index 0d7a065..3b5f5cf 100644 --- a/src/FiscalApi.Cli/Output/TextTable.cs +++ b/src/FiscalApi.Cli/Output/TextTable.cs @@ -1,9 +1,5 @@ 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 = "-"; @@ -20,10 +16,6 @@ public static void Write(TextWriter output, IReadOnlyList headers, IEnum } } - /// - /// 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 diff --git a/src/FiscalApi.Cli/Program.cs b/src/FiscalApi.Cli/Program.cs index 1717f50..8881294 100644 --- a/src/FiscalApi.Cli/Program.cs +++ b/src/FiscalApi.Cli/Program.cs @@ -11,19 +11,12 @@ using FiscalApi.Cli.Listen; using FiscalApi.Cli.Updates; -// UTF-8 sin BOM: en Windows la consola usa la página de códigos OEM, que rompe los acentos de los mensajes -// de la API y el JSON que se redirige a otra herramienta. En Windows esto cambia la página de códigos de la -// consola misma, no solo la del proceso; por eso se restaura al salir. Fijar UTF-8 solo en los writers no -// sirve: escribirían bytes UTF-8 que la consola interactiva interpretaría con su página OEM. var originalOutputEncoding = Console.OutputEncoding; Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); try { - // Composición manual (contrato común): sin contenedor DI, todo se pasa por constructor. using var cancellation = new CancellationTokenSource(); - // El primer Ctrl+C cancela la operación en curso y el CLI sale con 130; el segundo deja que el sistema - // termine el proceso, por si algo no respeta la cancelación. using var interruption = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => { context.Cancel = !cancellation.IsCancellationRequested; @@ -33,21 +26,16 @@ var console = new CliConsole(Console.Out, Console.Error); var globalOptions = new GlobalOptions(); - // El almacén del SO en que corre el binario; FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY ganan sobre él. var credentialStore = CredentialStores.ForCurrentOs(CredentialStores.ProductionService); var credentials = new CredentialResolver(credentialStore, Environment.GetEnvironmentVariable); - // Un solo handler (y su pool de conexiones) para todo el proceso; los comandos reciben apiClients por - // constructor y piden su cliente con el timeout que necesitan. using var httpHandler = FiscalApiClientFactory.CreateHandler(); var apiClients = new FiscalApiClientFactory(httpHandler, credentials, console.Error, RetryPolicy.Default); - // Con stdin redirigido no hay una persona que lea un prompt ni teclas que ocultar. ISecretReader secretReader = Console.IsInputRedirected ? new RedirectedSecretReader(Console.In) : new ConsoleSecretReader(console.Error, () => Console.KeyAvailable ? Console.ReadKey(intercept: true) : null); - // La confirmación de borrar solo se pregunta a una persona en la terminal; un script usa --yes. var confirmation = new ConsoleConfirmationPrompt(console.Error, Console.In, isInteractive: !Console.IsInputRedirected); IReadOnlyList commands = @@ -60,7 +48,6 @@ new EventsCommand(globalOptions, apiClients, console) ]; - // El aviso de versión nueva comparte el handler (y el proxy del sistema), pero no las credenciales de la API. var updateCheck = new UpdateCheck( httpHandler, UpdateCheckStateFile.ForCurrentUser(Environment.GetEnvironmentVariable), diff --git a/src/FiscalApi.Cli/Updates/CliRepository.cs b/src/FiscalApi.Cli/Updates/CliRepository.cs index 4b0b004..493df71 100644 --- a/src/FiscalApi.Cli/Updates/CliRepository.cs +++ b/src/FiscalApi.Cli/Updates/CliRepository.cs @@ -1,17 +1,9 @@ namespace FiscalApi.Cli.Updates; -/// -/// El repositorio público del CLI en GitHub, en un solo lugar: de él salen la consulta del último release y la liga de -/// descarga del aviso. Si el repo cambia de nombre, solo cambia . -/// internal static class CliRepository { private const string Slug = "FiscalAPI/fiscalapi-cli"; - /// - /// releases/latest ya descarta borradores y prereleases - /// (docs.github.com/rest/releases/releases#get-the-latest-release). - /// public static readonly Uri LatestReleaseApi = new($"https://api.github.com/repos/{Slug}/releases/latest"); public static Uri ReleasePage(SemanticVersion version) => new($"https://github.com/{Slug}/releases/tag/v{version}"); diff --git a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs index 4dd9f25..5fa3b5b 100644 --- a/src/FiscalApi.Cli/Updates/IUpdateCheck.cs +++ b/src/FiscalApi.Cli/Updates/IUpdateCheck.cs @@ -1,11 +1,6 @@ namespace FiscalApi.Cli.Updates; -/// Aviso de versión nueva que corre en paralelo al comando. public interface IUpdateCheck { - /// - /// Empieza la consulta, si toca, y devuelve en seguida. La tarea nunca falla y termina a más tardar en - /// ; su resultado es el aviso que mostrar o null. - /// Task Start(GlobalSettings settings, CancellationToken cancellationToken); } diff --git a/src/FiscalApi.Cli/Updates/SemanticVersion.cs b/src/FiscalApi.Cli/Updates/SemanticVersion.cs index c1e7105..ef67049 100644 --- a/src/FiscalApi.Cli/Updates/SemanticVersion.cs +++ b/src/FiscalApi.Cli/Updates/SemanticVersion.cs @@ -4,10 +4,6 @@ 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; @@ -27,10 +23,6 @@ private SemanticVersion(int major, int minor, int patch, string[] prerelease, st 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; @@ -54,7 +46,6 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out SemanticVersio return true; } - /// Precedencia de semver §11: los metadatos de build (+…) no cuentan. public int CompareTo(SemanticVersion? other) { if (other is null) @@ -66,11 +57,8 @@ public int CompareTo(SemanticVersion? other) 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) @@ -90,9 +78,6 @@ private static int ComparePrerelease(string[] left, string[] right) 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); @@ -116,8 +101,6 @@ private static int CompareIdentifier(string left, string right) 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-]*))*))?" + diff --git a/src/FiscalApi.Cli/Updates/UpdateCheck.cs b/src/FiscalApi.Cli/Updates/UpdateCheck.cs index 31e3ac4..653e502 100644 --- a/src/FiscalApi.Cli/Updates/UpdateCheck.cs +++ b/src/FiscalApi.Cli/Updates/UpdateCheck.cs @@ -4,11 +4,6 @@ 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, @@ -20,14 +15,11 @@ public sealed class UpdateCheck( public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); public static readonly TimeSpan Interval = TimeSpan.FromHours(24); - /// Apaga el aviso. CI lo definen GitHub Actions, GitLab CI y la mayoría de los CI. public const string OptOutVariable = "FISCALAPI_NO_UPDATE_CHECK"; private const string CiVariable = "CI"; - // Anónimo: el repo es público y el límite de 60 consultas por hora e IP sobra para una al día. private const string GitHubApiVersion = "2022-11-28"; - // La respuesta trae las notas del release y la lista de binarios: unos KB. El tope corta algo desmedido. private const int MaxResponseBytes = 1024 * 1024; public Task Start(GlobalSettings settings, CancellationToken cancellationToken) @@ -40,11 +32,9 @@ public sealed class UpdateCheck( 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); @@ -54,8 +44,6 @@ private bool IsOn(string variable) => 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) @@ -68,9 +56,6 @@ private bool IsOn(string variable) => { 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)) { @@ -80,22 +65,18 @@ private bool IsOn(string variable) => var latest = await FetchLatestReleaseAsync(cancellationToken); return latest is not null && latest.CompareTo(current) > 0 ? new UpdateNotice(current, latest) : null; } - catch (Exception) + catch (Exception exception) when (exception is HttpRequestException or JsonException) { - // Red, TLS, JSON, tope de tamaño, disco o cancelación: el aviso es opcional y nunca se muestra un error suyo. return null; } } private async Task FetchLatestReleaseAsync(CancellationToken cancellationToken) { - // Un cliente propio sobre el handler compartido (mismo proxy del sistema): no lleva los headers con las - // credenciales de FiscalAPI que agrega FiscalApiClientFactory. using var client = new HttpClient(handler, disposeHandler: false) { MaxResponseContentBufferSize = MaxResponseBytes }; using var request = new HttpRequestMessage(HttpMethod.Get, CliRepository.LatestReleaseApi); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); request.Headers.Add("X-GitHub-Api-Version", GitHubApiVersion); - // GitHub rechaza las peticiones sin User-Agent. request.Headers.UserAgent.Add(new ProductInfoHeaderValue("fiscalapi-cli", currentVersion)); using var response = await client.SendAsync(request, cancellationToken); @@ -107,8 +88,6 @@ private bool IsOn(string variable) => 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 diff --git a/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs b/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs index fc5bde5..83919c2 100644 --- a/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs +++ b/src/FiscalApi.Cli/Updates/UpdateCheckContracts.cs @@ -2,16 +2,10 @@ 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))] diff --git a/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs b/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs index 387e359..bb52755 100644 --- a/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs +++ b/src/FiscalApi.Cli/Updates/UpdateCheckStateFile.cs @@ -2,32 +2,21 @@ 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"; + private readonly string _directory = ParentDirectoryOf(path); + 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"); @@ -40,10 +29,6 @@ public sealed class UpdateCheckStateFile(string path) 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; @@ -63,16 +48,12 @@ internal bool IsDue(DateTimeOffset now, TimeSpan interval) 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)!); + Directory.CreateDirectory(_directory); File.WriteAllBytes(temporary, JsonSerializer.SerializeToUtf8Bytes(new UpdateCheckState(now), UpdateCheckJsonContext.Default.UpdateCheckState)); File.Move(temporary, Path, overwrite: true); return true; @@ -84,6 +65,11 @@ internal bool TryRecordCheck(DateTimeOffset now) } } + private static string ParentDirectoryOf(string path) => + System.IO.Path.GetDirectoryName(path) is { Length: > 0 } directory + ? directory + : throw new ArgumentException($"The update check state file needs a parent directory: '{path}'.", nameof(path)); + private static string? LocalApplicationDataDirectory() { var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.DoNotVerify); @@ -98,7 +84,6 @@ private static void DeleteQuietly(string 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 index 4924cb1..f8a66da 100644 --- a/src/FiscalApi.Cli/Updates/UpdateNotice.cs +++ b/src/FiscalApi.Cli/Updates/UpdateNotice.cs @@ -1,19 +1,13 @@ namespace FiscalApi.Cli.Updates; -/// Hay un release más nuevo que el CLI que corre: qué versión es y de dónde se descarga. public sealed record UpdateNotice(SemanticVersion Current, SemanticVersion Latest) { - /// - /// La página del release se arma con la versión ya validada y no con el html_url de la respuesta: así a la - /// terminal solo llega texto que el CLI controla. - /// public Uri DownloadUrl => CliRepository.ReleasePage(Latest); - /// Va a stderr: stdout es de los datos del comando. public async Task WriteToAsync(TextWriter writer) { await writer.WriteLineAsync(); - await writer.WriteLineAsync($"Hay una versión nueva de fiscalapi: {Latest} (tienes {Current})."); - await writer.WriteLineAsync($"Descárgala en {DownloadUrl}"); + await writer.WriteLineAsync($"fiscalapi {Latest} is available (you have {Current})."); + await writer.WriteLineAsync($"Download it from {DownloadUrl}"); } } diff --git a/src/FiscalApi.Cli/Webhooks/EventsApi.cs b/src/FiscalApi.Cli/Webhooks/EventsApi.cs index a13a6aa..4cc4999 100644 --- a/src/FiscalApi.Cli/Webhooks/EventsApi.cs +++ b/src/FiscalApi.Cli/Webhooks/EventsApi.cs @@ -4,18 +4,12 @@ namespace FiscalApi.Cli.Webhooks; -/// -/// Las rutas de los eventos de webhook (/api/v4/events) sobre . Cada una responde -/// el data tal como lo escribió la API. -/// public sealed class EventsApi(FiscalApiClient client) { private const string EventsPath = "api/v4/events"; - // El formato de los instantes que espera fapi-back (ISO-8601 en UTC); sin fracción si no la hay. private const string UtcInstantFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'"; - /// GET /events: una página de sobres de los últimos 30 días, del más nuevo al más viejo. public async Task ListAsync(WebhookEventsQuery query, CancellationToken cancellationToken) { KeyValuePair[] filters = @@ -31,15 +25,10 @@ public async Task ListAsync(WebhookEventsQuery query, CancellationT cancellationToken).ConfigureAwait(false)); } - /// GET /events/{id}: el sobre exacto que se firmó y se entregó. public async Task RetrieveAsync(string id, CancellationToken cancellationToken) => ApiData.Required(await client.GetAsync( EventPath(id), FiscalApiJsonContext.Default.ApiResponseJsonElement, cancellationToken).ConfigureAwait(false)); - /// - /// POST /events/{id}/resend: 202 con la entrega nueva. Sin va sin cuerpo y la - /// API lo entrega a la escucha de CLI de la persona, que tiene que tener un listen abierto. - /// public async Task ResendAsync(string id, string? webhookId, CancellationToken cancellationToken) { var path = $"{EventPath(id)}/resend"; @@ -55,10 +44,6 @@ 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. 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", diff --git a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs index b88ceba..e9b351f 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookContracts.cs @@ -2,43 +2,29 @@ namespace FiscalApi.Cli.Webhooks; -// Los contratos de los webhooks de la API, con sus mismos nombres. Los enums de la API (kind, -// status, disabledReason) se leen como texto: el CLI solo los muestra, y un valor nuevo no debe romper la lectura. - -/// Los valores de kind (WebhookKind en camelCase) que el CLI distingue. public static class WebhookKinds { - /// La escucha de fiscalapi listen: sin URL y no se edita con PUT. public const string CliListener = "cliListener"; } -/// Los valores de status (WebhookStatus en camelCase). public static class WebhookStatuses { public const string Enabled = "enabled"; public const string Disabled = "disabled"; } -/// -/// Un webhook como lo responde la API (WebhookDto), solo para la salida de texto. Las fechas se guardan como texto: -/// la API las escribe en la zona de la petición y el CLI las muestra tal cual. El secreto del alta y de la rotación no -/// pasa por aquí: se lee del JSON crudo para no depender de que la respuesta encaje en el modelo. -/// public sealed class Webhook { public required string Id { get; init; } public string? Kind { get; init; } - /// null en una escucha de CLI. public string? Url { get; init; } public string? Description { get; init; } - /// Los tipos suscritos, o solo * para todos. public IReadOnlyList EnabledEvents { get; init; } = []; - /// Las personas cuyos eventos recibe; null recibe los de todas. public IReadOnlyList? PersonIds { get; init; } public string? Status { get; init; } @@ -61,17 +47,12 @@ public sealed class Webhook 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, @@ -80,14 +61,6 @@ public sealed record UpdateWebhookRequest( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? PersonIds, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Status); -/// -/// Lo que la persona pidió cambiar con webhooks update; null es "no lo cambies". Como el PUT reemplaza -/// todo, los cambios se aplican sobre el webhook que se acaba de leer para no borrar lo demás. -/// -/// -/// Una lista vacía (--all-persons) recibe los eventos de todas las personas, igual que en la API. -/// -/// Un valor de . public sealed record WebhookChanges( string? Url, string? Description, @@ -95,10 +68,6 @@ public sealed record WebhookChanges( IReadOnlyList? PersonIds, string? Status) { - /// - /// El reemplazo completo: lo pedido y, de lo demás, lo que ya tiene. El estado solo viaja si - /// se pidió, así el PUT no deshace un cambio de estado que otra persona hizo entre la lectura y la escritura. - /// public UpdateWebhookRequest ApplyTo(Webhook current) => new( current.Id, Url ?? current.Url, diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs index c582891..24d00b6 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventContracts.cs @@ -2,13 +2,6 @@ namespace FiscalApi.Cli.Webhooks; -// Los contratos de los eventos de webhook de la API, con sus mismos nombres. - -/// -/// Lo que la tabla de events list muestra del sobre del evento ({id, type, createdAt, personId, -/// data:{relatedObject, status, previousStatus}}). createdAt es texto: la API lo entrega en UTC y se muestra tal -/// cual. -/// public sealed class WebhookEventEnvelope { public string? Id { get; init; } @@ -27,13 +20,11 @@ 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, @@ -41,22 +32,14 @@ public sealed record WebhookEventsQuery( DateTimeOffset? To, Http.PageRequest Page); -/// El cuerpo de POST /api/v4/events/{id}/resend cuando se indica el webhook (ResendWebhookEventCommand). public sealed record ResendWebhookEventRequest(string WebhookId); -/// El reenvío aceptado (WebhookEventResendDto): con estos ids se sigue en el log de entregas. public sealed record WebhookEventResend(string DeliveryId, string WebhookId); -/// -/// El cuerpo de POST /api/v4/events/trigger (TriggerWebhookEventCommand). -/// Lo que la persona no dio no se escribe: para la API, un id vacío es un error y uno omitido elige el valor por -/// omisión (sin , la escucha de CLI de quien llama). -/// public sealed record TriggerWebhookEventRequest( string Type, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? RelatedObjectId, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? PersonId, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? WebhookId); -/// El evento disparado (WebhookEventTriggerDto) y el webhook que lo recibe. public sealed record WebhookEventTrigger(string EventId, string WebhookId); diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs index ca0e1d5..045d033 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventTypes.cs @@ -1,10 +1,5 @@ namespace FiscalApi.Cli.Webhooks; -/// -/// Los tipos de evento que se suscriben, con los nombres de WebhookEventTypes de los SDK. Son la -/// ayuda de autocompletado de trigger, no una validación: el catálogo puede crecer y quien valida es la API. -/// webhook.test no está porque no se suscribe ni se dispara: solo lo manda la prueba de un webhook. -/// public static class WebhookEventTypes { public const string InvoiceCancellationRequested = "invoice.cancellation_requested"; diff --git a/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs index 636a5ad..591847e 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookEventView.cs @@ -3,18 +3,17 @@ 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"]; + private static readonly string[] Headers = ["ID", "TYPE", "CREATED (UTC)", "RESOURCE", "PERSON"]; 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}."); + output.WriteLine($"Resend created: delivery {resend.DeliveryId} to webhook {resend.WebhookId}."); public static void WriteTrigger(TextWriter output, WebhookEventTrigger trigger) => - output.WriteLine($"Evento disparado: {trigger.EventId} al webhook {trigger.WebhookId}."); + output.WriteLine($"Event triggered: {trigger.EventId} to webhook {trigger.WebhookId}."); } diff --git a/src/FiscalApi.Cli/Webhooks/WebhookView.cs b/src/FiscalApi.Cli/Webhooks/WebhookView.cs index 0a71643..31f4a0c 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhookView.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhookView.cs @@ -3,13 +3,9 @@ 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"]; + private static readonly string[] Headers = ["ID", "KIND", "STATUS", "URL", "EVENTS", "DESCRIPTION"]; public static void WriteList(TextWriter output, PagedList page) => PagedListView.Write(output, page, Headers, webhook => @@ -19,29 +15,21 @@ 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), + ("Kind", webhook.Kind), + ("Status", webhook.Status), + ("Disabled reason", webhook.DisabledReason), + ("Disabled at", webhook.DisabledAt), + ("Failing since", 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) + ("Description", webhook.Description), + ("Events", string.Join(", ", webhook.EnabledEvents)), + ("Persons", webhook.PersonIds is { Count: > 0 } personIds ? string.Join(", ", personIds) : "all"), + ("Listen session person", webhook.ListenerPersonId), + ("Session expires", webhook.SessionExpiresAt), + ("Created", webhook.CreatedAt), + ("Updated", webhook.UpdatedAt) ]); - /// - /// El secreto va a stdout, con el webhook (con --json ya va dentro del data); el aviso de guardarlo - /// (el mismo texto del dashboard) es para la persona y va a stderr, también con --json. Durante una - /// suplantación la API no lo responde y no hay nada que guardar. - /// - /// El de la respuesta, o null si no vino. - /// Si viene de rotate-secret: el anterior sigue firmando 24 horas. public static void WriteSigningSecret(CliConsole console, string? secret, bool json, bool rotated) { if (secret is null) @@ -52,14 +40,13 @@ public static void WriteSigningSecret(CliConsole console, string? secret, bool j if (!json) { console.Out.WriteLine(); - console.Out.WriteLine($"Secreto de firma: {secret}"); + console.Out.WriteLine($"Signing secret: {secret}"); } - console.Error.WriteLine("Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI."); + console.Error.WriteLine("Save this signing secret. Use it to verify that events come from FiscalAPI."); if (rotated) { - console.Error.WriteLine( - "El secreto anterior seguirá firmando durante 24 horas para que actualices tu receptor sin perder eventos."); + console.Error.WriteLine("The old signing secret also signs events for 24 hours, so you can update your server."); } } } diff --git a/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs b/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs index 5e57dfc..055f939 100644 --- a/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs +++ b/src/FiscalApi.Cli/Webhooks/WebhooksApi.cs @@ -3,27 +3,20 @@ 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, @@ -32,7 +25,6 @@ public async Task CreateAsync(CreateWebhookRequest request, Cancell 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), @@ -41,12 +33,10 @@ public async Task ReplaceAsync(UpdateWebhookRequest request, Cancel 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", diff --git a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs index 5d72f60..6e350c5 100644 --- a/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs +++ b/tests/FiscalApi.Cli.Tests/CliApplicationTests.cs @@ -7,9 +7,6 @@ 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(); @@ -96,10 +93,10 @@ public async Task Api_base_with_http_outside_loopback_exits_2_so_keys_never_trav [Fact] public async Task Usage_exception_from_a_command_exits_2() { - var exitCode = await RunAsync(["probe"], Throwing(new UsageException("Falta --url."))); + var exitCode = await RunAsync(["probe"], Throwing(new UsageException("Missing --url."))); Assert.Equal(ExitCodes.Usage, exitCode); - Assert.Contains("Falta --url.", _error.ToString()); + Assert.Contains("Missing --url.", _error.ToString()); } [Fact] @@ -120,21 +117,21 @@ public async Task Api_exception_from_a_command_exits_1_and_goes_to_stderr() [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 error = new NetworkException("Could not connect to 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()); + Assert.Contains("Could not connect", _error.ToString()); } [Fact] public async Task Unexpected_exception_exits_1_without_crashing() { - var exitCode = await RunAsync(["probe"], Throwing(new InvalidOperationException("estado imposible"))); + var exitCode = await RunAsync(["probe"], Throwing(new InvalidOperationException("impossible state"))); Assert.Equal(ExitCodes.Failure, exitCode); - Assert.Contains("estado imposible", _error.ToString()); + Assert.Contains("impossible state", _error.ToString()); } [Fact] @@ -176,7 +173,7 @@ public async Task Success_response_with_data_of_another_shape_exits_1_showing_th Assert.Equal(ExitCodes.Failure, exitCode); Assert.Contains("JsonException", _error.ToString()); - Assert.DoesNotContain("Respuesta inesperada", _error.ToString()); + Assert.DoesNotContain("Unexpected response", _error.ToString()); } [Fact] @@ -197,7 +194,6 @@ 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"); diff --git a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs index 0f4ff3d..d0a8d93 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/ApiCommandHarness.cs @@ -13,10 +13,6 @@ namespace FiscalApi.Cli.Tests.Commands; -/// -/// Corre fiscalapi webhooks …, fiscalapi events … y fiscalapi trigger … con la misma composición que Program, sobre -/// la API simulada () y las credenciales de las variables de entorno. -/// internal sealed class ApiCommandHarness { public const string ApiKey = "sk_test_clave-de-comandos"; @@ -31,11 +27,6 @@ internal sealed class ApiCommandHarness private readonly StringWriter _out = new(); private readonly StringWriter _error = new(); - /// El comando sin --api-base, que se agrega aquí. - /// Lo que la persona escribe al confirmar (delete sin --yes). - /// Si hay una persona en la terminal; con stdin redirigido, no. - /// Otra confirmación en lugar de la de consola (p. ej. una que simula Ctrl+C). - /// El token que Program cancela con Ctrl+C. public Task RunAsync( IEnumerable args, string confirmationInput = "", @@ -66,15 +57,12 @@ public Task RunAsync( 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); diff --git a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs index 5d2963a..1fecd3d 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/EventsCommandTests.cs @@ -4,17 +4,11 @@ namespace FiscalApi.Cli.Tests.Commands; -/// -/// fiscalapi events list|retrieve|resend contra las rutas de EventsController de la API, -/// simuladas con . retrieve imprime el sobre exactamente como lo entregó la -/// API. -/// public sealed class EventsCommandTests { private const string EventsUrl = "https://api.example.test/api/v4/events"; private const string EventId = "0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f"; - // El sobre del evento como lo guarda y entrega la API (WebhookEnvelopeSerializer): sin espacios y con las nulas. private const string Envelope = $$$"""{"id":"{{{EventId}}}","type":"invoice.canceled","createdAt":"2026-10-12T15:47:46Z","personId":"persona-1","data":{"relatedObject":{"id":"factura-1","type":"invoice","url":"/api/v4/invoices/factura-1"},"status":"X","previousStatus":"C"}}"""; @@ -26,8 +20,6 @@ public sealed class EventsCommandTests private readonly ApiCommandHarness _cli = new(); - // ---------------------------------------------------------------- list - [Fact] public async Task List_gets_the_events_without_query_by_default() { @@ -58,10 +50,9 @@ await _cli.RunAsync( 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); + _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")] @@ -73,7 +64,7 @@ public async Task To_with_only_a_date_includes_that_whole_day(string to, string await _cli.RunAsync("events", "list", "--to", to); - Assert.Equal($"{EventsUrl}?to={sent}", _cli.SingleRequest().RequestUri!.AbsoluteUri); + Assert.Equal($"{EventsUrl}?to={sent}", _cli.SingleRequest().RequestUri?.AbsoluteUri); } [Theory] @@ -87,7 +78,7 @@ public async Task A_date_without_zone_is_utc(string from, string sent) await _cli.RunAsync("events", "list", "--from", from); - Assert.Equal($"{EventsUrl}?from={sent}", _cli.SingleRequest().RequestUri!.AbsoluteUri); + Assert.Equal($"{EventsUrl}?from={sent}", _cli.SingleRequest().RequestUri?.AbsoluteUri); } [Theory] @@ -99,7 +90,7 @@ public async Task A_date_that_is_not_iso_8601_is_a_usage_error_without_calling_t 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.Contains($"{option} must be an ISO 8601 date", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -111,10 +102,10 @@ public async Task List_prints_a_table_with_one_row_per_event() 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(@"^ID\s+TYPE\s+CREATED \(UTC\)\s+RESOURCE\s+PERSON$", 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]); + Assert.Equal("Page 1 of 1 (2 total).", lines[3]); } [Fact] @@ -139,8 +130,6 @@ public async Task List_rejected_by_validation_exits_1_with_the_failure_text() 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() { @@ -159,9 +148,8 @@ public async Task Retrieve_gets_the_event_by_its_id() [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ú: 🚀\", " + + "{ \"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)); @@ -183,8 +171,6 @@ public async Task Retrieve_of_a_missing_event_exits_1_with_the_api_text() Assert.Empty(_cli.Out); } - // ---------------------------------------------------------------- resend - [Fact] public async Task Resend_without_webhook_posts_without_body_so_it_goes_to_the_cli_listener() { @@ -234,7 +220,7 @@ public async Task Resend_without_a_listen_session_exits_1_with_the_api_text() 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("HTTP status: 409", _cli.Error); Assert.Contains("0HN7:00000409", _cli.Error); Assert.Single(_cli.Api.Requests); } diff --git a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs index 2cd823d..da4a939 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LoginCommandTests.cs @@ -10,10 +10,6 @@ namespace FiscalApi.Cli.Tests.Commands; -/// -/// fiscalapi login lee la API key y la tenant key (por stdin redirigido o tecla por tecla), las valida con -/// GET /api/v4/me/features y las guarda por host. La API se simula con . -/// public sealed class LoginCommandTests { private const string ApiKey = "sk_test_clave-de-login"; @@ -33,7 +29,7 @@ public async Task Valid_credentials_are_saved_for_the_host_and_reported() var exitCode = await RunAsync(handler, $"{ApiKey}\n{TenantKey}\n"); Assert.Equal(ExitCodes.Success, exitCode); - Assert.Contains($"Listo. Credenciales guardadas para {Host}.", _error.ToString()); + Assert.Contains($"Done. Credentials saved for {Host}.", _error.ToString()); Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); Assert.DoesNotContain("Webhooks.Manage", _error.ToString()); } @@ -61,7 +57,7 @@ public async Task Rejected_credentials_are_not_saved_and_exit_1() 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.Contains($"Invalid credentials for {Host}.", _error.ToString()); Assert.Empty(_store.Entries); } @@ -87,9 +83,9 @@ 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 webhooks responderán 403.", + "This API key lacks the Webhooks.Manage permission. listen, trigger and webhooks will return 403.", _error.ToString()); - Assert.Contains($"Listo. Credenciales guardadas para {Host}.", _error.ToString()); + Assert.Contains($"Done. Credentials saved for {Host}.", _error.ToString()); Assert.Equal(ApiCredentials.Create(ApiKey, TenantKey), _store.Read(Host)); } @@ -103,7 +99,7 @@ public async Task Without_a_store_fails_with_the_approved_text_before_asking_for 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.", + "No credential store is available. Set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY.", _error.ToString()); Assert.DoesNotContain("API key: ", _error.ToString()); Assert.Equal(0, keys.Read); @@ -119,7 +115,7 @@ public async Task Locked_store_fails_before_asking_for_keys_or_calling_the_api() 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("The credential store is locked or not responding. Unlock it and try again.", _error.ToString()); Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); Assert.Equal(0, keys.Read); Assert.Empty(handler.Requests); @@ -135,7 +131,7 @@ public async Task Invalid_api_key_fails_before_asking_for_the_tenant_key(string 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 is empty, too long, or has invalid characters.", _error.ToString()); Assert.Contains("API key: ", _error.ToString()); Assert.DoesNotContain("Tenant key: ", _error.ToString()); Assert.Empty(handler.Requests); @@ -236,7 +232,7 @@ public async Task Credentials_are_saved_per_environment_host() 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()); + Assert.Contains("Done. Credentials saved for localhost:5000.", _error.ToString()); } private ConsoleSecretReader InteractiveReader(ScriptedKeys keys) => new(_error, keys.TryRead); @@ -262,7 +258,6 @@ private Task RunAsync( var globalOptions = new GlobalOptions(); var console = new CliConsole(_out, _error); var credentialStore = store ?? _store; - // Las variables de entorno no intervienen en login: la persona da las credenciales que se validan y guardan. var apiClients = new FiscalApiClientFactory( handler, new CredentialResolver(credentialStore, _ => null), console.Error, FiscalApiClientBuilder.NoDelayRetryPolicy); var login = new LoginCommand(globalOptions, apiClients, credentialStore, input, console); diff --git a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs index 3bde647..5cef0d2 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/LogoutCommandTests.cs @@ -5,10 +5,6 @@ namespace FiscalApi.Cli.Tests.Commands; -/// -/// fiscalapi logout es local (no recibe cliente HTTP). Termina con éxito si borró o si no había -/// nada guardado; un almacén que existe pero falla (bloqueado, sin respuesta) es un error con código 1. -/// public sealed class LogoutCommandTests { private readonly InMemoryCredentialStore _store = new(); @@ -27,7 +23,7 @@ public async Task Deletes_only_the_credentials_of_the_selected_environment() 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.Contains("Credentials deleted for test.fiscalapi.com.", _error.ToString()); Assert.Empty(_out.ToString()); } @@ -47,7 +43,7 @@ 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()); + Assert.Contains("No saved credentials for test.fiscalapi.com.", _error.ToString()); } [Fact] @@ -56,7 +52,7 @@ 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()); + Assert.Contains("No saved credentials for test.fiscalapi.com.", _error.ToString()); } [Fact] @@ -65,10 +61,10 @@ 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("Could not delete the saved credentials for test.fiscalapi.com.", _error.ToString()); + Assert.Contains("The credential store is locked or not responding. Unlock it and try again.", _error.ToString()); Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); - Assert.DoesNotContain("No había credenciales", _error.ToString()); + Assert.DoesNotContain("No saved credentials", _error.ToString()); } private Task RunAsync(string[] args, ICredentialStore? store = null) diff --git a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs index 9895515..1669210 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/TriggerCommandTests.cs @@ -5,11 +5,6 @@ namespace FiscalApi.Cli.Tests.Commands; -/// -/// fiscalapi trigger contra POST /api/v4/events/trigger de la API, -/// simulada con . Quien valida el tipo y el ambiente es la -/// API: el CLI solo arma la petición y muestra su respuesta. -/// public sealed class TriggerCommandTests { private const string TriggerUrl = "https://api.example.test/api/v4/events/trigger"; @@ -29,7 +24,6 @@ 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. ApiCommandHarness.AssertJsonEqual("""{"type":"invoice.canceled"}""", _cli.Body()); } @@ -57,14 +51,13 @@ public async Task Trigger_prints_the_event_id_and_its_destination() await _cli.RunAsync("trigger", "invoice.canceled"); - Assert.Equal($"Evento disparado: {EventId} al webhook escucha-1.{Environment.NewLine}", _cli.Out); + Assert.Equal($"Event triggered: {EventId} to 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)); @@ -73,8 +66,6 @@ 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 harness - // siempre agrega --api-base, que gana sobre --live, así que solo prueba que el flag no se rechaza en el cliente. [Fact] public async Task Trigger_with_live_flag_is_not_blocked_by_the_cli_and_shows_the_api_403() { @@ -86,7 +77,7 @@ public async Task Trigger_with_live_flag_is_not_blocked_by_the_cli_and_shows_the 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("HTTP status: 403", _cli.Error); Assert.Contains("0HN7:00000403", _cli.Error); Assert.Empty(_cli.Out); } @@ -101,11 +92,10 @@ public async Task Trigger_without_a_listen_session_exits_1_with_the_api_text() 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("HTTP status: 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() { @@ -117,7 +107,7 @@ public async Task Trigger_of_a_type_the_api_rejects_exits_1_with_the_validation_ 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.Contains("HTTP status: 400", _cli.Error); Assert.Empty(_cli.Out); } @@ -144,7 +134,6 @@ public async Task Trigger_without_type_is_a_usage_error_without_calling_the_api( [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", @@ -164,10 +153,6 @@ 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(); diff --git a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs index 4bfa47a..ee5b40c 100644 --- a/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Commands/WebhooksCommandTests.cs @@ -1,22 +1,16 @@ using System.Net; +using System.Text.Json.Nodes; using FiscalApi.Cli.Input; using FiscalApi.Cli.Tests.Http; namespace FiscalApi.Cli.Tests.Commands; -/// -/// fiscalapi webhooks list|retrieve|create|update|delete|rotate-secret contra las rutas de -/// WebhooksController de la API, simuladas con . Cada -/// subcomando arma la ruta, el método, la query y el cuerpo; --json deja en stdout el data sin cambios; el -/// secreto sale una sola vez. -/// public sealed class WebhooksCommandTests { private const string WebhooksUrl = "https://api.example.test/api/v4/webhooks"; private const string WebhookId = "6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d"; private const string Secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; - // Como lo escribe fapi-back (WebhookDto): camelCase, enums en camelCase y las nulas presentes. private const string UrlWebhook = $$"""{"id":"{{WebhookId}}","kind":"url","url":"https://example.com/webhooks","description":"Producción","enabledEvents":["invoice.canceled","invoice.cancellation_requested"],"personIds":["persona-1","persona-2"],"status":"enabled","disabledReason":null,"disabledAt":null,"failingSince":null,"listenerPersonId":null,"sessionExpiresAt":null,"createdAt":"2026-10-13T09:30:00.000","updatedAt":null}"""; @@ -34,8 +28,6 @@ public sealed class WebhooksCommandTests private readonly ApiCommandHarness _cli = new(); - // ---------------------------------------------------------------- list - [Fact] public async Task List_gets_the_webhooks_without_query_by_default() { @@ -68,11 +60,10 @@ public async Task List_prints_a_table_with_one_row_per_webhook_and_the_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(@"^ID\s+KIND\s+STATUS\s+URL\s+EVENTS\s+DESCRIPTION$", 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.Equal("Page 1 of 3 (5 total). Next: --page 2", lines[3]); Assert.Empty(_cli.Error); } @@ -85,7 +76,7 @@ public async Task List_without_webhooks_says_so() var exitCode = await _cli.RunAsync("webhooks", "list"); Assert.Equal(ExitCodes.Success, exitCode); - Assert.StartsWith("Sin datos.", _cli.Out); + Assert.StartsWith("No data.", _cli.Out); } [Fact] @@ -98,8 +89,6 @@ public async Task List_with_json_prints_the_data_unchanged() _cli.AssertJsonOut(Page); } - // ---------------------------------------------------------------- retrieve - [Fact] public async Task Retrieve_gets_the_webhook_by_its_escaped_id() { @@ -110,7 +99,7 @@ public async Task Retrieve_gets_the_webhook_by_its_escaped_id() Assert.Equal(ExitCodes.Success, exitCode); var request = _cli.SingleRequest(); Assert.Equal(HttpMethod.Get, request.Method); - Assert.Equal($"{WebhooksUrl}/we%2F1%202", request.RequestUri!.AbsoluteUri); + Assert.Equal($"{WebhooksUrl}/we%2F1%202", request.RequestUri?.AbsoluteUri); } [Fact] @@ -121,14 +110,13 @@ public async Task Retrieve_prints_the_webhook_detail() 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)^Status\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); + Assert.Matches(@"(?m)^Events\s+invoice\.canceled, invoice\.cancellation_requested\r?$", _cli.Out); + Assert.Matches(@"(?m)^Persons\s+persona-1, persona-2\r?$", _cli.Out); + Assert.Matches(@"(?m)^Created\s+2026-10-13T09:30:00\.000\r?$", _cli.Out); + Assert.DoesNotContain("Disabled reason", _cli.Out); + Assert.DoesNotContain("Signing secret", _cli.Out); } [Fact] @@ -150,12 +138,10 @@ public async Task Retrieve_of_a_missing_webhook_exits_1_with_the_api_text() 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.Contains("HTTP status: 404", _cli.Error); Assert.Empty(_cli.Out); } - // ---------------------------------------------------------------- create - [Fact] public async Task Create_posts_the_webhook_with_its_events_and_persons() { @@ -197,7 +183,7 @@ public async Task Create_sends_every_person_and_lets_the_api_apply_its_limit() 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.Equal(101, _cli.Body()?["personIds"]?.AsArray().Count); Assert.Contains("Un webhook filtra como máximo 100 personas.", _cli.Error); } @@ -209,9 +195,9 @@ public async Task Create_prints_the_secret_once_with_the_warning_to_keep_it() 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.Contains($"Signing secret: {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); + Assert.Contains("Save this signing secret. Use it to verify that events come from FiscalAPI.", _cli.Error); } [Fact] @@ -269,7 +255,7 @@ public async Task Create_rejected_by_validation_exits_1_with_each_failure_text() 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.Contains("HTTP status: 400", _cli.Error); Assert.Empty(_cli.Out); } @@ -284,8 +270,6 @@ public async Task Create_is_never_retried() Assert.Single(_cli.Api.Requests); } - // ---------------------------------------------------------------- update - [Fact] public async Task Update_reads_the_webhook_and_replaces_it_changing_only_what_was_given() { @@ -301,7 +285,6 @@ public async Task Update_reads_the_webhook_and_replaces_it_changing_only_what_wa 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)); @@ -336,9 +319,9 @@ public async Task Update_with_all_persons_removes_the_person_filter_explicitly() 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"]); + var body = _cli.Body(1); + Assert.Empty(Assert.IsType(body?["personIds"])); + Assert.Equal("enabled", (string?)body?["status"]); } [Fact] @@ -357,7 +340,6 @@ public async Task Update_of_a_webhook_for_every_person_keeps_omitting_person_ids _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", ",")] @@ -373,7 +355,7 @@ public async Task An_empty_person_list_is_a_usage_error_without_calling_the_api( 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.Contains("--person needs at least one person ID. Use --all-persons to receive events for all persons.", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -399,7 +381,7 @@ public async Task All_persons_with_person_is_a_usage_error(string subcommand) var exitCode = await _cli.RunAsync(args); Assert.Equal(ExitCodes.Usage, exitCode); - Assert.Contains("--all-persons no se combina con --person.", _cli.Error); + Assert.Contains("--all-persons cannot be used with --person.", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -433,7 +415,7 @@ public async Task Update_without_changes_is_a_usage_error_without_calling_the_ap var exitCode = await _cli.RunAsync("webhooks", "update", WebhookId); Assert.Equal(ExitCodes.Usage, exitCode); - Assert.Contains("Indica qué cambiar", _cli.Error); + Assert.Contains("Specify what to update", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -443,7 +425,7 @@ 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.Contains("--enabled cannot be used with --disabled.", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -454,9 +436,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"); - // 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.Contains("A listen session cannot be updated. Use `fiscalapi listen` and `fiscalapi trigger` instead.", _cli.Error); Assert.Single(_cli.Api.Requests); } @@ -484,11 +465,9 @@ public async Task Update_conflict_exits_1_with_the_api_text() 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); + Assert.Contains("HTTP status: 409", _cli.Error); } - // ---------------------------------------------------------------- delete - [Fact] public async Task Delete_with_yes_deletes_without_asking() { @@ -500,8 +479,8 @@ public async Task Delete_with_yes_deletes_without_asking() 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); + Assert.Contains($"Webhook {WebhookId} deleted.", _cli.Error); + Assert.DoesNotContain("[y/N]", _cli.Error); } [Fact] @@ -517,7 +496,7 @@ public async Task Delete_with_json_and_yes_prints_the_data_unchanged() [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); + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId], confirmationInput: "y\n", interactive: false); Assert.Equal(ExitCodes.Usage, exitCode); Assert.Contains("--yes", _cli.Error); @@ -527,18 +506,18 @@ public async Task Delete_without_yes_and_redirected_input_is_a_usage_error() [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); + var exitCode = await _cli.RunAsync(["webhooks", "delete", WebhookId, "--json"], confirmationInput: "y\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.Contains("Use --yes to delete without an interactive confirmation.", _cli.Error); + Assert.DoesNotContain("[y/N]", _cli.Error); Assert.Empty(_cli.Api.Requests); Assert.Empty(_cli.Out); } [Theory] - [InlineData("s\n")] - [InlineData("Sí\n")] + [InlineData("y\n")] + [InlineData("Yes\n")] [InlineData(" y \n")] public async Task Delete_confirmed_in_the_terminal_deletes(string answer) { @@ -547,12 +526,13 @@ public async Task Delete_confirmed_in_the_terminal_deletes(string answer) 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.Contains($"Delete webhook {WebhookId}? Its pending deliveries will be closed. [y/N]", _cli.Error); Assert.Equal(HttpMethod.Delete, _cli.SingleRequest().Method); } [Theory] [InlineData("n\n")] + [InlineData("s\n")] [InlineData("\n")] [InlineData("")] public async Task Delete_not_confirmed_in_the_terminal_deletes_nothing(string answer) @@ -560,7 +540,7 @@ public async Task Delete_not_confirmed_in_the_terminal_deletes_nothing(string an 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.Contains($"Webhook {WebhookId} was not deleted.", _cli.Error); Assert.Empty(_cli.Api.Requests); } @@ -573,11 +553,9 @@ public async Task Delete_of_a_missing_webhook_exits_1() Assert.Equal(ExitCodes.Failure, exitCode); Assert.Contains($"No such resource for the given Id: {WebhookId}", _cli.Error); - Assert.DoesNotContain("borrado", _cli.Error); + Assert.DoesNotContain($"Webhook {WebhookId} deleted.", _cli.Error); } - // ---------------------------------------------------------------- rotate-secret - [Fact] public async Task Rotate_secret_posts_without_body_and_prints_the_new_secret_once() { @@ -590,10 +568,10 @@ public async Task Rotate_secret_posts_without_body_and_prints_the_new_secret_onc 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.Contains($"Signing secret: {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); + Assert.Contains("Save this signing secret.", _cli.Error); + Assert.Contains("The old signing secret also signs events for 24 hours", _cli.Error); } [Fact] @@ -617,12 +595,9 @@ public async Task Ctrl_c_while_confirming_the_delete_exits_130_without_deleting( Assert.Equal(ExitCodes.Cancelled, exitCode); Assert.Empty(_cli.Api.Requests); - Assert.DoesNotContain("No se borró", _cli.Error); + Assert.DoesNotContain("was not deleted", _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] @@ -638,9 +613,9 @@ public async Task The_secret_is_shown_even_if_the_response_does_not_match_the_mo var exitCode = await _cli.RunAsync(args); Assert.Equal(ExitCodes.Failure, exitCode); - Assert.Contains($"Secreto de firma: {Secret}", _cli.Out); + Assert.Contains($"Signing secret: {Secret}", _cli.Out); Assert.Equal(1, ApiCommandHarness.Occurrences(_cli.Out + _cli.Error, Secret)); - Assert.Contains("Guarda este secreto de firma.", _cli.Error); + Assert.Contains("Save this signing secret.", _cli.Error); } [Theory] @@ -657,7 +632,7 @@ public async Task With_json_the_raw_data_is_printed_even_if_it_does_not_match_th Assert.Equal(ExitCodes.Success, exitCode); _cli.AssertJsonOut(UnexpectedWithSecret); - Assert.Contains("Guarda este secreto de firma.", _cli.Error); + Assert.Contains("Save this signing secret.", _cli.Error); } [Fact] @@ -668,7 +643,7 @@ public async Task A_null_data_is_reported_as_an_unexpected_response() 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.Contains("The API returned a success response without data.", _cli.Error); Assert.Empty(_cli.Out); } @@ -683,10 +658,6 @@ 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; @@ -698,7 +669,6 @@ public async Task ConfirmAsync(string question, CancellationToken cancella } } - // El 400 de validación de fapi-back (GlobalExceptionMiddleware): el texto de cada falla va en data. private static string ValidationFailure(string propertyName, string errorMessage) => $$$"""{"data":[{"propertyName":"{{{propertyName}}}","errorMessage":"{{{errorMessage}}}","attemptedValue":null,"customState":null,"severity":0,"errorCode":"PredicateValidator","formattedMessagePlaceholderValues":{}}],"succeeded":false,"message":"Sorry, it's not me, it's you.","details":"One or more validation failures have occurred.","traceIdentifier":"0HN7:00000400"}"""; } diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs index f1bbfa0..7f2064c 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialResolutionTests.cs @@ -7,10 +7,6 @@ namespace FiscalApi.Cli.Tests.Credentials; -/// -/// FISCALAPI_API_KEY/FISCALAPI_TENANT_KEY ganan sobre lo guardado, y las credenciales nunca -/// aparecen en --verbose ni en un error. -/// public sealed class CredentialResolutionTests { private const string EnvironmentApiKey = "sk_test_clave-del-entorno"; @@ -81,7 +77,7 @@ 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($"No credentials for {Host}.", _error.ToString()); Assert.Contains("fiscalapi login", _error.ToString()); Assert.Contains(CredentialResolver.ApiKeyVariable, _error.ToString()); } @@ -93,7 +89,7 @@ public async Task Without_store_and_without_environment_variables_shows_the_appr 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.", + "No credential store is available. Set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY.", _error.ToString()); Assert.Contains(UnavailableCredentialStore.Detail, _error.ToString()); } @@ -104,9 +100,9 @@ public async Task Locked_store_without_environment_variables_is_a_store_error_wi 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("The credential store is locked or not responding. Unlock it and try again.", _error.ToString()); Assert.Contains(LockedCredentialStore.Detail, _error.ToString()); - Assert.DoesNotContain("No hay un almacén", _error.ToString()); + Assert.DoesNotContain("No credential store", _error.ToString()); } [Fact] @@ -169,7 +165,7 @@ public async Task Verbose_and_a_network_failure_never_show_the_stored_credential var exitCode = await RunProbeAsync(handler, verbose: true); Assert.Equal(ExitCodes.Failure, exitCode); - Assert.Contains("No se pudo conectar", _error.ToString()); + Assert.Contains("Could not connect", _error.ToString()); AssertNoCredentialIn(_out.ToString() + _error.ToString()); } @@ -220,10 +216,6 @@ private static void AssertNoCredentialIn(string 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(); diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs index af8dd46..e866d57 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreFactAttribute.cs @@ -1,10 +1,5 @@ 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"; diff --git a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs index e3a236e..009a9ed 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/CredentialStoreTests.cs @@ -2,11 +2,6 @@ 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"; @@ -71,7 +66,6 @@ public void Store_of_this_system_is_available_with_or_without_an_entry() _store.EnsureAvailable(_host); } - // Cada borrado por separado: si el primero falla, el segundo igual limpia su entrada. public void Dispose() { try diff --git a/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs index ddb6203..dc6c151 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/InMemoryCredentialStore.cs @@ -2,7 +2,6 @@ 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); @@ -20,7 +19,6 @@ public void EnsureAvailable(string host) 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)"; @@ -34,10 +32,6 @@ internal sealed class UnavailableCredentialStore : ICredentialStore 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)"; diff --git a/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs index f2d21e3..24630fa 100644 --- a/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs +++ b/tests/FiscalApi.Cli.Tests/Credentials/StoredCredentialsFormatTests.cs @@ -3,10 +3,6 @@ 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"; @@ -22,10 +18,6 @@ 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() { diff --git a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj index 6eccdd4..6ddcc13 100644 --- a/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj +++ b/tests/FiscalApi.Cli.Tests/FiscalApi.Cli.Tests.csproj @@ -4,9 +4,7 @@ false - - @@ -16,7 +14,6 @@ - diff --git a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs index 885de92..ac962c5 100644 --- a/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs +++ b/tests/FiscalApi.Cli.Tests/GlobalOptionsTests.cs @@ -2,9 +2,6 @@ namespace FiscalApi.Cli.Tests; -/// -/// --api-base gana sobre --live, y este sobre test. -/// public sealed class GlobalOptionsTests { [Fact] diff --git a/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs b/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs index b718ef5..511aad0 100644 --- a/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/ExponentialBackoffTests.cs @@ -2,10 +2,6 @@ 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)); diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs index a17c109..955d64a 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientBuilder.cs @@ -4,18 +4,15 @@ namespace FiscalApi.Cli.Tests.Http; -/// Arma el cliente con la misma fábrica que usa producción, sobre un handler falso. internal static class FiscalApiClientBuilder { public static readonly Uri BaseAddress = new("https://api.example.test/"); public static readonly ApiCredentials Credentials = ApiCredentials.Create("sk_test_clave-de-prueba", "tenant-de-prueba-0001"); - /// Política real (3 intentos) sin espera entre ellos, para las pruebas que no miden tiempos. public static readonly RetryPolicy NoDelayRetryPolicy = new(maxAttempts: 3, baseDelay: TimeSpan.Zero, maxDelay: TimeSpan.FromSeconds(1), TimeProvider.System); - /// Fábrica sin credenciales guardadas ni variables de entorno: las pruebas pasan las suyas. public static FiscalApiClientFactory Factory( HttpMessageHandler handler, TextWriter? diagnostics = null, RetryPolicy? retryPolicy = null) => new( diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs index 352580b..b8af66d 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientErrorTests.cs @@ -4,9 +4,6 @@ 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; @@ -37,7 +34,6 @@ 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); } @@ -84,7 +80,6 @@ public async Task Not_found_has_no_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"}""", @@ -120,7 +115,7 @@ public async Task Body_that_is_not_json_is_reported_as_an_unexpected_response() var error = await Assert.ThrowsAsync(() => GetAsync(handler)); Assert.Equal(HttpStatusCode.BadGateway, error.StatusCode); - Assert.Equal("Respuesta inesperada (502).", error.Message); + Assert.Equal("Unexpected response (502).", error.Message); Assert.Null(error.TraceIdentifier); } @@ -144,7 +139,7 @@ public async Task Error_without_message_still_reports_the_status_code() var error = await Assert.ThrowsAsync(() => GetAsync(handler)); - Assert.Equal("La API respondió 403 sin mensaje.", error.Message); + Assert.Equal("The API returned 403 without a message.", error.Message); } [Fact] diff --git a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs index 16c474f..442e519 100644 --- a/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/FiscalApiClientRetryTests.cs @@ -5,10 +5,6 @@ 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"}}"""; @@ -91,7 +87,7 @@ public async Task Get_that_times_out_is_not_retried() var error = await Assert.ThrowsAsync(() => GetAsync(client)); - Assert.Contains("no respondió", error.Message); + Assert.Contains("did not respond", error.Message); Assert.Single(handler.Requests); } @@ -143,7 +139,6 @@ public async Task Get_with_429_waits_the_retry_after_seconds_before_retrying() var pending = GetAsync(TimedClient(handler)); await _time.WaitForDelayAsync(); - // 7 s es más que cualquier backoff de esta política (máximo 1 s en el primer reintento). _time.Advance(TimeSpan.FromSeconds(7) - TimeSpan.FromMilliseconds(1)); Assert.Single(handler.Requests); _time.Advance(TimeSpan.FromMilliseconds(1)); @@ -175,7 +170,6 @@ public async Task Get_with_a_retry_after_longer_than_the_maximum_delay_is_not_re .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); @@ -191,13 +185,11 @@ public async Task Backoff_doubles_the_wait_with_jitter_between_half_and_all_of_i 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)); @@ -236,7 +228,6 @@ public async Task User_cancellation_during_the_backoff_propagates_and_is_not_a_n 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( diff --git a/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs b/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs index 028ba32..4833625 100644 --- a/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs +++ b/tests/FiscalApi.Cli.Tests/Http/PersonFeaturesJson.cs @@ -1,9 +1,5 @@ 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 = """ @@ -16,7 +12,6 @@ internal static class PersonFeaturesJson "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"], diff --git a/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs index 517effb..d2a1c85 100644 --- a/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/RedirectTests.cs @@ -6,14 +6,10 @@ 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 ConcurrentQueue _hits = new(); private readonly Uri _baseAddress; public RedirectTests() @@ -63,7 +59,7 @@ private async Task ServeAsync() return; } - _hits.Enqueue(context.Request.Url!.AbsolutePath); + _hits.Enqueue(context.Request.Url?.AbsolutePath); context.Response.StatusCode = (int)HttpStatusCode.Found; context.Response.RedirectLocation = new Uri(_baseAddress, "destino").ToString(); context.Response.Close(); diff --git a/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs b/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs index fb22f19..d28f955 100644 --- a/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs +++ b/tests/FiscalApi.Cli.Tests/Http/SteppedTimeProvider.cs @@ -2,10 +2,6 @@ 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); @@ -26,10 +22,6 @@ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSp 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; @@ -44,7 +36,6 @@ public void Advance(TimeSpan delta) } } - /// 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 60c0ab9..6816fec 100644 --- a/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs +++ b/tests/FiscalApi.Cli.Tests/Http/StubHttpMessageHandler.cs @@ -5,26 +5,17 @@ namespace FiscalApi.Cli.Tests.Http; -/// -/// Simula la API: responde en orden lo que se le encola y guarda cada request recibido. -/// Así las pruebas nunca salen a Test ni a Live. -/// public sealed class StubHttpMessageHandler : HttpMessageHandler { private readonly Queue>> _responses = new(); public List Requests { get; } = []; - /// - /// El cuerpo de cada request, en el orden de (null si no llevó). Se lee al recibirlo: el - /// cliente libera el request (y su contenido) en cuanto termina. - /// public List RequestBodies { get; } = []; public StubHttpMessageHandler RespondJson(HttpStatusCode status, string json, int times = 1) => Respond(() => Json(status, json), times); - /// Como el 502/503 de nginx: un cuerpo que no es JSON. public StubHttpMessageHandler RespondHtml(HttpStatusCode status, string html, int times = 1) => Respond(() => new HttpResponseMessage(status) { @@ -47,7 +38,6 @@ public StubHttpMessageHandler FailWithNetworkError( 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 => { diff --git a/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs b/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs index 8eb4f6e..ddc02e5 100644 --- a/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs +++ b/tests/FiscalApi.Cli.Tests/Http/TestJsonContext.cs @@ -8,7 +8,6 @@ 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))] diff --git a/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs index 7b53133..fc28318 100644 --- a/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs +++ b/tests/FiscalApi.Cli.Tests/Http/VerboseLoggingHandlerTests.cs @@ -4,9 +4,6 @@ namespace FiscalApi.Cli.Tests.Http; -/// -/// --verbose muestra el detalle HTTP sin headers de credenciales ni cuerpos. -/// public sealed class VerboseLoggingHandlerTests { private readonly StringWriter _diagnostics = new(); @@ -57,7 +54,7 @@ public async Task Writes_a_line_when_the_request_fails_on_the_network() await Assert.ThrowsAsync(() => client.GetAsync("api/v4/probe")); - Assert.Contains("! GET https://api.example.test/api/v4/probe falló (ConnectionError)", _diagnostics.ToString()); + Assert.Contains("! GET https://api.example.test/api/v4/probe failed (ConnectionError)", _diagnostics.ToString()); } [Fact] @@ -69,7 +66,7 @@ public async Task Writes_a_line_when_the_request_gets_no_response() await Assert.ThrowsAsync(() => client.GetAsync("api/v4/probe")); - Assert.Contains("! GET https://api.example.test/api/v4/probe sin respuesta", _diagnostics.ToString()); + Assert.Contains("! GET https://api.example.test/api/v4/probe got no response", _diagnostics.ToString()); } private HttpClient CreateClient(StubHttpMessageHandler stub) => diff --git a/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs b/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs index a7f7cb6..7fd6045 100644 --- a/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs +++ b/tests/FiscalApi.Cli.Tests/Input/ConsoleSecretReaderTests.cs @@ -2,7 +2,6 @@ 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(); diff --git a/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs b/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs index 3aae3f1..569e692 100644 --- a/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs +++ b/tests/FiscalApi.Cli.Tests/Input/RedirectedSecretReaderTests.cs @@ -2,13 +2,11 @@ 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)); diff --git a/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs b/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs index 8f24444..b99ba58 100644 --- a/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs +++ b/tests/FiscalApi.Cli.Tests/Input/ScriptedKeys.cs @@ -1,14 +1,9 @@ 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() diff --git a/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs b/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs index 0a519cc..d7697ca 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ClosedPort.cs @@ -3,10 +3,6 @@ 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); diff --git a/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs index 204a9c9..90d6532 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/DeliveryDispatcherTests.cs @@ -2,11 +2,6 @@ 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); @@ -69,7 +64,6 @@ public async Task At_most_max_in_flight_deliveries_run_and_enqueue_never_waits() dispatcher.Enqueue($"evt_{i}", works[i].RunAsync); } - // Cuáles dos toman el cupo primero no está definido; lo que importa es que sean dos. await FakeListenerApi.WaitUntilAsync(() => works.Count(work => work.Started.IsCompleted) == 2, "No corrieron dos."); await Task.Delay(100); Assert.Equal(2, works.Count(work => work.Started.IsCompleted)); @@ -179,7 +173,6 @@ private static async Task WaitForFaultAsync(DeliveryD 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); diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs index 32dff86..7c57419 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeListenerApi.cs @@ -13,11 +13,6 @@ namespace FiscalApi.Cli.Tests.Listen; -/// -/// La escucha de CLI de la API en Kestrel, en proceso: abre sesiones, responde el long-polling con lo -/// que la prueba encola y guarda cada acuse. Sin pasos encolados, el long-polling espera hasta que el CLI se va, como el -/// real cuando no hay entregas. Nunca se llama a Test ni a Live. -/// internal sealed class FakeListenerApi : IAsyncDisposable { public const string ListenerId = "wh_listener_0001"; @@ -43,14 +38,12 @@ 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; - /// Con certificado, escucha en https. public static async Task StartAsync(X509Certificate2? certificate = null) { var builder = WebApplication.CreateSlimBuilder(); @@ -73,7 +66,6 @@ public static async Task StartAsync(X509Certificate2? certifica 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()); @@ -82,14 +74,12 @@ public FakeListenerApi Deliver(params FakeDelivery[] deliveries) 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++) @@ -104,21 +94,18 @@ 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) { var rejections = _ackRejections.GetOrAdd(deliveryId, _ => new ConcurrentQueue<(int, string)>()); @@ -167,7 +154,6 @@ 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); @@ -200,7 +186,6 @@ private async Task PollAsync(HttpContext context, string id) 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); @@ -267,7 +252,6 @@ 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, @@ -278,11 +262,9 @@ internal sealed record FakeDelivery( { public const string Timestamp = "1791900000"; - /// La firma real de Standard Webhooks con el secreto de la escucha: el receptor la puede verificar. public string Signature => SignatureHeader ?? StandardWebhooksSignature.Sign(EventId, Timestamp, Encoding.UTF8.GetBytes(EnvelopeBody)); - /// El sobre del evento con un formato que un re-serializador cambiaría (sangría y espacios). public string EnvelopeBody => Body ?? $$$""" {"id":"{{{EventId}}}","type":"{{{Type}}}","createdAt":"2026-10-12T18:47:53Z","personId":null, "data":{"relatedObject":{"id":"inv_1","type":"invoice","url":"/api/v4/invoices/inv_1"},"status":"X","previousStatus":"V"}} diff --git a/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs b/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs index e817859..f5df813 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/FakeReceiver.cs @@ -10,10 +10,6 @@ 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; @@ -34,8 +30,6 @@ private FakeReceiver(WebApplication app, Func 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) { @@ -67,7 +61,6 @@ public async ValueTask 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) diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs index 8faa5d3..703843d 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenCommandTests.cs @@ -13,19 +13,12 @@ namespace FiscalApi.Cli.Tests.Listen; -/// -/// fiscalapi listen contra la escucha de CLI de la API simulada en Kestrel () -/// y un receptor local también en Kestrel (), con sockets reales. Integridad de la firma, -/// acuses, reconexión y salida. -/// public sealed class ListenCommandTests : IAsyncLifetime { private const string ApiKey = "sk_test_clave-de-listen"; private const string TenantKey = "tenant-de-listen-0001"; private static readonly TimeSpan WaitLimit = TimeSpan.FromSeconds(10); - // Los plazos del contrato, más cortos: la prueba no espera 15 s a un receptor ni 30 s a una reconexión. El reenvío - // conserva unos segundos porque Windows tarda ~2 s en rechazar una conexión a un puerto local cerrado. private static readonly ListenTimings FastTimings = ListenTimings.Default with { ForwardTimeout = TimeSpan.FromSeconds(5), @@ -66,7 +59,7 @@ public async Task DisposeAsync() [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ú: 🚀✅\"," + + 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); @@ -90,7 +83,6 @@ 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() { - // 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(); @@ -168,7 +160,6 @@ 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. Assert.Equal(200, ack.StatusCode); Assert.Equal(0, ack.LatencyMs); Assert.Null(ack.Error); @@ -259,7 +250,6 @@ public async Task A_slow_delivery_does_not_hold_the_long_polling() [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") @@ -275,8 +265,8 @@ public async Task A_network_drop_reconnects_with_backoff_without_losing_deliveri 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.Matches(@"Lost connection to FiscalAPI \(.+\)\. Retrying in \d+(\.\d)? s\.", Error); + Assert.Contains("Reconnected to FiscalAPI.", Error); Assert.False(_run!.IsCompleted, "listen terminó por una caída de red."); } @@ -295,7 +285,7 @@ public async Task An_expired_session_is_reopened_without_exiting() [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.Contains("The listen session expired. Opening a new one.", Error); Assert.False(_run!.IsCompleted, "listen terminó por una sesión caducada."); } @@ -342,8 +332,6 @@ 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_0001", "invoice.canceled")); @@ -351,7 +339,7 @@ public async Task A_409_on_the_ack_is_reported_only_with_verbose(bool verbose) StartListen(verbose ? ["--verbose"] : []); await ExpectAsync(_api.WaitForAcksAsync(2)); - Assert.Equal(verbose, Error.Contains($"No se registró el acuse de la entrega dlv_0001: {notAwaiting}")); + Assert.Equal(verbose, Error.Contains($"Could not acknowledge delivery dlv_0001: {notAwaiting}")); Assert.Equal(2, _api.Acks.Count); Assert.False(_run!.IsCompleted, "listen terminó por un acuse tardío."); } @@ -367,7 +355,7 @@ public async Task Another_rejected_ack_is_reported_and_listen_goes_on() 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.Contains("Could not acknowledge delivery dlv_0001: No se encontró el recurso dlv_0001.", Error); Assert.False(_run!.IsCompleted, "listen terminó por un acuse rechazado."); } @@ -381,7 +369,7 @@ public async Task An_ack_that_fails_on_the_network_or_with_a_5xx_is_retried() 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); + Assert.DoesNotContain("Could not acknowledge", Error); } [Fact] @@ -425,7 +413,7 @@ public async Task Json_mode_writes_one_json_object_per_event_and_nothing_else_to 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); + Assert.Contains($"Ready. Your signing secret is {FakeListenerApi.Secret}", Error); } private static void AssertEventRecord(JsonObject record, FakeDelivery delivery, int statusCode) @@ -507,7 +495,7 @@ public async Task Skip_verify_never_relaxes_tls_toward_the_api() 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.Contains($"Could not connect to {_api.BaseAddress}", Error); Assert.Empty(_api.Opens); } @@ -520,8 +508,8 @@ public async Task An_unreachable_api_at_start_exits_1_without_retrying() StartListen(apiBase: unreachable); Assert.Equal(ExitCodes.Failure, await ExitCodeAsync()); - Assert.Contains($"No se pudo conectar con {unreachable}", Error); - Assert.DoesNotContain("reintentando", Error); + Assert.Contains($"Could not connect to {unreachable}", Error); + Assert.DoesNotContain("Retrying", Error); } [Fact] @@ -547,7 +535,7 @@ 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); + Assert.Contains($"Ready. Your signing secret is {FakeListenerApi.Secret} (listen session {FakeListenerApi.ListenerId}).", Error); var poll = Assert.Single(_api.PollsReceived); Assert.Equal(new FakeListenerApi.RecordedPoll(FakeListenerApi.ListenerId, FakeListenerApi.SessionId(1), "25"), poll); } @@ -573,14 +561,12 @@ 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); + Assert.Contains("Ends any running listen with the same API key.", 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")) @@ -607,7 +593,7 @@ await ExpectAsync(FakeListenerApi.WaitUntilAsync(() => _api.Acks.Any(ack => ack. 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.Contains("Delivery dlv_c25 (evt_0001) expired before forwarding. The API closed it unacknowledged.", Error); Assert.False(_run!.IsCompleted, "listen terminó."); } @@ -650,8 +636,8 @@ 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 webhooks está en curso; " + - @"intenta de nuevo\); reintentando en \d+(\.\d)? s\.", Error); + Assert.Matches(@"Could not reopen the listen session \(Otro cambio a los webhooks está en curso; " + + @"intenta de nuevo\)\. Retrying in \d+(\.\d)? s\.", Error); Assert.False(_run!.IsCompleted, "listen terminó por el candado de la apertura."); } @@ -672,7 +658,6 @@ public async Task A_410_after_another_without_a_successful_poll_waits_before_reo 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."); } @@ -762,7 +747,7 @@ public async Task Ack_retries_stop_when_the_lease_expires() StartListen(); await ExpectAsync(FakeListenerApi.WaitUntilAsync( - () => Error.Contains("No se registró el acuse de la entrega dlv_0001: Servicio no disponible"), + () => Error.Contains("Could not acknowledge delivery dlv_0001: Servicio no disponible"), "El acuse nunca dejó de reintentarse.")); var attempts = _api.Acks.Count; @@ -787,21 +772,21 @@ public async Task A_4xx_on_the_long_polling_exits_1(int status, string message) } [Theory] - [InlineData("--forward-to|localhost:3000/webhooks", "--forward-to debe ser una URL absoluta http o https")] - [InlineData("--forward-to|ftp://localhost/webhooks", "--forward-to debe ser una URL absoluta http o https")] - [InlineData("--forward-to|http://localhost:3000|--headers|X-Origen", "--headers espera pares Nombre:Valor")] - [InlineData("--forward-to|http://localhost:3000|--headers|:valor", "--headers espera pares Nombre:Valor")] - [InlineData("--forward-to|http://localhost:3000|--headers|Webhook-Signature:v1,falsa", "--headers no puede cambiar Webhook-Signature")] - [InlineData("--forward-to|http://localhost:3000|--headers|Content-Type:text/plain", "--headers trae un header que HTTP no admite: 'Content-Type'")] - [InlineData("--forward-to|http://localhost:3000|--headers|X Malo:1", "--headers trae un header que HTTP no admite: 'X Malo'")] - [InlineData("--headers|X-Origen:cli", "--headers solo aplica con --forward-to.")] - [InlineData("--skip-verify", "--skip-verify solo aplica con --forward-to.")] + [InlineData("--forward-to|localhost:3000/webhooks", "--forward-to must be an absolute http or https URL")] + [InlineData("--forward-to|ftp://localhost/webhooks", "--forward-to must be an absolute http or https URL")] + [InlineData("--forward-to|http://localhost:3000|--headers|X-Origen", "--headers expects comma-separated Name:Value pairs")] + [InlineData("--forward-to|http://localhost:3000|--headers|:valor", "--headers expects comma-separated Name:Value pairs")] + [InlineData("--forward-to|http://localhost:3000|--headers|Webhook-Signature:v1,falsa", "--headers cannot set Webhook-Signature")] + [InlineData("--forward-to|http://localhost:3000|--headers|Content-Type:text/plain", "--headers has a header that HTTP does not allow: 'Content-Type'")] + [InlineData("--forward-to|http://localhost:3000|--headers|X Malo:1", "--headers has a header that HTTP does not allow: 'X Malo'")] + [InlineData("--headers|X-Origen:cli", "--headers requires --forward-to.")] + [InlineData("--skip-verify", "--skip-verify requires --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.")] + [InlineData("--print-secret|--forward-to|http://localhost:3000", "--print-secret cannot be used with --forward-to.")] + [InlineData("--print-secret|--forward-to|http://localhost:3000|--headers|X-A:1", "--print-secret cannot be used with --headers.")] + [InlineData("--print-secret|--forward-to|http://localhost:3000|--skip-verify", "--print-secret cannot be used with --skip-verify.")] public async Task Invalid_options_are_usage_errors_without_calling_the_api(string options, string error) { StartListen(options.Split('|')); @@ -812,11 +797,9 @@ 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: 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?$"; + $@"(?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 @@ -832,10 +815,8 @@ private async Task ExpectAsync(Task condition) } } - /// 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(); diff --git a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs index 3e4a61b..ce25897 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/ListenTimingsTests.cs @@ -2,7 +2,6 @@ namespace FiscalApi.Cli.Tests.Listen; -/// Los plazos de listen son los del contrato con la API. public sealed class ListenTimingsTests { [Fact] @@ -10,14 +9,11 @@ public void Defaults_follow_the_listen_contract() { var timings = ListenTimings.Default; - // El long-polling espera hasta 25 s; el cliente de la API le da 35 s. Assert.Equal(25, timings.PollWaitSeconds); Assert.Equal(TimeSpan.FromSeconds(35), timings.ApiTimeout); Assert.Equal(TimeSpan.FromSeconds(15), timings.ForwardTimeout); Assert.Equal(TimeSpan.FromSeconds(30), timings.Reconnect.MaxDelay); - // WebhookConsts.DeliveryLease de fapi-back: después, la API cierra la entrega sin acuse. Assert.Equal(TimeSpan.FromMinutes(2), timings.DeliveryLease); - // Al salir con Ctrl+C, el único intento de acusar lo que ya tenía resultado. Assert.Equal(TimeSpan.FromSeconds(2), timings.ShutdownAckTimeout); } diff --git a/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs b/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs index 4e3bb25..51fad60 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/RawHttpReceiver.cs @@ -4,10 +4,6 @@ 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); @@ -57,7 +53,6 @@ private async Task RespondAsync(TcpClient client) } } - // Basta con consumir el request completo: el contenido no importa a estas pruebas. private static async Task ReadRequestAsync(NetworkStream stream) { var received = new List(); diff --git a/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs b/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs index 48227c3..9adb5e3 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/SelfSignedCertificate.cs @@ -4,7 +4,6 @@ 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() @@ -16,7 +15,6 @@ public static X509Certificate2 ForLoopback() 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 index 7012ce0..3ad3508 100644 --- a/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs +++ b/tests/FiscalApi.Cli.Tests/Listen/StandardWebhooksSignature.cs @@ -3,11 +3,6 @@ namespace FiscalApi.Cli.Tests.Listen; -/// -/// La firma de Standard Webhooks: v1, + HMAC-SHA256 en base64 sobre {id}.{timestamp}.{body}, con la -/// llave que va en base64 después de whsec_. El cuerpo entra en bytes: así la verificación prueba que el reenvío -/// no cambió ni un byte. -/// internal static class StandardWebhooksSignature { public static string Sign(string id, string timestamp, byte[] body) diff --git a/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs index 64008af..24905ce 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/CliApplicationUpdateCheckTests.cs @@ -4,10 +4,6 @@ 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(); @@ -72,7 +68,6 @@ public async Task Command_runs_while_the_check_is_still_pending() [Fact] public async Task Ctrl_c_still_shows_a_notice_that_was_already_known() { - // fiscalapi listen siempre termina con Ctrl+C: si el aviso ya llegó durante la escucha, se muestra. using var cancellation = new CancellationTokenSource(); var check = new ScriptedUpdateCheck(Task.FromResult(Notice())); @@ -108,7 +103,6 @@ public async Task Ctrl_c_ignores_a_check_that_ended_without_a_notice() 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"); @@ -154,7 +148,6 @@ private static UpdateNotice Notice() 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; } = []; diff --git a/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs b/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs index c0c7bb5..f57a4da 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/NoUpdateCheck.cs @@ -2,7 +2,6 @@ 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(); diff --git a/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs b/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs index ce9cd89..7345cd1 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/SemanticVersionTests.cs @@ -2,7 +2,6 @@ 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] diff --git a/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs index c375f64..abeabc4 100644 --- a/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs +++ b/tests/FiscalApi.Cli.Tests/Updates/UpdateCheckTests.cs @@ -7,26 +7,20 @@ namespace FiscalApi.Cli.Tests.Updates; -/// -/// Aviso de versión nueva: consulta releases/latest como mucho una vez cada 24 h, con 2 s de tope, y nunca -/// falla ni bloquea el comando. GitHub se simula con : las pruebas no salen a la red. -/// public sealed class UpdateCheckTests : IDisposable { private static readonly DateTimeOffset Now = new(2026, 10, 13, 12, 0, 0, TimeSpan.Zero); private static readonly TimeSpan RealTimeLimit = TimeSpan.FromSeconds(10); - private readonly string _directory = Path.Combine(Path.GetTempPath(), "fiscalapi-cli-tests", Guid.NewGuid().ToString("N")); + private static readonly string TestsDirectory = Path.Combine(Path.GetTempPath(), "fiscalapi-cli-tests"); + + private readonly string _directory = Path.Combine(TestsDirectory, 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++) @@ -73,10 +67,9 @@ public async Task Asks_github_anonymously_for_the_latest_release_of_the_cli() 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("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); @@ -88,13 +81,13 @@ 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(); + Assert.NotNull(notice); - await notice!.WriteToAsync(error); + 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); + Assert.Contains("fiscalapi 0.2.0 is available (you have 0.1.0).", text); + Assert.Contains("Download it from https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0", text); } [Fact] @@ -122,7 +115,6 @@ 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); @@ -240,7 +232,6 @@ public async Task Api_that_never_answers_does_not_block_and_gives_up_at_2_second 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); @@ -305,8 +296,7 @@ public async Task Check_time_in_the_future_counts_as_due_after_the_clock_goes_ba [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)!); + Directory.CreateDirectory(TestsDirectory); await File.WriteAllTextAsync(_directory, "no soy una carpeta"); var notice = await StartAsync(); @@ -318,7 +308,6 @@ public async Task State_location_that_cannot_be_written_skips_the_check_without_ [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(); @@ -364,7 +353,6 @@ public void Linux_state_directory_follows_xdg_state_home_or_falls_back_to_local_ 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")); @@ -381,6 +369,22 @@ public void State_file_for_the_current_user_lives_in_a_folder_of_the_cli() Assert.Equal("fiscalapi-cli", Path.GetFileName(Path.GetDirectoryName(stateFile.Path))); } + [Theory] + [InlineData("update-check.json")] + [InlineData("")] + public void State_file_without_a_parent_directory_is_rejected(string path) + { + Assert.Throws("path", () => new UpdateCheckStateFile(path)); + } + + [Fact] + public void State_file_at_the_file_system_root_is_rejected() + { + var root = Path.DirectorySeparatorChar.ToString(); + + Assert.Throws("path", () => new UpdateCheckStateFile(root)); + } + private string? Environment(string name) => _environment.GetValueOrDefault(name); private UpdateCheck CreateCheck(string currentVersion = "0.1.0", bool errorRedirected = false) => From 2fd7ca5c96c3d86ea3cfd2de3df95ea999c7e797 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 23:58:36 -0600 Subject: [PATCH 2/4] docs: reescribir README y docs/ para el cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README con el orden: qué es, instalación, autenticación, uso, comandos, solución de problemas y licencia. Se quitan las secciones de desarrollo y de releases. - La verificación del SHA-256 es opcional y va en su propia sección. - Sin menciones de .NET ni de detalles de implementación. - Los ejemplos de salida se alinean con lo que imprime el CLI: marcadores de --help, líneas de listen, aviso de versión y errores de uso. Los textos que devuelve la API se citan tal cual. - Los secretos y trazas de ejemplo son placeholders. - Enlaces verificados; markdownlint sin errores. - Se agrega LICENSE con la Mozilla Public License 2.0. --- LICENSE | 373 ++++++++++++++++++++++++++++++++++ README.md | 112 +++++----- docs/autenticacion.md | 120 ++++++----- docs/instalacion.md | 149 ++++++++------ docs/listen.md | 212 ++++++++----------- docs/salida-y-codigos.md | 108 ++++++---- docs/solucion-de-problemas.md | 160 ++++++++------- docs/trigger.md | 86 ++++---- docs/webhooks-y-eventos.md | 227 +++++++++++---------- 9 files changed, 963 insertions(+), 584 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index 2aef881..342b831 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,26 @@ -# fiscalapi — CLI de FiscalAPI +# fiscalapi, el CLI de FiscalAPI -`fiscalapi` lleva los webhooks de FiscalAPI a tu terminal: recibe los eventos en tu equipo y los reenvía a tu servidor -local, dispara eventos de prueba y administra tus webhooks y eventos sin salir de la consola. - -Es un solo binario nativo para Windows, Linux y macOS: no necesitas instalar .NET. +`fiscalapi` lleva los webhooks de FiscalAPI a tu terminal: recibe los eventos de tu cuenta en tu equipo y los reenvía a +tu servidor local, dispara eventos de prueba y administra tus webhooks y eventos desde la consola. ## Instalación -Descarga el empaque de tu sistema y `SHA256SUMS` desde -[Releases](https://github.com/FiscalAPI/fiscalapi-cli/releases), y **verifica el SHA-256 antes de ejecutarlo**: +Descarga el empaque de tu sistema desde [Releases](https://github.com/FiscalAPI/fiscalapi-cli/releases): -| Sistema | Empaque | -|---|---| -| Windows x64 | `fiscalapi-win-x64.zip` | -| Linux x64 (glibc 2.28+) | `fiscalapi-linux-x64.tar.gz` | +| Sistema | Empaque | +| ------------------- | ---------------------------- | +| Windows x64 | `fiscalapi-win-x64.zip` | +| Linux x64 | `fiscalapi-linux-x64.tar.gz` | | macOS Apple Silicon | `fiscalapi-osx-arm64.tar.gz` | -| macOS Intel | `fiscalapi-osx-x64.tar.gz` | +| macOS Intel | `fiscalapi-osx-x64.tar.gz` | -Linux (en macOS, cambia el empaque y usa `shasum -a 256 -c -` en lugar de `sha256sum -c -`): +Linux (en macOS, cambia el nombre del empaque): ```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 +curl -fsSLO https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download/fiscalapi-linux-x64.tar.gz tar -xzf fiscalapi-linux-x64.tar.gz sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi +fiscalapi --version ``` Windows (PowerShell): @@ -33,72 +28,65 @@ Windows (PowerShell): ```powershell $base = 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download' Invoke-WebRequest "$base/fiscalapi-win-x64.zip" -OutFile fiscalapi-win-x64.zip -Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS -$expected = (Select-String -Path SHA256SUMS -Pattern ' fiscalapi-win-x64\.zip$').Line.Split(' ')[0] -$actual = (Get-FileHash fiscalapi-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() -if ($actual -ne $expected) { throw "El SHA-256 no coincide: $actual" } else { 'SHA-256 OK' } Expand-Archive fiscalapi-win-x64.zip -DestinationPath (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi') -Force ``` -Cómo agregarlo al `PATH`, Gatekeeper y SmartScreen, actualizar, el aviso de versión nueva y desinstalar: -[docs/instalacion.md](docs/instalacion.md). +Si quieres confirmar que la descarga está íntegra, compara su SHA-256 con el archivo `SHA256SUMS` del release: +[Verificar la descarga (opcional)](docs/instalacion.md#verificar-la-descarga-opcional). En +[Instalación](docs/instalacion.md) están los requisitos de cada sistema, cómo agregar `fiscalapi` al `PATH`, actualizar +y desinstalar. + +## Autenticación + +Cada comando que llama a la API usa tu API key y tu tenant key. Guárdalas una vez en el almacén de credenciales del +sistema con `fiscalapi login`, o defínelas en las variables de entorno `FISCALAPI_API_KEY` y `FISCALAPI_TENANT_KEY` +(por ejemplo, en un CI). El ambiente por omisión es el de pruebas; agrega `--live` para producción. Detalles en +[Autenticación](docs/autenticacion.md). -## Inicio rápido +## Uso ```bash -# 1. Guarda tu API key y tu tenant key en el almacén de credenciales del sistema (ambiente de pruebas) +# 1. Guarda tu API key y tu tenant key del ambiente de pruebas fiscalapi login # 2. Recibe los eventos de tu cuenta y reenvíalos a tu servidor local fiscalapi listen --forward-to http://localhost:3000/webhooks -# Listo. Tu secreto de firma es whsec_… (escucha …) -# 2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] -# 3. En otra terminal, dispara un evento de prueba hacia tu escucha +# 3. En otra terminal, dispara un evento de prueba fiscalapi trigger invoice.canceled ``` -Verifica la firma de cada entrega en tu servidor con el secreto `whsec_…` que muestra `listen` y el `Verify` del SDK de -tu lenguaje ([cómo](docs/listen.md#verificar-la-firma)). `fiscalapi trigger` solo funciona en el -ambiente de pruebas ([detalles](docs/trigger.md)). +`listen` muestra el secreto de firma de tu sesión y una línea por cada evento que reenvía: -## Documentación +```text +Ready. Your signing secret is whsec_ (listen session ). +2026-10-12 09:47:46 invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] +``` -| Tema | Contenido | -|---|---| -| [Instalación](docs/instalacion.md) | Binarios por sistema, verificación, `PATH`, actualización, aviso de versión nueva y desinstalación. | -| [Autenticación](docs/autenticacion.md) | `login`/`logout`, almacén de credenciales, variables de entorno, ambientes y CI. | -| [`fiscalapi listen`](docs/listen.md) | Recibir eventos en local: opciones, firma, reconexión y salida `--json`. | -| [Webhooks y eventos](docs/webhooks-y-eventos.md) | `webhooks list/retrieve/create/update/delete/rotate-secret` y `events list/retrieve/resend`. | -| [`fiscalapi trigger`](docs/trigger.md) | Eventos de prueba hacia tu escucha o un webhook: tipos, opciones y errores. | -| [Salida y códigos](docs/salida-y-codigos.md) | stdout/stderr, `--json`, `--verbose` y códigos de salida. | -| [Solución de problemas](docs/solucion-de-problemas.md) | Errores frecuentes con su causa y remedio. | +Configura tu servidor con ese secreto de firma para verificar cada entrega +([Verificar la firma](docs/listen.md#verificar-la-firma)). -`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-info](https://docs.fiscalapi.com/webhooks-info). +## Comandos -## Desarrollo +| Comando | Qué hace | Guía | +| --------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------ | +| `fiscalapi login`, `fiscalapi logout` | Guarda o elimina tus credenciales de un ambiente. | [Autenticación](docs/autenticacion.md) | +| `fiscalapi listen` | Recibe los eventos en tu equipo y los reenvía a tu servidor. | [`fiscalapi listen`](docs/listen.md) | +| `fiscalapi trigger ` | Dispara un evento de prueba. Solo en el ambiente de pruebas. | [`fiscalapi trigger`](docs/trigger.md) | +| `fiscalapi webhooks list`, `retrieve`, `create`, `update` | Lista, muestra, crea y actualiza tus webhooks. | [Webhooks y eventos](docs/webhooks-y-eventos.md) | +| `fiscalapi webhooks delete`, `rotate-secret` | Elimina un webhook o genera un secreto de firma nuevo. | [Webhooks y eventos](docs/webhooks-y-eventos.md) | +| `fiscalapi events list`, `retrieve`, `resend` | Lista, muestra y reenvía los eventos de los últimos 30 días. | [Webhooks y eventos](docs/webhooks-y-eventos.md) | -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. +Las opciones globales `--live`, `--api-base`, `--json` y `--verbose`, y los códigos de salida, están en +[Salida y códigos](docs/salida-y-codigos.md). `fiscalapi --help` y `fiscalapi --help` describen cada opción. -```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 -``` +La guía de webhooks de FiscalAPI (forma del evento, firma, reintentos y rotación del secreto de firma) está en +[docs.fiscalapi.com/webhooks-info](https://docs.fiscalapi.com/webhooks-info). -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. +## Solución de problemas -### Releases +Los errores frecuentes, con su causa y remedio, están en [Solución de problemas](docs/solucion-de-problemas.md). -`.github/workflows/release.yml` compila cada binario en un runner de su sistema (AOT no compila entre sistemas): -Windows, Linux dentro de AlmaLinux 8 (para que el binario corra desde glibc 2.28), macOS Apple Silicon y macOS Intel. -Corre las pruebas del almacén de credenciales en Windows y macOS, prueba cada binario con `--version` y deja un -release **en borrador** con los 4 empaques y `SHA256SUMS`. Publicarlo es una decisión manual. +## Licencia -- 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`. +Este proyecto se distribuye bajo la Mozilla Public License 2.0. Ver [LICENSE](LICENSE). diff --git a/docs/autenticacion.md b/docs/autenticacion.md index 30da0ac..d6fb4c4 100644 --- a/docs/autenticacion.md +++ b/docs/autenticacion.md @@ -1,76 +1,74 @@ # Autenticación y ambientes -Cada comando que llama a la API manda tu **API key** (header `X-API-KEY`) y tu **tenant key** (`X-TENANT-KEY`). El CLI -las toma de las variables de entorno o, si no están, de las que guardó `fiscalapi login`. Ningún comando las acepta -como argumento: quedarían en el historial del shell y en la lista de procesos. +Cada comando que llama a la API manda tu API key (header `X-API-KEY`) y tu tenant key (`X-TENANT-KEY`). El CLI las +toma de las variables de entorno o, si no están, de las que guardó `fiscalapi login`. Ningún comando las acepta como +argumento: quedarían en el historial del shell y en la lista de procesos. ## Ambientes -| Opción | URL base | Uso | -|---|---|---| -| (ninguna) | `https://test.fiscalapi.com` | Pruebas. Es el ambiente por omisión. | -| `--live` | `https://live.fiscalapi.com` | Producción. | -| `--api-base ` | La que indiques | Otra URL base, p. ej. un back local. Gana sobre `--live`. | +| 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, por ejemplo una API local. Gana sobre `--live`. | -`--api-base` acepta solo URLs absolutas `http` o `https`. Como las llaves viajan en headers, `http` solo se permite hacia -tu propio equipo (`localhost`, `127.0.0.1` o `::1`): +`--api-base` acepta solo URLs absolutas `http` o `https`. Como la API key y la tenant key viajan en headers, `http` solo +se permite hacia tu propio equipo (`localhost`, `127.0.0.1` o `::1`): ```text $ fiscalapi webhooks list --api-base http://fiscalapi.example.com -Error: --api-base con http solo se permite para localhost, 127.0.0.1 o ::1; usa https para 'http://fiscalapi.example.com'. -Usa `fiscalapi --help` para ver los comandos y opciones. +Error: --api-base allows http only for localhost, 127.0.0.1 or ::1. Use https for 'http://fiscalapi.example.com'. +Run `fiscalapi --help` to see commands and options. ``` -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. +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:` son independientes. ## `fiscalapi login` ```text $ fiscalapi login -API key: -Tenant key: -Listo. Credenciales guardadas para test.fiscalapi.com. +API key: +Tenant key: +Done. Credentials saved for test.fiscalapi.com. ``` -`login` pide la API key y la tenant key sin mostrar lo que escribes, las valida con `GET /api/v4/me/features` y las -guarda en el almacén de credenciales del sistema. Para producción, usa `fiscalapi login --live`. Los prompts y los -mensajes van a stderr. +`login` pide la API key y la tenant key sin mostrar lo que escribes, las valida con la API 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. +Los espacios alrededor de cada valor se ignoran. -| Caso | Resultado | -|---|---| -| La API rechaza las llaves (401) | No guarda nada y sale con 1: `Error: Las credenciales no son válidas para test.fiscalapi.com.` | -| Otro error de la API | No guarda nada y muestra el error de la API (salida 1). | -| La persona de la API key no tiene `Webhooks.Manage` | Guarda las llaves y avisa: `Esta API key no tiene el permiso Webhooks.Manage; listen, trigger y webhooks responderán 403.` | -| Llave vacía, de más de 200 caracteres o con caracteres que no son ASCII visible | Error de uso (salida 2): `La API key está vacía, es demasiado larga o tiene caracteres no válidos.` | - -Los espacios alrededor de cada llave se ignoran. +| Caso | Resultado | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| La API rechaza la API key o la tenant key (401) | No guarda nada y sale con 1: `Error: Invalid credentials for test.fiscalapi.com.` | +| Otro error de la API | No guarda nada, muestra el error de la API y sale con 1. | +| La persona de la API key no tiene el permiso `Webhooks.Manage` | Guarda las credenciales y avisa: `This API key lacks the Webhooks.Manage permission. listen, trigger and webhooks will return 403.` | +| Valor vacío, de más de 200 caracteres o con caracteres que no son ASCII visible | Sale con 2: `Error: The API key is empty, too long, or has invalid characters.` | ### 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. +Si stdin viene redirigido (un script), `login` no muestra prompts y lee un valor 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 +`printf` es un comando interno del shell, así que los valores no aparecen en la lista de procesos. Un archivo guardado como "UTF-8 con BOM" también funciona. -> **Terminales sin ConPTY.** En mintty (la terminal de Git Bash en Windows) y en `docker run -i` sin `-t`, la entrada -> no llega como terminal: `login` la trata como redirigida, sin prompt y **mostrando lo que escribes**. Usa Windows -> Terminal o PowerShell, `docker run -it`, o las variables de entorno. +> **Terminales sin TTY.** En mintty (la terminal de Git Bash en Windows) y en `docker run -i` sin `-t`, la entrada no +> llega como terminal: `login` la trata como redirigida, sin prompt y mostrando lo que escribes. Usa Windows Terminal o +> PowerShell, `docker run -it`, o las variables de entorno. ## `fiscalapi logout` -Borra la entrada del ambiente. Termina con 0 aunque no hubiera nada guardado: +Elimina las credenciales guardadas del ambiente. Termina con 0 aunque no hubiera nada guardado: ```text $ fiscalapi logout -Credenciales borradas para test.fiscalapi.com. +Credentials deleted for test.fiscalapi.com. $ fiscalapi logout --live -No había credenciales guardadas para live.fiscalapi.com. +No saved credentials for live.fiscalapi.com. ``` Si el almacén está bloqueado o falla, la entrada puede seguir ahí: `logout` lo dice y sale con 1. @@ -79,34 +77,34 @@ Si el almacén está bloqueado o falla, la entrada puede seguir ahí: `logout` l ## 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=` | +| Sistema | Almacén | Entrada | +| ------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Windows | Administrador de credenciales, "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 (GNOME Keyring o compatible) | Entrada `FiscalAPI CLI ()` con los atributos `service=fiscalapi-cli` y `host=` | No hay respaldo en archivo: si el sistema no tiene almacén, `login` no guarda nada en texto plano. ## Variables de entorno -| Variable | Valor | -|---|---| -| `FISCALAPI_API_KEY` | Tu API key. | +| Variable | Valor | +| ---------------------- | -------------- | +| `FISCALAPI_API_KEY` | Tu API key. | | `FISCALAPI_TENANT_KEY` | Tu tenant key. | -Si las dos están definidas, ganan sobre lo que guardó `login` y valen para **cualquier** ambiente: con llaves de +Si las dos están definidas, ganan sobre lo que guardó `login` y valen para cualquier ambiente: con una API key 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: +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. +Error: Set both FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY; only FISCALAPI_API_KEY is set. ``` 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. +Error: No credentials for test.fiscalapi.com. Run `fiscalapi login` or set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY. ``` ## Casos especiales @@ -123,30 +121,28 @@ env: ``` 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)). +definida, el aviso de versión nueva no aparece ([Instalación](instalacion.md#aviso-de-versión-nueva)). ### WSL, servidores y contenedores -En Linux sin Secret Service (WSL, un servidor sin sesión gráfica, un contenedor), `login` sale con 2 antes de pedir -las llaves: +En Linux sin Secret Service (WSL, un servidor sin sesión gráfica, un contenedor), `login` sale con 2 antes de pedir la +API key: ```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). +Error: No credential store is available. Set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY. + Details: ``` -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. +La línea `Details` dice qué falta. Usa las variables de entorno o, si quieres el almacén, instala GNOME Keyring (o un +servicio compatible con Secret Service) y abre una sesión de escritorio. ### 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: +Si el almacén existe pero está bloqueado o no respondió, el comando sale con 1: ```text -Error: El almacén de credenciales del sistema existe, pero está bloqueado o no respondió. - Detalle: … +Error: The credential store is locked or not responding. Unlock it and try again. + Details: ``` Desbloquéalo (en macOS, abre "Acceso a Llaveros"; en Linux, inicia sesión en el escritorio o desbloquea el llavero) y @@ -157,7 +153,7 @@ repite el comando, o usa las variables de entorno. 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`. +Error: Saved credentials for test.fiscalapi.com are unreadable. Run `fiscalapi logout`, then `fiscalapi login`. ``` Siguiente: [`fiscalapi listen`](listen.md). diff --git a/docs/instalacion.md b/docs/instalacion.md index 511afff..92cd48e 100644 --- a/docs/instalacion.md +++ b/docs/instalacion.md @@ -1,42 +1,35 @@ # Instalación -`fiscalapi` es un solo binario nativo (.NET Native AOT): no necesitas instalar .NET ni ninguna otra dependencia. +`fiscalapi` es un solo binario. Descárgalo, ponlo en una carpeta de tu `PATH` y listo: no requiere instalar nada más. ## Sistemas y empaques Cada [release](https://github.com/FiscalAPI/fiscalapi-cli/releases) trae un empaque por sistema, con solo el binario, y un archivo `SHA256SUMS` con el SHA-256 de cada empaque. -| Sistema | Empaque | Requisito | -|---|---|---| -| Windows x64 | `fiscalapi-win-x64.zip` | Windows 10 u 11, o Windows Server 2012 o más nuevo | -| Linux x64 | `fiscalapi-linux-x64.tar.gz` | glibc 2.28 o más nueva: RHEL/AlmaLinux/Rocky 8+, Debian 10+, Ubuntu 20.04+. No corre en Alpine (musl). | -| macOS Apple Silicon | `fiscalapi-osx-arm64.tar.gz` | macOS 15 o más nuevo, Mac con chip Apple (M1 o posterior) | -| macOS Intel | `fiscalapi-osx-x64.tar.gz` | macOS 15 o más nuevo, Mac con procesador Intel | +| 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` | Linux x64 con glibc 2.28 o más nueva: RHEL 8+, Debian 10+, Ubuntu 20.04+ | +| 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)). +El binario de Linux no corre en distribuciones con musl, como Alpine. -Los comandos de abajo descargan la **última versión publicada**. Para una versión fija, cambia -`releases/latest/download` por `releases/download/v1.2.3`. - -**Verifica siempre el SHA-256 antes de ejecutar el binario.** La verificación debe decir `OK`; si dice `FAILED`, borra -lo que descargaste y vuelve a bajarlo. +Los comandos de abajo descargan la última versión publicada. Para una versión fija, cambia `releases/latest/download` +por `releases/download/v`, por ejemplo `releases/download/v4.0.462`. ## 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 +curl -fsSLO https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download/fiscalapi-linux-x64.tar.gz tar -xzf fiscalapi-linux-x64.tar.gz sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi fiscalapi --version ``` Sin permisos de administrador, instálalo en tu usuario con `install -D -m 0755 fiscalapi ~/.local/bin/fiscalapi` y -verifica que `~/.local/bin` esté en tu `PATH`. +revisa que `~/.local/bin` esté en tu `PATH`. ## macOS @@ -45,10 +38,7 @@ 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 +curl -fsSLO "https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download/$package" tar -xzf "$package" sudo mkdir -p /usr/local/bin sudo install -m 0755 fiscalapi /usr/local/bin/fiscalapi @@ -62,24 +52,26 @@ navegador, Gatekeeper lo bloquea la primera vez. Quítale la marca de cuarentena xattr -d com.apple.quarantine /usr/local/bin/fiscalapi ``` -## Windows (PowerShell) +## Windows + +En PowerShell: ```powershell $base = 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download' Invoke-WebRequest "$base/fiscalapi-win-x64.zip" -OutFile fiscalapi-win-x64.zip -Invoke-WebRequest "$base/SHA256SUMS" -OutFile SHA256SUMS -$expected = (Select-String -Path SHA256SUMS -Pattern ' fiscalapi-win-x64\.zip$').Line.Split(' ')[0] -$actual = (Get-FileHash fiscalapi-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant() -if ($actual -ne $expected) { throw "El SHA-256 no coincide: $actual" } else { 'SHA-256 OK' } +Expand-Archive fiscalapi-win-x64.zip -DestinationPath (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi') -Force +``` -$dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' -Expand-Archive fiscalapi-win-x64.zip -DestinationPath $dir -Force +El binario no lleva firma Authenticode. Si bajaste el zip con el navegador, SmartScreen puede advertir al abrirlo +("Windows protegió su PC"). Quita la marca de descarga: + +```powershell +Unblock-File (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi\fiscalapi.exe') ``` ### Agregar `fiscalapi` al PATH -Este bloque agrega la carpeta al `Path` de tu usuario. Lo escribe en el registro como `REG_EXPAND_SZ`, sin expandir las -variables que ya tenga (`%USERPROFILE%`…), y no la duplica si ya está: +Este bloque agrega la carpeta al `Path` de tu usuario, sin alterar las rutas que ya tiene y sin duplicarla: ```powershell $dir = Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi' @@ -88,52 +80,87 @@ $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') } ``` +La última línea avisa a Windows del cambio para que las terminales nuevas lo vean; no crea ninguna variable. + 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: +## Verificar la descarga (opcional) + +Puedes comparar el SHA-256 del empaque con el del archivo `SHA256SUMS` del mismo release. Descarga `SHA256SUMS` en la +carpeta donde está el empaque: + +```bash +curl -fsSLO https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download/SHA256SUMS +``` ```powershell -Unblock-File (Join-Path $env:LOCALAPPDATA 'Programs\fiscalapi\fiscalapi.exe') +Invoke-WebRequest 'https://github.com/FiscalAPI/fiscalapi-cli/releases/latest/download/SHA256SUMS' -OutFile SHA256SUMS ``` +Luego compáralo. En Linux debe decir `fiscalapi-linux-x64.tar.gz: OK`: + +```bash +grep ' fiscalapi-linux-x64.tar.gz$' SHA256SUMS | sha256sum -c - +``` + +En macOS, con el mismo `$package` de la instalación, debe decir `OK`: + +```bash +grep " $package\$" SHA256SUMS | shasum -a 256 -c - +``` + +En Windows debe decir `True`: + +```powershell +$expected = (Select-String -Path SHA256SUMS -Pattern ' fiscalapi-win-x64\.zip$').Line.Split(' ')[0] +(Get-FileHash fiscalapi-win-x64.zip -Algorithm SHA256).Hash -eq $expected +``` + +Si no coincide, elimina lo que descargaste y vuelve a bajarlo. + ## Actualizar Repite la instalación de tu sistema: el binario nuevo reemplaza al anterior. Tus credenciales de `fiscalapi login` se -conservan, porque viven en el almacén del sistema y no junto al binario. +conservan, porque viven en el almacén de credenciales del sistema y no junto al binario. + +`fiscalapi --version` imprime la versión instalada: + +```text +$ fiscalapi --version +4.0.462 +``` ### Aviso de versión nueva -Mientras corre un comando, `fiscalapi` pregunta a GitHub por el último release, **como mucho una vez cada 24 horas** y -sin enviar tus credenciales. Si hay uno más nuevo, al terminar el comando escribe en stderr: +Mientras corre un comando, `fiscalapi` consulta en GitHub el último release, como mucho una vez cada 24 horas y sin +enviar tus credenciales. Si hay uno más nuevo, al terminar el comando escribe en stderr: ```text -Hay una versión nueva de fiscalapi: 0.2.0 (tienes 0.1.0). -Descárgala en https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v0.2.0 +fiscalapi 4.0.463 is available (you have 4.0.462). +Download it from https://github.com/FiscalAPI/fiscalapi-cli/releases/tag/v ``` -- 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). +- La consulta tarda como mucho 2 s. Si GitHub no responde, la respuesta no es válida o el archivo de estado no se puede + escribir, no se muestra nada y el comando termina con su mismo código de salida. +- Solo se anuncian releases publicados y estables, nunca una prerelease (`4.1.0-rc.1`). +- Tras Ctrl+C, el aviso solo aparece si la respuesta ya había llegado. -El aviso **no corre**: +El aviso no aparece en estos casos: -| Caso | Por qué | -|---|---| -| Con `--json` | La salida es para otra herramienta. | -| Con stderr redirigido (`2> log.txt`) | No ensucia el log de un script. | -| Con la variable `CI` definida, como en GitHub Actions y la mayoría de los CI | En un CI no hay quién lo lea. | -| Con `FISCALAPI_NO_UPDATE_CHECK=1` | Para apagarlo siempre. | +| Caso | Por qué | +| -------------------------------------------- | ----------------------------------- | +| Con `--json` | La salida es para otra herramienta. | +| Con stderr redirigido (`2> log.txt`) | No ensucia el log de un script. | +| Con la variable `CI` definida, como en un CI | En un CI no hay quién lo lea. | +| Con `FISCALAPI_NO_UPDATE_CHECK=1` | Para apagarlo siempre. | `CI` y `FISCALAPI_NO_UPDATE_CHECK` cuentan como definidas con cualquier valor salvo vacío, `0` o `false`. Para apagar el aviso de forma permanente: @@ -148,25 +175,25 @@ echo 'export FISCALAPI_NO_UPDATE_CHECK=1' >> ~/.bashrc # o ~/.zshrc 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/…`) | +| 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, en `~/.local/state`) | -Borrarlo solo adelanta la siguiente consulta. Si la carpeta no se puede escribir, el CLI no consulta. +Eliminarlo 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: +1. Elimina las credenciales guardadas de cada ambiente que usaste, antes de eliminar el binario: ```bash fiscalapi logout fiscalapi logout --live - fiscalapi logout --api-base http://localhost:5001 # si usaste otra URL base + fiscalapi logout --api-base http://localhost: # si usaste una API local ``` -2. Borra el binario y la carpeta de estado: +2. Elimina el binario y la carpeta de estado: ```bash # Linux diff --git a/docs/listen.md b/docs/listen.md index baa5c46..5d5cda5 100644 --- a/docs/listen.md +++ b/docs/listen.md @@ -5,34 +5,38 @@ Recibe en tu equipo los eventos de webhook de tu cuenta y los reenvía a tu serv ## Uso ```text -fiscalapi listen [--forward-to ] [--events ] [--headers "K:V,K:V"] [--skip-verify] [--print-secret] +fiscalapi listen [--forward-to ] [--events ] [--headers "Name:Value,Name:Value"] [--skip-verify] [--print-secret] ``` Requiere una API key con el permiso `Webhooks.Manage` ([Autenticación](autenticacion.md)). ## Opciones -| Opción | Descripción | -|---|---| -| `--forward-to ` | URL absoluta `http` o `https` de tu servidor, p. ej. `http://localhost:3000/webhooks`. Sin ella, `listen` solo muestra los eventos. | -| `--events ` | Tipos separados por coma, p. ej. `invoice.canceled,invoice.cancellation_rejected`. Por omisión, `*` (todos). | -| `--headers "K:V,K:V"` | Headers extra en cada reenvío. El valor llega hasta la coma siguiente y puede llevar `:`. | -| `--skip-verify` | No verifica el certificado TLS de `--forward-to` (p. ej. uno autofirmado). **Nunca** afecta a la conexión con la API. | -| `--print-secret` | Imprime solo el secreto de firma en stdout y termina. | +| Opción | Descripción | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `--forward-to ` | URL absoluta `http` o `https` de tu servidor, por ejemplo `http://localhost:3000/webhooks`. Sin ella, `listen` solo muestra los eventos. | +| `--events ` | Tipos separados por coma, por ejemplo `invoice.canceled,invoice.cancellation_rejected`. Por omisión, `*` (todos). | +| `--headers ` | Headers extra en cada reenvío, como `"Name:Value,Name:Value"`. El valor puede llevar `:`. | +| `--skip-verify` | No verifica el certificado TLS de `--forward-to` (por ejemplo, uno autofirmado). Nunca afecta a la conexión con la API. | +| `--print-secret` | Imprime solo el secreto de firma de una sesión nueva en stdout y termina. | También acepta las opciones globales `--live`, `--api-base`, `--json` y `--verbose` ([Salida y códigos](salida-y-codigos.md)). Estas combinaciones son errores de uso (salida 2): -| Caso | Error | -|---|---| -| `--headers` o `--skip-verify` sin `--forward-to` | `Error: --skip-verify solo aplica con --forward-to.` | -| `--headers` con un header firmado | `Error: --headers no puede cambiar webhook-id: lo firma FiscalAPI.` | -| `--print-secret` con `--forward-to`, `--headers` o `--skip-verify` | `Error: --print-secret no se combina con --forward-to.` | +| Caso | Error | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| `--forward-to` que no es una URL absoluta | `Error: --forward-to must be an absolute http or https URL. Got 'localhost'.` | +| `--headers` o `--skip-verify` sin `--forward-to` | `Error: --skip-verify requires --forward-to.` | +| `--headers` con un header firmado | `Error: --headers cannot set webhook-id. FiscalAPI signs it.` | +| `--headers` sin la forma `Nombre:Valor` | `Error: --headers expects comma-separated Name:Value pairs. Got 'X-Env'.` | +| `--print-secret` con `--forward-to`, `--headers` o `--skip-verify` | `Error: --print-secret cannot be used with --forward-to.` | -> **`--print-secret` abre una sesión**, igual que `listen`. Si ya corre un `fiscalapi listen` con tu misma persona, lo -> reemplaza y aquel termina con el [409](#conexión-y-sesiones). Úsalo antes de arrancar `listen`. +Cada error de uso termina con la línea ``Run `fiscalapi --help` to see commands and options.`` + +`--print-secret` abre una sesión de `listen`, igual que `listen`. Si ya corre un `fiscalapi listen` con la misma API +key, lo reemplaza y aquel termina con un [409](#conexión-y-sesiones). Úsalo antes de arrancar `listen`. ## Ejemplos @@ -40,8 +44,8 @@ Reenvía los eventos a tu servidor local: ```text $ fiscalapi listen --forward-to http://localhost:3000/webhooks -Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a) -2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] +Ready. Your signing secret is whsec_ (listen session ). +2026-10-12 09:47:46 invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] ``` Cada línea trae la hora local, el tipo, el id del evento (el header `webhook-id`) y lo que respondió tu servidor. Para @@ -54,169 +58,129 @@ fiscalapi listen --events invoice.canceled,invoice.cancellation_rejected # Servidor con TLS autofirmado y un header de autorización propio fiscalapi listen --forward-to https://localhost:5443/webhooks --skip-verify --headers "Authorization:Bearer token-local" -# Guarda el secreto para configurar tu servidor +# Guarda el secreto de firma para configurar tu servidor export FISCALAPI_WEBHOOK_SECRET=$(fiscalapi listen --print-secret) ``` ## Cómo funciona -1. `listen` abre **tu escucha de CLI**: un webhook sin URL, uno por persona, con su propio secreto de firma - (`POST /api/v4/webhooks/cli-listener`). El secreto no cambia entre reinicios: configúralo una vez. -2. Cada evento de tu cuenta de los tipos de `--events` genera una entrega para tu escucha. `listen` las pide por - long-polling (`GET /api/v4/webhooks/{id}/cli-deliveries`, hasta 25 s por petición). -3. Reenvía cada entrega a `--forward-to` como un `POST` con el cuerpo **exacto** que firmó FiscalAPI +1. `listen` abre tu sesión de `listen`: un webhook sin URL, uno por persona, con su propio secreto de firma. El + secreto de firma se conserva entre reinicios: configúralo una vez en tu servidor. +2. Cada evento de tu cuenta de los tipos de `--events` genera una entrega para tu sesión de `listen`. +3. `listen` 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. +4. Le informa a la API el resultado, así la entrega queda en el historial como la de un webhook con URL. Se reenvían hasta 16 entregas a la vez; las de un mismo evento conservan su orden. Las redirecciones no se siguen. -**No hay reintentos.** Si tu servidor falla, la entrega queda con ese resultado; repítela con +No hay reintentos. Si tu servidor falla, la entrega queda con ese resultado; repítela con [`fiscalapi events resend`](webhooks-y-eventos.md#reenviar-un-evento). ## Resultado de cada reenvío -| Final de la línea | Significado | -|---|---| -| `[200]` | Tu servidor respondió ese código. Cualquier código de 100 a 599 se muestra y se acusa tal cual. | -| `[error: Timeout]` | Tu servidor no respondió en 15 s. | -| `[error: ConnectionFailed]` | No se pudo conectar: nada escucha en ese puerto, DNS o conexión rechazada. | -| `[error: TlsFailed]` | Falló el TLS hacia `--forward-to`, p. ej. un certificado autofirmado sin `--skip-verify`. | -| `[error: InvalidResponse]` | La respuesta no es HTTP válido o se cortó. | -| (nada) | Sin `--forward-to`: el evento solo se mostró y se acusa como entregado. | - -Son los mismos nombres de error del historial de entregas de la API. +| Final de la línea | Significado | +| --------------------------- | ---------------------------------------------------------------------------------------------- | +| `[200]` | Tu servidor respondió ese código. Cualquier código de 100 a 599 se muestra tal cual. | +| `[error: Timeout]` | Tu servidor no respondió en 15 s. | +| `[error: ConnectionFailed]` | No se pudo conectar: nada escucha en ese puerto, falla el DNS o se rechazó la conexión. | +| `[error: TlsFailed]` | Falló el TLS hacia `--forward-to`, por ejemplo 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 cuenta como entregado. | ## Verificar la firma -Cada reenvío lleva la firma de FiscalAPI con el secreto de tu escucha (`whsec_…`), según -[Standard Webhooks](https://www.standardwebhooks.com/). Verifícala como la de un webhook con URL, con el **cuerpo -crudo** tal como llegó. Si lo deserializas y lo vuelves a serializar, cambian sus bytes y la firma no coincide. - -Los SDK oficiales traen la verificación: - -| SDK | Función | -|---|---| -| .NET (`Fiscalapi`) | `Fiscalapi.Webhooks.Webhook.Verify(headers, body, secret)` | -| Node.js (`fiscalapi`) | `Webhook.verify(headers, body, secret)` | -| Python (`fiscalapi`) | `fiscalapi.webhooks.verify(headers, body, secret)` | -| PHP | `Fiscalapi\Webhooks\Webhook::verify(...)` | -| Java | `com.fiscalapi.webhooks.Webhook.verify(headers, body, secret)` | - -Todas comparan la firma en tiempo constante, rechazan un `webhook-timestamp` con más de 5 minutos de diferencia y -devuelven el evento verificado. En ASP.NET Core: - -```csharp -[HttpPost("webhooks/fiscalapi")] -public async Task Receive() -{ - string body; - using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) - body = await reader.ReadToEndAsync(); - - WebhookEvent webhookEvent; - try - { - var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()); - webhookEvent = Webhook.Verify(headers, body, _webhookSecret); - } - catch (WebhookVerificationException) - { - return BadRequest(); // firma inválida, header faltante o timestamp fuera de tolerancia - } - - // Responde 2xx rápido y procesa después. - return Ok(); -} -``` +Cada reenvío lleva la firma de FiscalAPI con el secreto de firma de tu sesión de `listen` (`whsec_`), según +[Standard Webhooks](https://www.standardwebhooks.com/). Verifícala como la de un webhook con URL, con el cuerpo crudo +tal como llegó: si lo deserializas y lo vuelves a serializar, cambian sus bytes y la firma no coincide. -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. +Los SDK de FiscalAPI traen la verificación: -| Dato | Valor | -|---|---| -| Secreto | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` | -| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` | -| `webhook-timestamp` | `1614265330` | -| Cuerpo | `{"test": 2432232314}` | -| `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` | +| SDK | Función | +| --------------------- | -------------------------------------------------------------- | +| 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)` | -La forma del evento y la verificación a mano en cada lenguaje están en la +La forma del evento, la verificación a mano y el resto de los SDK están en la [guía de webhooks de FiscalAPI](https://docs.fiscalapi.com/webhooks-info). ## Conexión y sesiones -`listen` corre hasta que lo detienes. Si la primera apertura de la escucha falla (p. ej. un `--api-base` equivocado o -una API key sin permiso), termina de inmediato con el error. Después: +`listen` corre hasta que lo detienes. Si la primera apertura de la sesión de `listen` falla (por ejemplo, un +`--api-base` equivocado o una API key sin permiso), termina de inmediato con el error. Después: + +| Situación | Qué hace `listen` | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| Red caída, 408, 429 o 5xx | Reintenta con espera creciente y aleatoria (la primera de 0.5 a 1 s, como máximo 30 s) y lo avisa en stderr. | +| 410, sesión caducada | La API cerró la sesión por falta de conexión (un corte largo, el equipo suspendido). `listen` abre una nueva y sigue. | +| 409, sesión reemplazada | Otro `listen` o un `--print-secret` con la misma API key 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 | Informa durante 2 s los resultados que ya tenía y sale con 130. | -| 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. | +Los avisos de reconexión van a 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. -La sesión de fiscalapi listen caducó; se abre una nueva. +Lost connection to FiscalAPI (Could not connect to https://test.fiscalapi.com/: ). Retrying in 0.8 s. +Reconnected to FiscalAPI. +The listen session expired. Opening a new one. ``` -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 `. +Los eventos que ocurren sin una sesión de `listen` abierta no llegan después. Búscalos con `fiscalapi events list` y +reenvíalos con `fiscalapi events resend `. -Una sesión reemplazada termina así: +Una sesión reemplazada termina así (el texto de `Details` lo envía la API): ```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): … + Details: Otra sesión de fiscalapi listen reemplazó a esta. + HTTP status: 409 + traceIdentifier (for support): ``` Otros avisos en stderr: -| Aviso | Qué pasó | -|---|---| -| `La entrega () venció antes de reenviarse; la API la cerró sin acuse.` | La entrega esperó su turno más de 2 minutos y la API ya la cerró: no se reenvía. | -| `No se registró el acuse de la entrega : …` | La API rechazó el acuse. Un 409 (la entrega ya no esperaba acuse) solo se muestra con `--verbose`. | -| `No se pudo reabrir la sesión de escucha (…); reintentando en s.` | Al reabrir tras un 410, la API respondió 409 porque otra apertura de tu persona estaba en curso. Se reintenta con espera creciente. | +| Aviso | Qué pasó | +| ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `Delivery () expired before forwarding. The API closed it unacknowledged.` | La entrega esperó su turno más de 2 minutos y la API ya la cerró: no se reenvía. | +| `Could not acknowledge delivery : ` | La API no registró el resultado de la entrega. Si la entrega ya estaba cerrada (409), el aviso solo aparece con `--verbose`. | +| `Could not reopen the listen session (). Retrying in s.` | Al reabrir la sesión, otra apertura con la misma API key estaba en curso. Se reintenta con espera creciente. | ## Salida con `--json` -Con `--json`, stdout lleva **un objeto JSON por entrega**, uno por línea (JSON Lines). Los avisos, los errores y la -línea `Listo. Tu secreto de firma es …` siguen en stderr como texto. +Con `--json`, stdout lleva un objeto JSON por entrega, uno por línea (JSON Lines). Los avisos, los errores y la línea +`Ready. Your signing secret is` siguen en stderr como texto. ```json {"time":"2026-10-12T09:47:46.1234567-06:00","deliveryId":"a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d","eventId":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","type":"invoice.canceled","statusCode":200,"latencyMs":12,"event":{"id":"3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b","type":"invoice.canceled","createdAt":"2026-10-12T15:47:46Z","personId":null,"data":{"relatedObject":{"id":"c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f","type":"invoice","url":"/api/v4/invoices/c4d3e2f1-a0b9-4c8d-9e7f-6a5b4c3d2e1f"},"status":"X","previousStatus":"C"}}} ``` -| Campo | Contenido | -|---|---| -| `time` | Cuándo terminó el reenvío, en hora local con su desfase. | -| `deliveryId` | Id de la entrega. | -| `eventId` | El header `webhook-id`, o `?` si no vino. | -| `type` | El tipo del evento, o `?` si el cuerpo no es un evento. | -| `statusCode` | Lo que respondió tu servidor. Solo con `--forward-to` y si respondió. | -| `latencyMs` | Lo que tardó el reenvío. Solo con `--forward-to`. | -| `error` | `Timeout`, `ConnectionFailed`, `TlsFailed` o `InvalidResponse`. Solo con `--forward-to` y si no hubo respuesta. | -| `event` | El evento tal como llegó. Se omite si el cuerpo no es JSON. | +| Campo | Contenido | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `time` | Cuándo terminó el reenvío, en hora local con su desfase. | +| `deliveryId` | Id de la entrega. | +| `eventId` | El header `webhook-id`, o `?` si no vino. | +| `type` | El tipo del evento, o `?` si el cuerpo no es un evento. | +| `statusCode` | Lo que respondió tu servidor. Solo con `--forward-to` y si respondió. | +| `latencyMs` | Lo que tardó el reenvío. Solo con `--forward-to`. | +| `error` | `Timeout`, `ConnectionFailed`, `TlsFailed` o `InvalidResponse`. Solo con `--forward-to` y si no hubo respuesta. | +| `event` | El evento tal como llegó. Se omite si el cuerpo no es JSON. | -Con `--print-secret --json`, la única línea es `{"secret":"whsec_…"}`. +Con `--print-secret --json`, la única línea es `{"secret":"whsec_"}`. ```bash # El tipo y el resultado de cada reenvío fiscalapi listen --forward-to http://localhost:3000/webhooks --json | jq -r '"\(.type) \(.statusCode // .error)"' ``` -## Docker y terminales sin ConPTY +## Docker y terminales sin TTY -Dentro de un contenedor, `http://localhost:…` es el propio contenedor. Para llegar a un servidor del equipo anfitrión +Dentro de un contenedor, `http://localhost` es el propio contenedor. Para llegar a tu servidor en el 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 +`listen` funciona en cualquier terminal. Solo `login` necesita una terminal real para ocultar lo que escribes ([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 5ae67e6..6a77bb7 100644 --- a/docs/salida-y-codigos.md +++ b/docs/salida-y-codigos.md @@ -1,17 +1,28 @@ # Salida y códigos de salida +## Opciones globales + +Todos los comandos aceptan: + +| Opción | Descripción | +| ------------------ | -------------------------------------------------------------------------------------------------------- | +| `--live` | Usa producción (`https://live.fiscalapi.com`) en lugar de pruebas. | +| `--api-base ` | Otra URL base, por ejemplo una API local. Gana sobre `--live` ([Ambientes](autenticacion.md#ambientes)). | +| `--json` | Escribe solo JSON en stdout. | +| `--verbose` | Muestra en stderr el detalle HTTP de cada llamada a la API, sin las credenciales. | + ## stdout y stderr `fiscalapi` separa los datos de los mensajes, para que puedas redirigir o procesar los datos sin ruido: -| Va a stdout | Va a stderr | -|---|---| -| Las tablas y los detalles (`webhooks list`, `events retrieve`…) | Los errores (`Error: …`) y sus detalles | -| La línea de cada entrega de `listen` | `Listo. Tu secreto de firma es …` de `listen` | -| `Secreto de firma: whsec_…` de `create` y `rotate-secret` | El aviso de guardar el secreto | -| El secreto de `listen --print-secret` | Los prompts y mensajes de `login` y `logout` | -| `Reenvío creado: …` de `events resend` y `Evento disparado: …` de `trigger` | La confirmación de `webhooks delete` y `Webhook … borrado.` | -| `--version` y `--help` (sin `--json`) | El detalle HTTP de `--verbose` y el aviso de versión nueva | +| 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` | La línea `Ready. Your signing secret is` de `listen` | +| `Signing secret:` de `create` y `rotate-secret` | El aviso de guardar el secreto de firma | +| El secreto de firma de `listen --print-secret` | Los prompts y mensajes de `login` y `logout` | +| `Resend created:` de `events resend` y `Event triggered:` de `trigger` | La confirmación de `webhooks delete` y `Webhook deleted.` | +| `--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 @@ -20,16 +31,16 @@ 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: +Con `--json`, stdout trae solo JSON y todo lo demás va a stderr: -| Comando | stdout con `--json` | -|---|---| -| `webhooks …`, `events list`, `events resend`, `trigger` | El `data` de la respuesta de la API, sin cambios. | -| `events retrieve` | El evento como lo devuelve la API (igual que sin `--json`). No es el cuerpo compacto que se entregó, así que no sirve para verificar una firma. | -| `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. | +| Comando | stdout con `--json` | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `webhooks`, `events list`, `events resend`, `trigger` | El `data` de la respuesta de la API, sin cambios. | +| `events retrieve` | El evento como lo devuelve la API (igual que sin `--json`). No sirve para verificar una firma. | +| `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. @@ -43,57 +54,64 @@ 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: +`--verbose` escribe en stderr cada petición HTTP a la API y su respuesta, con el tiempo que tardó. 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 +$ fiscalapi events list --page-size 1 --verbose +> GET https://test.fiscalapi.com/api/v4/events?pageSize=1 +> User-Agent: fiscalapi-cli/4.0.462 +< 200 OK (154 ms) < Content-Type: application/json; charset=utf-8 -< Content-Length: 679 ``` -Un fallo de red aparece como `! GET … falló (ConnectionError) tras 12 ms: …`. +Los headers de la respuesta varían según el servidor. Un fallo de red aparece así, una vez por intento: -`--verbose` **nunca** muestra, ni enmascarados: +```text +! GET https://test.fiscalapi.com/api/v4/events?pageSize=1 failed (ConnectionError) after 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). +- los cuerpos de las peticiones y respuestas, porque pueden traer secretos (como el secreto de firma de un webhook). Así puedes pegar la salida en un ticket de soporte. El detalle de `--verbose` es de las llamadas a la API, no de los reenvíos de `listen` a tu servidor. ## Códigos de salida -| Código | Significado | Causas típicas | -|---|---|---| -| 0 | Éxito | El comando terminó bien. También `logout` sin nada guardado y `webhooks delete` cuando respondes "no". | -| 1 | Error de la API o de la red | 401 (credenciales), 403 (sin `Webhooks.Manage`), 404, 400 de validación, 5xx; la API no respondió; `listen` reemplazado por otra sesión (409); `trigger` en producción (403) o sin `listen` abierto (409); el almacén de credenciales está bloqueado; editar tu escucha de CLI. | -| 2 | Error de uso | Opción o argumento inválido; falta de credenciales; solo una de `FISCALAPI_API_KEY`/`FISCALAPI_TENANT_KEY`; no hay almacén de credenciales; `delete` sin `--yes` en un script. | -| 130 | Cancelado | Ctrl+C. | +| Código | Significado | Causas típicas | +| ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | Éxito | El comando terminó bien. También `logout` sin nada guardado y `webhooks delete` cuando no confirmas. | +| 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; actualizar tu sesión de `listen` con `webhooks update`. | +| 2 | Error de uso | Opción o argumento inválido; faltan credenciales; solo una de `FISCALAPI_API_KEY` y `FISCALAPI_TENANT_KEY`; no hay almacén de credenciales; `webhooks 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: +la hay, una sugerencia. El mensaje y el detalle los envía la API: ```text $ fiscalapi webhooks list Error: Sorry, it's not me, it's you. - Detalle: Invalid tenant key - Código HTTP: 401 - traceIdentifier (para soporte): 4C2E9A7B1D3F5E608B9A0C1D2E3F4A5B - Sugerencia: Ejecuta `fiscalapi login` o revisa las variables de entorno FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. + Details: Invalid tenant key + HTTP status: 401 + traceIdentifier (for support): + Hint: Run `fiscalapi login` or set FISCALAPI_API_KEY and 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).` +Un 400 de validación agrega, debajo de `Details`, 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 así: + +```text +Error: Unexpected response (503). + HTTP status: 503 +``` -Los GET se reintentan solos, hasta 3 intentos en total con espera creciente (de 0.5 s hasta 10 s), ante una conexión -fallida o cortada, 408, 429 y 5xx. Se respeta `Retry-After`; si pide más de 10 s, no se reintenta. Nunca se reintentan -una API que no responde a tiempo, un error de DNS o de TLS, otro 4xx ni un POST, PUT o DELETE. +Las consultas (GET) se reintentan solas, hasta 3 intentos en total con una espera corta y creciente (la primera de +0.25 a 0.5 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 0f6793f..e1e83f7 100644 --- a/docs/solucion-de-problemas.md +++ b/docs/solucion-de-problemas.md @@ -1,7 +1,8 @@ # 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. +Cada caso trae el texto que muestra el CLI o la API, la causa y el remedio. Para ver el detalle de una llamada a la +API, repite el comando con `--verbose` ([Salida y códigos](salida-y-codigos.md#--verbose)); nunca muestra tu API key +ni tu tenant key. ## Credenciales @@ -9,10 +10,10 @@ repite el comando con `--verbose` ([Salida y códigos](salida-y-codigos.md#--ver ```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. + Details: Invalid tenant key + HTTP status: 401 + traceIdentifier (for support): + Hint: Run `fiscalapi login` or set FISCALAPI_API_KEY and 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 @@ -23,57 +24,63 @@ pruebas no sirven con `--live` y al revés). ### 401 `Missing required http X-API-KEY.` **Causa:** la petición llegó sin el header `X-API-KEY`. -**Remedio:** el mismo de arriba. +**Remedio:** el mismo del caso anterior. ### 401 `Invalid 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.` +**Remedio:** el mismo del primer caso. `fiscalapi login` valida la API key y la tenant key antes de guardarlas: +`Error: Invalid credentials for test.fiscalapi.com.` ### 403 sin `Webhooks.Manage` ```text Error: Sorry, it's not me, it's you. - Detalle: No tienes permiso para administrar webhooks. - Código HTTP: 403 - traceIdentifier (para soporte): … + Details: No tienes permiso para administrar webhooks. + HTTP status: 403 + traceIdentifier (for support): ``` **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. +`This API key lacks the Webhooks.Manage permission. listen, trigger and webhooks will return 403.` +**Remedio:** pide al administrador de la cuenta que dé el permiso a esa persona, o usa la API key de una persona que lo +tenga. -### `No hay credenciales para …` +### `No credentials for` ```text -Error: No hay credenciales para test.fiscalapi.com. Ejecuta `fiscalapi login` o define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. +Error: No credentials for test.fiscalapi.com. Run `fiscalapi login` or set FISCALAPI_API_KEY and 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`. +**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` +### `Set both FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY` + +```text +Error: Set both FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY; only FISCALAPI_API_KEY is set. +``` **Causa:** solo una de las dos variables está definida. **Remedio:** define las dos o ninguna. -### No hay almacén de credenciales +### `No credential store is available` ```text -Error: No hay un almacén de credenciales disponible en este sistema. Define FISCALAPI_API_KEY y FISCALAPI_TENANT_KEY. - Detalle: … +Error: No credential store is available. Set FISCALAPI_API_KEY and FISCALAPI_TENANT_KEY. + Details: ``` -**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 +**Causa:** Linux sin Secret Service: WSL, un servidor sin sesión gráfica o un contenedor. +**Remedio:** usa las variables de entorno, o instala GNOME Keyring o un servicio compatible ([Autenticación](autenticacion.md#wsl-servidores-y-contenedores)). -### Almacén bloqueado +### `The credential store is locked or not responding` ```text -Error: El almacén de credenciales del sistema existe, pero está bloqueado o no respondió. +Error: The credential store is locked or not responding. Unlock it and try again. + Details: ``` **Causa:** el llavero de macOS o GNOME Keyring está bloqueado, o no respondió a tiempo. @@ -81,46 +88,48 @@ Error: El almacén de credenciales del sistema existe, pero está bloqueado o no ### `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. +**Causa:** la terminal no es un TTY (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…` +### `--api-base allows http only for 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'. +Error: --api-base allows http only for localhost, 127.0.0.1 or ::1. Use https for 'http://fiscalapi.example.com'. +Run `fiscalapi --help` to see commands and options. ``` -**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. +**Causa:** la API key y la tenant key viajan en headers; sin TLS solo se permite hablar con tu propio equipo. +**Remedio:** usa `https://`. Para una API 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. +Error: --skip-verify requires --forward-to. +Error: --print-secret cannot be used with --forward-to. +Error: --person needs at least one person ID. Use --all-persons to receive events for all persons. +Error: Use --yes to delete without an interactive confirmation. ``` `fiscalapi --help` lista las opciones de cada comando. ## `fiscalapi listen` -### 409 `Otra sesión de fiscalapi listen reemplazó a esta.` +### 409 al reemplazar la sesión de `listen` -**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. +**Causa:** abriste otro `fiscalapi listen` o un `fiscalapi listen --print-secret` con la misma API key (en otra +terminal, otra máquina o un script). Solo puede haber una sesión de `listen` a la vez; la anterior termina con código +1 ([ejemplo](listen.md#conexión-y-sesiones)). **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]` +### Tu servidor no responde: `[error: Timeout]`, `[error: ConnectionFailed]` ```text -2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [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,41 +138,41 @@ servidor tardó más de 15 s (`Timeout`). Dentro de un contenedor, `localhost` e 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 +### La firma no verifica en tu 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)). +**Causa más común:** tu servidor verifica un cuerpo re-serializado. Si tu framework deserializa el JSON 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 a la función de verificación del SDK +([Verificar la firma](listen.md#verificar-la-firma)). Otras causas: -- **Otro secreto:** el de tu escucha de CLI no es el de tus webhooks con URL. Toma el de la línea - `Listo. Tu secreto de firma es whsec_…` o de `fiscalapi listen --print-secret`, completo y con su prefijo `whsec_`. +- **Otro secreto de firma:** el de tu sesión de `listen` no es el de tus webhooks con URL. Toma el de la línea + `Ready. Your signing secret is` o el 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). +- **Rotaste el secreto de firma:** durante 24 horas configura los dos secretos de firma. - **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.` +### `The listen session expired. Opening a new one.` -**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 `. +**Causa:** la sesión de `listen` estuvo sin conexión con la API (corte de red, equipo suspendido). `listen` se +recupera solo, pero los eventos de ese intervalo no llegan. +**Remedio:** búscalos con `fiscalapi events list --from ` y reenvíalos con `fiscalapi events resend `. ### `events resend` sin `--webhook` falla ```text Error: Sorry, it's not me, it's you. - Detalle: La escucha de CLI no está conectada; ejecuta fiscalapi listen. - Código HTTP: 409 - traceIdentifier (para soporte): … + Details: La escucha de CLI no está conectada; ejecuta fiscalapi listen. + HTTP status: 409 + traceIdentifier (for support): ``` -**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 `. +**Causa:** sin `--webhook`, el reenvío va a tu sesión de `listen`, y no hay una abierta. +**Remedio:** abre `fiscalapi listen` en otra terminal o usa `--webhook `. ## `fiscalapi trigger` @@ -172,9 +181,9 @@ Error: Sorry, it's not me, it's you. ```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): … + Details: fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas. + HTTP status: 403 + traceIdentifier (for support): ``` **Causa:** los eventos de prueba solo existen en el ambiente de pruebas: en producción se confundirían con uno real. @@ -182,20 +191,21 @@ Error: Sorry, it's not me, it's you. ### 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. +**Causa:** sin `--webhook`, el evento va a tu sesión de `listen`, y no hay una abierta con tu API key. **Remedio:** abre `fiscalapi listen` en otra terminal y repite el `trigger`, o usa `--webhook `. -### 400 `El tipo de evento '…' no existe.` +### 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`. +dispara con `trigger`. **Remedio:** usa uno de los tipos de `fiscalapi trigger --help`. -### El evento no llega a mi servidor +### El evento no llega a tu servidor -**Causa:** `trigger` respondió `Evento disparado`, pero la entrega es de un solo intento: si tu servidor no respondió -2xx, queda fallida y no se reintenta. **Remedio:** revisa la línea de `listen` (`[500]`, `[error: …]`) o el log de -entregas del webhook en el dashboard, corrige y repite con `fiscalapi events resend [--webhook ]`, con -el mismo `--webhook` del `trigger` (sin él, el reenvío va a tu escucha de `listen`). +**Causa:** `trigger` respondió `Event triggered`, 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: Timeout]`) o el historial de entregas del webhook en el +dashboard, corrige y repite con `fiscalapi events resend `, con el mismo `--webhook` del `trigger` (sin él, +el reenvío va a tu sesión de `listen`). ## Instalación @@ -207,8 +217,8 @@ cuarentena. **Remedio:** `xattr -d com.apple.quarantine /usr/local/bin/fiscalapi ### 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`. +**Remedio:** quita la marca de descarga con `Unblock-File` ([Instalación](instalacion.md#windows)). Si quieres +confirmar que el archivo está íntegro, [verifica la descarga](instalacion.md#verificar-la-descarga-opcional). ### `fiscalapi` no se encuentra después de instalar @@ -218,10 +228,10 @@ cuarentena. **Remedio:** `xattr -d com.apple.quarantine /usr/local/bin/fiscalapi ### 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+. +**Causa:** la distribución trae una glibc anterior a la 2.28, o usa musl (Alpine). +**Remedio:** usa una distribución con glibc 2.28 o más nueva, como RHEL 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. +error. Ninguno de los dos incluye tu API key ni tu tenant key. diff --git a/docs/trigger.md b/docs/trigger.md index 7afb8c3..34f2d9d 100644 --- a/docs/trigger.md +++ b/docs/trigger.md @@ -1,7 +1,7 @@ # `fiscalapi trigger` -Dispara un evento de prueba y lo entrega a tu escucha de [`fiscalapi listen`](listen.md) o a un webhook. Sirve para -probar tu receptor sin timbrar, cancelar ni descargar nada real. Solo funciona en el ambiente de pruebas. +Dispara un evento de prueba y lo entrega a tu sesión de [`fiscalapi listen`](listen.md) o a un webhook. Sirve para +probar tu servidor sin timbrar, cancelar ni descargar nada real. Solo funciona en el ambiente de pruebas. ## Uso @@ -13,46 +13,44 @@ Requiere una API key con el permiso `Webhooks.Manage` ([Autenticación](autentic ## 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`. | +| 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. +`fiscalapi trigger --help` lista los tipos. Quien valida el tipo es la API, así que un tipo nuevo funciona sin +actualizar el CLI. `webhook.test` no se dispara con `trigger`. ## 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. | +| 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. | +| `--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 sesión de `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: +Dispara un evento hacia tu sesión de `listen`: ```text # Terminal 1 $ fiscalapi listen --forward-to http://localhost:3000/webhooks -Listo. Tu secreto de firma es whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw (escucha 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a) -2026-10-12 09:47:46 --> invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] +Ready. Your signing secret is whsec_ (listen session ). +2026-10-12 09:47:46 invoice.canceled [3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b] [200] # Terminal 2 $ fiscalapi trigger invoice.canceled -Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a. +Event triggered: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b to webhook 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a. ``` Dispara un evento hacia un webhook con URL, sin `listen`: ```text $ fiscalapi trigger invoice.canceled --webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d -Evento disparado: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b al webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. +Event triggered: 3f9c1d2e-7b4a-4e8f-9a1b-2c3d4e5f6a7b to webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. ``` Usa un recurso y una persona reales de tu cuenta, para que tu servidor pueda consultarlos: @@ -63,7 +61,7 @@ fiscalapi trigger download_request.completed \ --person-id 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f ``` -Con `--json`, imprime el `data` de la respuesta sin cambios, con el formato indentado de la API: +Con `--json`, imprime el `data` de la respuesta sin cambios: ```text $ fiscalapi trigger invoice.canceled --json @@ -77,49 +75,47 @@ $ fiscalapi trigger invoice.canceled --json - El evento tiene la forma y la firma de uno real: el recurso, `status` y `previousStatus` de su tipo (por ejemplo, `invoice.canceled` pasa de `C` a `X`). Tu servidor lo verifica con el mismo código. -- Llega **solo** al destino indicado, aunque no esté suscrito a ese tipo ni a esa persona (`--events` de `listen` o - los eventos del webhook). Tus demás webhooks no lo reciben. -- Es **un solo intento**: si tu servidor falla, no se reintenta. Repítelo con `trigger` o con - [`fiscalapi events resend [--webhook ]`](webhooks-y-eventos.md#reenviar-un-evento), con el mismo - `--webhook` del `trigger`: sin él, el reenvío va a tu escucha de `listen`. +- Llega solo al destino indicado, aunque no esté suscrito a ese tipo ni a esa persona. 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), con el mismo `--webhook` del + `trigger`: sin él, el reenvío va a tu sesión de `listen`. - Queda en `fiscalapi events list` como cualquier otro evento. ## Errores -Salen con código 1 y el texto en stderr: +Los errores de la API salen con código 1 y el texto en stderr. El mensaje y la línea `Details` los envía la API: ```text $ fiscalapi trigger invoice.canceled --live Error: Sorry, it's not me, it's you. - Detalle: fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas. - Código HTTP: 403 - traceIdentifier (para soporte): 8F3A2C1D9B7E4F60A5C3D2E1F0B9A877 + Details: fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas. + HTTP status: 403 + traceIdentifier (for support): ``` -Un 400 de validación pone el texto general en `Detalle:` y una línea `- ` por cada regla que falló. Un tipo de +Un 400 de validación pone el texto general en `Details` y una línea `- ` por cada regla que falló. Un tipo de evento que no existe sale así: ```text $ fiscalapi trigger invoice.cancelled Error: Sorry, it's not me, it's you. - Detalle: One or more validation failures have occurred. + Details: One or more validation failures have occurred. - El tipo de evento 'invoice.cancelled' no existe. - Código HTTP: 400 - traceIdentifier (para soporte): … + HTTP status: 400 + traceIdentifier (for support): ``` -Los demás 400 de la tabla traen su texto directo en `Detalle:`. - -| Código | Texto | Qué hacer | -|---|---|---| -| 409 | `La escucha de CLI no está conectada; ejecuta fiscalapi listen.` | Abre `fiscalapi listen` o usa `--webhook`. | -| 400 | `El tipo de evento 'invoice.cancelled' no existe.` | Usa un tipo de la lista. | -| 400 | `El webhook está deshabilitado: habilítalo antes de probarlo.` | `fiscalapi webhooks update --enabled`. | -| 400 | `La persona especificada no es válida o no pertenece a tu organización.` | Revisa `--person-id`. | -| 403 | `fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas.` | Quita `--live`. | -| 403 | `No tienes permiso para administrar webhooks.` | Usa una API key con el permiso `Webhooks.Manage` ([detalles](solucion-de-problemas.md#403-sin-webhooksmanage)). | -| 404 | `No such resource for the given Id: …` | Revisa `--webhook`: no existe, está dado de baja o no es tuyo. | -| 429 | `Se excedió el límite de solicitudes de la familia BusinessWrite (…). Reintenta en N segundos. …` | Demasiadas peticiones con la misma API key: espera los segundos que indica y repite. | +| Código | Texto de la API | Qué hacer | +| ------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| 409 | `La escucha de CLI no está conectada; ejecuta fiscalapi listen.` | Abre `fiscalapi listen` o usa `--webhook`. | +| 400 | `El tipo de evento 'invoice.cancelled' no existe.` | Usa un tipo de la lista. | +| 400 | `El webhook está deshabilitado: habilítalo antes de probarlo.` | `fiscalapi webhooks update --enabled`. | +| 400 | `La persona especificada no es válida o no pertenece a tu organización.` | Revisa `--person-id`. | +| 403 | `fiscalapi trigger no está disponible en producción; usa el ambiente de pruebas.` | Quita `--live`. | +| 403 | `No tienes permiso para administrar webhooks.` | Usa una API key con el permiso `Webhooks.Manage` ([detalles](solucion-de-problemas.md#403-sin-webhooksmanage)). | +| 404 | `No such resource for the given Id: ` | Revisa `--webhook`: no existe, se eliminó o no es de tu cuenta. | +| 429 | `Se excedió el límite de solicitudes de la familia BusinessWrite (). Reintenta en N segundos.` | 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 27e5447..99c32ef 100644 --- a/docs/webhooks-y-eventos.md +++ b/docs/webhooks-y-eventos.md @@ -5,86 +5,85 @@ ## `fiscalapi webhooks` -| Comando | Ruta | Qué hace | -|---|---|---| -| `webhooks list` | `GET /api/v4/webhooks` | Lista los webhooks, sin su secreto. | -| `webhooks retrieve ` | `GET /api/v4/webhooks/{id}` | Muestra un webhook, sin su secreto. | -| `webhooks create` | `POST /api/v4/webhooks` | Da de alta un webhook e imprime su secreto **una sola vez**. | -| `webhooks update ` | `GET` y `PUT /api/v4/webhooks/{id}` | Cambia URL, descripción, eventos, personas o estado. | -| `webhooks delete ` | `DELETE /api/v4/webhooks/{id}` | Da de baja un webhook y cierra sus entregas pendientes; su historial se conserva. | -| `webhooks rotate-secret ` | `POST /api/v4/webhooks/{id}/rotate-secret` | Genera un secreto nuevo y lo imprime una sola vez. | - -El `` de un webhook es un GUID: otro valor responde 404 con `Detalle: Not Found`, y un GUID que no es de tu cuenta, -`No such resource for the given Id: …`. +| Comando | Qué hace | +| ----------------------------- | ------------------------------------------------------------------------------ | +| `webhooks list` | Lista los webhooks, sin su secreto de firma. | +| `webhooks retrieve ` | Muestra un webhook, sin su secreto de firma. | +| `webhooks create` | Crea un webhook e imprime su secreto de firma una sola vez. | +| `webhooks update ` | Cambia la URL, la descripción, los eventos, las personas o el estado. | +| `webhooks delete ` | Elimina un webhook y cierra sus entregas pendientes; su historial se conserva. | +| `webhooks rotate-secret ` | Genera un secreto de firma nuevo y lo imprime una sola vez. | ### Listar webhooks ```text -$ fiscalapi webhooks list -ID TIPO ESTADO URL EVENTOS DESCRIPCIÓN +$ fiscalapi webhooks list --page-size 2 +ID KIND STATUS URL EVENTS DESCRIPTION 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d url enabled https://example.com/webhooks invoice.canceled,invoice.cancellation_requested Producción 9b2e7c4a-1d3f-4e6b-8a5c-0f1e2d3c4b5a cliListener enabled - * - -Página 1 de 3 (5 en total). Siguiente: --page 2 +Page 1 of 3 (5 total). Next: --page 2 ``` -`TIPO` es `url` para un webhook con URL y `cliListener` para tu [escucha de `fiscalapi listen`](listen.md), que no -tiene URL. El pie indica la opción para pedir la página siguiente. +`KIND` es `url` para un webhook con URL y `cliListener` para tu [sesión de `listen`](listen.md), que no tiene URL. El +pie indica la opción para pedir la página siguiente. Sin resultados, la tabla se reemplaza por `No data.` ### Paginación `webhooks list` y `events list` aceptan: -| Opción | Descripción | -|---|---| -| `--page ` | Número de página, desde 1. Por omisión, 1. | -| `--page-size ` | Elementos por página, de 1 a 50. Por omisión, 10. | +| Opción | Descripción | +| -------------------- | ------------------------------------------------- | +| `--page ` | Número de página, desde 1. Por omisión, 1. | +| `--page-size ` | Elementos por página, de 1 a 50. Por omisión, 10. | ### Ver un webhook ```text $ fiscalapi webhooks retrieve 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d -Tipo url -Estado enabled +Kind url +Status enabled URL https://example.com/webhooks -Descripción Producción -Eventos invoice.canceled, invoice.cancellation_requested -Personas 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f, 7c3b2a1d-0e9f-4d8c-a7b6-5f4e3d2c1b0a -Creado 2026-10-13T09:30:00.000 -Actualizado 2026-10-13T09:30:00.000 +Description Producción +Events invoice.canceled, invoice.cancellation_requested +Persons 2e7d4c1b-9a8f-4e6d-b5c4-3a2b1c0d9e8f, 7c3b2a1d-0e9f-4d8c-a7b6-5f4e3d2c1b0a +Created 2026-10-13T09:30:00.000 +Updated 2026-10-13T09:30:00.000 ``` -Solo aparecen los campos con valor. Según el caso también verás `Motivo` y `Deshabilitado` (webhook deshabilitado), -`Fallando desde`, `Persona de la escucha` y `Sesión vence` (escucha de CLI). +Solo aparecen los campos con valor. Según el caso también verás `Disabled reason` y `Disabled at` (webhook +deshabilitado), `Failing since` (entregas que fallan), y `Listen session person` y `Session expires` (sesión de +`listen`). ### Crear un webhook -| Opción | Descripción | -|---|---| -| `--url ` | **Obligatoria.** URL `https` que recibe las entregas. | -| `--events ` | **Obligatoria.** Tipos separados por coma, o `*` para todos. | -| `--description ` | Descripción libre, hasta 256 caracteres. | -| `--person ` | Solo los eventos de esta persona, hasta 100. Se repite (`--person a --person b`) o va separado por coma. | -| `--all-persons` | Los eventos de todas las personas. Es el comportamiento sin `--person`. | +| 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 el comportamiento sin `--person`. | ```text -$ fiscalapi webhooks create --url https://example.com/webhooks --events '*' +$ fiscalapi webhooks create --url https://example.com/webhooks --events '*' --description Producción ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d -Tipo url -Estado enabled +Kind url +Status enabled URL https://example.com/webhooks -Eventos * -Personas todas -Creado 2026-10-13T09:30:00.000 -Actualizado 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. +Description Producción +Events * +Persons all +Created 2026-10-13T09:30:00.000 +Updated 2026-10-13T09:30:00.000 + +Signing secret: whsec_ +Save this signing secret. Use it to verify that events come from FiscalAPI. ``` -**El secreto se muestra una sola vez.** `list` y `retrieve` nunca lo muestran: guárdalo en ese momento en la -configuración segura de tu receptor. Si lo pierdes, genera otro con `rotate-secret`. La línea `Secreto de firma: …` va -a stdout y el aviso, a stderr. +El secreto de firma se muestra una sola vez: `list` y `retrieve` nunca lo muestran. Guárdalo en ese momento en la +configuración de tu servidor; si lo pierdes, genera otro con `rotate-secret`. La línea `Signing secret:` va a stdout y +el aviso de guardarlo, a stderr. En bash y zsh, escribe `'*'` entre comillas para que el shell no lo expanda. @@ -105,7 +104,8 @@ fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --all-persons # personas sin querer: ```text -Error: --person necesita al menos un id de persona; para recibir los eventos de todas usa --all-persons. +Error: --person needs at least one person ID. Use --all-persons to receive events for all persons. +Run `fiscalapi --help` to see commands and options. ``` `--person` y `--all-persons` no se combinan. @@ -115,83 +115,89 @@ Error: --person necesita al menos un id de persona; para recibir los eventos de `update` lee el webhook, cambia solo lo que indicas y lo guarda. Acepta las opciones de `create`, todas opcionales, y además: -| Opción | Descripción | -|---|---| -| `--enabled` | Habilita el webhook y limpia el motivo y la fecha de la deshabilitación. | -| `--disabled` | Deshabilita el webhook y cierra sus entregas pendientes. | +| Opción | Descripción | +| ------------ | --------------------------------------------------------------------- | +| `--enabled` | Habilita el webhook y limpia el motivo y la fecha de deshabilitación. | +| `--disabled` | Deshabilita el webhook y cierra sus entregas pendientes. | ```bash fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --events invoice.canceled,invoice.cancellation_rejected fiscalapi webhooks update 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --disabled ``` -Imprime el webhook actualizado. Estos casos fallan: +Imprime el webhook actualizado, como `retrieve`. Estos casos fallan: -| Caso | Salida | Error | -|---|---|---| -| Sin ninguna opción | 2 | `Error: Indica qué cambiar: --url, --description, --events, --person, --all-persons, --enabled o --disabled.` | -| `--enabled` con `--disabled` | 2 | `Error: --enabled y --disabled no se combinan.` | -| El id es tu escucha de CLI | 1 | `Error: Una escucha de CLI no se edita ni se prueba; usa fiscalapi listen y fiscalapi trigger.` | +| Caso | Salida | Error | +| ------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------ | +| Sin ninguna opción | 2 | `Error: Specify what to update: --url, --description, --events, --person, --all-persons, --enabled or --disabled.` | +| `--enabled` con `--disabled` | 2 | `Error: --enabled cannot be used with --disabled.` | +| El id es tu sesión de `listen` | 1 | ``Error: A listen session cannot be updated. Use `fiscalapi listen` and `fiscalapi trigger` instead.`` | -### Borrar un webhook +### Eliminar un webhook -En una terminal, `delete` pide confirmación. Solo `s`, `si`, `sí`, `y` o `yes` confirman: +En una terminal, `delete` pide confirmación. Solo `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. +Delete webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d? Its pending deliveries will be closed. [y/N] y +Webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d deleted. ``` -Cualquier otra respuesta imprime `No se borró el webhook .` y sale con 0. En un script (stdin redirigido) o con +Cualquier otra respuesta imprime `Webhook was not deleted.` y sale con 0. En un script (stdin redirigido) o con `--json` no hay a quién preguntar: agrega `--yes`, o sale con 2. ```text $ fiscalapi webhooks delete 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d --json -Error: Para borrar sin confirmación interactiva usa --yes. +Error: Use --yes to delete without an interactive confirmation. ``` -### Rotar el secreto +### Rotar el secreto de firma ```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. +ID 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d +Kind url +Status enabled +URL https://example.com/webhooks +Description Producción +Events * +Persons all +Created 2026-10-13T09:30:00.000 +Updated 2026-10-14T11:05:00.000 + +Signing secret: whsec_ +Save this signing secret. Use it to verify that events come from FiscalAPI. +The old signing secret also signs events for 24 hours, so you can update your server. ``` -Durante esas 24 horas cada entrega lleva las dos firmas: configura tu receptor con los dos secretos (los SDK aceptan -una lista) y quita el anterior al terminar. Firman como mucho dos secretos: si vuelves a rotar dentro de esas 24 horas, -el anterior sigue siendo el original y el intermedio deja de firmar. - -`rotate-secret` también funciona con el id de tu escucha de CLI. +Durante esas 24 horas configura tu servidor con los dos secretos de firma y quita el anterior al terminar. Los detalles +de la rotación están en la [guía de webhooks](https://docs.fiscalapi.com/webhooks-info). `rotate-secret` también +funciona con el id de tu sesión de `listen`. ## `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`. | +| Comando | Qué hace | +| ------------------------- | --------------------------------------------------------------------- | +| `events list` | Lista los eventos de los últimos 30 días, del más nuevo al más viejo. | +| `events retrieve ` | Imprime el evento tal como se entregó. | +| `events resend ` | Reenvía un evento a un webhook o a tu sesión de `listen`. | ### Listar eventos -| Opción | Descripción | -|---|---| -| `--type ` | Solo los eventos de este tipo, p. ej. `invoice.canceled` o `webhook.test`. | -| `--related-object-id ` | Solo los eventos de este recurso (`data.relatedObject.id`), p. ej. el id de una factura. | -| `--from ` | Desde este instante, incluido. | -| `--to ` | Hasta este instante, incluido. Por omisión, ahora. | -| `--page`, `--page-size` | Ver [Paginación](#paginación). | +| Opción | Descripción | +| -------------------------- | --------------------------------------------------------------------------------------------- | +| `--type ` | Solo los eventos de este tipo, por ejemplo `invoice.canceled` o `webhook.test`. | +| `--related-object-id ` | Solo los eventos de este recurso (`data.relatedObject.id`), por ejemplo el id de una factura. | +| `--from ` | Desde este instante, incluido. | +| `--to ` | Hasta este instante, incluido. Por omisión, ahora. | +| `--page`, `--page-size` | Ver [Paginación](#paginación). | ```text $ fiscalapi events list -ID TIPO CREADO (UTC) RECURSO PERSONA +ID TYPE CREATED (UTC) RESOURCE PERSON 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). +Page 1 of 1 (2 total). ``` ```bash @@ -203,24 +209,25 @@ fiscalapi events list --type invoice.canceled --related-object-id c4d3e2f1-a0b9- `--from` y `--to` aceptan ISO 8601: -| Escribes | Significa | -|---|---| -| `2026-10-01` | Con `--from`, el inicio del día en UTC. Con `--to`, **el día completo**, hasta las 23:59:59.9999999 UTC. | -| `2026-10-01T08:00` o `2026-10-01T08:00:00` | Ese instante en UTC. | -| `2026-10-01T08:00:00-06:00` | Ese instante en esa zona horaria. | +| Escribes | Significa | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `2026-10-01` | Con `--from`, el inicio del día en UTC. Con `--to`, el día completo, hasta las 23:59:59.9999999 UTC. | +| `2026-10-01T08:00` o `2026-10-01T08:00:00` | Ese instante en UTC. | +| `2026-10-01T08:00:00-06:00` | Ese instante en esa zona horaria. | Sin zona, la hora se toma como UTC. Otro formato es un error de uso (salida 2): ```text $ fiscalapi events list --from ayer -Error: --from debe ser una fecha ISO 8601 (p. ej. 2026-10-01 o 2026-10-01T08:00:00-06:00); se recibió 'ayer'. +Error: --from must be an ISO 8601 date, e.g. 2026-10-01 or 2026-10-01T08:00:00-06:00. Got 'ayer'. +Run `fiscalapi --help` to see commands and options. ``` ### Ver un evento -`events retrieve` imprime el evento como lo devuelve la API, con o sin `--json`. Su `id` es el del header -`webhook-id` de sus entregas. La API lo devuelve indentado y el cuerpo que recibe tu endpoint va compacto, así que esta -salida no sirve para verificar una firma: verifica siempre con el cuerpo crudo que recibió tu endpoint. +`events retrieve` imprime el evento como lo devuelve la API, con o sin `--json`. Su `id` es el del header `webhook-id` +de sus entregas. La API lo devuelve indentado y el cuerpo que recibe tu servidor va compacto, así que esta salida no +sirve para verificar una firma: verifica con el cuerpo crudo que recibió tu servidor. ```text $ fiscalapi events retrieve 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f @@ -245,32 +252,32 @@ $ fiscalapi events retrieve 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f ```text $ fiscalapi events resend 0d6f5c1e-8a2b-4c3d-9e8f-7a6b5c4d3e2f --webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d -Reenvío creado: entrega d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a al webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. +Resend created: delivery d1e2f3a4-b5c6-4d7e-8f9a-0b1c2d3e4f5a to webhook 6f1c2a4e-0b7d-4c55-9a3e-1d2f3a4b5c6d. ``` -| Opción | Descripción | -|---|---| -| `--webhook ` | Webhook que recibe el reenvío. Por omisión, tu escucha de CLI, que debe tener un `fiscalapi listen` abierto. | +| Opción | Descripción | +| ---------------- | ------------------------------------------------------------------------------------------ | +| `--webhook ` | Webhook que recibe el reenvío. Por omisión, tu sesión de `listen`, que debe estar abierta. | -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.`). +Sin `--webhook` recibes en tu equipo un evento que ocurrió mientras `listen` no corría. Si no hay una sesión de +`listen` abierta, la API responde 409 ([Solución de problemas](solucion-de-problemas.md#events-resend-sin---webhook-falla)). -El reenvío crea una entrega nueva del mismo evento, con el mismo `webhook-id` y el mismo cuerpo. Tu receptor debe +El reenvío crea una entrega nueva del mismo evento, con el mismo `webhook-id` y el mismo cuerpo. Tu servidor 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 y con su formato indentado: +Con `--json`, cada comando imprime en stdout el `data` de la respuesta de la API, sin cambios: ```bash # Los ids de los webhooks habilitados fiscalapi webhooks list --page-size 50 --json | jq -r '.items[] | select(.status == "enabled") | .id' -# El secreto de un webhook nuevo, directo a una variable +# El secreto de firma de un webhook nuevo, directo a una variable secret=$(fiscalapi webhooks create --url https://example.com/webhooks --events '*' --json | jq -r .secret) ``` Una página trae `items`, `pageNumber`, `totalPages`, `totalCount`, `hasPreviousPage` y `hasNextPage`. El aviso de -guardar el secreto y los errores siguen en stderr como texto. Más en [Salida y códigos](salida-y-codigos.md). +guardar el secreto de firma 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 a629144243d6cc818144b245bce1e004686b6f07 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 23:58:36 -0600 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20versi=C3=B3n=204.0.462=20y=20CI=20?= =?UTF-8?q?sin=20comentarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FiscalApi.Cli.csproj: Version 4.0.462. release.yml toma la versión del tag o de la entrada manual, valida semver y compara el --version del binario contra ella. - Directory.Build.props y el .csproj de pruebas pierden sus comentarios XML; sus propiedades no cambian. La documentación XML no está activada, así que no hay CS1591 que atender. - Los workflows y scripts de .github pierden sus comentarios; los SHA de las acciones siguen fijados. Se quitan los IDs internos y el texto de infraestructura de los comentarios. - .markdownlint-cli2.jsonc pierde su encabezado de comentarios. --- .markdownlint-cli2.jsonc | 2 -- Directory.Build.props | 16 ---------------- src/FiscalApi.Cli/FiscalApi.Cli.csproj | 4 +--- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 92acbc5..afb6e4e 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,5 +1,3 @@ -// 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": { diff --git a/Directory.Build.props b/Directory.Build.props index e976240..5f0eecb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,29 +4,13 @@ net10.0 enable enable - true - $(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904 - true - false - true - true diff --git a/src/FiscalApi.Cli/FiscalApi.Cli.csproj b/src/FiscalApi.Cli/FiscalApi.Cli.csproj index ad383b2..1fb3bd3 100644 --- a/src/FiscalApi.Cli/FiscalApi.Cli.csproj +++ b/src/FiscalApi.Cli/FiscalApi.Cli.csproj @@ -5,10 +5,9 @@ fiscalapi FiscalApi.Cli true - 0.1.0 + 4.0.462 FiscalAPI CLI FiscalAPI - true @@ -16,7 +15,6 @@ - From 30ba6ac400d6288a50491b2dc240ad0ede65477c Mon Sep 17 00:00:00 2001 From: mendozagit Date: Thu, 22 Oct 2026 23:58:47 -0600 Subject: [PATCH 4/4] chore(ci): quitar comentarios de workflows y scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Se quitan los comentarios de .github/workflows y de los scripts. Los SHA de las acciones siguen fijados; el comentario de versión de cada acción va con él. - Se eliminan los IDs internos y el texto de infraestructura de los comentarios. Los nombres de los pasos de CI (Native AOT, AlmaLinux 8) se conservan, porque son parte de la ejecución. - Los shebangs y el contenido de los scripts no cambian. --- .github/scripts/publish-linux.sh | 13 ------ .github/scripts/verify-dotnet-install.sh | 11 ----- .github/scripts/with-test-keychain.sh | 7 ---- .github/scripts/with-test-secret-service.sh | 5 --- .github/workflows/build.yml | 15 ++----- .github/workflows/release.yml | 45 ++++----------------- 6 files changed, 10 insertions(+), 86 deletions(-) diff --git a/.github/scripts/publish-linux.sh b/.github/scripts/publish-linux.sh index 6a86189..a2dc630 100644 --- a/.github/scripts/publish-linux.sh +++ b/.github/scripts/publish-linux.sh @@ -1,10 +1,4 @@ #!/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 @@ -14,15 +8,10 @@ fi version="$1" output="$2" -# La glibc de AlmaLinux 8. Si el binario pide una más nueva, se rompió la promesa de compatibilidad y el release no sigue. max_glibc="2.28" -# Prerrequisitos de Native AOT en RHEL 8 (Microsoft Learn): clang y zlib-devel; binutils trae objcopy (StripSymbols) y -# objdump. libicu es del SDK, no del binario (InvariantGlobalization). gnupg2 verifica el script de instalación. dnf install -y --setopt=install_weak_deps=False clang zlib-devel binutils libicu findutils gnupg2 > /dev/null -# El SDK exacto de global.json, con el script oficial (https://dot.net/v1/dotnet-install.sh), que solo se ejecuta si su -# firma GPG es la de Microsoft. El script baja el SDK por HTTPS de builds.dotnet.microsoft.com. export DOTNET_ROOT=/opt/dotnet export PATH="$DOTNET_ROOT:$PATH" export DOTNET_CLI_TELEMETRY_OPTOUT=1 @@ -32,7 +21,6 @@ curl -fsSL --retry 3 https://dot.net/v1/dotnet-install.sig -o /tmp/dotnet-instal bash "$(dirname "${BASH_SOURCE[0]}")/verify-dotnet-install.sh" /tmp/dotnet-install.sh /tmp/dotnet-install.sig bash /tmp/dotnet-install.sh --jsonfile global.json --install-dir "$DOTNET_ROOT" --no-path -# TreatWarningsAsErrors (Directory.Build.props) convierte cualquier advertencia IL de trimming/AOT en un error. dotnet publish src/FiscalApi.Cli -c Release -r linux-x64 -p:Version="$version" -o "$output" binary="$output/fiscalapi" @@ -44,7 +32,6 @@ if [ "$newest" != "$max_glibc" ]; then 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 diff --git a/.github/scripts/verify-dotnet-install.sh b/.github/scripts/verify-dotnet-install.sh index ce7595a..ccdb0c5 100644 --- a/.github/scripts/verify-dotnet-install.sh +++ b/.github/scripts/verify-dotnet-install.sh @@ -1,12 +1,4 @@ #!/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 @@ -19,14 +11,11 @@ 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 diff --git a/.github/scripts/with-test-keychain.sh b/.github/scripts/with-test-keychain.sh index a53b4db..8739fe8 100644 --- a/.github/scripts/with-test-keychain.sh +++ b/.github/scripts/with-test-keychain.sh @@ -1,14 +1,8 @@ #!/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)" @@ -22,7 +16,6 @@ restore() { 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[@]}"} diff --git a/.github/scripts/with-test-secret-service.sh b/.github/scripts/with-test-secret-service.sh index 6530296..a2609a6 100644 --- a/.github/scripts/with-test-secret-service.sh +++ b/.github/scripts/with-test-secret-service.sh @@ -1,13 +1,8 @@ #!/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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 881d3f7..311c245 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,33 +5,27 @@ on: workflow_dispatch: permissions: - contents: read # Required for actions/checkout + contents: read -# Un push nuevo al mismo PR cancela la corrida anterior: solo importa el último commit. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: - # Runner hospedado por GitHub: el CLI no depende de Docker ni del runner self-hosted de fapi-back. runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: - # Sin el token en .git/config: ningún paso posterior lo necesita y así no queda al alcance de otro proceso. persist-credentials: false - # El SDK sale de global.json, el mismo que se usa en local. - name: Set up .NET - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 with: global-json-file: global.json - # Prerrequisitos de Native AOT en Linux (el linker clang y zlib) y un Secret Service de prueba para las - # pruebas del almacén de credenciales (libsecret, gnome-keyring y D-Bus con dbus-send). - name: Install Native AOT and Secret Service prerequisites run: | sudo apt-get update @@ -43,8 +37,6 @@ jobs: - name: Build run: dotnet build FiscalApi.Cli.slnx -c Release --no-restore - # gnome-keyring en una sesión de D-Bus propia: así corren contra libsecret las pruebas de - # CredentialStoreTests, que sin FISCALAPI_CLI_SECRET_SERVICE_TESTS se omiten en Linux. - name: Test env: FISCALAPI_CLI_SECRET_SERVICE_TESTS: "1" @@ -52,7 +44,6 @@ jobs: dbus-run-session -- bash .github/scripts/with-test-secret-service.sh dotnet test FiscalApi.Cli.slnx -c Release --no-build - # TreatWarningsAsErrors convierte cualquier advertencia IL de trimming/AOT en un error que rompe este paso. - name: Publish Native AOT (linux-x64) run: dotnet publish src/FiscalApi.Cli -r linux-x64 -c Release -o artifacts/linux-x64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0e6e64..495f899 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,9 @@ 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: @@ -15,10 +12,8 @@ on: type: string permissions: - contents: read # Required for actions/checkout; solo el job release escribe. + contents: read -# 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 @@ -37,8 +32,6 @@ jobs: 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: @@ -51,14 +44,12 @@ 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::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 @@ -72,12 +63,10 @@ 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. + GH_TOKEN: ${{ github.token }} TAG: v${{ steps.version.outputs.version }} run: | if output=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .ref 2>&1); then @@ -101,7 +90,6 @@ jobs: 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 @@ -110,7 +98,6 @@ jobs: 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 @@ -125,25 +112,19 @@ jobs: 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 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: - # Sin el token en .git/config: ningún paso posterior lo necesita y así no queda al alcance de otro proceso. persist-credentials: false - # En Linux el SDK se instala dentro del contenedor de compilación. - name: Set up .NET if: runner.os != 'Linux' - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 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 @@ -153,9 +134,6 @@ 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 - # 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: | @@ -176,7 +154,6 @@ jobs: 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 @@ -186,7 +163,6 @@ jobs: 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') @@ -197,8 +173,6 @@ jobs: "./$PUBLISH_DIR/$BINARY" --help > /dev/null echo "$RID: fiscalapi $actual" - # Solo el binario: los símbolos (.pdb, .dbg, .dSYM) se quedan fuera, y cada empaque debe traer exactamente una - # entrada, el binario. - name: Package (zip) if: runner.os == 'Windows' shell: pwsh @@ -212,7 +186,6 @@ jobs: exit 1 } - # COPYFILE_DISABLE: el tar de macOS no agrega los archivos ._* de atributos extendidos. - name: Package (tar.gz) if: runner.os != 'Windows' run: | @@ -226,13 +199,12 @@ jobs: fi - name: Upload package - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: package-${{ matrix.rid }} path: dist/${{ matrix.package }} if-no-files-found: error retention-days: 7 - # Ya va comprimido. compression-level: 0 release: @@ -241,20 +213,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write # Crear el release borrador y subir sus archivos. + contents: write 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 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c 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: | @@ -273,8 +244,6 @@ jobs: 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 }}