using GuestOps.Web; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using System.Net; using System.Net.Http.Json; using System.Text.Json; public static class TeamTests { static string Token(AccountLinkResult link)=>link.Link.Split("#token=")[1]; public static async Task Run(Action check,IStore store) { var hotel=Guid.NewGuid().ToString("N");var email=hotel+"@example.invalid"; var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"PublicUrl","https://hotel.example.invalid"}}).Build(); var hasher=new PasswordHasher();var service=new TeamAccounts(store,hasher,config); var link=await service.Invite(hotel,new("Test Colleague",email));var token=Token(link); var user=(await store.Get(hotel,link.UserId))!; check("Invitation stores hash and creates inactive Staff only",user.Role=="Staff"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64); check("Account link uses configured HTTPS origin and fragment",link.Link.StartsWith("https://hotel.example.invalid/account#token=")&&!link.Link.Contains('?')); check("Token inspection rejects malformed token",await service.Inspect("bad")==null); bool denied=false;try{await service.Invite("foreign",new("Other Colleague",email));}catch(AccountInvalid){denied=true;}check("Invitation cannot claim another hotel's account",denied); var replacement=await service.Invite(hotel,new("Test Colleague",email)); check("Reissued invitation invalidates earlier token",await service.Inspect(token)==null); var password="Test-only-passphrase-2026!"; denied=false;try{await service.Accept(new(Token(replacement),password,"different"));}catch(AccountInvalid){denied=true;}check("Password confirmation mismatch preserves invitation",denied&&await service.Inspect(Token(replacement))!=null); var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(_=>service.Accept(new(Token(replacement),password,password)))); check("Concurrent invitation acceptance succeeds exactly once",attempts.Count(x=>x)==1); user=(await store.Get(hotel,link.UserId))!; check("Accepted invitation activates hashed password and clears link",user.Active&&user.AccountLinkHash==""&&hasher.VerifyHashedPassword(user,user.PasswordHash,password)!=PasswordVerificationResult.Failed); check("Consumed invitation cannot be reused",!await service.Accept(new(Token(replacement),password,password))); var stamp=user.SecurityStamp;var reset=await service.ResetStaff(user,user.Version); user=(await store.Get(hotel,user.Id))!;check("Issuing reset preserves current session",TeamAccounts.SessionValid(user,stamp)); await service.Accept(new(Token(reset),password+"new",password+"new"));user=(await store.Get(hotel,user.Id))!; check("Accepted reset invalidates previous sessions",!TeamAccounts.SessionValid(user,stamp)&&TeamAccounts.SessionValid(user,user.SecurityStamp)); var stale=user.Version;reset=await service.ResetStaff(user,user.Version);user=(await store.Get(hotel,user.Id))!; check("Stale staff disable is rejected",!await service.Disable(user,stale)); check("Owner can revoke an unused recovery link",await service.Revoke(user,user.Version)&&await service.Inspect(Token(reset))==null); user=(await store.Get(hotel,user.Id))!;reset=await service.ResetStaff(user,user.Version);user=(await store.Get(hotel,user.Id))!; user.AccountLinkExpiresAt=DateTime.UtcNow.AddMinutes(-1);var v=user.Version;user.Version++;await store.Replace(hotel,user.Id,v,user); check("Expired recovery link is rejected",await service.Inspect(Token(reset))==null); user=(await store.Get(hotel,user.Id))!;await service.Disable(user,user.Version);user=(await store.Get(hotel,user.Id))!; check("Disabled staff loses sessions",!TeamAccounts.SessionValid(user,user.SecurityStamp)); var restore=await service.Restore(user,user.Version);check("Restoration does not reactivate old password",!(await store.Get(hotel,user.Id))!.Active); check("Restoration requires a new password through single-use link",await service.Accept(new(Token(restore),password,password))); var owner=new StaffUser{HotelId=hotel,Email="owner-"+email,Name="Owner",PasswordHash=hasher.HashPassword(new(),password)};await store.Insert(owner); check("Team controls cannot disable owner",!await service.Disable(owner,owner.Version)); denied=false;try{await service.ResetStaff(owner,owner.Version);}catch(AccountConflict){denied=true;}check("Team controls cannot reset owner",denied); var ownerReset=await service.RecoverOwner(owner);check("Server admin can issue owner recovery",await service.Inspect(Token(ownerReset))!=null); var badConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"PublicUrl","http://hotel.example.invalid"}}).Build(); denied=false;try{await new TeamAccounts(store,hasher,badConfig).Invite(hotel,new("No Account","bad-"+email));}catch(AccountInvalid){denied=true;}check("Untrusted public URL rejects link before inserting account",denied&&await store.FindLogin("bad-"+email)==null); var safe=JsonSerializer.Serialize(TeamAccounts.View(user));check("Team views omit password, token hash and security stamp",!safe.Contains("Hash")&&!safe.Contains("Stamp")); } public static async Task Http(Action check,HttpClient owner,HttpClient other,string baseUrl) { async Task Read(HttpResponseMessage response){response.EnsureSuccessStatusCode();return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();} async Task Csrf(HttpClient c){var s=await Read(await c.GetAsync("/api/session"));c.DefaultRequestHeaders.Remove("X-CSRF-TOKEN");c.DefaultRequestHeaders.Add("X-CSRF-TOKEN",s.GetProperty("csrfToken").GetString());} var email="staff-"+Guid.NewGuid().ToString("N")+"@example.invalid"; var invite=await Read(await owner.PostAsJsonAsync("/api/team/invite",new{name="HTTP Colleague",email}));var id=invite.GetProperty("userId").GetString();var token=invite.GetProperty("link").GetString()!.Split("#token=")[1]; using var staff=new HttpClient(new HttpClientHandler{CookieContainer=new CookieContainer(),AllowAutoRedirect=false}){BaseAddress=new Uri(baseUrl)}; check("Invitation consumption requires CSRF",(await staff.PostAsJsonAsync("/api/account-links/inspect",new{token})).StatusCode==HttpStatusCode.BadRequest);await Csrf(staff); check("Invitation inspection works without signing in",(await staff.PostAsJsonAsync("/api/account-links/inspect",new{token})).IsSuccessStatusCode); var password="HTTP-test-passphrase-2026!";check("Invitation acceptance works",(await staff.PostAsJsonAsync("/api/account-links/accept",new{token,password,confirmPassword=password})).IsSuccessStatusCode); check("Invitation acceptance does not automatically sign in",(await staff.GetAsync("/api/hotel")).StatusCode==HttpStatusCode.Unauthorized); await Csrf(staff);check("Invited colleague can sign in",(await staff.PostAsJsonAsync("/api/auth/login",new{email,password})).IsSuccessStatusCode);await Csrf(staff); check("Staff cannot list or invite team members",(await staff.GetAsync("/api/team")).StatusCode==HttpStatusCode.Forbidden&&(await staff.PostAsJsonAsync("/api/team/invite",new{name="No",email="no@example.invalid"})).StatusCode==HttpStatusCode.Forbidden); check("Staff cannot change hotel settings",(await staff.PutAsJsonAsync("/api/hotel",new{name="No",signature="",timezone="UTC",version=0})).StatusCode==HttpStatusCode.Forbidden); foreach(var action in new[]{"disconnect","reconnect","retry"})check("Staff cannot "+action+" mailboxes",(await staff.PostAsJsonAsync($"/api/mailboxes/unknown/{action}",new{version=0})).StatusCode==HttpStatusCode.Forbidden); check("Foreign hotel cannot reset staff",(await other.PostAsJsonAsync($"/api/team/{id}/reset",new{version=2})).StatusCode==HttpStatusCode.NotFound); var list=await Read(await owner.GetAsync("/api/team"));var member=list.EnumerateArray().Single(x=>x.GetProperty("id").GetString()==id);var version=member.GetProperty("version").GetInt64(); check("HTTP team listing excludes secrets",!list.GetRawText().Contains("passwordHash")&&!list.GetRawText().Contains("securityStamp")&&!list.GetRawText().Contains(token)); var reset=await Read(await owner.PostAsJsonAsync($"/api/team/{id}/reset",new{version}));token=reset.GetProperty("link").GetString()!.Split("#token=")[1]; using var recovery=new HttpClient(new HttpClientHandler{CookieContainer=new CookieContainer()}){BaseAddress=new Uri(baseUrl)};await Csrf(recovery); check("Staff reset succeeds from separate browser",(await recovery.PostAsJsonAsync("/api/account-links/accept",new{token,password=password+"new",confirmPassword=password+"new"})).IsSuccessStatusCode); check("Password reset invalidates existing HTTP session",(await staff.GetAsync("/api/hotel")).StatusCode==HttpStatusCode.Unauthorized); check("Owner onboarding lists saved setup state",(await Read(await owner.GetAsync("/api/onboarding"))).GetProperty("steps").GetArrayLength()==5); } }