Files
MoShiro/backend/tests/MoShiro.Backend.Tests/SetupTests.cs
T

163 lines
7.9 KiB
C#

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;
/// <summary>
/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。
/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。
/// </summary>
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<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件
TryDelete(_directory);
}
/// <summary>临时目录:尽力删除。SQLite 的 WAL 临时文件可能还没释放,留下一个临时目录比让测试失败好。</summary>
private static void TryDelete(string directory)
{
try
{
Directory.Delete(directory, recursive: true);
}
catch (IOException)
{
// 系统临时目录里的残留,会被清理
}
}
private Task<HttpResponseMessage> 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<bool>());
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<bool>()); // 还是没人
}
[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<string>());
Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue<string>())); // 马上登录,接着引导 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<bool>());
// 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,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<IServer>().Features.Get<IServerAddressesFeature>()!.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<bool>());
}
finally
{
await generated.StopAsync();
await generated.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
TryDelete(directory);
}
}
}