Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,16 @@ jobs:
cd src/Deckle.Web
docker build -t deckle-web:local .

- name: Build MCP container
run: |
cd src
docker build -f Deckle.MCP/Dockerfile -t deckle-mcp:local .

- name: Prepare docker-compose artifacts
run: |
# Copy template and replace environment variables with actual image names
cp docker-compose-artifacts/docker-compose.yaml docker-compose-artifacts/docker-compose.yaml.template
sed 's/\${API_IMAGE}/deckle-api:local/g; s/\${WEB_IMAGE}/deckle-web:local/g' docker-compose-artifacts/docker-compose.yaml.template > docker-compose-artifacts/docker-compose.yaml
sed 's/\${API_IMAGE}/deckle-api:local/g; s/\${WEB_IMAGE}/deckle-web:local/g; s/\${MCP_IMAGE}/deckle-mcp:local/g' docker-compose-artifacts/docker-compose.yaml.template > docker-compose-artifacts/docker-compose.yaml

- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
Expand Down Expand Up @@ -197,11 +202,18 @@ jobs:

- name: Trigger Railway Deployment
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
env:
RAILWAY_API_WEBHOOK_URL: ${{ secrets.RAILWAY_API_WEBHOOK_URL }}
RAILWAY_WEB_WEBHOOK_URL: ${{ secrets.RAILWAY_WEB_WEBHOOK_URL }}
RAILWAY_MCP_WEBHOOK_URL: ${{ secrets.RAILWAY_MCP_WEBHOOK_URL }}
run: |
echo "Triggering Railway API deployment..."
curl -f -X POST "${{ secrets.RAILWAY_API_WEBHOOK_URL }}" || echo "Warning: API webhook failed"
curl -f -X POST "$RAILWAY_API_WEBHOOK_URL" || echo "Warning: API webhook failed"

echo "Triggering Railway Web deployment..."
curl -f -X POST "${{ secrets.RAILWAY_WEB_WEBHOOK_URL }}" || echo "Warning: Web webhook failed"
curl -f -X POST "$RAILWAY_WEB_WEBHOOK_URL" || echo "Warning: Web webhook failed"

echo "Triggering Railway MCP deployment..."
curl -f -X POST "$RAILWAY_MCP_WEBHOOK_URL" || echo "Warning: MCP webhook failed"

echo "Railway deployments triggered successfully"
25 changes: 25 additions & 0 deletions docker-compose-artifacts/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ services:
condition: "service_started"
networks:
- "aspire"
mcp:
image: "${MCP_IMAGE}"
environment:
OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true"
OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true"
OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
HTTP_PORTS: "${MCP_PORT}"
ConnectionStrings__deckledb: "Host=postgres;Port=5432;Username=postgres;Password=${POSTGRES_PASSWORD};Database=deckledb"
DECKLEDB_HOST: "postgres"
DECKLEDB_PORT: "5432"
DECKLEDB_USERNAME: "postgres"
DECKLEDB_PASSWORD: "${POSTGRES_PASSWORD}"
DECKLEDB_URI: "postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/deckledb"
DECKLEDB_DATABASENAME: "deckledb"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://compose-dashboard:18889"
OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
OTEL_SERVICE_NAME: "mcp"
expose:
- "${MCP_PORT}"
depends_on:
postgres:
condition: "service_started"
networks:
- "aspire"
networks:
aspire:
driver: "bridge"
Expand Down
207 changes: 207 additions & 0 deletions src/Deckle.API.Tests/ApiKeyEndpointsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Deckle.Domain.Data;
using Deckle.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace Deckle.API.Tests;

/// <summary>
/// Verifies the key-generation invariants of the POST /api-keys handler.
/// The handler generates: rawKey = "dk_" + base64url(32 random bytes), stores SHA256(rawKey).
/// These tests validate those properties independently of the HTTP layer.
/// </summary>
public class ApiKeyEndpointsTests : IDisposable
{
private bool _disposed;
private readonly AppDbContext _context;

public ApiKeyEndpointsTests()
{
_context = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
_context.Dispose();
_disposed = true;
}
}

private static string GenerateRawKey()
{
var rawBytes = RandomNumberGenerator.GetBytes(32);
return "dk_" + Convert.ToBase64String(rawBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
}

private static string HashKey(string rawKey) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)));

private async Task<Guid> SeedUser()
{
var userId = Guid.NewGuid();
_context.Users.Add(new User
{
Id = userId,
Email = $"{userId}@test.com",
GoogleId = Guid.NewGuid().ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
return userId;
}

#region Key generation format

[Fact]
public void GeneratedKey_StartsWithDkPrefix()
{
var key = GenerateRawKey();

Assert.StartsWith("dk_", key);

Check warning on line 75 in src/Deckle.API.Tests/ApiKeyEndpointsTests.cs

View workflow job for this annotation

GitHub Actions / build-and-push

'Xunit.Assert.StartsWith(string?, string?)' has a method overload that takes a 'StringComparison' parameter. Replace this call in 'Deckle.API.Tests.ApiKeyEndpointsTests.GeneratedKey_StartsWithDkPrefix()' with a call to 'Xunit.Assert.StartsWith(string?, string?, System.StringComparison)' for clarity of intent. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1307)
}

[Fact]
public void GeneratedKey_ContainsNoBase64PaddingOrUnsafeChars()
{
for (var i = 0; i < 20; i++)
{
var key = GenerateRawKey();
Assert.DoesNotContain("+", key);

Check warning on line 84 in src/Deckle.API.Tests/ApiKeyEndpointsTests.cs

View workflow job for this annotation

GitHub Actions / build-and-push

'Xunit.Assert.DoesNotContain(string, string?)' has a method overload that takes a 'StringComparison' parameter. Replace this call in 'Deckle.API.Tests.ApiKeyEndpointsTests.GeneratedKey_ContainsNoBase64PaddingOrUnsafeChars()' with a call to 'Xunit.Assert.DoesNotContain(string, string?, System.StringComparison)' for clarity of intent. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1307)
Assert.DoesNotContain("/", key);

Check warning on line 85 in src/Deckle.API.Tests/ApiKeyEndpointsTests.cs

View workflow job for this annotation

GitHub Actions / build-and-push

'Xunit.Assert.DoesNotContain(string, string?)' has a method overload that takes a 'StringComparison' parameter. Replace this call in 'Deckle.API.Tests.ApiKeyEndpointsTests.GeneratedKey_ContainsNoBase64PaddingOrUnsafeChars()' with a call to 'Xunit.Assert.DoesNotContain(string, string?, System.StringComparison)' for clarity of intent. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1307)
Assert.DoesNotContain("=", key);

Check warning on line 86 in src/Deckle.API.Tests/ApiKeyEndpointsTests.cs

View workflow job for this annotation

GitHub Actions / build-and-push

'Xunit.Assert.DoesNotContain(string, string?)' has a method overload that takes a 'StringComparison' parameter. Replace this call in 'Deckle.API.Tests.ApiKeyEndpointsTests.GeneratedKey_ContainsNoBase64PaddingOrUnsafeChars()' with a call to 'Xunit.Assert.DoesNotContain(string, string?, System.StringComparison)' for clarity of intent. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1307)
}
}

[Fact]
public void GeneratedKey_IsUrlSafe()
{
var key = GenerateRawKey();

Assert.Matches(new Regex(@"^dk_[A-Za-z0-9\-_]+$"), key);
}

[Fact]
public void GeneratedKeys_AreUnique()
{
var keys = Enumerable.Range(0, 50).Select(_ => GenerateRawKey()).ToList();

Assert.Equal(keys.Count, keys.Distinct().Count());
}

#endregion

#region Key hashing invariants

[Fact]
public void StoredHash_IsSha256HexOfRawKey()
{
var rawKey = GenerateRawKey();

var hash = HashKey(rawKey);

Assert.Equal(64, hash.Length);
Assert.Matches(new Regex("^[0-9A-F]{64}$"), hash);
}

[Fact]
public void StoredHash_DifferentFromRawKey()
{
var rawKey = GenerateRawKey();

Assert.NotEqual(rawKey, HashKey(rawKey));
}

[Fact]
public void StoredHash_IsDeterministicForSameInput()
{
var rawKey = GenerateRawKey();

Assert.Equal(HashKey(rawKey), HashKey(rawKey));
}

[Fact]
public void StoredHash_DifferentForDifferentKeys()
{
var key1 = GenerateRawKey();
var key2 = GenerateRawKey();

Assert.NotEqual(HashKey(key1), HashKey(key2));
}

#endregion

#region User scoping (database-level)

[Fact]
public async Task ApiKey_StoredWithCorrectUserId()
{
var userId = await SeedUser();
var rawKey = GenerateRawKey();
_context.ApiKeys.Add(new ApiKey
{
Id = Guid.NewGuid(),
UserId = userId,
Name = "My Key",
KeyHash = HashKey(rawKey),
CreatedAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();

_context.ChangeTracker.Clear();
var stored = await _context.ApiKeys.FirstAsync(k => k.UserId == userId);
Assert.Equal("My Key", stored.Name);
Assert.Equal(HashKey(rawKey), stored.KeyHash);
}

[Fact]
public async Task ListApiKeys_ReturnsOnlyOwnersKeys()
{
var userId1 = await SeedUser();
var userId2 = await SeedUser();

_context.ApiKeys.AddRange(
new ApiKey { Id = Guid.NewGuid(), UserId = userId1, Name = "Key A", KeyHash = HashKey("dk_a"), CreatedAt = DateTime.UtcNow },
new ApiKey { Id = Guid.NewGuid(), UserId = userId2, Name = "Key B", KeyHash = HashKey("dk_b"), CreatedAt = DateTime.UtcNow });
await _context.SaveChangesAsync();

var user1Keys = await _context.ApiKeys.Where(k => k.UserId == userId1).ToListAsync();
Assert.Single(user1Keys);
Assert.Equal("Key A", user1Keys[0].Name);
}

[Fact]
public async Task DeleteApiKey_OnlyDeletesOwnersKey()
{
var userId1 = await SeedUser();
var userId2 = await SeedUser();
var keyId = Guid.NewGuid();

_context.ApiKeys.AddRange(
new ApiKey { Id = keyId, UserId = userId1, Name = "Owner Key", KeyHash = HashKey("dk_owner"), CreatedAt = DateTime.UtcNow },
new ApiKey { Id = Guid.NewGuid(), UserId = userId2, Name = "Other Key", KeyHash = HashKey("dk_other"), CreatedAt = DateTime.UtcNow });
await _context.SaveChangesAsync();

var key = await _context.ApiKeys.FirstOrDefaultAsync(k => k.Id == keyId && k.UserId == userId1);
Assert.NotNull(key);

var wrongUserKey = await _context.ApiKeys.FirstOrDefaultAsync(k => k.Id == keyId && k.UserId == userId2);
Assert.Null(wrongUserKey);
}

#endregion
}
5 changes: 4 additions & 1 deletion src/Deckle.API.Tests/Services/UserServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using Deckle.API.Services;
using Deckle.Domain.Data;
using Deckle.Domain.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;

namespace Deckle.API.Tests.Services;
Expand All @@ -19,7 +21,8 @@ public UserServiceTests()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
_service = new UserService(_context);
var passwordHasher = new Mock<IPasswordHasher<User>>().Object;
_service = new UserService(_context, passwordHasher);
}

#region Helpers
Expand Down
7 changes: 7 additions & 0 deletions src/Deckle.API/DTOs/ApiKeyDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Deckle.API.DTOs;

public record ApiKeyDto(Guid Id, string Name, DateTime CreatedAt, DateTime? LastUsedAt);

public record CreateApiKeyRequest(string Name);

public record CreateApiKeyResponse(Guid Id, string Name, string Key, DateTime CreatedAt);
75 changes: 75 additions & 0 deletions src/Deckle.API/Endpoints/ApiKeyEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
using Deckle.API.DTOs;
using Deckle.API.Filters;
using Deckle.Domain.Data;
using Deckle.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace Deckle.API.Endpoints;

public static class ApiKeyEndpoints
{
public static RouteGroupBuilder MapApiKeyEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api-keys")
.WithTags("API Keys")
.RequireAuthorization()
.RequireUserId();

group.MapGet("", async (HttpContext ctx, AppDbContext db) =>
{
var userId = ctx.GetUserId();
var keys = await db.ApiKeys
.Where(k => k.UserId == userId)
.OrderByDescending(k => k.CreatedAt)
.Select(k => new ApiKeyDto(k.Id, k.Name, k.CreatedAt, k.LastUsedAt))
.ToListAsync();
return Results.Ok(keys);
})
.WithName("ListApiKeys");

group.MapPost("", async (HttpContext ctx, AppDbContext db, CreateApiKeyRequest request) =>
{
var userId = ctx.GetUserId();

var rawBytes = RandomNumberGenerator.GetBytes(32);
var rawKey = "dk_" + Convert.ToBase64String(rawBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');

var keyHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)));

var apiKey = new ApiKey
{
Id = Guid.NewGuid(),
UserId = userId,
Name = request.Name,
KeyHash = keyHash,
CreatedAt = DateTime.UtcNow
};

db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync();

return Results.Created(
$"/api-keys/{apiKey.Id}",
new CreateApiKeyResponse(apiKey.Id, apiKey.Name, rawKey, apiKey.CreatedAt));
})
.WithName("CreateApiKey");

group.MapDelete("{id:guid}", async (Guid id, HttpContext ctx, AppDbContext db) =>
{
var userId = ctx.GetUserId();
var key = await db.ApiKeys.FirstOrDefaultAsync(k => k.Id == id && k.UserId == userId);
if (key == null)
return Results.NotFound();

db.ApiKeys.Remove(key);
await db.SaveChangesAsync();
return Results.NoContent();
})
.WithName("DeleteApiKey");

return group;
}
}
1 change: 1 addition & 0 deletions src/Deckle.API/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public static WebApplication MapDeckleEndpoints(this WebApplication app)
app.MapFileDirectoryEndpoints();
app.MapAdminEndpoints();
app.MapUserEndpoints();
app.MapApiKeyEndpoints();

return app;
}
Expand Down
Loading
Loading