152 lines
17 KiB
C#
152 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using GuestOps.Web;
|
|
using GuestOps.Models;
|
|
using GuestOps.Services;
|
|
using Microsoft.Extensions.Configuration;
|
|
using MongoDB.Driver;
|
|
|
|
int passed = 0;
|
|
void Check(string name, bool value) { if (!value) throw new Exception("FAIL " + name); Console.WriteLine("PASS " + name); passed++; }
|
|
var uri = Environment.GetEnvironmentVariable("MONGO_TEST_URI");
|
|
var dbName = "guestops_test_" + Guid.NewGuid().ToString("N");
|
|
IStore store = uri == null ? new PreviewStore() : new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
|
|
await store.Initialize();
|
|
try
|
|
{
|
|
var proofProvider=new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider();
|
|
var proof=BackupProbe.Create(proofProvider);
|
|
Check("Backup proof decrypts with the original key provider",BackupProbe.Verify(proofProvider,proof));
|
|
bool wrongKeys=false;try{BackupProbe.Verify(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(),proof);}catch(System.Security.Cryptography.CryptographicException){wrongKeys=true;}
|
|
Check("Backup proof rejects unrelated encryption keys",wrongKeys);
|
|
await store.Ping();
|
|
if(uri!=null){await store.RecordWorkerHeartbeat();Check("Worker heartbeat persists in MongoDB",await store.WorkerLastSeen()>DateTime.UtcNow.AddMinutes(-1));}
|
|
await MailboxTests.Run(Check, store);
|
|
await TeamTests.Run(Check, store);
|
|
await ReplyTests.Run(Check, store);
|
|
await PmsTests.Run(Check, store);
|
|
await PaymentTests.Run(Check, store);
|
|
await AutoReplyTests.Run(Check, store);
|
|
var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id;
|
|
var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id;
|
|
await store.Insert(a); await store.Insert(b);
|
|
Check("Hotels cannot read each other's settings", await store.Get<Hotel>(b.Id, a.Id) == null);
|
|
var row = new Conversation { HotelId = a.Id, MailboxId = "mailbox", ProviderMessageId = "message-1", Subject = "Parking" };
|
|
await store.Import(row); await store.Import(row);
|
|
Check("Duplicate email import is idempotent", (await store.List<Conversation>(a.Id)).Count == 1);
|
|
Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(b.Id)).Count == 0);
|
|
var pagingHotel=new Hotel{Name="Paging hotel"};pagingHotel.HotelId=pagingHotel.Id;await store.Insert(pagingHotel);var pagingNow=DateTime.UtcNow;var pagingAt=new DateTime(pagingNow.Ticks-pagingNow.Ticks%TimeSpan.TicksPerMillisecond,DateTimeKind.Utc);
|
|
foreach(var id in new[]{"00000000000000000000000000000003","00000000000000000000000000000002","00000000000000000000000000000001"})await store.Insert(new Conversation{Id=id,HotelId=pagingHotel.Id,ReceivedAt=pagingAt,Subject=id});
|
|
var firstPage=await store.ConversationPage(pagingHotel.Id,null,"",2);Check("Conversation page has stable bounded cursor",firstPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000003","00000000000000000000000000000002"})&&firstPage.NextCursor!=null);
|
|
Check("Conversation cursor round-trips safely",ConversationPaging.TryDecode(firstPage.NextCursor,out var pageAt,out var pageId)&&pageAt==pagingAt&&pageId=="00000000000000000000000000000002"&&!ConversationPaging.TryDecode("not-a-cursor",out _,out _));
|
|
var secondPage=await store.ConversationPage(pagingHotel.Id,pageAt,pageId,2);Check("Conversation pagination has no overlap or tenant leakage",secondPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000001"})&&secondPage.NextCursor==null);
|
|
var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 };
|
|
Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered));
|
|
Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered));
|
|
Check("Saved settings read back", (await store.Get<Hotel>(a.Id,a.Id))?.Name == "Updated");
|
|
await store.Delete<Conversation>(b.Id,row.Id);
|
|
Check("Other hotel cannot delete message", await store.Get<Conversation>(a.Id,row.Id) != null);
|
|
var state = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(1) }; await store.Insert(state);
|
|
Check("OAuth state bound to hotel and user", await store.ConsumeOAuth(state.Id,b.Id,"owner") == null && await store.ConsumeOAuth(state.Id,a.Id,"intruder") == null);
|
|
Check("OAuth state accepted exactly once", await store.ConsumeOAuth(state.Id,a.Id,"owner") != null && await store.ConsumeOAuth(state.Id,a.Id,"owner") == null);
|
|
var expired = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }; await store.Insert(expired);
|
|
Check("Expired OAuth state rejected", await store.ConsumeOAuth(expired.Id,a.Id,"owner") == null);
|
|
if (uri != null)
|
|
{
|
|
Check("Worker lease excludes a second worker", await store.TryLease("mailbox","one") && !await store.TryLease("mailbox","two"));
|
|
await store.ReleaseLease("mailbox","two"); Check("Only owner can release worker lease", !await store.TryLease("mailbox","two"));
|
|
await store.ReleaseLease("mailbox","one"); Check("Worker lease can be acquired after release", await store.TryLease("mailbox","two"));
|
|
var reloaded = new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
|
|
Check("Settings survive new database client", (await reloaded.Get<Hotel>(a.Id,a.Id))?.Name == "Updated");
|
|
var mailbox = new Mailbox { HotelId = a.Id, Email = "hotel@example.invalid" }; await store.SaveMailbox(mailbox);
|
|
bool denied = false; try { await store.SaveMailbox(new Mailbox { HotelId = b.Id, Email = mailbox.Email }); } catch (MongoWriteException) { denied = true; }
|
|
Check("Mailbox cannot be connected to two hotels", denied);
|
|
var oldMailbox = await store.Get<Mailbox>(a.Id, mailbox.Id);
|
|
mailbox.ProtectedRefreshToken = "new-protected-token"; await store.SaveMailbox(mailbox);
|
|
oldMailbox!.SyncError = "stale worker failure"; await store.SaveSync(oldMailbox);
|
|
var newMailbox = await store.Get<Mailbox>(a.Id, mailbox.Id);
|
|
Check("Stale worker cannot overwrite reconnected credentials", newMailbox?.ProtectedRefreshToken == "new-protected-token" && newMailbox.SyncError == "");
|
|
await new MongoClient(uri).GetDatabase(dbName).GetCollection<Mailbox>("mailbox").UpdateOneAsync(x=>x.Id==mailbox.Id,Builders<Mailbox>.Update.Unset(x=>x.Version));
|
|
var legacy=(await store.Get<Mailbox>(a.Id,mailbox.Id))!;legacy.SyncError="Legacy sync health";await store.SaveSync(legacy);
|
|
Check("Pre-migration mailboxes accept guarded health updates",(await store.Get<Mailbox>(a.Id,mailbox.Id))?.SyncError=="Legacy sync health");
|
|
Check("Pre-migration mailbox can be disconnected with version zero",await MailboxManagement.Change(store,legacy,0,"disconnect"));
|
|
}
|
|
var parsed = AiExtractionPrompt.BuildRowsFromJson("{\"action\":\"CreateBooking\",\"first_name\":\"Guest {test}\"}", new ParsedBooking());
|
|
Check("Migrated parser preserves braces in JSON strings", parsed.Count > 0 && parsed[0].GuestFirstName == "Guest {test}");
|
|
Check("Migrated booking validation rejects multiple rooms", BookingValidation.Stay(new ParsedBooking { Rooms = 2 }) != null);
|
|
Check("Migrated redactor removes OAuth secrets", !SecretRedactor.Redact("{\"refresh_token\":\"secret-value\"}").Contains("secret-value"));
|
|
|
|
var baseUrl = Environment.GetEnvironmentVariable("TEST_API_URL");
|
|
if (baseUrl != null)
|
|
{
|
|
using var one = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) };
|
|
using var two = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) };
|
|
async Task<JsonElement> Read(HttpClient h,string path) => JsonDocument.Parse(await h.GetStringAsync(path)).RootElement.Clone();
|
|
async Task SetCsrf(HttpClient h) { var s=await Read(h,"/api/session");h.DefaultRequestHeaders.Remove("X-CSRF-TOKEN");h.DefaultRequestHeaders.Add("X-CSRF-TOKEN",s.GetProperty("csrfToken").GetString()); }
|
|
Check("Anonymous inbox access blocked", (await one.GetAsync("/api/conversations")).StatusCode == HttpStatusCode.Unauthorized);
|
|
Check("Anonymous readiness returns only status",(await Read(one,"/health/ready")).GetRawText()=="{\"status\":\"ready\"}");
|
|
Check("Unsafe requests require CSRF token", (await one.PostAsJsonAsync("/api/preview/start",new {})).StatusCode == HttpStatusCode.BadRequest);
|
|
await SetCsrf(one); await SetCsrf(two);
|
|
Check("Preview creates signed-in workspace", (await one.PostAsJsonAsync("/api/preview/start",new {})).IsSuccessStatusCode);
|
|
await two.PostAsJsonAsync("/api/preview/start",new {}); await SetCsrf(one); await SetCsrf(two);
|
|
var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel");
|
|
Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString());
|
|
var health=await Read(one,"/api/operations");
|
|
Check("Health overview is hotel scoped and carries hotel timezone",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"&&health.GetProperty("timeZone").GetString()=="Europe/London");
|
|
var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString();
|
|
Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode);
|
|
Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict);
|
|
Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode);
|
|
var boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString();
|
|
Check("Mailbox health includes timezone and recovery state without secrets",boxes.GetProperty("timeZone").GetString()=="Europe/London"&&boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken"));
|
|
foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Cross-hotel reply approval is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Cross-hotel AI generation is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/generate",new {version=1})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Preview cannot send real mail", (await one.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Cross-hotel delivery status is blocked", (await two.GetAsync($"/api/conversations/{id}")).StatusCode==HttpStatusCode.NotFound);
|
|
Check("PMS status exposes no credentials", !(await one.GetStringAsync("/api/pms/status")).Contains("clientSecret"));
|
|
Check("Preview cannot access real PMS lookup", (await one.PostAsJsonAsync("/api/pms/lookup",new{confirmation="CONF123"})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Preview cannot enable PMS updates", (await one.PutAsJsonAsync("/api/pms/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Unknown PMS snapshot cannot be proposed", (await two.PostAsJsonAsync("/api/pms/changes",new{snapshotId="foreign",kind="AddNote",arrival="",departure="",note="test"})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Unknown PMS operation cannot be applied", (await two.PostAsJsonAsync("/api/pms/changes/foreign/apply",new{version=0,availabilityAndPriceChecked=true})).StatusCode==HttpStatusCode.NotFound);
|
|
var paymentStatus=await Read(one,"/api/payments/status");
|
|
Check("Preview payment status is disabled and contains no secret", !paymentStatus.GetProperty("configured").GetBoolean()&&!paymentStatus.GetRawText().Contains("securityKey"));
|
|
Check("Preview cannot enable invoice creation",(await one.PutAsJsonAsync("/api/payments/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Preview cannot prepare a real payment",(await one.PostAsJsonAsync("/api/payments/requests",new{reference="TEST",email="guest@example.invalid",description="Deposit",amount=80,currency="GBP"})).StatusCode==HttpStatusCode.BadRequest);
|
|
var payments=await Read(one,"/api/payments/requests");var paymentId=payments[0].GetProperty("id").GetString();
|
|
Check("Foreign payment cannot be approved",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Foreign payment cannot be reconciled",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/check",new{version=0})).StatusCode==HttpStatusCode.NotFound);
|
|
Check("Preview sample invoice cannot be created",(await one.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.BadRequest);
|
|
var autoStatus=await Read(one,"/api/auto-replies/status");
|
|
Check("Preview cannot enable FAQ live sending",!autoStatus.GetProperty("liveConfigured").GetBoolean()&&(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Live",version=0,acceptanceConfirmed=true})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("FAQ test mode can be saved without sending",(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Test",version=0,acceptanceConfirmed=false})).IsSuccessStatusCode);
|
|
var ownKnowledge=await Read(one,"/api/knowledge");var answerId=ownKnowledge[0].GetProperty("id").GetString();
|
|
Check("FAQ rule rejects another hotel's knowledge",(await two.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).StatusCode==HttpStatusCode.BadRequest);
|
|
Check("Owner can save a reviewed FAQ rule",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).IsSuccessStatusCode);
|
|
var autoTest=await one.PostAsJsonAsync("/api/auto-replies/test",new{subject="Parking",body="Is parking available?"});
|
|
Check("FAQ content tester returns a match without a delivery",autoTest.IsSuccessStatusCode&&JsonDocument.Parse(await autoTest.Content.ReadAsStringAsync()).RootElement.GetProperty("matches").GetBoolean());
|
|
Check("FAQ rule updates reject stale versions",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=false,version=0})).StatusCode==HttpStatusCode.Conflict);
|
|
Check("Uncertain or foreign replies cannot be released",(await two.PostAsJsonAsync($"/api/conversations/{id}/delivery/release",new{version=0})).StatusCode==HttpStatusCode.NotFound);
|
|
await TeamTests.Http(Check,one,two,baseUrl);
|
|
var cookie=(await one.GetAsync("/api/session")).Headers;
|
|
Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true);
|
|
await one.PostAsJsonAsync("/api/auth/logout",new {});
|
|
Check("Logout removes inbox access", (await one.GetAsync("/api/conversations")).StatusCode==HttpStatusCode.Unauthorized);
|
|
}
|
|
Console.WriteLine($"{passed} checks passed. MongoDB integration: {uri!=null}; HTTP integration: {baseUrl!=null}.");
|
|
}
|
|
finally
|
|
{
|
|
// This suite owns only a fresh, randomly named database under a fixed test prefix.
|
|
if (uri != null && dbName.StartsWith("guestops_test_")) await new MongoClient(uri).DropDatabaseAsync(dbName);
|
|
}
|
|
|
|
|
|
|
|
|
|
|