refinement phase 0
This commit is contained in:
+9
-5
@@ -89,7 +89,7 @@ src/
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
||||
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
@@ -515,15 +515,19 @@ RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies,
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
|
||||
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
|
||||
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive)
|
||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||
ConfigureGrpcPluginServices()
|
||||
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
|
||||
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
|
||||
```
|
||||
|
||||
Pipeline order: exception handler → Swagger → routing → **rate limiter → authentication →
|
||||
authorization** → controllers → metrics → health checks → gRPC. `UseRateLimiter()` is placed
|
||||
**before** `UseAuthentication()` so over-limit auth/OTP attempts are rejected (`429`) before hitting
|
||||
the auth stack.
|
||||
Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication →
|
||||
authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits
|
||||
**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the
|
||||
limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP
|
||||
attempts are rejected (`429`) before hitting the auth stack.
|
||||
|
||||
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
|
||||
never inline registrations there directly.
|
||||
|
||||
+35
-20
@@ -1,22 +1,37 @@
|
||||
version: "3.9" # optional since v1.27.0
|
||||
# Local development database for the Balinyaar API.
|
||||
#
|
||||
# Brings up a throwaway SQL Server 2022 (Developer edition) on localhost:1433 so a developer with no
|
||||
# access to any remote database can boot the API. On first run Program.cs applies all EF migrations and
|
||||
# seeds roles + an admin user + a sandbox gateway against this empty instance.
|
||||
#
|
||||
# The SA password below is a well-known DEV-ONLY value: it is NOT a secret, is used only on localhost,
|
||||
# and never reaches a deployed environment. Point the API at this instance via `dotnet user-secrets`
|
||||
# (see dev/post-phase/refinement/RUNBOOK.md) — never by editing a committed appsettings*.json.
|
||||
#
|
||||
# docker compose up -d
|
||||
# # then set ConnectionStrings:SqlServer via user-secrets (see the runbook), then `dotnet run`
|
||||
#
|
||||
# The API itself runs on the host via `dotnet run` (not in a container) for the local-dev loop — this
|
||||
# compose file intentionally provisions only the database.
|
||||
|
||||
services:
|
||||
web_api:
|
||||
image: bobby-baya
|
||||
container_name: bobby-baya-app
|
||||
environment:
|
||||
"ASPNETCORE_URLS": "https://+;http://+"
|
||||
"ASPNETCORE_Kestrel__Certificates__Default__Password": "Strong@Password"
|
||||
"ASPNETCORE_Kestrel__Certificates__Default__Path": "/https/baya.pfx"
|
||||
ports:
|
||||
- "5000:80"
|
||||
- "5001:443"
|
||||
volumes:
|
||||
- ~/.aspnet/https:/https
|
||||
sql:
|
||||
image: "mcr.microsoft.com/mssql/server:2022-latest"
|
||||
container_name: sql_server2022
|
||||
ports: # not actually needed, because the two services are on the same network
|
||||
- "1435:1433"
|
||||
db:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
container_name: balinyaar-sqlserver
|
||||
environment:
|
||||
- ACCEPT_EULA=y
|
||||
- SA_PASSWORD=A&VeryComplex123Password
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_PID: "Developer"
|
||||
MSSQL_SA_PASSWORD: "Balinyaar_Dev1433"
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- balinyaar-mssql-data:/var/opt/mssql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q 'SELECT 1' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
balinyaar-mssql-data:
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<IsPackable>true</IsPackable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
<!-- Enables `dotnet user-secrets` for the local-dev connection string (never a committed secret). -->
|
||||
<UserSecretsId>baya-web-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using AppModels = Baya.Application.Models.Common;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "Development-only helpers (return 404 outside the Development environment)")]
|
||||
public sealed class DevController(IHostEnvironment environment) : BaseController
|
||||
{
|
||||
/// <summary>
|
||||
/// Development-only: returns the most recent OTP for <paramref name="phone"/> so a browser or an
|
||||
/// automated end-to-end flow can complete phone-OTP login without an SMS gateway (the code is otherwise
|
||||
/// only written to the server log by <c>LoggingSmsSender</c>). Returns 404 in every non-Development
|
||||
/// environment — the capture is not even wired there — so it can never leak a code in staging/production.
|
||||
/// It does not touch the OTP rate-limit or the per-phone resend window. Superseded by the real SMS gateway
|
||||
/// in refinement Phase 8.
|
||||
/// </summary>
|
||||
[HttpGet("[action]/{phone}")]
|
||||
[ProducesOkApiResponseType<DevLastOtpResult>]
|
||||
public IActionResult LastOtp(string phone)
|
||||
{
|
||||
if (!environment.IsDevelopment())
|
||||
return NotFound();
|
||||
|
||||
var code = HttpContext.RequestServices.GetService<DevOtpStore>()?.GetLatest(phone);
|
||||
|
||||
return code is null
|
||||
? OperationResult(AppModels.OperationResult<DevLastOtpResult>.NotFoundResult("No OTP has been issued for this phone yet."))
|
||||
: OperationResult(AppModels.OperationResult<DevLastOtpResult>.SuccessResult(new DevLastOtpResult(phone, code)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The most recent OTP captured for a phone (Development only).</summary>
|
||||
public record DevLastOtpResult(string Phone, string Code);
|
||||
@@ -72,8 +72,14 @@ builder.Services.AddApplicationServices()
|
||||
.AddPersistenceServices(configuration)
|
||||
.AddCrossCuttingSeams(configuration)
|
||||
.AddWebFrameworkServices()
|
||||
.AddCorsPolicies(configuration)
|
||||
.AddRateLimitingPolicies();
|
||||
|
||||
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
|
||||
// without an SMS gateway. Nothing here is wired in any other environment.
|
||||
if (builder.Environment.IsDevelopment())
|
||||
builder.Services.AddDevelopmentOtpCapture();
|
||||
|
||||
builder.Services.RegisterValidatorsAsServices();
|
||||
builder.Services.AddExceptionHandler<ExceptionHandler>();
|
||||
|
||||
@@ -114,6 +120,10 @@ app.UseSwaggerAndUi();
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
// After UseRouting and before the rate limiter / authentication so a pre-flight OPTIONS is answered
|
||||
// (and not rejected as 429/401) before the browser sends the real cross-origin request.
|
||||
app.UseCors(CorsServiceExtension.PolicyName);
|
||||
|
||||
app.UseRateLimiter();
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;"
|
||||
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
||||
@@ -25,6 +25,9 @@
|
||||
"ResolvedConfidence": 0.9
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:3000" ]
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;"
|
||||
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey",
|
||||
@@ -25,6 +25,9 @@
|
||||
"ResolvedConfidence": 0.9
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Kestrel": {
|
||||
"EndpointDefaults": {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Baya.WebFramework.ServiceConfiguration;
|
||||
|
||||
public static class CorsServiceExtension
|
||||
{
|
||||
/// <summary>The single named CORS policy the browser SPA is allowed through. Registered in DI by
|
||||
/// <see cref="AddCorsPolicies"/> and applied in the pipeline by <c>app.UseCors(PolicyName)</c>.</summary>
|
||||
public const string PolicyName = "BalinyaarWebClient";
|
||||
|
||||
/// <summary>Configuration key holding the allowed browser origins (a string array).</summary>
|
||||
public const string AllowedOriginsKey = "Cors:AllowedOrigins";
|
||||
|
||||
/// <summary>Fallback origin when <see cref="AllowedOriginsKey"/> is unset — the Next.js client's default
|
||||
/// dev URL. A deployed environment lists its real web origin(s) in configuration.</summary>
|
||||
private const string DefaultDevelopmentOrigin = "http://localhost:3000";
|
||||
|
||||
// The client (client/src/lib/api/client.ts + the payment hooks) sets exactly these request headers on
|
||||
// its cross-origin calls; the pre-flight response must echo them back or the browser blocks the real
|
||||
// request. Kept explicit rather than AllowAnyHeader so the surface is auditable.
|
||||
private static readonly string[] AllowedHeaders =
|
||||
[
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept-Language",
|
||||
"Idempotency-Key"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Registers the browser CORS policy from <see cref="AllowedOriginsKey"/> (a string array), falling back
|
||||
/// to the Next.js dev origin when unset so Development is permissive to localhost only. Credentials are
|
||||
/// NOT allowed: the client authenticates with a bearer <c>Authorization</c> header, not a cookie, so
|
||||
/// <c>AllowCredentials()</c> is unnecessary — and combining it with a wildcard origin is forbidden by the
|
||||
/// CORS spec anyway. Pair with <c>app.UseCors(<see cref="PolicyName"/>)</c> placed after
|
||||
/// <c>UseRouting()</c> and before the rate limiter / authentication, so a pre-flight OPTIONS is answered
|
||||
/// before those run.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCorsPolicies(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var origins = configuration.GetSection(AllowedOriginsKey).Get<string[]>();
|
||||
if (origins is null || origins.Length == 0)
|
||||
origins = [DefaultDevelopmentOrigin];
|
||||
|
||||
services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy(PolicyName, policy =>
|
||||
policy.WithOrigins(origins)
|
||||
.WithHeaders(AllowedHeaders)
|
||||
.AllowAnyMethod());
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Development-only <see cref="ISmsSender"/> decorator: forwards to the real (mock) sender so the code is
|
||||
/// still logged, and additionally captures it in <see cref="DevOtpStore"/> so the Development-only
|
||||
/// <c>/api/v1/dev/last_otp/{phone}</c> endpoint can serve it to a browser / e2e test. Registered ONLY in the
|
||||
/// Development environment (see <c>DevelopmentSeamExtensions.AddDevelopmentOtpCapture</c>); it changes no
|
||||
/// auth behaviour — the OTP is generated, validated and rate-limited exactly as before.
|
||||
/// </summary>
|
||||
public sealed class DevCapturingSmsSender(ISmsSender inner, DevOtpStore store) : ISmsSender
|
||||
{
|
||||
public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
store.Capture(phone, code);
|
||||
return inner.SendOtpAsync(phone, code, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
|
||||
=> inner.SendAsync(phone, message, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.Seams;
|
||||
|
||||
/// <summary>
|
||||
/// Development-only, in-memory capture of the most recent OTP per phone. It exists so a browser or an
|
||||
/// automated end-to-end test can complete phone-OTP login without a real SMS gateway — OTP "delivery" is
|
||||
/// <see cref="LoggingSmsSender"/>, which only writes the code to the server log. It is populated ONLY when
|
||||
/// <see cref="DevCapturingSmsSender"/> is registered, which happens only in the Development environment (see
|
||||
/// <c>DevelopmentSeamExtensions.AddDevelopmentOtpCapture</c>). It is never wired outside Development, and the
|
||||
/// endpoint that exposes it (<c>/api/v1/dev/last_otp/{phone}</c>) is additionally gated on
|
||||
/// <c>IHostEnvironment.IsDevelopment()</c>. Superseded by the real SMS gateway in refinement Phase 8.
|
||||
/// </summary>
|
||||
public sealed class DevOtpStore
|
||||
{
|
||||
// Bounded so a long-lived dev session can't grow the map without limit; only the newest code per phone
|
||||
// matters for completing a login.
|
||||
private const int MaxEntries = 500;
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _codesByPhone = new();
|
||||
|
||||
public void Capture(string phone, string code)
|
||||
{
|
||||
var key = NormalizeKey(phone);
|
||||
if (key is null)
|
||||
return;
|
||||
|
||||
if (_codesByPhone.Count >= MaxEntries && !_codesByPhone.ContainsKey(key))
|
||||
_codesByPhone.Clear();
|
||||
|
||||
_codesByPhone[key] = code;
|
||||
}
|
||||
|
||||
public string? GetLatest(string phone)
|
||||
{
|
||||
var key = NormalizeKey(phone);
|
||||
return key is not null && _codesByPhone.TryGetValue(key, out var code) ? code : null;
|
||||
}
|
||||
|
||||
// Digits-only, last 10 → matches IranianPhone's canonical 09xxxxxxxxx regardless of how the caller
|
||||
// spelled it (+98 / 0098 / 98 / 0 prefix). Kept self-contained so this dev helper needs no dependency on
|
||||
// the Application layer's internal phone normalizer.
|
||||
private static string? NormalizeKey(string? phone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return null;
|
||||
|
||||
var digits = new string(phone.Where(char.IsAsciiDigit).ToArray());
|
||||
return digits.Length >= 10 ? digits[^10..] : null;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Baya.Infrastructure.CrossCutting.ServiceConfiguration;
|
||||
|
||||
public static class DevelopmentSeamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Development-only wiring for the OTP bring-up bridge. Registers <see cref="DevOtpStore"/> and decorates
|
||||
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> (the log-only
|
||||
/// <see cref="LoggingSmsSender"/>) with <see cref="DevCapturingSmsSender"/>, so each OTP is also captured
|
||||
/// in memory for <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
|
||||
/// <c>builder.Environment.IsDevelopment()</c>: nothing here is wired in any other environment, which —
|
||||
/// together with the endpoint's own <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable
|
||||
/// outside Development. Superseded by the real SMS gateway in refinement Phase 8.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<DevOtpStore>();
|
||||
|
||||
// Re-register ISmsSender as the capturing decorator over a fresh LoggingSmsSender (built through DI so
|
||||
// it still gets its ILogger). The last registration wins for a single resolve, so callers transparently
|
||||
// get the decorator; the code is still logged exactly as before, just also captured for the dev endpoint.
|
||||
services.AddSingleton<ISmsSender>(sp => new DevCapturingSmsSender(
|
||||
ActivatorUtilities.CreateInstance<LoggingSmsSender>(sp),
|
||||
sp.GetRequiredService<DevOtpStore>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Baya.Test.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Refinement Phase 0 — proves the integration seam the browser depends on: the named CORS policy answers a
|
||||
/// pre-flight from the client's origin (and only that origin), and the Development-only OTP helper is a hard
|
||||
/// 404 in any non-Development environment (the factory boots as "Testing").
|
||||
/// </summary>
|
||||
public class CorsAndDevBringUpTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
|
||||
{
|
||||
private const string AllowedOrigin = "http://localhost:3000";
|
||||
private const string CorsOriginHeader = "Access-Control-Allow-Origin";
|
||||
|
||||
[Fact]
|
||||
public async Task Preflight_FromAllowedOrigin_ReflectsTheOrigin()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Options, "/api/v1/auth/request_otp");
|
||||
request.Headers.Add("Origin", AllowedOrigin);
|
||||
request.Headers.Add("Access-Control-Request-Method", "POST");
|
||||
request.Headers.Add("Access-Control-Request-Headers", "authorization,content-type");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.True(response.Headers.TryGetValues(CorsOriginHeader, out var origins),
|
||||
"the pre-flight for an allowed origin must carry Access-Control-Allow-Origin");
|
||||
Assert.Equal(AllowedOrigin, Assert.Single(origins));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preflight_FromForeignOrigin_IsNotAllowed()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Options, "/api/v1/auth/request_otp");
|
||||
request.Headers.Add("Origin", "https://evil.example");
|
||||
request.Headers.Add("Access-Control-Request-Method", "POST");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.False(response.Headers.Contains(CorsOriginHeader),
|
||||
"a foreign origin must never be echoed back as allowed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DevLastOtp_OutsideDevelopment_Returns404()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/dev/last_otp/09120000001");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation;
|
||||
|
||||
/// <summary>
|
||||
/// Refinement Phase 0 — the Development-only OTP bring-up bridge. The store captures the latest code per
|
||||
/// phone (spelling-insensitive) and the capturing sender both captures and still delegates to the real
|
||||
/// (log-only) sender, changing no delivery behaviour.
|
||||
/// </summary>
|
||||
public class DevOtpBringUpTests
|
||||
{
|
||||
[Fact]
|
||||
public void Store_CapturesAndReturnsLatestCode()
|
||||
{
|
||||
var store = new DevOtpStore();
|
||||
|
||||
store.Capture("09120000001", "111111");
|
||||
store.Capture("09120000001", "222222");
|
||||
|
||||
Assert.Equal("222222", store.GetLatest("09120000001"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("09120000001")]
|
||||
[InlineData("+989120000001")]
|
||||
[InlineData("00989120000001")]
|
||||
[InlineData("9120000001")]
|
||||
public void Store_MatchesAnyPhoneSpelling(string lookup)
|
||||
{
|
||||
var store = new DevOtpStore();
|
||||
store.Capture("09120000001", "424242");
|
||||
|
||||
Assert.Equal("424242", store.GetLatest(lookup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReturnsNullForUnknownPhone()
|
||||
{
|
||||
var store = new DevOtpStore();
|
||||
store.Capture("09120000001", "123456");
|
||||
|
||||
Assert.Null(store.GetLatest("09350000009"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapturingSender_CapturesTheCodeAndStillDelegates()
|
||||
{
|
||||
var inner = Substitute.For<ISmsSender>();
|
||||
var store = new DevOtpStore();
|
||||
var sender = new DevCapturingSmsSender(inner, store);
|
||||
|
||||
await sender.SendOtpAsync("09120000001", "135790");
|
||||
|
||||
Assert.Equal("135790", store.GetLatest("09120000001"));
|
||||
await inner.Received(1).SendOtpAsync("09120000001", "135790", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapturingSender_PlainMessageDelegatesWithoutCapturing()
|
||||
{
|
||||
var inner = Substitute.For<ISmsSender>();
|
||||
var store = new DevOtpStore();
|
||||
var sender = new DevCapturingSmsSender(inner, store);
|
||||
|
||||
await sender.SendAsync("09120000001", "your booking is confirmed");
|
||||
|
||||
Assert.Null(store.GetLatest("09120000001"));
|
||||
await inner.Received(1).SendAsync("09120000001", "your booking is confirmed", Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user