Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using XWorld.Server.Auth;
|
||||
|
||||
namespace XWorld.Server.Auth.Tests
|
||||
{
|
||||
public class AccountStoreTests
|
||||
{
|
||||
// 用共享缓存的内存库(连接保持打开期间存活)
|
||||
static AccountStore NewStore() => new AccountStore("Data Source=acct_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
|
||||
|
||||
[Fact]
|
||||
public void CreateAccount_returns_positive_id()
|
||||
{
|
||||
using var s = NewStore();
|
||||
int id = s.CreateAccount("alice", "pw123456");
|
||||
Assert.True(id > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateAccount_duplicate_username_rejected_case_insensitive()
|
||||
{
|
||||
using var s = NewStore();
|
||||
s.CreateAccount("Bob", "pw123456");
|
||||
Assert.Throws<DuplicateUsernameException>(() => s.CreateAccount("bob", "other123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_correct_password_returns_same_id()
|
||||
{
|
||||
using var s = NewStore();
|
||||
int id = s.CreateAccount("carol", "secret-pw");
|
||||
Assert.Equal(id, s.VerifyCredentials("carol", "secret-pw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_wrong_password_returns_null()
|
||||
{
|
||||
using var s = NewStore();
|
||||
s.CreateAccount("dave", "secret-pw");
|
||||
Assert.Null(s.VerifyCredentials("dave", "WRONG"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_unknown_user_returns_null()
|
||||
{
|
||||
using var s = NewStore();
|
||||
Assert.Null(s.VerifyCredentials("nobody", "x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Password_not_stored_in_plaintext()
|
||||
{
|
||||
using var s = NewStore();
|
||||
s.CreateAccount("erin", "plaintext-marker");
|
||||
Assert.False(s.DebugRawContains(Encoding.UTF8.GetBytes("plaintext-marker")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>disable</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>
|
||||
<!-- AuthEndpointsTests 用 WebApplication/HttpClient 起真 Kestrel -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\Auth\Auth.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit;
|
||||
using XWorld.Server.Auth;
|
||||
|
||||
namespace XWorld.Server.Auth.Tests
|
||||
{
|
||||
public class AuthEndpointsTests : IAsyncLifetime
|
||||
{
|
||||
WebApplication _app;
|
||||
HttpClient _http;
|
||||
AccountStore _store;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_store = new AccountStore("Data Source=ep_" + Guid.NewGuid().ToString("N") + ";Mode=Memory;Cache=Shared");
|
||||
var tokens = new TokenService(new byte[32]);
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.ConfigureKestrel(o => o.Listen(System.Net.IPAddress.Loopback, 0));
|
||||
_app = builder.Build();
|
||||
AuthEndpoints.Map(_app, _store, tokens, TimeSpan.FromHours(1));
|
||||
await _app.StartAsync();
|
||||
string baseUrl = _app.Services.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>().Addresses.First();
|
||||
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
}
|
||||
|
||||
public async Task DisposeAsync() { _http?.Dispose(); _store?.Dispose(); await _app.DisposeAsync(); }
|
||||
|
||||
[Fact]
|
||||
public async Task Register_then_login_yields_token_and_pid()
|
||||
{
|
||||
var reg = await _http.PostAsJsonAsync("/register", new { username = "neo", password = "redpill1" });
|
||||
Assert.Equal(HttpStatusCode.OK, reg.StatusCode);
|
||||
var regBody = await reg.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
|
||||
Assert.True(regBody.pid > 0);
|
||||
Assert.False(string.IsNullOrEmpty(regBody.token));
|
||||
|
||||
var login = await _http.PostAsJsonAsync("/login", new { username = "neo", password = "redpill1" });
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
var loginBody = await login.Content.ReadFromJsonAsync<AuthEndpoints.AuthResponse>();
|
||||
Assert.Equal(regBody.pid, loginBody.pid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_duplicate_returns_409()
|
||||
{
|
||||
await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
|
||||
var again = await _http.PostAsJsonAsync("/register", new { username = "dup", password = "pw123456" });
|
||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_wrong_password_returns_401()
|
||||
{
|
||||
await _http.PostAsJsonAsync("/register", new { username = "trinity", password = "pw123456" });
|
||||
var bad = await _http.PostAsJsonAsync("/login", new { username = "trinity", password = "WRONG" });
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, bad.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_empty_fields_returns_400()
|
||||
{
|
||||
var r = await _http.PostAsJsonAsync("/register", new { username = "", password = "" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, r.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using XWorld.Server.Auth;
|
||||
|
||||
namespace XWorld.Server.Auth.Tests
|
||||
{
|
||||
public class TokenServiceTests
|
||||
{
|
||||
static byte[] Key() => new byte[32]; // 全零密钥,测试用固定值
|
||||
|
||||
[Fact]
|
||||
public void Issue_then_verify_roundtrip_returns_pid()
|
||||
{
|
||||
var t = new TokenService(Key());
|
||||
string tok = t.Issue(4242, TimeSpan.FromHours(1));
|
||||
Assert.True(t.Verify(tok, out int pid));
|
||||
Assert.Equal(4242, pid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tampered_token_rejected()
|
||||
{
|
||||
var t = new TokenService(Key());
|
||||
string tok = t.Issue(7, TimeSpan.FromHours(1));
|
||||
string bad = tok.Substring(0, tok.Length - 1) + (tok[tok.Length - 1] == 'A' ? 'B' : 'A');
|
||||
Assert.False(t.Verify(bad, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expired_token_rejected()
|
||||
{
|
||||
var t = new TokenService(Key());
|
||||
string tok = t.Issue(7, TimeSpan.FromSeconds(-1)); // 已过期
|
||||
Assert.False(t.Verify(tok, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrong_key_rejected()
|
||||
{
|
||||
var signer = new TokenService(Key());
|
||||
string tok = signer.Issue(7, TimeSpan.FromHours(1));
|
||||
var other = new TokenService(new byte[32] { 1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 });
|
||||
Assert.False(other.Verify(tok, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Garbage_token_rejected()
|
||||
{
|
||||
var t = new TokenService(Key());
|
||||
Assert.False(t.Verify("not-a-token", out _));
|
||||
Assert.False(t.Verify("", out _));
|
||||
Assert.False(t.Verify(null, out _));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user