GuestOps/tests/GuestOps.Tests/PaymentTests.cs

83 lines
10 KiB
C#

using GuestOps.Web;
using Microsoft.Extensions.Configuration;
using System.Net;
using System.Text;
using System.Text.Json.Nodes;
public static class PaymentTests
{
public static async Task Run(Action<string,bool> check,IStore store)
{
var hotel=new Hotel{PaymentsEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel);
var prefix="Payments:Hotels:"+hotel.Id+":";
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{prefix+"BaseUrl","https://sandbox.nmi.com"},{prefix+"MerchantAccount","test-merchant"},{prefix+"SecurityKey","fake-test-key"},{prefix+"CreatesEnabled","true"}}).Build();
var handler=new Fixture();var work=new PaymentWork(store,new NmiInvoices(new HttpClient(handler)),config);
int counter=0;
Task<PaymentRequest> Propose()=>work.Propose(hotel.Id,"owner",new("TEST-"+(++counter),"guest@example.invalid","Booking deposit",80.25m,"GBP"));
var p=await Propose();
check("Payment proposal is durable without contacting NMI",handler.Posts==0&&handler.Reads==0&&(await store.Get<PaymentRequest>(hotel.Id,p.Id))?.Amount==80.25m);
check("Duplicate payment reference is rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new(p.Reference.ToLowerInvariant(),p.Email,p.Description,p.Amount,p.Currency))));
check("Other hotels cannot read payment requests",await store.Get<PaymentRequest>("other",p.Id)==null);
check("Currency and fractional penny amounts are rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid","Deposit",1.001m,"GBP")))&&await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid","Deposit",1m,"JPY"))));
check("Payment email injection and display-name addresses are rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","Guest <guest@example.invalid>","Deposit",1m,"GBP")))&&await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid\r\nBcc: other@example.invalid","Deposit",1m,"GBP"))));
check("Creating an invoice requires email and amount approval",await Blocked(()=>work.Create(p,0,"owner",false,default))&&handler.Posts==0);
check("Payment approval rejects stale versions",await Blocked(()=>work.Create(p,5,"owner",true,default))&&handler.Posts==0);
var one=(await store.Get<PaymentRequest>(hotel.Id,p.Id))!;var two=(await store.Get<PaymentRequest>(hotel.Id,p.Id))!;
await Task.WhenAll(Blocked(()=>work.Create(one,0,"owner",true,default)),Blocked(()=>work.Create(two,0,"owner",true,default)));
p=(await store.Get<PaymentRequest>(hotel.Id,p.Id))!;
check("Concurrent payment approvals create one invoice",handler.Posts==1&&p.State=="Open");
check("NMI payload preserves exact amount and correlation",handler.Last!["amount"]!.GetValue<decimal>()==80.25m&&handler.Last["currency"]!.GetValue<string>()=="GBP"&&handler.Last["order_details"]!["order_id"]!.GetValue<string>()==p.Id);
check("Invoice creation never calls a separate email endpoint",handler.Paths.All(x=>!x.EndsWith("/send")));
check("Completed invoice creation cannot be replayed",await Blocked(()=>work.Create(p,p.Version,"owner",true,default))&&handler.Posts==1);
handler.Invoice!["status"]="partially_paid";p=await work.Check(p,p.Version,default);check("Partial invoice remains partial",p.State=="Partial");
handler.Invoice["status"]="paid";p=await work.Check(p,p.Version,default);check("Matching NMI invoice can be observed paid",p.State=="Paid"&&p.CheckedAt!=null);
handler.Invoice["amount"]="80.24";p=await work.Check(p,p.Version,default);check("Paid status cannot bypass amount verification",p.State=="NeedsReview");handler.Invoice["amount"]="80.25";
handler.Invoice["currency"]="USD";p=await work.Check(p,p.Version,default);check("Paid status cannot bypass currency verification",p.State=="NeedsReview");handler.Invoice["currency"]="GBP";
handler.Invoice["billing_address"]!["email"]="other@example.invalid";p=await work.Check(p,p.Version,default);check("Payment recipient mismatch stays held",p.State=="NeedsReview");handler.Invoice["billing_address"]!["email"]="guest@example.invalid";
handler.Invoice["order_details"]!["order_id"]="other-request";p=await work.Check(p,p.Version,default);check("Payment order identity mismatch stays held",p.State=="NeedsReview");handler.Invoice["order_details"]!["order_id"]=p.Id;
handler.Invoice["id"]=99999;p=await work.Check(p,p.Version,default);check("Payment invoice ID mismatch stays held",p.State=="NeedsReview");handler.Invoice["id"]=int.Parse(p.InvoiceId);
handler.Invoice["status"]="unknown";p=await work.Check(p,p.Version,default);check("Unknown invoice status is not accepted",p.State=="NeedsReview");handler.Invoice["status"]="paid";
config[prefix+"CreatesEnabled"]="false";p=await work.Check(p,p.Version,default);check("Read-only payment verification works with creation disabled",p.State=="Paid");config[prefix+"CreatesEnabled"]="true";
config[prefix+"SecurityKey"]="rotated-fake-key";p=await work.Check(p,p.Version,default);check("Key rotation preserves same-merchant reconciliation",p.State=="Paid");
config[prefix+"MerchantAccount"]="different-merchant";check("Changed merchant binding blocks payment reconciliation",await Blocked(()=>work.Check(p,p.Version,default)));config[prefix+"MerchantAccount"]="test-merchant";
p=await Propose();handler.TimeoutAfterCreate=true;p=await work.Create(p,0,"owner",true,default);handler.TimeoutAfterCreate=false;int posts=handler.Posts;
check("Timed-out invoice creation is held without retry",p.State=="NeedsReview"&&p.InvoiceId==""&&await Blocked(()=>work.Create(p,p.Version,"owner",true,default))&&handler.Posts==posts);
handler.DuplicateSearch=true;p=await work.Check(p,p.Version,default);check("Ambiguous invoice recovery stays held",p.State=="NeedsReview");handler.DuplicateSearch=false;
handler.IncompleteSearch=true;p=await work.Check(p,p.Version,default);check("Incomplete invoice pagination cannot reconcile",p.State=="NeedsReview");handler.IncompleteSearch=false;
handler.EmptySearch=true;p=await work.Check(p,p.Version,default);check("Missing invoice recovery never authorizes another creation",p.State=="NeedsReview"&&handler.Posts==posts);handler.EmptySearch=false;
p=await work.Check(p,p.Version,default);check("Lost create response reconciles by exact order ID with reads only",p.State=="Open"&&p.InvoiceId.Length>0&&handler.Posts==posts);
handler.FailRead=true;p=await work.Check(p,p.Version,default);check("Provider read failure records a held state",p.State=="NeedsReview");handler.FailRead=false;
p=await Propose();handler.TimeoutAfterCreate=true;p=await work.Create(p,0,"owner",true,default);handler.TimeoutAfterCreate=false;
var v=p.Version;p.State="Creating";p.UpdatedAt=DateTime.UtcNow;p.Version++;await store.Replace(hotel.Id,p.Id,v,p);
check("Interrupted invoice is not reconciled during active deadline",await Blocked(()=>work.Check(p,p.Version,default)));
v=p.Version;p.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);p.Version++;await store.Replace(hotel.Id,p.Id,v,p);p=await work.Check(p,p.Version,default);check("Interrupted invoice can reconcile after restart",p.State=="Open");
p=await Propose();p=await work.Cancel(p,0);check("Only unsubmitted payment proposals can be cancelled",p.State=="Cancelled"&&await Blocked(()=>work.Create(p,p.Version,"owner",true,default)));
p=await Propose();p.ExpiresAt=DateTime.UtcNow.AddSeconds(-1);check("Expired payment approval is blocked",await Blocked(()=>work.Create(p,0,"owner",true,default)));
p=await Propose();hotel.PaymentsEnabled=false;hotel.Version++;await store.Replace(hotel.Id,hotel.Id,hotel.Version-1,hotel);check("Hotel payment stop control blocks invoice creation",await Blocked(()=>work.Create(p,0,"owner",true,default)));
check("Unconfigured hotel cannot use another merchant",NmiProfile.Read(config,Guid.NewGuid().ToString("N"))==null);
config[prefix+"BaseUrl"]="https://evil.example.invalid";check("NMI origin is restricted to known provider hosts",await Blocked(()=>Task.FromResult(NmiProfile.Read(config,hotel.Id))));
}
static async Task<bool> Blocked(Func<Task> action){try{await action();return false;}catch(PaymentInvalid){return true;}catch(PaymentConflict){return true;}}
sealed class Fixture:HttpMessageHandler
{
public int Posts,Reads;public bool TimeoutAfterCreate,DuplicateSearch,IncompleteSearch,EmptySearch,FailRead;
public JsonNode? Last,Invoice;public List<string> Paths=[];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct)
{
if(request.RequestUri?.Host!="sandbox.nmi.com")throw new InvalidOperationException("Only fake NMI requests are permitted by this handler.");
var path=request.RequestUri.AbsolutePath;Paths.Add(path);
if(request.Method==HttpMethod.Post)
{
if(path!="/api/v5/invoices")throw new InvalidOperationException("Unexpected external write.");
Posts++;Last=JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct));
Invoice=new JsonObject{["object"]="invoice",["id"]=1000+Posts,["status"]="open",["amount"]=Last!["amount"]!.ToString(),["currency"]=Last["currency"]!.DeepClone(),["billing_address"]=Last["billing_address"]!.DeepClone(),["order_details"]=Last["order_details"]!.DeepClone()};
if(TimeoutAfterCreate)throw new TaskCanceledException("Simulated lost response");return Response(Invoice);
}
if(request.Method!=HttpMethod.Get)throw new InvalidOperationException("Unexpected external mutation.");Reads++;
if(FailRead)return new(HttpStatusCode.ServiceUnavailable);
if(path=="/api/v5/invoices")return Response(new JsonObject{["invoices"]=EmptySearch?new JsonArray():DuplicateSearch?new JsonArray(Invoice!.DeepClone(),Invoice!.DeepClone()):new JsonArray(Invoice!.DeepClone()),["next_cursor"]=IncompleteSearch?123:null});
return Response(Invoice!);
}
static HttpResponseMessage Response(JsonNode n)=>new(HttpStatusCode.OK){Content=new StringContent(n.ToJsonString(),Encoding.UTF8,"application/json")};
}
}