using System.Net; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using MoShiro.Backend.Application; namespace MoShiro.Backend.Tests; /// /// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。 /// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。 /// public sealed class SetupTests : IAsyncLifetime { private const string Code = "ABCD-EFGH-JKLM-NPQR"; private WebApplication _app = null!; private HttpClient _http = null!; private string _directory = ""; public async ValueTask InitializeAsync() { _directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N")); _app = BackendHost.Build( ["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"], options => { options.DataDirectory = _directory; options.Pbkdf2Iterations = 1000; options.SecureCookies = false; options.ClaimCode = Code; options.LoginAttemptsPerMinute = 1000; options.GeoLookupUrl = string.Empty; }); await _app.StartAsync(); _http = new HttpClient { BaseAddress = new Uri(_app.Services.GetRequiredService().Features.Get()!.Addresses.First()) }; } public async ValueTask DisposeAsync() { _http.Dispose(); await _app.StopAsync(); await _app.DisposeAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件 TryDelete(_directory); } /// 临时目录:尽力删除。SQLite 的 WAL 临时文件可能还没释放,留下一个临时目录比让测试失败好。 private static void TryDelete(string directory) { try { Directory.Delete(directory, recursive: true); } catch (IOException) { // 系统临时目录里的残留,会被清理 } } private Task SetupAsync(string? code, string username, string password, bool returnSession = false) => _http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = code, ["username"] = username, ["password"] = password, ["return_session"] = returnSession })); [Fact] public async Task ABrandNewPanel_HasNoUsers_AndNoDefaultAccountToSignInWith() { JsonNode status = JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!; Assert.True(status["needs_setup"]!.GetValue()); foreach (string username in new[] { "admin", "administrator", "root" }) { using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = username, ["password"] = "admin" })); Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode); } } [Fact] public async Task TheClaimCode_IsRequired_SoTheFirstVisitorCannotTakeThePanel() { using HttpResponseMessage none = await SetupAsync(null, "owner", "correct horse battery"); using HttpResponseMessage wrong = await SetupAsync("AAAA-BBBB-CCCC-DDDD", "owner", "correct horse battery"); using HttpResponseMessage empty = await SetupAsync("", "owner", "correct horse battery"); Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, empty.StatusCode); Assert.True(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue()); // 还是没人 } [Fact] public async Task TheCredentialsAreValidated_AndAMistakeDoesNotBurnTheCode() { using HttpResponseMessage shortPassword = await SetupAsync(Code, "owner", "short"); using HttpResponseMessage badName = await SetupAsync(Code, "x", "correct horse battery"); Assert.Equal(HttpStatusCode.BadRequest, shortPassword.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, badName.StatusCode); // 填错了还能再来 using HttpResponseMessage ok = await SetupAsync(Code, "owner", "correct horse battery"); Assert.Equal(HttpStatusCode.Created, ok.StatusCode); } [Fact] public async Task TheFirstAdministrator_IsCreatedByTheDeployer_SignedInRightAway_AndTheDoorCloses() { // 认领码不分大小写、不看分隔符 using HttpResponseMessage created = await SetupAsync("abcd efgh-jklm npqr", "owner", "correct horse battery", returnSession: true); Assert.Equal(HttpStatusCode.Created, created.StatusCode); JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!; Assert.Equal("owner", body["username"]!.GetValue()); Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue())); // 马上登录,接着引导 passkey 和信任根 // 入口关了:再用同一个码、别的用户名也不行,状态也变了 using HttpResponseMessage again = await SetupAsync(Code, "intruder", "correct horse battery"); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); Assert.False(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue()); // 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,passkey 之后再加) using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "owner", ["password"] = "correct horse battery" })); Assert.Equal(HttpStatusCode.OK, login.StatusCode); } [Fact] public async Task WithoutAConfiguredCode_TheBackendGeneratesOne_AndNothingElseUnlocksTheSetup() { // 另起一个没有指定认领码的后端:随机生成并写进日志,猜不到;任何别的码都进不去 string directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N")); await using WebApplication generated = BackendHost.Build( ["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"], options => { options.DataDirectory = directory; options.Pbkdf2Iterations = 1000; options.SecureCookies = false; options.LoginAttemptsPerMinute = 1000; options.GeoLookupUrl = string.Empty; }); await generated.StartAsync(); try { using var http = new HttpClient { BaseAddress = new Uri(generated.Services.GetRequiredService().Features.Get()!.Addresses.First()) }; foreach (string guess in new[] { Code, "0000-0000-0000-0000", "admin", "TEST-CLAIM-CODE-0001" }) { using HttpResponseMessage response = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = guess, ["username"] = "owner", ["password"] = "correct horse battery" })); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } Assert.True(JsonNode.Parse(await http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue()); } finally { await generated.StopAsync(); await generated.DisposeAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); TryDelete(directory); } } }