GuestOps/tests/GuestOps.Tests/MailboxTests.cs

79 lines
10 KiB
C#

using GuestOps.Web;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using System.Net;
using System.Text;
using System.Text.Json;
public static class MailboxTests
{
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"}}).Build();
var protection=new EphemeralDataProtectionProvider();var protector=protection.CreateProtector("GoogleMailbox.refresh.v1");
var fixture=new Fixture();var google=new GoogleMailbox(new HttpClient(fixture),config,store,protection);
var hotel=new Hotel{StaffSendingEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel);
async Task<Mailbox> NewBox(){var box=new Mailbox{HotelId=hotel.Id,Email=Guid.NewGuid().ToString("N")+"@example.invalid",ProtectedRefreshToken=protector.Protect("fixture-refresh"),CanSend=true};await store.Insert(box);fixture.Email=box.Email;return box;}
async Task<Mailbox> Reload(Mailbox box)=>(await store.Get<Mailbox>(box.HotelId,box.Id))!;
async Task Failure(Mailbox box){try{await google.Sync(box,default);}catch(Exception ex){await google.RecordFailure(box,ex);}}
var box=await NewBox();fixture.NextPage=true;await google.Sync(box,default);var saved=await Reload(box);
check("Gmail import skips messages deleted after listing",(await store.List<Conversation>(hotel.Id)).Count==1);
check("Gmail page checkpoint and health persist",saved.PageToken=="fixture-next"&&saved.LastSyncAt!=null&&saved.LastAttemptAt!=null);
fixture.NextPage=false;await google.Sync(saved,default);saved=await Reload(box);
check("Replayed Gmail page does not duplicate imported messages",(await store.List<Conversation>(hotel.Id)).Count==1&&saved.PageToken=="");
box=await NewBox();box.PageToken="expired-page";box.Version++;await store.Replace(box.HotelId,box.Id,0,box);var start=box.WindowStart;fixture.BadPage=true;await google.Sync(box,default);saved=await Reload(box);
check("Rejected Gmail page restarts same window without advancing",saved.PageToken==""&&Math.Abs((saved.WindowStart-start).TotalMilliseconds)<1&&saved.SyncErrorCode=="CheckpointRestart"&&saved.NextAttemptAt>DateTime.UtcNow);fixture.BadPage=false;
box=await NewBox();fixture.TokenError="invalid_grant";await Failure(box);saved=await Reload(box);
check("Revoked Google refresh token requires reconnection",saved.Status=="NeedsReconnect"&&saved.SyncErrorCode=="ReconnectRequired");var requests=fixture.Requests;await google.Sync(saved,default);
check("Revoked connection does not keep requesting Google tokens",fixture.Requests==requests);fixture.TokenError="";
box=await NewBox();fixture.Throttle=true;await Failure(box);saved=await Reload(box);
check("Google rate limit schedules retry and retains connected state",saved.Status=="Connected"&&saved.NextAttemptAt>=DateTime.UtcNow.AddSeconds(110)&&saved.FailureCount==1);requests=fixture.Requests;await google.Sync(saved,default);
check("Mailbox backoff skips provider requests until due",fixture.Requests==requests);
check("Owner cannot bypass provider backoff",!await MailboxManagement.Change(store,saved,saved.Version,"retry"));fixture.Throttle=false;
box=await NewBox();fixture.TokenError="invalid_client";await Failure(box);saved=await Reload(box);check("Invalid Google client is distinguished from revoked user consent",saved.Status=="Connected"&&saved.SyncErrorCode=="Configuration"&&saved.NextAttemptAt>DateTime.UtcNow.AddMinutes(59));fixture.TokenError="";
box=await NewBox();var old=await Reload(box);await MailboxManagement.Change(store,box,box.Version,"disconnect");saved=await Reload(box);
check("Disconnect removes credentials and rotates connection identity",saved.Status=="Disconnected"&&saved.ProtectedRefreshToken==""&&!saved.CanSend&&saved.ConnectionEpoch!="");
old.SyncError="stale worker";old.PageToken="stale-page";await store.SaveSync(old);saved=await Reload(box);
check("Stale worker cannot undo disconnect or restore checkpoint",saved.Status=="Disconnected"&&saved.SyncError!="stale worker"&&saved.PageToken!="stale-page");
bool denied=false;try{await google.AccessToken(old,default);}catch(MailboxConflict){denied=true;}check("Disconnected snapshot cannot request access token",denied);
denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddMinutes(-5));}catch(MailboxConflict){denied=true;}check("OAuth started before disconnect cannot reconnect mailbox",denied);
var epoch=saved.ConnectionEpoch;await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1),box.Email);saved=await Reload(box);
check("Fresh reconnect preserves mailbox ID and rotates approval identity",saved.Status=="Connected"&&saved.ConnectionEpoch!=epoch&&saved.Id==box.Id&&saved.PageToken=="");
denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1),"wrong@example.invalid");}catch(InvalidOperationException){denied=true;}check("Targeted reconnect rejects different Google account",denied);
fixture.Scope="https://www.googleapis.com/auth/gmail.send";denied=false;try{await google.Connect(hotel.Id,"fixture-code",DateTime.UtcNow.AddSeconds(1));}catch(InvalidOperationException){denied=true;}check("Connection requires granted Gmail read scope",denied);fixture.Scope=Fixture.FullScope;
denied=false;try{await google.Connect("another-hotel","fixture-code",DateTime.UtcNow.AddSeconds(1));}catch(MailboxConflict){denied=true;}check("Google mailbox cannot be reassigned to a different hotel",denied);
box=await NewBox();old=await Reload(box);check("Owner can restart an import pass",await MailboxManagement.Change(store,box,box.Version,"retry"));old.PageToken="old-inflight-page";await store.SaveSync(old);saved=await Reload(box);check("Old sync cannot overwrite explicitly restarted checkpoint",saved.PageToken==""&&saved.SyncErrorCode=="RestartQueued");
box=await NewBox();fixture.OnMessage=async()=>{var current=await Reload(box);await MailboxManagement.Change(store,current,current.Version,"disconnect");};var before=(await store.List<Conversation>(hotel.Id)).Count;await google.Sync(box,default);fixture.OnMessage=null;
check("Disconnect during message fetch prevents subsequent import",(await store.List<Conversation>(hotel.Id)).Count==before);
box=await NewBox();var conversation=new Conversation{HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="pending",ProviderThreadId="ab123",RfcMessageId="<guest@example.invalid>",Subject="Parking",Delivery=new(){Recipient="guest@example.invalid",Body="Approved answer",MailboxEpoch="previous-connection"}};await store.Insert(conversation);await new ReplyDelivery(store,google).Process(conversation,default);
check("Reconnection does not silently send earlier queued approvals",(await store.Get<Conversation>(hotel.Id,conversation.Id))!.Delivery!.State=="Rejected"&&fixture.Sends==0);
conversation=new Conversation{HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="during-token",ProviderThreadId="ab123",RfcMessageId="<guest@example.invalid>",Subject="Parking",Delivery=new(){Recipient="guest@example.invalid",Body="Approved answer",MailboxEpoch=box.ConnectionEpoch}};await store.Insert(conversation);fixture.OnToken=async()=>{var current=await Reload(box);await MailboxManagement.Change(store,current,current.Version,"disconnect");};await new ReplyDelivery(store,google).Process(conversation,default);fixture.OnToken=null;
check("Disconnect during token refresh blocks Gmail send",(await store.Get<Conversation>(hotel.Id,conversation.Id))!.Delivery!.State=="Rejected"&&fixture.Sends==0);
var view=JsonSerializer.Serialize(MailboxManagement.View(box));check("Mailbox health view omits credentials and page tokens",!view.Contains("ProtectedRefreshToken")&&!view.Contains("PageToken")&&!view.Contains("ConnectionEpoch"));
}
sealed class Fixture:HttpMessageHandler
{
public const string FullScope="https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send";
public string Email="",TokenError="",Scope=FullScope;public bool NextPage,BadPage,Throttle;public int Requests,Sends;public Func<Task>? OnMessage,OnToken;
static HttpResponseMessage Json(object value,HttpStatusCode status=HttpStatusCode.OK)=>new(status){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")};
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct)
{
Requests++;var path=request.RequestUri!.AbsolutePath;
if(path=="/token") {if(OnToken!=null)await OnToken();return TokenError!=""?Json(new{error=TokenError,error_description="fixture-secret-never-display"},HttpStatusCode.BadRequest):Json(new{access_token="fixture-access",refresh_token="fixture-new-refresh",scope=Scope});}
if(path.EndsWith("/profile"))return Json(new{emailAddress=Email});
if(path.EndsWith("/messages"))
{
if(Throttle){var result=Json(new{error=new{code=429}},HttpStatusCode.TooManyRequests);result.Headers.RetryAfter=new(TimeSpan.FromSeconds(120));return result;}
if(BadPage&&request.RequestUri.Query.Contains("pageToken="))return Json(new{error=new{code=400}},HttpStatusCode.BadRequest);
return NextPage?Json(new{messages=new[]{new{id="deleted"},new{id="fixture-message"}},nextPageToken="fixture-next"}):Json(new{messages=new[]{new{id="deleted"},new{id="fixture-message"}}});
}
if(path.EndsWith("/messages/deleted"))return Json(new{error=new{code=404}},HttpStatusCode.NotFound);
if(path.EndsWith("/messages/fixture-message"))
{
if(OnMessage!=null)await OnMessage();return Json(new{id="fixture-message",threadId="ab123",internalDate=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(),payload=new{mimeType="text/plain",headers=new[]{new{name="From",value="guest@example.invalid"},new{name="To",value=Email},new{name="Subject",value="Parking"},new{name="Message-ID",value="<fixture@example.invalid>"}},body=new{data=Convert.ToBase64String(Encoding.UTF8.GetBytes("Is parking available?"))}}});
}
if(path.EndsWith("/messages/send")){Sends++;return Json(new{id="sent"});}
throw new InvalidOperationException("Unexpected fixture request.");
}
}
}