diff --git a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs index 40fd6eace..259d0a5cd 100644 --- a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs @@ -6,6 +6,7 @@ using Taskdeck.Api.RateLimiting; using Taskdeck.Application.Services; using Taskdeck.Domain.Exceptions; +using Taskdeck.Infrastructure.Mcp; namespace Taskdeck.Api.Extensions; @@ -106,11 +107,21 @@ private static RateLimitPartition BuildFixedWindowPartition(string parti private static string ResolveUserOrClientIdentifier(HttpContext context) { + // Check JWT claims first (REST API path). var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? context.User.FindFirstValue("sub"); - return !string.IsNullOrWhiteSpace(userId) - ? userId - : ResolveClientAddress(context); + if (!string.IsNullOrWhiteSpace(userId)) + return userId; + + // Check API key user ID from HttpContext.Items (MCP HTTP path). + // ApiKeyMiddleware sets this after validating the Bearer token. + if (context.Items.TryGetValue(HttpUserContextProvider.UserIdItemKey, out var apiKeyUserId) + && apiKeyUserId is Guid apiKeyGuid) + { + return apiKeyGuid.ToString(); + } + + return ResolveClientAddress(context); } private static string ResolveClientAddress(HttpContext context) diff --git a/backend/src/Taskdeck.Api/Program.cs b/backend/src/Taskdeck.Api/Program.cs index 468b7fe6b..012d2cd54 100644 --- a/backend/src/Taskdeck.Api/Program.cs +++ b/backend/src/Taskdeck.Api/Program.cs @@ -10,12 +10,145 @@ using Taskdeck.Infrastructure; using Taskdeck.Infrastructure.Mcp; -// ── MCP stdio mode ────────────────────────────────────────────────────────── -// When launched with "--mcp", run as a stdio MCP server instead of a web API. -// This path intentionally skips JWT, CORS, SignalR, rate limiting, and the -// HTTP pipeline — none of those are meaningful over a local stdio connection. +// ── MCP modes ─────────────────────────────────────────────────────────────── +// When launched with "--mcp", run as an MCP server instead of the full web API. +// --mcp → stdio transport (default, for Claude Code / Cursor) +// --mcp --transport http → HTTP transport with API key auth (for cloud/remote) +// --mcp --transport http --port 5001 → HTTP transport on a specific port if (args.Contains("--mcp")) { + var transport = "stdio"; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--transport", StringComparison.OrdinalIgnoreCase)) + transport = args[i + 1].ToLowerInvariant(); + } + + if (transport != "stdio" && transport != "http") + { + Console.Error.WriteLine($"Error: unknown transport '{transport}'. Supported values: stdio, http"); + return 1; + } + + if (transport == "http") + { + // ── MCP HTTP mode ─────────────────────────────────────────────────── + // Minimal web server exposing only the MCP endpoint with API key auth. + // No controllers, no SignalR, no Swagger, no frontend — just MCP. + var mcpPort = 5001; + var mcpBindHost = "0.0.0.0"; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--port", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(args[i + 1], out var parsedPort) + && parsedPort is >= 1 and <= 65535) + { + mcpPort = parsedPort; + } + else + { + Console.Error.WriteLine($"Error: invalid --port value '{args[i + 1]}'. Must be an integer between 1 and 65535."); + return 1; + } + } + else if (string.Equals(args[i], "--host", StringComparison.OrdinalIgnoreCase)) + { + mcpBindHost = args[i + 1]; + } + } + + var mcpHttpBuilder = WebApplication.CreateBuilder(args); + + // Load appsettings.local.json for locally-generated secrets (mirrors stdio mode). + mcpHttpBuilder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: false); + + mcpHttpBuilder.WebHost.UseUrls($"http://{mcpBindHost}:{mcpPort}"); + + // Infrastructure (DbContext, Repositories, UoW) + mcpHttpBuilder.Services.AddInfrastructure(mcpHttpBuilder.Configuration); + + // Register Application services needed by MCP resources and tools. + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(sp => + new Taskdeck.Application.Services.BoardService( + sp.GetRequiredService(), + sp.GetRequiredService())); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + mcpHttpBuilder.Services.AddScoped(); + mcpHttpBuilder.Services.AddScoped( + sp => sp.GetRequiredService()); + + // HTTP identity: maps API key to user via HttpUserContextProvider. + mcpHttpBuilder.Services.AddHttpContextAccessor(); + mcpHttpBuilder.Services.AddScoped(); + + // Rate limiting: register the McpPerApiKey policy for per-key throttling. + var mcpRateLimitingSettings = mcpHttpBuilder.Configuration + .GetSection("RateLimiting") + .Get() + ?? new Taskdeck.Application.Services.RateLimitingSettings(); + mcpHttpBuilder.Services.AddSingleton(mcpRateLimitingSettings); + if (mcpRateLimitingSettings.Enabled) + { + mcpHttpBuilder.Services.AddTaskdeckRateLimiting(mcpRateLimitingSettings); + } + + // MCP server: HTTP transport + all resources and tools. + mcpHttpBuilder.Services.AddMcpServer() + .WithHttpTransport() + .WithResources() + .WithResources() + .WithResources() + .WithTools() + .WithTools() + .WithTools(); + + var mcpHttpApp = mcpHttpBuilder.Build(); + + // Apply EF Core migrations before starting. + using (var scope = mcpHttpApp.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.Migrate(); + } + + // API key authentication for MCP requests. + mcpHttpApp.UseMiddleware(); + + // Apply rate limiting before endpoint routing. + if (mcpRateLimitingSettings.Enabled) + { + mcpHttpApp.UseRateLimiter(); + } + + // Map the MCP endpoint with per-API-key rate limiting. + var mcpEndpoint = mcpHttpApp.MapMcp(); + if (mcpRateLimitingSettings.Enabled) + { + mcpEndpoint.RequireRateLimiting(Taskdeck.Api.RateLimiting.RateLimitingPolicyNames.McpPerApiKey); + } + + var mcpHttpLogger = mcpHttpApp.Services.GetRequiredService>(); + mcpHttpLogger.LogInformation("Taskdeck MCP HTTP server starting on http://{Host}:{Port}", mcpBindHost, mcpPort); + + await mcpHttpApp.RunAsync(); + return 0; + } + + // ── MCP stdio mode ────────────────────────────────────────────────────── + // This path intentionally skips JWT, CORS, SignalR, rate limiting, and the + // HTTP pipeline — none of those are meaningful over a local stdio connection. var mcpHost = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((_, config) => { @@ -45,7 +178,7 @@ services.AddScoped(sp => new Taskdeck.Application.Services.BoardService( sp.GetRequiredService(), - sp.GetService())); + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -82,9 +215,9 @@ } await mcpHost.RunAsync(); - return; + return 0; } -// ── End MCP stdio mode ─────────────────────────────────────────────────────── +// ── End MCP modes ─────────────────────────────────────────────────────────── var builder = WebApplication.CreateBuilder(args); @@ -237,5 +370,6 @@ }); app.Run(); +return 0; public partial class Program { } diff --git a/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs new file mode 100644 index 000000000..cc80c8d99 --- /dev/null +++ b/backend/src/Taskdeck.Cli/Commands/ApiKeysCommandHandler.cs @@ -0,0 +1,187 @@ +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Cli.Commands; + +internal sealed class ApiKeysCommandHandler +{ + private readonly ApiKeyService _apiKeyService; + private readonly IUnitOfWork _unitOfWork; + + public ApiKeysCommandHandler(ApiKeyService apiKeyService, IUnitOfWork unitOfWork) + { + _apiKeyService = apiKeyService; + _unitOfWork = unitOfWork; + } + + public async Task HandleAsync(string command, string[] args) + { + return command switch + { + "create" => await CreateAsync(args), + "list" => await ListAsync(args), + "revoke" => await RevokeAsync(args), + _ => ConsoleOutput.PrintUsageError( + $"Unknown api-key command: '{command}'.", + "taskdeck api-key [create|list|revoke]") + }; + } + + private async Task CreateAsync(string[] args) + { + var name = ArgParser.GetOption(args, "--name"); + if (string.IsNullOrWhiteSpace(name)) + { + return ConsoleOutput.PrintUsageError( + "Missing --name.", + "taskdeck api-key create --name [--expires ]"); + } + + var expiresText = ArgParser.GetOption(args, "--expires"); + TimeSpan? expiresIn = null; + if (expiresText is not null) + { + // Support formats: "90d" or "90" + var daysText = expiresText.TrimEnd('d', 'D'); + if (!int.TryParse(daysText, out var days) || days <= 0) + { + return ConsoleOutput.PrintUsageError( + $"Invalid --expires value: '{expiresText}'. Provide a positive number of days (e.g., 90 or 90d).", + "taskdeck api-key create --name [--expires ]"); + } + expiresIn = TimeSpan.FromDays(days); + } + + var userId = await GetOrCreateCliActorIdAsync(); + + try + { + var (plaintextKey, entity) = await _apiKeyService.CreateKeyAsync(userId, name, expiresIn); + + ConsoleOutput.WriteJson(new + { + id = entity.Id, + key = plaintextKey, + keyPrefix = entity.KeyPrefix_, + name = entity.Name, + createdAt = entity.CreatedAt, + expiresAt = entity.ExpiresAt, + message = "Save this key — it cannot be retrieved again." + }); + + return ExitCodes.Success; + } + catch (DomainException ex) + { + return ConsoleOutput.PrintFailure(ex.ErrorCode, ex.Message); + } + } + + private async Task ListAsync(string[] args) + { + var userId = await GetOrCreateCliActorIdAsync(); + var keys = await _apiKeyService.ListKeysAsync(userId); + + var items = keys.Select(k => new + { + id = k.Id, + keyPrefix = k.KeyPrefix_, + name = k.Name, + createdAt = k.CreatedAt, + expiresAt = k.ExpiresAt, + revokedAt = k.RevokedAt, + lastUsedAt = k.LastUsedAt, + isActive = k.IsActive + }); + + ConsoleOutput.WriteJson(items); + return ExitCodes.Success; + } + + private async Task RevokeAsync(string[] args) + { + var name = ArgParser.GetOption(args, "--name"); + var idText = ArgParser.GetOption(args, "--id"); + + if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(idText)) + { + return ConsoleOutput.PrintUsageError( + "Provide --name or --id to identify the key to revoke.", + "taskdeck api-key revoke --name | --id "); + } + + var userId = await GetOrCreateCliActorIdAsync(); + + try + { + if (!string.IsNullOrWhiteSpace(idText)) + { + if (!Guid.TryParse(idText, out var keyId)) + { + return ConsoleOutput.PrintUsageError( + $"Invalid --id value: '{idText}'.", + "taskdeck api-key revoke --id "); + } + + await _apiKeyService.RevokeKeyAsync(keyId, userId); + ConsoleOutput.WriteJson(new { revoked = keyId, status = "ok" }); + return ExitCodes.Success; + } + + // Revoke by name: find active keys with this name + var keys = await _apiKeyService.ListKeysAsync(userId); + var matches = keys.Where(k => k.Name == name && k.IsActive).ToList(); + if (matches.Count == 0) + { + return ConsoleOutput.PrintFailure( + ErrorCodes.NotFound, + $"No active API key found with name '{name}'."); + } + + if (matches.Count > 1) + { + // Ambiguous: multiple active keys share this name — require --id. + Console.Error.WriteLine($"Multiple active keys found with name '{name}'. Use --id to specify which key to revoke:"); + foreach (var match in matches) + { + Console.Error.WriteLine($" --id {match.Id} (prefix: {match.KeyPrefix_}, created: {match.CreatedAt:u})"); + } + return ExitCodes.Failure; + } + + var target = matches[0]; + await _apiKeyService.RevokeKeyAsync(target.Id, userId); + ConsoleOutput.WriteJson(new { revoked = target.Id, name = target.Name, status = "ok" }); + return ExitCodes.Success; + } + catch (DomainException ex) + { + return ConsoleOutput.PrintFailure(ex.ErrorCode, ex.Message); + } + } + + /// + /// Returns the CLI system actor's user ID, creating the actor if it does not exist. + /// Looks up by email (not username) to prevent identity hijacking: the + /// @system.taskdeck domain is non-routable and cannot be registered + /// through the normal authentication flow, which checks email uniqueness. + /// + private async Task GetOrCreateCliActorIdAsync() + { + var existingActor = await _unitOfWork.Users.GetByEmailAsync(CliActorIdentity.ActorEmail); + if (existingActor is not null) + { + return existingActor.Id; + } + + var actor = new User( + CliActorIdentity.ActorUsername, + CliActorIdentity.ActorEmail, + Guid.NewGuid().ToString("N")); + await _unitOfWork.Users.AddAsync(actor); + await _unitOfWork.SaveChangesAsync(); + return actor.Id; + } +} diff --git a/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs b/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs index b7ba9ebb2..4060598cf 100644 --- a/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs +++ b/backend/src/Taskdeck.Cli/Commands/BoardsCommandHandler.cs @@ -152,18 +152,23 @@ private async Task UpdateAsync(string[] args, bool outputJson) return ExitCodes.Success; } + /// + /// Returns the CLI system actor's user ID, creating the actor if it does not exist. + /// Looks up by email (not username) to prevent identity hijacking via the + /// non-routable @system.taskdeck domain. + /// private async Task GetOrCreateCliActorIdAsync() { - const string actorUsername = "taskdeck_cli_actor"; - const string actorEmail = "taskdeck-cli-actor@local.taskdeck"; - - var existingActor = await _unitOfWork.Users.GetByUsernameAsync(actorUsername); + var existingActor = await _unitOfWork.Users.GetByEmailAsync(CliActorIdentity.ActorEmail); if (existingActor is not null) { return existingActor.Id; } - var actor = new User(actorUsername, actorEmail, Guid.NewGuid().ToString("N")); + var actor = new User( + CliActorIdentity.ActorUsername, + CliActorIdentity.ActorEmail, + Guid.NewGuid().ToString("N")); await _unitOfWork.Users.AddAsync(actor); await _unitOfWork.SaveChangesAsync(); return actor.Id; diff --git a/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs b/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs new file mode 100644 index 000000000..5e997cbc6 --- /dev/null +++ b/backend/src/Taskdeck.Cli/Commands/CliActorIdentity.cs @@ -0,0 +1,20 @@ +namespace Taskdeck.Cli.Commands; + +/// +/// Shared identity constants for the CLI system actor. +/// The email uses the non-routable @system.taskdeck domain, which cannot +/// be registered through the normal authentication flow (email uniqueness check +/// blocks it). This prevents identity hijacking where an attacker registers the +/// CLI username before the CLI creates its actor. +/// +internal static class CliActorIdentity +{ + /// Username for the CLI system actor. + public const string ActorUsername = "taskdeck_cli_actor"; + + /// + /// Email for the CLI system actor. Uses a non-routable domain to prevent + /// registration-based hijacking. Actor lookup uses email, not username. + /// + public const string ActorEmail = "cli-actor@system.taskdeck"; +} diff --git a/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs b/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs index 9be89f523..37a15fcf9 100644 --- a/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs +++ b/backend/src/Taskdeck.Cli/Commands/CommandDispatcher.cs @@ -29,6 +29,7 @@ public async Task DispatchAsync(string[] args) "boards" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "columns" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "cards" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), + "api-key" => await scope.ServiceProvider.GetRequiredService().HandleAsync(command, remainingArgs), "help" => ReturnHelp(), _ => ReturnUnknownCommand(group) }; diff --git a/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs b/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs index 9ff6ddfdd..4c0b9b4b0 100644 --- a/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs +++ b/backend/src/Taskdeck.Cli/Commands/ConsoleOutput.cs @@ -46,6 +46,10 @@ taskdeck cards add --board --column --title [--de taskdeck cards move --card <card-id> --target-column <column-id> [--position <position>] taskdeck cards list --board <board-id> [--search <text>] [--column <column-id>] [--label <label-id>] + taskdeck api-key create --name <name> [--expires <days>] + taskdeck api-key list + taskdeck api-key revoke --name <name> | --id <key-id> + Exit codes: 0 success 1 command failed diff --git a/backend/src/Taskdeck.Cli/Program.cs b/backend/src/Taskdeck.Cli/Program.cs index d33ae6b2f..3cdff9ae5 100644 --- a/backend/src/Taskdeck.Cli/Program.cs +++ b/backend/src/Taskdeck.Cli/Program.cs @@ -29,9 +29,11 @@ builder.Services.AddScoped<BoardService>(); builder.Services.AddScoped<ColumnService>(); builder.Services.AddScoped<CardService>(); +builder.Services.AddScoped<ApiKeyService>(); builder.Services.AddScoped<BoardsCommandHandler>(); builder.Services.AddScoped<ColumnsCommandHandler>(); builder.Services.AddScoped<CardsCommandHandler>(); +builder.Services.AddScoped<ApiKeysCommandHandler>(); using var host = builder.Build(); diff --git a/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs b/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs new file mode 100644 index 000000000..d4d8afd03 --- /dev/null +++ b/backend/tests/Taskdeck.Cli.Tests/ApiKeyCommandTests.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using Xunit; + +namespace Taskdeck.Cli.Tests; + +public class ApiKeyCommandTests +{ + [Fact] + public async Task ApiKeyCreate_ReturnsJsonWithTdskPrefix() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Test Key\""); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("key").GetString().Should().StartWith("tdsk_"); + doc.RootElement.GetProperty("name").GetString().Should().Be("Test Key"); + doc.RootElement.GetProperty("message").GetString().Should().Contain("cannot be retrieved"); + } + + [Fact] + public async Task ApiKeyCreate_WithExpires_SetsExpiresAt() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Expiring\" --expires 90d"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("expiresAt").GetDateTimeOffset() + .Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(90), TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task ApiKeyCreate_WithNumericExpires_AcceptsDaysWithoutSuffix() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"NumExpiry\" --expires 30"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.GetProperty("expiresAt").GetDateTimeOffset() + .Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(30), TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task ApiKeyCreate_MissingName_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("--name"); + } + + [Fact] + public async Task ApiKeyCreate_InvalidExpires_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key create --name \"Bad\" --expires abc"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("Invalid --expires"); + } + + [Fact] + public async Task ApiKeyList_ReturnsCreatedKeys() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"List Key A\""); + await harness.RunAsync("api-key create --name \"List Key B\""); + + var result = await harness.RunAsync("api-key list"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array); + + var names = doc.RootElement.EnumerateArray() + .Select(e => e.GetProperty("name").GetString()) + .ToList(); + names.Should().Contain("List Key A"); + names.Should().Contain("List Key B"); + } + + [Fact] + public async Task ApiKeyList_ShowsKeyPrefixNotFullKey() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"Prefix Key\""); + + var result = await harness.RunAsync("api-key list"); + + result.ExitCode.Should().Be(0, result.StdErr); + using var doc = JsonDocument.Parse(result.StdOut); + var firstKey = doc.RootElement.EnumerateArray().First(); + firstKey.GetProperty("keyPrefix").GetString()!.Length.Should().Be(8); + firstKey.GetProperty("keyPrefix").GetString().Should().StartWith("tdsk_"); + } + + [Fact] + public async Task ApiKeyRevoke_ByName_SetsRevokedStatus() + { + await using var harness = new CliHarness(); + + await harness.RunAsync("api-key create --name \"Revoke Me\""); + + var revokeResult = await harness.RunAsync("api-key revoke --name \"Revoke Me\""); + revokeResult.ExitCode.Should().Be(0, revokeResult.StdErr); + using var revokeDoc = JsonDocument.Parse(revokeResult.StdOut); + revokeDoc.RootElement.GetProperty("status").GetString().Should().Be("ok"); + + // Verify in list + var listResult = await harness.RunAsync("api-key list"); + using var listDoc = JsonDocument.Parse(listResult.StdOut); + var revokedKey = listDoc.RootElement.EnumerateArray() + .First(e => e.GetProperty("name").GetString() == "Revoke Me"); + revokedKey.GetProperty("isActive").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task ApiKeyRevoke_ById_SetsRevokedStatus() + { + await using var harness = new CliHarness(); + + var createResult = await harness.RunAsync("api-key create --name \"Revoke By Id\""); + using var createDoc = JsonDocument.Parse(createResult.StdOut); + var keyId = createDoc.RootElement.GetProperty("id").GetGuid(); + + var revokeResult = await harness.RunAsync($"api-key revoke --id {keyId}"); + revokeResult.ExitCode.Should().Be(0, revokeResult.StdErr); + } + + [Fact] + public async Task ApiKeyRevoke_NonexistentName_ReturnsFailure() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key revoke --name \"Does Not Exist\""); + + result.ExitCode.Should().Be(1); + result.StdErr.Should().Contain("No active API key found"); + } + + [Fact] + public async Task ApiKeyRevoke_MissingIdentifier_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key revoke"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("--name"); + } + + [Fact] + public async Task ApiKey_UnknownSubcommand_ReturnsUsageError() + { + await using var harness = new CliHarness(); + + var result = await harness.RunAsync("api-key unknown"); + + result.ExitCode.Should().Be(2); + result.StdErr.Should().Contain("Unknown api-key command"); + } + + /// <summary> + /// Shared harness that runs the CLI as a subprocess against an ephemeral database. + /// </summary> + private sealed class CliHarness : IAsyncDisposable + { + private readonly string _repoRoot; + private readonly string _databasePath; + private readonly string _connectionString; + + public CliHarness() + { + _repoRoot = FindRepoRoot(); + _databasePath = Path.Combine(Path.GetTempPath(), $"taskdeck-cli-apikey-tests-{Guid.NewGuid():N}.db"); + _connectionString = $"Data Source={_databasePath}"; + } + + public async Task<CliCommandResult> RunAsync(string arguments) + { + var cliDllPath = ResolveCliDllPath(_repoRoot); + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{cliDllPath}\" {arguments}", + WorkingDirectory = _repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + + startInfo.Environment["TASKDECK_CONNECTION_STRING"] = _connectionString; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + + var stdOut = await process.StandardOutput.ReadToEndAsync(); + var stdErr = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CliCommandResult(process.ExitCode, stdOut.Trim(), stdErr.Trim()); + } + + public ValueTask DisposeAsync() + { + foreach (var path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm", $"{_databasePath}-journal" }) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) { } + } + + return ValueTask.CompletedTask; + } + + private static string FindRepoRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + + while (current != null) + { + var solutionPath = Path.Combine(current.FullName, "backend", "Taskdeck.sln"); + if (File.Exists(solutionPath)) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not locate repository root from test execution directory."); + } + + private static string ResolveCliDllPath(string repoRoot) + { + var cliProjectBin = Path.Combine(repoRoot, "backend", "src", "Taskdeck.Cli", "bin"); + var debugPath = Path.Combine(cliProjectBin, "Debug", "net8.0", "Taskdeck.Cli.dll"); + if (File.Exists(debugPath)) + { + return debugPath; + } + + var releasePath = Path.Combine(cliProjectBin, "Release", "net8.0", "Taskdeck.Cli.dll"); + if (File.Exists(releasePath)) + { + return releasePath; + } + + throw new FileNotFoundException("Taskdeck.Cli.dll was not found in Debug or Release output directories."); + } + } + + private sealed record CliCommandResult(int ExitCode, string StdOut, string StdErr); +}