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

This commit is contained in:
justme committed 2026-10-08 06:47:10 +08:00
1 parent 2ae63653c4
commit f22fdf8b68
31 files changed
+799 -49

No files matched your search

@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
@@ -25,6 +26,9 @@ public class BackendFixture : IAsyncLifetime
public const string AdminUser = "admin";
public const string AdminPassword = "correct horse battery";
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
private readonly List<AgentSimulator> _agents = [];
private string _adminToken = "";
private int _counter;
@@ -256,8 +260,7 @@ public class BackendFixture : IAsyncLifetime
options.SecureCookies = false;
options.WindowGrace = TimeSpan.Zero;
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
options.InitialAdminUser = AdminUser;
options.InitialAdminPassword = AdminPassword;
options.ClaimCode = ClaimCode;
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
@@ -282,8 +285,8 @@ public class BackendFixture : IAsyncLifetime
{
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync(
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
login.EnsureSuccessStatusCode();
"/api/admin/auth/setup", Json(new JsonObject { ["claim_code"] = ClaimCode, ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
Assert.Equal(HttpStatusCode.Created, login.StatusCode);
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
@@ -36,7 +36,7 @@ public sealed class CapabilityTests
}
// 只有登录(密码和 passkey)是匿名的
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
}
[Fact]
@@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options")))
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
{
JsonObject responses = operation["responses"]!.AsObject();
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
@@ -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);
}
}
}
@@ -250,6 +250,20 @@ public sealed class TrustTests
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
}
[Fact]
public async Task TheTrustState_SaysWhichAccountsPasskeyEachOperatorIs()
{
// 添加节点时选属主、信任页都要显示「哪个账户的哪把 passkey」,不只是一串凭证 id
Operator op = await OperatorAsync();
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
JsonNode detail = state["operator_details"]!.AsArray().First(d => d!["credential_id"]!.GetValue<string>() == op.Passkey.Id)!;
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
}
[Fact]
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
{