89 lines
7.6 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 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 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 == "");
}
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("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 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 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);
}