78 lines
9.4 KiB
C#
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);
|
|
}
|
|
}
|