agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
1 parent
d5ebf29e57
commit
ad5efc62ff
206 files changed
+4202
-4964
No files matched your search
@@ -15,14 +15,14 @@ public sealed class KnockTests
|
||||
[Fact]
|
||||
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity other = NodeIdentity.Generate();
|
||||
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
|
||||
Assert.Equal(fromProbe, fromAgent);
|
||||
Assert.Equal(32, fromProbe.Length);
|
||||
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
|
||||
byte[] fromDialer = Knock.DeriveKey(dialer, agent.PublicKeySpki);
|
||||
byte[] fromAgent = Knock.DeriveKey(agent, dialer.PublicKeySpki);
|
||||
Assert.Equal(fromDialer, fromAgent);
|
||||
Assert.Equal(32, fromDialer.Length);
|
||||
Assert.NotEqual(fromDialer, Knock.DeriveKey(other, agent.PublicKeySpki));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,36 +61,36 @@ public sealed class KnockTests
|
||||
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity stranger = NodeIdentity.Generate();
|
||||
var roster = new List<byte[]> { probe.PublicKeySpki };
|
||||
var roster = new List<byte[]> { dialer.PublicKeySpki };
|
||||
var gate = new KnockGate(agent, () => roster, new Clock());
|
||||
long now = 1_700_000_000_000;
|
||||
|
||||
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
|
||||
byte[] packet = Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now);
|
||||
Assert.True(gate.Accept(packet, now));
|
||||
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
|
||||
|
||||
// 名册变了:新成员马上能进,被移除的马上进不来
|
||||
using NodeIdentity second = NodeIdentity.Generate();
|
||||
roster.Add(second.PublicKeySpki);
|
||||
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
|
||||
roster.RemoveAt(0);
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
|
||||
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(dialer, agent.PublicKeySpki), now), now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
|
||||
{
|
||||
using NodeIdentity agent = NodeIdentity.Generate();
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
var clock = new Clock();
|
||||
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
|
||||
var gate = new KnockGate(agent, () => [dialer.PublicKeySpki], clock, attemptsPerPeriod: 3);
|
||||
IPAddress noisy = IPAddress.Parse("203.0.113.9");
|
||||
IPAddress quiet = IPAddress.Parse("203.0.113.10");
|
||||
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
|
||||
byte[] key = Knock.DeriveKey(dialer, agent.PublicKeySpki);
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class LinkConnectTimeoutTests
|
||||
{
|
||||
Server = new Uri($"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"),
|
||||
Identity = NodeIdentity.Generate(),
|
||||
NodeId = new NodeId("prb_test"),
|
||||
NodeId = new NodeId("dlr_test"),
|
||||
ConnectTimeout = TimeSpan.FromMilliseconds(500),
|
||||
Backoff = new Backoff(TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(50)),
|
||||
};
|
||||
@@ -79,7 +79,15 @@ public sealed class LinkConnectTimeoutTests
|
||||
finally
|
||||
{
|
||||
await stop.CancelAsync();
|
||||
await running;
|
||||
try
|
||||
{
|
||||
await running;
|
||||
}
|
||||
catch (OperationCanceledException) when (stop.IsCancellationRequested)
|
||||
{
|
||||
// Canceling the retry delay ends RunAsync with the requested cancellation.
|
||||
}
|
||||
|
||||
await acceptLoop.CancelAsync();
|
||||
await accepting;
|
||||
foreach (TcpClient client in accepted)
|
||||
|
||||
@@ -328,10 +328,10 @@ public sealed class LinkTests
|
||||
|
||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
||||
|
||||
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
|
||||
/// <summary>Agent 的 TLS 证书公钥:拨号方 用它固定证书、派生敲门密钥。</summary>
|
||||
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
|
||||
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
|
||||
/// <summary>名册:能敲开门的 拨号方 公钥。</summary>
|
||||
public List<byte[]> KnockPeers { get; } = [];
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -352,25 +352,25 @@ public sealed class LinkTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_AuthenticatesTheProbe_AndBothSidesDeriveTheSameDataPlaneCredentials()
|
||||
public async Task PeerHandshake_AuthenticatesTheDialer_AndBothSidesDeriveTheSameDataPlaneCredentials()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
var seen = new TaskCompletionSource<PeerSessionInfo>();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null, (info, transport, ct) =>
|
||||
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null, (info, transport, ct) =>
|
||||
{
|
||||
seen.TrySetResult(info);
|
||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
var scoped = new ContextNodeLog(logs, ("attempt_id", "test-attempt"));
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, scoped, default);
|
||||
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, scoped, default);
|
||||
await using (transport)
|
||||
{
|
||||
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("prb_1", server.PeerId);
|
||||
Assert.Equal("dlr_1", server.PeerId);
|
||||
Assert.Equal(client.Credentials.Key, server.Credentials.Key);
|
||||
Assert.Equal(client.Credentials.SessionId, server.Credentials.SessionId);
|
||||
NodeLogBatch batch = logs.TakeBatch(32);
|
||||
@@ -380,62 +380,62 @@ public sealed class LinkTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_RejectsUnknownProbes_AndForgedSignatures_WithTheSameError()
|
||||
public async Task PeerHandshake_RejectsUnknownDialers_AndForgedSignatures_WithTheSameError()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
|
||||
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
|
||||
await using var agent = new PeerFixture(id => id == "dlr_1" ? dialer.PublicKeySpki : null);
|
||||
agent.KnockPeers.AddRange([dialer.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
var logs = new Fleet.Hosting.BufferedNodeLog(NullNodeLog.Instance, TimeProvider.System);
|
||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, logs, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_unknown", null, 1 << 20, TimeProvider.System, logs, default));
|
||||
NodeLogRecord failure = Assert.Single(logs.TakeBatch(32).Records, entry => entry.Message == "peer control handshake failed");
|
||||
Assert.Equal("peer_auth", failure.Fields["phase"]);
|
||||
Assert.Equal("InvalidOperationException", failure.Fields["exception"]);
|
||||
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "dlr_1", null, 1 << 20, TimeProvider.System, default));
|
||||
Assert.Equal(unknown.Message, forged.Message);
|
||||
Assert.Contains("fleet_unauthorized", forged.Message);
|
||||
|
||||
// 正主仍然可以连接
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerHandshake_PinsTheAgentCertificate_ByItsSpkiFingerprint()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
|
||||
using NodeIdentity another = NodeIdentity.Generate();
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
|
||||
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
|
||||
await using var agent = new PeerFixture(
|
||||
_ => probe.PublicKeySpki,
|
||||
_ => dialer.PublicKeySpki,
|
||||
async (info, transport, ct) =>
|
||||
{
|
||||
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||
await session.RunAsync(ct);
|
||||
});
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
|
||||
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "dialer", TimeProvider.System);
|
||||
Task run = client.RunAsync(default);
|
||||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
|
||||
Assert.Equal(42, result.Value.Value);
|
||||
@@ -446,9 +446,9 @@ public sealed class LinkTests
|
||||
[Fact]
|
||||
public async Task PeerListener_LimitsConcurrentSessions_AndSurvivesGarbageConnections()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 不说 TLS 的连接:握手失败,监听继续
|
||||
@@ -458,16 +458,16 @@ public sealed class LinkTests
|
||||
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
|
||||
}
|
||||
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
|
||||
{
|
||||
using NodeIdentity probe = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||
using NodeIdentity dialer = NodeIdentity.Generate();
|
||||
await using var agent = new PeerFixture(_ => dialer.PublicKeySpki);
|
||||
agent.KnockPeers.Add(dialer.PublicKeySpki);
|
||||
await agent.Listener.Started.Task;
|
||||
|
||||
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
|
||||
@@ -495,8 +495,8 @@ public sealed class LinkTests
|
||||
Assert.Equal(0, read);
|
||||
}
|
||||
|
||||
// 名册里的 Probe 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
|
||||
// 名册里的 拨号方 仍然进得来
|
||||
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, dialer, "dlr_1", null, 1 << 20, TimeProvider.System, default);
|
||||
await ok.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@ public sealed class MethodDirectionTests
|
||||
// t.ping 声明成请求:不能按通知注册
|
||||
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
|
||||
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
|
||||
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
|
||||
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
// 节点这一侧:t.note 只给 agent 角色,dialer 注册不了;t.push 是它该处理的
|
||||
var dialer = new RpcRouter<string>(RpcSide.Node, s_table, "dialer");
|
||||
dialer.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
|
||||
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "dialer").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
|
||||
|
||||
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
|
||||
}
|
||||
@@ -99,7 +99,7 @@ public sealed class MethodDirectionTests
|
||||
{
|
||||
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
|
||||
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync("dialer", Notification("t.note"), default)).Violation);
|
||||
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class MethodDirectionTests
|
||||
// 对端的角色也要允许
|
||||
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
|
||||
node.CheckSend("t.note", MethodKind.Notification, "agent");
|
||||
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
|
||||
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "dialer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,7 +126,7 @@ public sealed class MethodDirectionTests
|
||||
|
||||
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
|
||||
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
|
||||
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "dialer", []));
|
||||
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
|
||||
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
|
||||
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
|
||||
|
||||
@@ -4,7 +4,7 @@ using Fleet.Identity;
|
||||
|
||||
namespace Fleet.Tests;
|
||||
|
||||
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
|
||||
/// <summary>节点自己要求有属主(passkey):拨号方和监听方共用这一份,没有属主就不运行。</summary>
|
||||
public sealed class OwnerGuardTests : IDisposable
|
||||
{
|
||||
private readonly TestAuthenticator _alice = new();
|
||||
|
||||
Reference in new issue
Block a user