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
@@ -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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user