78 lines
9.4 KiB
C#

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<string,bool> check,IStore store)
{
var hotel=Guid.NewGuid().ToString("N");var email=hotel+"@example.invalid";
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"PublicUrl","https://hotel.example.invalid"}}).Build();
var hasher=new PasswordHasher<StaffUser>();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<StaffUser>(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<StaffUser>(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<StaffUser>(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<StaffUser>(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<StaffUser>(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<StaffUser>(hotel,user.Id))!;reset=await service.ResetStaff(user,user.Version);user=(await store.Get<StaffUser>(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<StaffUser>(hotel,user.Id))!;await service.Disable(user,user.Version);user=(await store.Get<StaffUser>(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<StaffUser>(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<string,string?>{{"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<string,bool> check,HttpClient owner,HttpClient other,string baseUrl)
{
async Task<JsonElement> 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);
check("Operational health is owner only",(await staff.GetAsync("/api/operations")).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);
}
}