feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
2ae63653c4
commit
f22fdf8b68
31 files changed
+799
-49
No files matched your search
@@ -0,0 +1,149 @@
|
||||
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(); // 连接池还拿着数据库文件
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
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();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user