77 lines
8.7 KiB
C#
77 lines
8.7 KiB
C#
using GuestOps.Web;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.Extensions.Configuration;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
static class ReplyTests
|
|
{
|
|
public static async Task Run(Action<string,bool> check, IStore store)
|
|
{
|
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Google:ClientId"]="fixture-client", ["Google:ClientSecret"]="fixture-secret", ["Google:EnableSending"]="true", ["Ai:ApiKey"]="fixture-only", ["Ai:Model"]="fixture-model" }).Build();
|
|
var protection = new EphemeralDataProtectionProvider();
|
|
var hotel = new Hotel { StaffSendingEnabled=true, AiDraftsEnabled=true }; hotel.HotelId=hotel.Id; await store.Insert(hotel);
|
|
var mailbox = new Mailbox { HotelId=hotel.Id, Email=$"hotel-{hotel.Id}@example.invalid", CanSend=true, ProtectedRefreshToken=protection.CreateProtector("GoogleMailbox.refresh.v1").Protect("fixture-refresh") }; await store.Insert(mailbox);
|
|
Conversation Message()=>new() { HotelId=hotel.Id, MailboxId=mailbox.Id, ProviderMessageId=Guid.NewGuid().ToString("N"), ProviderThreadId="ab123", RfcMessageId="<guest@example.invalid>", ReplyAddress="guest@example.invalid", Subject="Parking question", Draft="Parking is available.", Delivery=new() { Recipient="guest@example.invalid", Body="Parking is available.", ApprovedBy="fixture-owner" } };
|
|
var handler=new Fixture(); var google=new GoogleMailbox(new HttpClient(handler),config,store,protection); var worker=new ReplyDelivery(store,google);
|
|
var message=Message(); await store.Insert(message);
|
|
var stale=(await store.Get<Conversation>(hotel.Id,message.Id))!;
|
|
await Task.WhenAll(worker.Process(message,default),worker.Process(stale,default));
|
|
var saved=await store.Get<Conversation>(hotel.Id,message.Id);
|
|
check("Competing workers send one approved reply only",handler.Sends==1&&saved!.Delivery!.State=="Sent");
|
|
await worker.Process(saved!,default); check("Completed delivery is never replayed",handler.Sends==1);
|
|
var raw=Encoding.UTF8.GetString(Convert.FromBase64String(handler.Raw!.Replace('-','+').Replace('_','/').PadRight((handler.Raw.Length+3)/4*4,'=')));
|
|
check("Gmail payload has stable identity and reply headers",raw.Contains(message.Delivery!.MessageId)&&raw.Contains("In-Reply-To: <guest@example.invalid>")&&handler.Thread=="ab123"&&raw.Contains("To: guest@example.invalid"));
|
|
check("Multiple or injected recipients are rejected",ReplyMime.Address("a@example.invalid,b@example.invalid")==""&&ReplyMime.Address("a@example.invalid\r\nBcc: b@example.invalid")=="");
|
|
var malicious=Message();malicious.RfcMessageId="<x>\r\nBcc: bad@example.invalid";bool blocked=false;try{ReplyMime.Build(malicious,mailbox);}catch{blocked=true;}check("Reply header injection is blocked",blocked);
|
|
handler.FailSend=true;var uncertain=Message();await store.Insert(uncertain);await worker.Process(uncertain,default);
|
|
saved=await store.Get<Conversation>(hotel.Id,uncertain.Id);int sends=handler.Sends;await worker.Process(saved!,default);
|
|
check("Timeout after send is held without retry",saved!.Delivery!.State=="NeedsReview"&&handler.Sends==sends);
|
|
handler.FailSend=false;handler.FailToken=true;var rejected=Message();await store.Insert(rejected);await worker.Process(rejected,default);
|
|
check("Token failure is recorded before any send",(await store.Get<Conversation>(hotel.Id,rejected.Id))!.Delivery!.State=="Rejected"&&handler.Sends==sends);
|
|
handler.FailToken=false;var interrupted=Message();interrupted.Delivery!.State="Sending";interrupted.Delivery.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);await store.Insert(interrupted);await worker.Process(interrupted,default);
|
|
check("Interrupted send after restart requires verification",(await store.Get<Conversation>(hotel.Id,interrupted.Id))!.Delivery!.State=="NeedsReview"&&handler.Sends==sends);
|
|
check("Other hotels cannot read delivery evidence",await store.Get<Conversation>("other-hotel",interrupted.Id)==null);
|
|
var disabled=Message();hotel.StaffSendingEnabled=false;hotel.Version++;await store.Replace(hotel.Id,hotel.Id,0,hotel);await store.Insert(disabled);await worker.Process(disabled,default);
|
|
check("Hotel stop control blocks queued delivery",(await store.Get<Conversation>(hotel.Id,disabled.Id))!.Delivery!.State=="Rejected"&&handler.Sends==sends);
|
|
handler.FoundMessageId=uncertain.Delivery!.MessageId;handler.FoundRecipient=uncertain.ReplyAddress;handler.FoundFrom=mailbox.Email;
|
|
check("Uncertain delivery verifies matching Gmail sent record",await google.FindSent(uncertain,mailbox,default)=="sent-found");
|
|
handler.FoundRecipient="someone-else@example.invalid";check("Mismatched Gmail recipient cannot reconcile delivery",await google.FindSent(uncertain,mailbox,default)==null);
|
|
var knowledge=new[] { new KnowledgeEntry { Id="approved",HotelId=hotel.Id,Title="Parking",Answer="Parking is available.",Approved=true },new KnowledgeEntry { Id="unapproved",HotelId=hotel.Id,Title="Parking",Answer="SECRET DRAFT",Approved=false },new KnowledgeEntry { Id="foreign",HotelId="other-hotel",Title="Parking",Answer="OTHER HOTEL",Approved=true } };
|
|
var sources=AiDrafts.SelectSources(message,knowledge);check("AI retrieval excludes unapproved and foreign hotel answers",sources.Length==1&&sources[0].Id=="approved");
|
|
var ai=new AiDrafts(new HttpClient(handler),config);
|
|
var suggestion=await ai.Generate(message,sources,default);
|
|
check("Structured AI result preserves validated evidence",suggestion.Draft=="Parking is available."&&suggestion.SourceIds.SequenceEqual(new[]{"approved"}));
|
|
check("AI request disables storage and excludes hidden knowledge",handler.AiPayload!.Contains("\"store\":false")&&!handler.AiPayload.Contains("SECRET DRAFT")&&!handler.AiPayload.Contains("OTHER HOTEL"));
|
|
handler.AiSource="foreign";blocked=false;try{await ai.Generate(message,sources,default);}catch{blocked=true;}check("Invented AI citations fail closed",blocked);
|
|
handler.AiSource="approved";handler.AiEscalate=true;suggestion=await ai.Generate(message,sources,default);check("AI escalation never yields a sendable generated reply",suggestion.NeedsReview&&suggestion.Draft=="");
|
|
var requests=handler.AiRequests; suggestion=await ai.Generate(message,[],default);check("Missing hotel knowledge escalates without an API call",suggestion.NeedsReview&&handler.AiRequests==requests);
|
|
handler.AiIncomplete=true;blocked=false;try{await ai.Generate(message,sources,default);}catch{blocked=true;}check("Incomplete AI output cannot become a draft",blocked);
|
|
}
|
|
|
|
sealed class Fixture : HttpMessageHandler
|
|
{
|
|
public int Sends,AiRequests;public bool FailSend,FailToken,AiEscalate,AiIncomplete;public string AiSource="approved";public string? Raw,Thread,AiPayload,FoundMessageId,FoundRecipient,FoundFrom;
|
|
static HttpResponseMessage Json(object value)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")};
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct)
|
|
{
|
|
var url=request.RequestUri!.AbsoluteUri;
|
|
if(url=="https://oauth2.googleapis.com/token") return FailToken?new(HttpStatusCode.Unauthorized):Json(new{access_token="fixture-access"});
|
|
if(url=="https://gmail.googleapis.com/gmail/v1/users/me/messages/send")
|
|
{
|
|
Interlocked.Increment(ref Sends);using var json=JsonDocument.Parse(await request.Content!.ReadAsStringAsync(ct));Raw=json.RootElement.GetProperty("raw").GetString();Thread=json.RootElement.GetProperty("threadId").GetString();
|
|
if(FailSend)throw new TaskCanceledException("Simulated uncertain delivery");return Json(new{id="sent-fixture"});
|
|
}
|
|
if(url.Contains("/messages?"))return Json(new{messages=new[]{new{id="sent-found"}}});
|
|
if(url.Contains("/messages/sent-found?"))return Json(new{labelIds=new[]{"SENT"},payload=new{headers=new[]{new{name="Message-ID",value=FoundMessageId},new{name="To",value=FoundRecipient},new{name="From",value=FoundFrom}}}});
|
|
if(url=="https://api.openai.com/v1/responses")
|
|
{
|
|
AiRequests++;AiPayload=await request.Content!.ReadAsStringAsync(ct);
|
|
return Json(new{status=AiIncomplete?"incomplete":"completed",output=new[]{new{type="message",content=new[]{new{type="output_text",text=JsonSerializer.Serialize(new{draft="Parking is available.",needsReview=AiEscalate,reason="Check approved parking information.",sourceIds=new[]{AiSource}})}}}}});
|
|
}
|
|
throw new InvalidOperationException("Unexpected fixture URL: "+url);
|
|
}
|
|
}
|
|
}
|