feat(server): implement sealed share lifecycle

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:56 +08:00
1 parent e02e024605
commit 42b93ae2d8
45 files changed
+6327

No files matched your search

@@ -0,0 +1,654 @@
using System.Buffers.Binary;
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public class ChannelServiceTests : IDisposable
{
private readonly TestEnv _env = new();
private ChannelService Svc => _env.Service;
private readonly byte[] _ownerEp = TestEnv.Endpoint();
private readonly byte[] _memberEp = TestEnv.Endpoint();
public void Dispose() => _env.Dispose();
// ───── 辅助 ─────
private static async Task AssertCode(ErrorCode code, Func<Task> act) =>
Assert.Equal(code, (await Assert.ThrowsAsync<WebDropException>(act)).Code);
private static void AssertCode(ErrorCode code, Action act) =>
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) =>
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label);
private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) =>
Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill));
private static (long Seq, LogKind Kind) Head(LogRecord r) =>
(BinaryPrimitives.ReadInt64BigEndian(r.Bytes), (LogKind)r.Bytes[8]);
private async Task<List<(long Seq, LogKind Kind)>> LogOf(TestChannel ch, Credentials who, long from = 0) =>
(await Svc.ReadLogAsync(ch.Id, who, from, 1000)).Records.Select(Head).ToList();
// ───── 创建 ─────
[Fact]
public void CreateRejectsDuplicateId()
{
var ch = _env.Create(_ownerEp);
var dup = new CreateChannelRequest
{
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp));
}
[Fact]
public void CreateEnforcesModeSpecificHashes()
{
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() };
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp));
}
[Fact]
public void CreateSeatsTheOwnerAndReportsDefaults()
{
var ch = _env.Create(_ownerEp);
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
Assert.Equal(1, info.SeatsUsed);
Assert.Equal(2, info.Settings.Seats);
Assert.Equal(1, info.OldestSeq);
Assert.Equal(0, info.LastSeq);
}
// ───── 鉴权与席位 ─────
[Fact]
public void WrongTokenIsUnauthorized()
{
var ch = _env.Create(_ownerEp);
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp)));
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp)));
// 用读令牌冒充所有者
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp)));
}
[Fact]
public void UnknownOrExpiredChannelIsNotFound()
{
var ch = _env.Create(_ownerEp);
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(TestEnv.NewId(), ch.Owner(_ownerEp)));
_env.Clock.Advance(TimeSpan.FromHours(25));
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
}
[Fact]
public void JoinIsIdempotentAndEnforcesSeatLimit()
{
var ch = _env.Create(_ownerEp);
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
Assert.Equal(a.EndpointHash, again.EndpointHash);
Assert.Equal(2, again.SeatsUsed);
Assert.Equal(2, again.SeatsMax);
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
}
[Fact]
public async Task DataOperationsRequireASeat()
{
var ch = _env.Create(_ownerEp);
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
}
[Fact]
public async Task ReleasedSeatLosesAccess()
{
var ch = _env.Create(_ownerEp);
var joined = Svc.Join(ch.Id, ch.Member(_memberEp));
await AppendSmall(ch, ch.Member(_memberEp));
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash));
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
}
private static byte[] Codec_Decode(string b64) =>
Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException();
[Fact]
public void OnlyTheOwnerManagesSeatsAndSettings()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Member(_memberEp));
AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 }));
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp));
var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp));
Assert.Equal(2, seats.Count);
}
[Fact]
public void SeatLabelIsStoredAndBounded()
{
var ch = _env.Create(_ownerEp);
Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]);
var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single();
Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label);
AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257]));
AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), []));
}
[Fact]
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
{
var ch = _env.Create(_ownerEp);
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
_env.Clock.Advance(TimeSpan.FromMinutes(1));
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 });
Assert.Equal(3, after.Settings.Seats);
Assert.Equal(3600, after.Settings.TtlSeconds);
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
Assert.True(after.ExpiresAt < before.ExpiresAt);
Assert.Equal(1, _env.Events.Settings);
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 }));
}
// ───── 追加:幂等、形状、权限 ─────
[Fact]
public async Task AppendIsIdempotentAndDetectsConflicts()
{
var ch = _env.Create(_ownerEp);
var item = TestEnv.NewId();
var first = await AppendSmall(ch, ch.Owner(_ownerEp), item);
var same = await AppendSmall(ch, ch.Owner(_ownerEp), item);
Assert.True(first.Created);
Assert.False(same.Created);
Assert.Equal(first.Seq, same.Seq);
await AssertCode(ErrorCode.Conflict, () => AppendSmall(ch, ch.Owner(_ownerEp), item, fill: 0x43));
Assert.Single(await LogOf(ch, ch.Owner(_ownerEp)));
}
[Fact]
public async Task AppendRejectsMalformedShapes()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
// 非 GATED 条目必须是单帧且 FINAL
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, TestEnv.SmallEnc, TestEnv.EncPayload)));
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Enc, TestEnv.EncPayload)));
// 快照不能是 GATED
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
// enc 负载必须是 档位+16
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, TestEnv.SmallEnc, 1000)));
// 不是合法封装
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, new byte[10]));
// 超过负载上限
await AssertCode(ErrorCode.TooLarge, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final | FrameFlags.Enc, _env.Limits.MaxPayloadBytes + 1)));
}
[Fact]
public async Task EncFlagMustMatchChannelMode()
{
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
await AssertCode(ErrorCode.ModeMismatch, () => Append(enc, enc.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100)));
await AssertCode(ErrorCode.ModeMismatch, () => Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload)));
// raw 模式负载任意长度
var ok = await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100));
Assert.True(ok.Created);
}
[Fact]
public async Task WritePermissionsAreEnforced()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Reader(_memberEp));
// 读令牌不能写
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Reader(_memberEp)));
// 写令牌可以写
Assert.True((await AppendSmall(ch, ch.Member(_memberEp))).Created);
// 关闭成员上传后,写令牌失效,所有者仍可写
Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MembersCanUpload = false });
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Member(_memberEp)));
Assert.True((await AppendSmall(ch, ch.Owner(_ownerEp))).Created);
}
[Fact]
public async Task ChannelWithoutWriteHashRejectsWriteRole()
{
var ch = _env.Create(_ownerEp, withWriteHash: false);
Svc.Join(ch.Id, ch.Reader(_memberEp));
await AssertCode(ErrorCode.Unauthorized, () => AppendSmall(ch, ch.Member(_memberEp)));
}
[Fact]
public async Task RawChannelIsReadableByIdAloneButEncIsNot()
{
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10));
var anonymous = new Credentials(Role.Read, null, _memberEp);
Svc.Join(raw.Id, anonymous);
Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records);
AssertCode(ErrorCode.Unauthorized, () => Svc.Join(enc.Id, anonymous));
}
// ───── 日志 ─────
[Fact]
public async Task LogReturnsRecordsInOrderAndSupportsResume()
{
var ch = _env.Create(_ownerEp);
var frames = new List<byte[]>();
for (int i = 0; i < 3; i++)
{
var f = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload, (byte)(i + 1));
frames.Add(f);
await Append(ch, ch.Owner(_ownerEp), f);
}
var page = await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 100);
Assert.Equal(3, page.LastSeq);
Assert.Equal([1L, 2L, 3L], page.Records.Select(r => Head(r).Seq));
// 线格式 = seq(8) ‖ kind(1) ‖ 原封装
for (int i = 0; i < 3; i++)
{
Assert.Equal(LogKind.Frame, Head(page.Records[i]).Kind);
Assert.Equal(frames[i], page.Records[i].Bytes[9..]);
}
Assert.Equal(2, (await LogOf(ch, ch.Owner(_ownerEp), from: 2)).Count);
Assert.Empty(await LogOf(ch, ch.Owner(_ownerEp), from: 4));
Assert.Single((await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 1)).Records); // limit
}
[Fact]
public async Task ResumingBeforeCompactionWatermarkIsGone()
{
_env.Limits.TombstoneRetentionSeconds = 1;
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var a = TestEnv.NewId();
await AppendSmall(ch, o, a); // seq 1
Svc.DeleteItem(ch.Id, o, a); // 墓碑 seq 2
_env.Clock.Advance(TimeSpan.FromSeconds(5));
await AppendSmall(ch, o); // seq 3,顺带清理过期墓碑
var info = Svc.GetChannel(ch.Id, o);
Assert.Equal(3, info.OldestSeq);
var ex = await Assert.ThrowsAsync<WebDropException>(() => Svc.ReadLogAsync(ch.Id, o, 2, 10));
Assert.Equal(ErrorCode.Gone, ex.Code);
Assert.Equal(3, ex.OldestSeq);
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 3, 10)).Records); // 水位之后可续传
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 0, 10)).Records); // 全量同步永远允许
}
// ───── GATED 条目与下载 ─────
private async Task<Id16> UploadGated(TestChannel ch, Credentials who, int frames, long? maxDownloads = null)
{
var item = TestEnv.NewId();
for (uint i = 0; i < frames; i++)
{
var flags = FrameFlags.Gated | FrameFlags.Enc | (i == frames - 1 ? FrameFlags.Final : 0);
await Append(ch, who, TestEnv.Frame(item, i, flags, TestEnv.EncPayload, (byte)(i + 1)), maxDownloads);
}
return item;
}
[Fact]
public async Task GatedItemIsAnnouncedNotPushedAndFetchedInOrder()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var item = await UploadGated(ch, o, 3);
// 日志里只有一条 announce
var log = await LogOf(ch, o);
Assert.Equal([LogKind.Announce], log.Select(l => l.Kind));
Svc.StartItemDownload(ch.Id, o, item, 0);
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
Assert.Equal(3, chunk.Frames.Count);
Assert.True(chunk.Complete);
Assert.Equal(3u, chunk.NextIndex);
for (int i = 0; i < 3; i++) Assert.Equal((byte)(i + 1), chunk.Frames[i][^1]);
var tail = await Svc.ReadItemFramesAsync(ch.Id, o, item, 2, 10);
Assert.Single(tail.Frames);
}
[Fact]
public async Task IncompleteGatedItemStopsAtTheGapAndIsNotComplete()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
Assert.Single(chunk.Frames);
Assert.Equal(1u, chunk.NextIndex);
Assert.False(chunk.Complete);
// FINAL 不能早于已存在的更大序号
await AssertCode(ErrorCode.BadRequest, () =>
Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
}
[Fact]
public async Task GatedFrameFlagsMustStayConsistentWithinAnItem()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
// 后续帧改变 GATED 属性会被 ValidateShape 拒绝(非 GATED 必须是单帧 FINAL)
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
// 帧序号超出已声明的 FINAL
await Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload));
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
}
[Fact]
public async Task DownloadLimitIsEnforcedWithAGraceWindowForResume()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { FileMaxDownloads = 2 });
var o = ch.Owner(_ownerEp);
Svc.Join(ch.Id, ch.Reader(_memberEp));
var item = await UploadGated(ch, o, 2);
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0);
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0); // 第 2 次达到上限,进入宽限期
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0));
// 宽限期内可以续传已开始的下载
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1);
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
Assert.Equal(1, Svc.Sweep());
var tombstone = (await Svc.ReadLogAsync(ch.Id, o, 0, 100)).Records.Single(r => Head(r).Kind == LogKind.Tombstone);
Assert.Equal((byte)TombstoneReason.DownloadLimit, tombstone.Bytes[^1]);
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
}
[Fact]
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var unlimited = await UploadGated(ch, o, 1, maxDownloads: 0);
for (int i = 0; i < 25; i++) Svc.StartItemDownload(ch.Id, o, unlimited, 0);
var one = await UploadGated(ch, o, 1, maxDownloads: 1);
Svc.StartItemDownload(ch.Id, o, one, 0);
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, one, 0));
}
// ───── 删除、快照、淘汰、过期 ─────
[Theory]
[InlineData(DeletePolicy.Own, true, true)]
[InlineData(DeletePolicy.Own, false, false)]
[InlineData(DeletePolicy.OwnerOnly, true, false)]
[InlineData(DeletePolicy.Anyone, false, true)]
public async Task MemberDeletePermissionFollowsPolicy(DeletePolicy policy, bool ownItem, bool allowed)
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = policy });
Svc.Join(ch.Id, ch.Member(_memberEp));
var item = TestEnv.NewId();
await AppendSmall(ch, ownItem ? ch.Member(_memberEp) : ch.Owner(_ownerEp), item);
if (allowed) Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item);
else AssertCode(ErrorCode.Forbidden, () => Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item));
}
[Fact]
public async Task OwnerCanAlwaysDeleteAndDeletionLeavesATombstone()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = DeletePolicy.OwnerOnly });
Svc.Join(ch.Id, ch.Member(_memberEp));
var item = TestEnv.NewId();
await AppendSmall(ch, ch.Member(_memberEp), item);
Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item);
var log = await LogOf(ch, ch.Owner(_ownerEp));
Assert.Equal([LogKind.Tombstone], log.Select(l => l.Kind)); // 帧记录已随条目删除
AssertCode(ErrorCode.NotFound, () => Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item));
}
[Fact]
public async Task NewSnapshotSupersedesTheOldOne()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 1
var second = await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 2,墓碑 seq 3
await AppendSmall(ch, o); // 普通条目不受影响,seq 4
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
Assert.Equal([(2L, LogKind.Frame), (3L, LogKind.Tombstone), (4L, LogKind.Frame)], page.Records.Select(Head));
Assert.Equal((byte)TombstoneReason.Superseded, page.Records[1].Bytes[^1]);
Assert.Equal(2, second.Seq);
}
[Fact]
public async Task ExceedingMaxItemsEvictsTheOldest()
{
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxItems = 3 });
var o = ch.Owner(_ownerEp);
var ids = new List<Id16>();
for (int i = 0; i < 5; i++)
{
ids.Add(TestEnv.NewId());
await AppendSmall(ch, o, ids[i]);
_env.Clock.Advance(TimeSpan.FromMilliseconds(10));
}
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
var frames = page.Records.Where(r => Head(r).Kind == LogKind.Frame).ToList();
var tombstones = page.Records.Where(r => Head(r).Kind == LogKind.Tombstone).ToList();
Assert.Equal(3, frames.Count);
Assert.Equal(2, tombstones.Count);
Assert.All(tombstones, t => Assert.Equal((byte)TombstoneReason.Evicted, t.Bytes[^1]));
// 保留的是最新的三个
Assert.Equal(ids.Skip(2).Select(i => i.ToArray()), frames.Select(f => f.Bytes[(9 + 2)..(9 + 2 + 16)]));
}
[Fact]
public async Task SweepRemovesExpiredItemsButKeepsLiveOnes()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var old = TestEnv.NewId();
await AppendSmall(ch, o, old);
_env.Clock.Advance(TimeSpan.FromHours(12));
await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
Assert.Equal(1, Svc.Sweep());
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
Assert.Equal([LogKind.Frame, LogKind.Tombstone], log.Records.Select(r => Head(r).Kind));
Assert.Equal((byte)TombstoneReason.Expired, log.Records[1].Bytes[^1]);
}
[Fact]
public async Task SweepDeletesExpiredChannelsAndTheirBlobs()
{
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100_000)); // 走 blob
Assert.True(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
_env.Clock.Advance(TimeSpan.FromHours(25));
Svc.Sweep();
Assert.False(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
Assert.Equal(1, _env.Events.Deleted);
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
}
[Fact]
public async Task DeleteChannelRemovesEverything()
{
var ch = _env.Create(_ownerEp);
await AppendSmall(ch, ch.Owner(_ownerEp));
Svc.DeleteChannel(ch.Id, ch.Owner(_ownerEp));
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
Assert.Equal(1, _env.Events.Deleted);
}
// ───── blob 路径 ─────
[Fact]
public async Task LargeFramesGoToBlobStoreAndRoundTrip()
{
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x7A);
await Append(ch, o, frame);
string itemDir = Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex());
Assert.Single(Directory.GetFiles(itemDir));
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
Assert.Equal(frame, page.Records.Single().Bytes[9..]);
// 幂等重发不会重复落盘
await Append(ch, o, frame);
Assert.Single(Directory.GetFiles(itemDir));
Svc.DeleteItem(ch.Id, o, item);
Assert.False(Directory.Exists(itemDir));
}
[Fact]
public async Task ConflictingLargeFrameLeavesNoOrphanBlob()
{
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x01));
await AssertCode(ErrorCode.Conflict, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x02)));
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex())));
}
[Fact]
public async Task OrphanBlobSweepRemovesOnlyUnreferencedOldFiles()
{
// 孤立判断基于真实文件时间,所以时钟要先对齐到真实时间,再建频道
_env.Clock.NowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
var item = TestEnv.NewId();
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(item, 0, FrameFlags.Final, 100_000));
await _env.Blobs.PutAsync("aa/bb/0.dead", new byte[10]);
await _env.Blobs.PutAsync("aa/bb/1.fresh", new byte[10]);
string stale = Path.Combine(_env.Dir, "blobs", "aa", "bb", "0.dead");
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-1));
Svc.Sweep(includeOrphanBlobs: true);
Assert.False(File.Exists(stale)); // 孤立且够旧:删除
Assert.True(File.Exists(Path.Combine(_env.Dir, "blobs", "aa", "bb", "1.fresh"))); // 孤立但太新:保留
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()))); // 被引用:保留
}
// ───── 并发 ─────
[Fact]
public async Task ParallelAppendsGetUniqueContiguousSeqs()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var results = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => Task.Run(() => AppendSmall(ch, o))));
Assert.All(results, r => Assert.True(r.Created));
Assert.Equal(Enumerable.Range(1, 60).Select(i => (long)i), results.Select(r => r.Seq).Order());
Assert.Equal(60, (await LogOf(ch, o)).Count);
Assert.Equal(60, Svc.GetChannel(ch.Id, o).LastSeq);
}
[Fact]
public async Task ParallelIdenticalAppendsCreateExactlyOneFrame()
{
var ch = _env.Create(_ownerEp);
var o = ch.Owner(_ownerEp);
var frame = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload);
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => Append(ch, o, frame))));
Assert.Equal(1, results.Count(r => r.Created));
Assert.Single(results.Select(r => r.Seq).Distinct());
Assert.Single(await LogOf(ch, o));
}
[Fact]
public async Task ParallelIdenticalLargeAppendsKeepTheBlob()
{
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
var o = ch.Owner(_ownerEp);
var item = TestEnv.NewId();
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000);
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => Append(ch, o, frame))));
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
Assert.Equal(frame, page.Records.Single().Bytes[9..]); // blob 仍在且内容正确
}
[Fact]
public async Task AppendPublishesTheNewSeq()
{
var ch = _env.Create(_ownerEp);
var r = await AppendSmall(ch, ch.Owner(_ownerEp));
Assert.Contains(_env.Events.Advances, e => e.Channel == ch.Id && e.Seq == r.Seq);
}
}
@@ -0,0 +1,167 @@
using System.Buffers.Binary;
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public class Id16Tests
{
[Fact]
public void RoundTrips()
{
var id = TestEnv.NewId();
Assert.True(Id16.TryParse(id.ToBase64Url(), out var parsed));
Assert.Equal(id, parsed);
Assert.Equal(22, id.ToBase64Url().Length);
Assert.Equal(32, id.ToHex().Length);
}
[Theory]
[InlineData("")]
[InlineData("short")]
[InlineData("AAAAAAAAAAAAAAAAAAAAAAA")] // 23 字符
[InlineData("AAAAAAAAAAAAAAAAAAAA+/")] // 非 url 字母表
public void RejectsMalformed(string text) => Assert.False(Id16.TryParse(text, out _));
[Fact]
public void RejectsNonCanonicalEncoding()
{
// 末字符的低 2 位在 16 字节编码中必须为 0;AAAAAAAAAAAAAAAAAAAAAB 解码后与 ...AA 同值
Assert.True(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAA", out var zero));
Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _));
Assert.Equal(new byte[16], zero.ToArray());
}
}
public class EnvelopeTests
{
[Fact]
public void ParsesValidHeader()
{
var item = TestEnv.NewId();
var bytes = TestEnv.Frame(item, 7, FrameFlags.Final | FrameFlags.Gated | FrameFlags.Enc, 1040);
Assert.True(Envelope.TryParse(bytes, out var env));
Assert.Equal(item, env.ItemId);
Assert.Equal(7u, env.FrameIndex);
Assert.Equal(1040, env.PayloadLength);
Assert.True(env.IsFinal && env.IsGated && env.IsEnc && !env.IsSnapshot);
Assert.Equal(FrameFlags.Gated, env.ItemFlags);
}
[Fact]
public void RejectsBadVersion()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
bytes[0] = 2;
Assert.False(Envelope.TryParse(bytes, out _));
}
[Fact]
public void RejectsReservedFlagBits()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
bytes[1] |= 0x10;
Assert.False(Envelope.TryParse(bytes, out _));
}
[Fact]
public void RejectsLengthMismatch()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 8);
Assert.False(Envelope.TryParse(bytes.AsSpan(0, bytes.Length - 1), out _));
Assert.False(Envelope.TryParse([.. bytes, 0], out _));
Assert.False(Envelope.TryParse(bytes.AsSpan(0, 25), out _));
}
[Fact]
public void RejectsHugePayloadLength()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), uint.MaxValue);
Assert.False(Envelope.TryParse(bytes, out _));
}
[Theory]
[InlineData(1024 + 16, true)]
[InlineData(4096 + 16, true)]
[InlineData(1048576 + 16, true)]
[InlineData(1024, false)]
[InlineData(16, false)]
[InlineData(1000 + 16, false)]
[InlineData(0, false)]
public void EncPayloadMustBeTierPlusTag(int length, bool valid) =>
Assert.Equal(valid, PayloadTiers.IsValidEncPayloadLength(length));
}
public class AuthTests
{
[Fact]
public void TokenHashMatchesOnlyTheSameToken()
{
var token = TestEnv.Random(32);
var hash = TokenHash.Compute(token);
Assert.True(TokenHash.Matches(token, hash));
Assert.False(TokenHash.Matches(TestEnv.Random(32), hash));
Assert.False(TokenHash.Matches(token, null));
Assert.False(TokenHash.Matches(token, hash[..16]));
}
}
public class PolicyTests
{
private static Channel Chan(ChannelSettings s, bool writeHash = true) => new(
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0);
private static Item ItemBy(byte[] author) => new(
TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null);
[Fact]
public void WritePermission()
{
var ep = TestEnv.Endpoint();
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep)));
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep)));
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep)));
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep)));
}
[Theory]
[InlineData(DeletePolicy.OwnerOnly, Role.Write, true, false)]
[InlineData(DeletePolicy.OwnerOnly, Role.Owner, false, true)]
[InlineData(DeletePolicy.Own, Role.Write, true, true)]
[InlineData(DeletePolicy.Own, Role.Write, false, false)]
[InlineData(DeletePolicy.Own, Role.Owner, false, true)]
[InlineData(DeletePolicy.Anyone, Role.Read, false, true)]
public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected)
{
var me = TestEnv.Endpoint();
var item = ItemBy(isAuthor ? me : TestEnv.Endpoint());
var channel = Chan(new() { DeletePolicy = policy });
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me)));
}
[Fact]
public void SettingsPatchOnlyChangesProvidedFields()
{
var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 };
var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before);
Assert.Equal(5, after.Seats);
Assert.Equal(100, after.TtlSeconds);
}
[Theory]
[InlineData(0, 86400, 10)]
[InlineData(999, 86400, 10)]
[InlineData(2, 10, 10)]
[InlineData(2, 99999999, 10)]
[InlineData(2, 86400, 0)]
public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems)
{
var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems };
var ex = Assert.Throws<WebDropException>(() => s.Validate(new ServerLimits()));
Assert.Equal(ErrorCode.BadRequest, ex.Code);
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Core.Tests;
public sealed class FakeClock : IClock
{
public long NowMs { get; set; } = 1_700_000_000_000;
public void Advance(TimeSpan span) => NowMs += (long)span.TotalMilliseconds;
}
public sealed class RecordingEvents : IChannelEvents
{
public List<(Id16 Channel, long Seq)> Advances { get; } = [];
public int Settings, Seats, Deleted;
public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); }
public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings);
public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats);
public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted);
}
/// <summary>一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。</summary>
public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner)
{
public Id16 Id { get; } = id;
public ChannelMode Mode { get; } = mode;
public byte[] ReadToken { get; } = read;
public byte[] WriteToken { get; } = write;
public byte[] OwnerToken { get; } = owner;
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint);
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint);
}
public sealed class TestEnv : IDisposable
{
public string Dir { get; } = Path.Combine(Path.GetTempPath(), "webdrop-tests-" + Guid.NewGuid().ToString("N"));
public FakeClock Clock { get; } = new();
public RecordingEvents Events { get; } = new();
public ServerLimits Limits { get; } = new();
public SqliteChannelStore Store { get; }
public DiskBlobStore Blobs { get; }
public ChannelService Service { get; private set; }
public TestEnv()
{
Directory.CreateDirectory(Dir);
Store = new SqliteChannelStore(Path.Combine(Dir, "test.db"));
Blobs = new DiskBlobStore(Path.Combine(Dir, "blobs"));
Service = new ChannelService(Store, Blobs, Clock, Limits, Events);
}
public static byte[] Random(int n) => RandomNumberGenerator.GetBytes(n);
public static byte[] Endpoint() => TokenHash.Compute(Random(32));
public static Id16 NewId() => Id16.FromBytes(Random(16));
public TestChannel Create(
byte[] ownerEndpoint,
ChannelMode mode = ChannelMode.Enc,
ChannelSettings? settings = null,
bool withWriteHash = true)
{
var id = NewId();
var read = Random(32);
var write = Random(32);
var owner = Random(32);
Service.CreateChannel(new CreateChannelRequest
{
Id = id.ToBase64Url(),
Mode = mode,
Settings = settings,
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
OwnerHash = Codec.Encode(TokenHash.Compute(owner)),
}, ownerEndpoint);
return new TestChannel(id, mode, read, write, owner);
}
/// <summary>构造一个封装。enc 模式的负载长度必须是 档位+16。</summary>
public static byte[] Frame(Id16 item, uint index, FrameFlags flags, int payloadLength, byte fill = 0x42)
{
var bytes = new byte[Envelope.HeaderSize + payloadLength];
bytes[0] = Envelope.Version;
bytes[1] = (byte)flags;
item.WriteTo(bytes.AsSpan(2));
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index);
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength);
bytes.AsSpan(Envelope.HeaderSize).Fill(fill);
return bytes;
}
public const int EncPayload = 1024 + 16;
public const FrameFlags SmallEnc = FrameFlags.Final | FrameFlags.Enc;
public void Dispose()
{
Store.Dispose();
try { Directory.Delete(Dir, recursive: true); } catch (IOException) { }
}
}
@@ -0,0 +1,279 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using WebDrop.Core;
namespace WebDrop.Core.Tests;
/// <summary>
/// 用 test-vectors/vectors.json 校验服务端这一侧能复现协议的每个字节。
/// 向量由 node:crypto 独立生成,这里用 .NET 的 HKDF / AesGcm / SHA256 再算一遍;
/// 两套互不相干的实现一致,才说明 protocol.md 没有歧义。
/// </summary>
public class VectorTests
{
private static readonly JsonElement V = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "vectors.json"))).RootElement;
private static byte[] H(JsonElement e) => Convert.FromHexString(e.GetString()!);
private static byte[] H(string s) => Convert.FromHexString(s);
private static byte[] Hkdf(byte[] ikm, byte[] salt, string info) =>
HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, Encoding.UTF8.GetBytes(info));
private static byte[] ChannelId => H(V.GetProperty("inputs").GetProperty("channel_id"));
private static byte[] KEnc => Hkdf(H(V.GetProperty("inputs").GetProperty("k")), ChannelId, "webdrop/v1/enc");
private static byte[] ItemKey(byte[] itemId) => Hkdf(KEnc, itemId, "webdrop/v1/item");
private static byte[] Aad(byte[] channelId, byte[] envelope)
{
// "WD1" ‖ channel_id ‖ envelope[0..22)
var aad = new byte[3 + 16 + 22];
"WD1"u8.CopyTo(aad);
channelId.CopyTo(aad, 3);
envelope.AsSpan(0, 22).CopyTo(aad.AsSpan(19));
return aad;
}
/// <summary>用 AES-GCM 解密一帧,返回明文;认证失败抛 CryptographicException。</summary>
private static byte[] Open(byte[] channelId, byte[] envelope)
{
Assert.True(Envelope.TryParse(envelope, out var env));
var iv = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(iv.AsSpan(8), env.FrameIndex);
var payload = envelope.AsSpan(Envelope.HeaderSize);
var plain = new byte[payload.Length - 16];
using var gcm = new AesGcm(ItemKey(env.ItemId.ToArray()), 16);
gcm.Decrypt(iv, payload[..^16], payload[^16..], plain, Aad(channelId, envelope));
return plain;
}
[Fact]
public void KeyDerivationMatchesTheVectors()
{
var inputs = V.GetProperty("inputs");
var kdf = V.GetProperty("kdf");
var k = H(inputs.GetProperty("k"));
var a = H(inputs.GetProperty("a"));
Assert.Equal(H(kdf.GetProperty("k_enc")), Hkdf(k, ChannelId, "webdrop/v1/enc"));
Assert.Equal(H(kdf.GetProperty("t_read")), Hkdf(k, ChannelId, "webdrop/v1/token/read"));
Assert.Equal(H(kdf.GetProperty("t_write")), Hkdf(k, ChannelId, "webdrop/v1/token/write"));
Assert.Equal(H(kdf.GetProperty("t_owner")), Hkdf(a, ChannelId, "webdrop/v1/token/owner"));
Assert.Equal(H(kdf.GetProperty("k_label")), Hkdf(KEnc, ChannelId, "webdrop/v1/label"));
foreach (var item in V.GetProperty("item_keys").EnumerateArray())
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
}
[Fact]
public void ServerTokenHashMatchesTheStoredHashes()
{
// 服务端存的是令牌的 SHA-256;向量里的 *_hash 必须与 TokenHash.Compute 一致,
// 客户端才能发出服务端认得的哈希
var kdf = V.GetProperty("kdf");
foreach (var (token, hash) in new[] { ("t_read", "read_hash"), ("t_write", "write_hash"), ("t_owner", "owner_hash"), ("raw_write_token", "raw_write_hash") })
{
byte[] t = H(kdf.GetProperty(token));
Assert.Equal(H(kdf.GetProperty(hash)), TokenHash.Compute(t));
Assert.True(TokenHash.Matches(t, H(kdf.GetProperty(hash))));
}
}
[Fact]
public void IvAndAadMatchTheVectors()
{
foreach (var iv in V.GetProperty("iv").EnumerateArray())
{
var expected = new byte[12];
BinaryPrimitives.WriteUInt32BigEndian(expected.AsSpan(8), (uint)iv.GetProperty("index").GetInt64());
Assert.Equal(H(iv.GetProperty("iv")), expected);
}
foreach (var c in V.GetProperty("aad").EnumerateArray())
{
// 用一个只含头部的封装(payload_len = 0)构造 AAD 的前 22 字节
var env = new byte[Envelope.HeaderSize];
env[0] = 1;
env[1] = (byte)c.GetProperty("flags").GetInt32();
H(c.GetProperty("item_id")).CopyTo(env, 2);
BinaryPrimitives.WriteUInt32BigEndian(env.AsSpan(18), (uint)c.GetProperty("index").GetInt64());
Assert.Equal(H(c.GetProperty("aad")), Aad(ChannelId, env));
}
}
[Fact]
public void PaddingTiersMatchThePolicy()
{
foreach (var p in V.GetProperty("padding").EnumerateArray())
{
int len = p.GetProperty("data_len").GetInt32();
int tier = p.GetProperty("plaintext_len").GetInt32();
Assert.True(PayloadTiers.IsValidEncPayloadLength(tier + 16), $"data_len={len}");
Assert.True(tier >= 4 + len);
}
}
[Fact]
public void EveryFrameVectorDecryptsToTheExpectedPlaintextAndPassesServerValidation()
{
foreach (var f in V.GetProperty("frames").EnumerateArray())
{
string name = f.GetProperty("name").GetString()!;
byte[] envelope = H(f.GetProperty("envelope"));
// 服务端的封装解析与校验:这些帧就是合法客户端会发出的
Assert.True(Envelope.TryParse(envelope, out var env), name);
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
Assert.Equal((uint)f.GetProperty("index").GetInt64(), env.FrameIndex);
Assert.True(env.IsEnc, name);
Assert.True(PayloadTiers.IsValidEncPayloadLength(env.PayloadLength), name);
// 解密得到的明文(含 padding)必须逐字节一致
byte[] plaintext = Open(ChannelId, envelope);
Assert.Equal(H(f.GetProperty("plaintext")), plaintext);
// 明文布局:u32 data_len ‖ data ‖ 零填充
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plaintext);
Assert.Equal(H(f.GetProperty("data")), plaintext.AsSpan(4, dataLen).ToArray());
Assert.All(plaintext.AsSpan(4 + dataLen).ToArray(), b => Assert.Equal(0, b));
}
}
[Fact]
public void ItemVectorsFollowTheServerFramingRules()
{
foreach (var item in V.GetProperty("items").EnumerateArray())
{
string name = item.GetProperty("name").GetString()!;
bool gated = item.GetProperty("gated").GetBoolean();
var frames = item.GetProperty("frames").EnumerateArray().Select(e => H(e)).ToList();
var content = new List<byte>();
for (int i = 0; i < frames.Count; i++)
{
Assert.True(Envelope.TryParse(frames[i], out var env), name);
Assert.Equal((uint)i, env.FrameIndex);
Assert.Equal(gated, env.IsGated);
Assert.Equal(item.GetProperty("snapshot").GetBoolean(), env.IsSnapshot);
Assert.Equal(i == frames.Count - 1, env.IsFinal);
byte[] plain = Open(ChannelId, frames[i]);
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plain);
var data = plain.AsSpan(4, dataLen).ToArray();
if (i == 0)
{
// 第 0 帧:u16 meta_len ‖ meta_json ‖ 内容
int metaLen = BinaryPrimitives.ReadUInt16BigEndian(data);
Assert.Equal(item.GetProperty("meta_json").GetString(), Encoding.UTF8.GetString(data.AsSpan(2, metaLen)));
content.AddRange(data.AsSpan(2 + metaLen).ToArray());
}
else
{
content.AddRange(data);
}
}
Assert.Equal(H(item.GetProperty("content")), content.ToArray());
// 非 GATED 条目必须是单帧且 FINAL,这是服务端强制的规则
if (!gated) Assert.Single(frames);
}
}
[Fact]
public void RawFrameVectorsParse()
{
foreach (var f in V.GetProperty("raw_frames").EnumerateArray())
{
Assert.True(Envelope.TryParse(H(f.GetProperty("envelope")), out var env));
Assert.False(env.IsEnc);
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
Assert.Equal(H(f.GetProperty("payload")).Length, env.PayloadLength);
}
}
[Fact]
public void LabelVectorsDecryptWithTheDerivedKey()
{
byte[] kLabel = Hkdf(KEnc, ChannelId, "webdrop/v1/label");
foreach (var l in V.GetProperty("labels").EnumerateArray())
{
byte[] blob = H(l.GetProperty("blob"));
Assert.True(blob.Length <= 256, "设备名总长不得超过 256 字节(服务端上限)");
var plain = new byte[blob.Length - 12 - 16];
using var gcm = new AesGcm(kLabel, 16);
gcm.Decrypt(blob.AsSpan(0, 12), blob.AsSpan(12, plain.Length), blob.AsSpan(blob.Length - 16), plain, H(l.GetProperty("aad")));
Assert.Equal(l.GetProperty("name").GetString(), Encoding.UTF8.GetString(plain));
}
}
[Fact]
public void LogRecordVectorsMatchTheServerEncoders()
{
foreach (var r in V.GetProperty("log_records").EnumerateArray())
{
var parsed = r.GetProperty("parsed");
long seq = parsed.GetProperty("seq").GetInt64();
byte[] expected = H(r.GetProperty("hex"));
switch (parsed.GetProperty("kind").GetString())
{
case "tombstone":
var item = Id16.FromBytes(H(parsed.GetProperty("item_id")));
Assert.Equal(expected, LogRecord.EncodeTombstone(seq, item, (TombstoneReason)parsed.GetProperty("reason").GetInt32()));
break;
case "announce":
Assert.Equal(expected, LogRecord.EncodeAnnounce(seq, Id16.FromBytes(H(parsed.GetProperty("item_id"))),
(FrameFlags)parsed.GetProperty("flags").GetInt32(), parsed.GetProperty("created_at").GetInt64()));
break;
case "frame":
Assert.Equal(expected, LogRecord.Encode(seq, LogKind.Frame, H(parsed.GetProperty("envelope"))));
break;
}
}
}
[Fact]
public void LinkIdsAreCanonicalForTheServer()
{
string id = V.GetProperty("inputs").GetProperty("channel_id_b64").GetString()!;
Assert.True(Id16.TryParse(id, out var parsed));
Assert.Equal(ChannelId, parsed.ToArray());
Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _)); // 非规范编码,对应向量 non_canonical_id
}
[Fact]
public void NegativeVectorsAreRejectedByTheServerSideChecksOrByAuthentication()
{
byte[] original = ChannelId;
foreach (var n in V.GetProperty("negative").EnumerateArray())
{
string name = n.GetProperty("name").GetString()!;
string expect = n.GetProperty("expect").GetString()!;
byte[] envelope = H(n.GetProperty("envelope"));
byte[] channel = n.TryGetProperty("channel_id", out var c) ? H(c) : original;
switch (expect)
{
case "bad_envelope":
Assert.False(Envelope.TryParse(envelope, out _), name);
break;
case "auth_failed":
// 结构合法,但 GCM 认证必须失败(篡改、换头部、换频道都被 AAD 抓住)
Assert.True(Envelope.TryParse(envelope, out _), name);
Assert.ThrowsAny<CryptographicException>(() => Open(channel, envelope));
break;
case "wrong_mode":
// 没有 ENC 标志的帧:封装合法,但在 enc 频道上服务端会以 mode_mismatch 拒绝
Assert.True(Envelope.TryParse(envelope, out var env), name);
Assert.False(env.IsEnc, name);
break;
default:
Assert.Fail($"未知的期望类型 {expect}");
break;
}
}
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
</ItemGroup>
<!-- 协议测试向量:由 test-vectors/generate.mjs 独立生成,这里用 .NET 的密码学实现复现 -->
<ItemGroup>
<Content Include="../../../test-vectors/vectors.json" Link="vectors.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>