diff --git a/api/.gitignore b/api/.gitignore index 0808c4a..94f4e9c 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -480,3 +480,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# SQLite database file +klapi.db \ No newline at end of file diff --git a/api/App.Tests/ApiEndpointsTests.cs b/api/App.Tests/ApiEndpointsTests.cs index f6915df..5d399ae 100644 --- a/api/App.Tests/ApiEndpointsTests.cs +++ b/api/App.Tests/ApiEndpointsTests.cs @@ -3,12 +3,13 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; namespace App.Tests; -public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture +public class ApiEndpointsTests(DevelopmentApiTestFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); @@ -36,34 +37,8 @@ public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture(); - Assert.NotNull(auth); - Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken)); - - _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); - var createPayload = new { id = 0, @@ -159,17 +134,85 @@ public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture +public class ProductionAuthTests(ProductionApiTestFactory factory) : IClassFixture { + private readonly HttpClient _client = factory.CreateClient(); + + [Fact] + public async Task WriteEndpoints_RequireAuthInProduction() + { + var createWithoutAuthResponse = await _client.PostAsJsonAsync("/lok/open-hours", new + { + id = 0, + name = "unauthorized", + version = DateTime.UtcNow.ToString("O"), + paragraph1 = "p1", + paragraph2 = "p2", + paragraph3 = "p3", + paragraph4 = "p4", + kitchenNotice = "k1" + }); + + Assert.Equal(HttpStatusCode.Unauthorized, createWithoutAuthResponse.StatusCode); + + var activateWithoutAuthResponse = await _client.PutAsync("/lok/open-hours/1/active", null); + Assert.Equal(HttpStatusCode.Unauthorized, activateWithoutAuthResponse.StatusCode); + } + + [Fact] + public async Task CreateOpenHours_WorksWithAuthInProduction() + { + var tokenResponse = await _client.PostAsJsonAsync("/auth/token", new + { + email = "admin@klapi.local", + password = "changeme" + }); + + Assert.Equal(HttpStatusCode.OK, tokenResponse.StatusCode); + var auth = await tokenResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(auth); + Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken)); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + + var createPayload = new + { + id = 0, + name = "authorized", + version = DateTime.UtcNow.ToString("O"), + paragraph1 = "p1", + paragraph2 = "p2", + paragraph3 = "p3", + paragraph4 = "p4", + kitchenNotice = "k1" + }; + + var createResponse = await _client.PostAsJsonAsync("/lok/open-hours", createPayload); + + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + } +} + +public abstract class ApiTestFactoryBase(string environmentName) : WebApplicationFactory +{ + private readonly string _environmentName = environmentName; private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"klapi-tests-{Guid.NewGuid():N}.db"); protected override void ConfigureWebHost(IWebHostBuilder builder) { + builder.UseEnvironment(_environmentName); + builder.ConfigureAppConfiguration((_, configBuilder) => { configBuilder.AddInMemoryCollection(new Dictionary { - ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}" + ["ConnectionStrings:DefaultConnection"] = $"Data Source={_dbPath}", + ["Auth:Issuer"] = "klapi-api-tests", + ["Auth:Audience"] = "klapi-ui-tests", + ["Auth:SigningKey"] = "test-signing-key-which-is-at-least-32-characters-long", + ["Auth:AllowedOrigins:0"] = "http://localhost:5173", + ["Auth:Users:0:Email"] = "admin@klapi.local", + ["Auth:Users:0:Password"] = "changeme" }); }); } @@ -196,6 +239,20 @@ public class ApiTestFactory : WebApplicationFactory } } +public sealed class DevelopmentApiTestFactory : ApiTestFactoryBase +{ + public DevelopmentApiTestFactory() : base(Environments.Development) + { + } +} + +public sealed class ProductionApiTestFactory : ApiTestFactoryBase +{ + public ProductionApiTestFactory() : base(Environments.Production) + { + } +} + public class LokOpenHoursDto { public long Id { get; set; } diff --git a/api/App/Endpoints/LokEndpoints.cs b/api/App/Endpoints/LokEndpoints.cs index 32a57a4..313785f 100644 --- a/api/App/Endpoints/LokEndpoints.cs +++ b/api/App/Endpoints/LokEndpoints.cs @@ -2,7 +2,7 @@ public static class LokEndpoints { public static void MapLokEndpoints(WebApplication app) { - app.MapPost("/lok/open-hours", async (HttpContext httpContext) => + var createLokOpenHoursEndpoint = app.MapPost("/lok/open-hours", async (HttpContext httpContext) => { var lokService = httpContext.RequestServices.GetRequiredService(); var openHours = await httpContext.Request.ReadFromJsonAsync(); @@ -32,10 +32,14 @@ public static class LokEndpoints httpContext.Response.Headers.Location = "/lok/open-hours"; await httpContext.Response.WriteAsJsonAsync(createdOpenHours); }) - .RequireAuthorization("OpenHoursWrite") .RequireCors("FrontendWriteCors") .WithName("CreateLokOpenHours"); + if (!app.Environment.IsDevelopment()) + { + createLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); + } + app.MapGet("/lok/open-hours", async (HttpContext httpContext) => { var lokService = httpContext.RequestServices.GetRequiredService(); @@ -56,7 +60,7 @@ public static class LokEndpoints .RequireCors("PublicReadCors") .WithName("GetLokOpenHours"); - app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => + var deleteLokOpenHoursEndpoint = app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var deleted = await lokService.DeleteOpenHours(id); @@ -73,11 +77,15 @@ public static class LokEndpoints httpContext.Response.StatusCode = StatusCodes.Status204NoContent; }) - .RequireAuthorization("OpenHoursWrite") .RequireCors("FrontendWriteCors") .WithName("DeleteLokOpenHours"); - app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => + if (!app.Environment.IsDevelopment()) + { + deleteLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); + } + + var updateLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var openHours = await httpContext.Request.ReadFromJsonAsync(); @@ -116,11 +124,15 @@ public static class LokEndpoints await httpContext.Response.WriteAsJsonAsync(updatedOpenHours); }) - .RequireAuthorization("OpenHoursWrite") .RequireCors("FrontendWriteCors") .WithName("UpdateLokOpenHours"); - app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) => + if (!app.Environment.IsDevelopment()) + { + updateLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); + } + + var setActiveLokOpenHoursEndpoint = app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) => { var lokService = httpContext.RequestServices.GetRequiredService(); var activated = await lokService.SetActiveOpenHours(id); @@ -141,8 +153,12 @@ public static class LokEndpoints IsActive = true }); }) - .RequireAuthorization("OpenHoursWrite") .RequireCors("FrontendWriteCors") .WithName("SetActiveLokOpenHours"); + + if (!app.Environment.IsDevelopment()) + { + setActiveLokOpenHoursEndpoint.RequireAuthorization("OpenHoursWrite"); + } } } \ No newline at end of file diff --git a/api/Database/klapi.db b/api/Database/klapi.db index 515e777..4b0b0e6 100644 Binary files a/api/Database/klapi.db and b/api/Database/klapi.db differ diff --git a/start.sh b/start.sh index b2a3e2b..7ada83d 100755 --- a/start.sh +++ b/start.sh @@ -33,8 +33,23 @@ ensure_app_window() { start_or_restart_services() { ensure_app_window - tmux respawn-pane -k -t "$SESSION_NAME":app.0 -c "$UI_DIR" "bun dev" tmux respawn-pane -k -t "$SESSION_NAME":app.1 -c "$API_DIR" "dotnet run --project $API_DIR/App/App.csproj" + + local health_url="http://127.0.0.1:5013/health/db" + local max_attempts=60 + local attempt=1 + + until curl --silent --fail "$health_url" >/dev/null 2>&1; do + if [[ "$attempt" -ge "$max_attempts" ]]; then + echo "API did not become healthy in time; starting UI anyway." >&2 + break + fi + + sleep 0.5 + attempt=$((attempt + 1)) + done + + tmux respawn-pane -k -t "$SESSION_NAME":app.0 -c "$UI_DIR" "bun dev" tmux select-pane -t "$SESSION_NAME":app.0 tmux select-window -t "$SESSION_NAME":app } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b7a4c32..6d28e0e 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:5013", + target: "http://127.0.0.1:5013", changeOrigin: true, rewrite: (pathValue) => pathValue.replace(/^\/api/, ""), },