163 lines
7.9 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|