Add transfer tracking and secure admin access

This commit is contained in:
2026-07-26 11:57:57 +08:00
parent 0738953e6d
commit 7df25edd96
111 changed files with 6379 additions and 1934 deletions
@@ -21,8 +21,11 @@ public sealed class ApiCollection : ICollectionFixture<ApiFixture>
public const string Name = "api";
}
public sealed class ApiFixture : IAsyncLifetime
{
public sealed class ApiFixture : IAsyncLifetime
{
private const string AdminUsername = "test_admin";
private const string BootstrapPassword = "test-bootstrap-password-123";
private const string AdminPassword = "test-permanent-password-456";
private readonly MySqlContainer _database = new MySqlBuilder("mysql:8.4")
.WithDatabase("miaoji_test")
.WithUsername("miaoji_test")
@@ -41,9 +44,9 @@ public sealed class ApiFixture : IAsyncLifetime
Environment.SetEnvironmentVariable(
"Jwt__Secret",
"test-only-jwt-secret-at-least-thirty-two-characters");
Environment.SetEnvironmentVariable(
"Admin__Key",
"test-only-admin-key-at-least-24-characters");
Environment.SetEnvironmentVariable("Admin__BootstrapUsername", AdminUsername);
Environment.SetEnvironmentVariable("Admin__BootstrapPassword", BootstrapPassword);
Environment.SetEnvironmentVariable("Admin__CookieSecure", "false");
Environment.SetEnvironmentVariable(
"Push__TokenEncryptionKey",
Convert.ToBase64String(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()));
@@ -60,8 +63,9 @@ public sealed class ApiFixture : IAsyncLifetime
});
});
using var client = Factory.CreateClient();
var ping = await client.GetAsync("/api/ping");
ping.EnsureSuccessStatusCode();
var ping = await client.GetAsync("/api/ping");
ping.EnsureSuccessStatusCode();
await BootstrapAdminAsync();
}
public async Task DisposeAsync()
@@ -70,12 +74,14 @@ public sealed class ApiFixture : IAsyncLifetime
await _database.DisposeAsync();
Environment.SetEnvironmentVariable("ConnectionStrings__Default", null);
Environment.SetEnvironmentVariable("Jwt__Secret", null);
Environment.SetEnvironmentVariable("Admin__Key", null);
Environment.SetEnvironmentVariable("Admin__BootstrapUsername", null);
Environment.SetEnvironmentVariable("Admin__BootstrapPassword", null);
Environment.SetEnvironmentVariable("Admin__CookieSecure", null);
Environment.SetEnvironmentVariable("Push__TokenEncryptionKey", null);
Environment.SetEnvironmentVariable("RateLimiting__AuthPermitLimit", null);
}
public async Task<HttpClient> RegisterAsync(string username)
public async Task<HttpClient> RegisterAsync(string username)
{
var client = Factory.CreateClient();
var response = await client.PostAsJsonAsync(
@@ -86,8 +92,39 @@ public sealed class ApiFixture : IAsyncLifetime
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
payload.GetProperty("token").GetString());
return client;
}
return client;
}
public async Task<HttpClient> AdminAsync()
{
var client = Factory.CreateClient();
var login = await client.PostAsJsonAsync(
"/api/admin/auth/login",
new { username = AdminUsername, password = AdminPassword });
login.EnsureSuccessStatusCode();
var payload = await login.Content.ReadFromJsonAsync<JsonElement>();
client.DefaultRequestHeaders.Add(
AdminSessionService.CsrfHeader,
payload.GetProperty("csrfToken").GetString());
return client;
}
private async Task BootstrapAdminAsync()
{
using var client = Factory.CreateClient();
var login = await client.PostAsJsonAsync(
"/api/admin/auth/login",
new { username = AdminUsername, password = BootstrapPassword });
login.EnsureSuccessStatusCode();
var payload = await login.Content.ReadFromJsonAsync<JsonElement>();
client.DefaultRequestHeaders.Add(
AdminSessionService.CsrfHeader,
payload.GetProperty("csrfToken").GetString());
var changed = await client.PutAsJsonAsync(
"/api/admin/auth/password",
new { currentPassword = BootstrapPassword, newPassword = AdminPassword });
changed.EnsureSuccessStatusCode();
}
}
[Collection(ApiCollection.Name)]
@@ -324,10 +361,7 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
var profile = await client.GetFromJsonAsync<JsonElement>("/api/users/me");
var userId = profile.GetProperty("userId").GetInt64();
using var admin = fixture.Factory.CreateClient();
admin.DefaultRequestHeaders.Add(
"X-Admin-Key",
"test-only-admin-key-at-least-24-characters");
using var admin = await fixture.AdminAsync();
var update = await admin.PutAsJsonAsync(
$"/api/admin/users/{userId}/ai-quota",
new { limit = 1, period = "week", resetUsage = true });
@@ -419,39 +453,54 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
candidateId = "transfer-1",
clientRequestId = "recognition-wechat-flow-1",
categoryId,
type = "expense",
type = "transfer",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:01+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
transferDirection = "out",
counterparty = "张三",
provider = "wechat",
providerTransactionId = "wx-transfer-order-1",
recognitionOccurrenceId = "wx-transfer-flow-1",
},
new
{
candidateId = "transfer-2",
clientRequestId = "recognition-wechat-flow-2",
categoryId,
type = "expense",
type = "transfer",
amount = 20m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:10+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
transferDirection = "out",
counterparty = "张三",
provider = "wechat",
providerTransactionId = "wx-transfer-order-2",
recognitionOccurrenceId = "wx-transfer-flow-2",
},
new
{
candidateId = "transfer-3",
clientRequestId = "recognition-wechat-flow-3",
categoryId,
type = "expense",
type = "transfer",
amount = 30m,
note = "转账给张三",
paymentMethod = "微信",
occurredAt = "2026-07-25T10:00:20+08:00",
source = "recognition_ai",
sourceText = "微信转账成功",
transferDirection = "out",
counterparty = "张三",
provider = "wechat",
providerTransactionId = "wx-transfer-order-3",
recognitionOccurrenceId = "wx-transfer-flow-3",
},
},
};
@@ -476,6 +525,205 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(3, month.GetProperty("count").GetInt32());
Assert.Equal(70m, month.GetProperty("expense").GetDecimal());
Assert.All(
month.GetProperty("days")[0].GetProperty("items").EnumerateArray(),
item =>
{
Assert.Equal("transfer", item.GetProperty("type").GetString());
Assert.Equal("out", item.GetProperty("transferDirection").GetString());
Assert.Equal("张三", item.GetProperty("counterparty").GetString());
});
}
[Fact]
public async Task TransferDirections_AffectStatisticsAndBudgets_AndRecognitionIdsAreIdempotent()
{
using var client = await fixture.RegisterAsync("transfer_semantics");
var ledgers = await client.GetFromJsonAsync<JsonElement>("/api/ledgers");
var ledgerId = ledgers[0].GetProperty("id").GetInt64();
var expenseCategories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=expense");
var incomeCategories = await client.GetFromJsonAsync<JsonElement>(
"/api/categories?type=income");
var expenseCategoryId = expenseCategories[0].GetProperty("id").GetInt64();
var incomeCategoryId = incomeCategories[0].GetProperty("id").GetInt64();
var totalBudget = await client.PutAsJsonAsync(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
new { categoryId = (long?)null, amount = 100m });
totalBudget.EnsureSuccessStatusCode();
var categoryBudget = await client.PutAsJsonAsync(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}",
new { categoryId = expenseCategoryId, amount = 80m });
categoryBudget.EnsureSuccessStatusCode();
var transferOut = new
{
ledgerId,
categoryId = expenseCategoryId,
type = "transfer",
transferDirection = "out",
counterparty = "李四",
amount = 40m,
occurredAt = "2026-07-25T11:00:00+08:00",
source = "accessibility",
clientRequestId = "wx-event-out-a",
provider = "wechat",
providerTransactionId = "wx-order-out-001",
recognitionOccurrenceId = "wx-occurrence-out-001",
};
var outResponse = await client.PostAsJsonAsync("/api/transactions", transferOut);
outResponse.EnsureSuccessStatusCode();
var outTransaction = await outResponse.Content.ReadFromJsonAsync<JsonElement>();
var providerRetry = await client.PostAsJsonAsync(
"/api/transactions",
new
{
transferOut.ledgerId,
transferOut.categoryId,
transferOut.type,
transferOut.transferDirection,
transferOut.counterparty,
transferOut.amount,
transferOut.occurredAt,
transferOut.source,
clientRequestId = "wx-event-out-b",
transferOut.provider,
transferOut.providerTransactionId,
recognitionOccurrenceId = "wx-occurrence-out-changed",
});
providerRetry.EnsureSuccessStatusCode();
var retried = await providerRetry.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(
outTransaction.GetProperty("id").GetInt64(),
retried.GetProperty("id").GetInt64());
var inResponse = await client.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId = incomeCategoryId,
type = "transfer",
transferDirection = "in",
counterparty = "李四",
amount = 75m,
occurredAt = "2026-07-25T11:05:00+08:00",
source = "accessibility",
clientRequestId = "wx-event-in-a",
recognitionOccurrenceId = "wx-occurrence-in-001",
});
inResponse.EnsureSuccessStatusCode();
var inTransaction = await inResponse.Content.ReadFromJsonAsync<JsonElement>();
var occurrenceRetry = await client.PostAsJsonAsync(
"/api/transactions",
new
{
ledgerId,
categoryId = incomeCategoryId,
type = "transfer",
transferDirection = "in",
counterparty = "李四",
amount = 75m,
occurredAt = "2026-07-25T11:05:00+08:00",
source = "accessibility",
clientRequestId = "wx-event-in-b",
recognitionOccurrenceId = "wx-occurrence-in-001",
});
occurrenceRetry.EnsureSuccessStatusCode();
var occurrenceRetried = await occurrenceRetry.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(
inTransaction.GetProperty("id").GetInt64(),
occurrenceRetried.GetProperty("id").GetInt64());
var month = await client.GetFromJsonAsync<JsonElement>(
$"/api/transactions/month?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(2, month.GetProperty("count").GetInt32());
Assert.Equal(40m, month.GetProperty("expense").GetDecimal());
Assert.Equal(75m, month.GetProperty("income").GetDecimal());
var stats = await client.GetFromJsonAsync<JsonElement>(
$"/api/transactions/stats?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(40m, stats.GetProperty("totalExpense").GetDecimal());
Assert.Equal(75m, stats.GetProperty("totalIncome").GetDecimal());
var budgets = await client.GetFromJsonAsync<JsonElement>(
$"/api/budgets?year=2026&month=7&ledgerId={ledgerId}");
Assert.Equal(40m, budgets.GetProperty("total").GetProperty("spent").GetDecimal());
var expenseBudget = Assert.Single(
budgets.GetProperty("categories").EnumerateArray(),
item => item.GetProperty("categoryId").GetInt64() == expenseCategoryId);
Assert.Equal(40m, expenseBudget.GetProperty("spent").GetDecimal());
}
[Fact]
public async Task AdminSessions_EnforceCsrfViewerRevocationAndAudit()
{
using var superAdmin = await fixture.AdminAsync();
var suffix = Guid.NewGuid().ToString("N")[..10];
var username = $"viewer_{suffix}";
const string initialPassword = "viewer-initial-password-123";
const string permanentPassword = "viewer-permanent-password-456";
var createdResponse = await superAdmin.PostAsJsonAsync(
"/api/admin/security/accounts",
new { username, password = initialPassword, role = "viewer" });
createdResponse.EnsureSuccessStatusCode();
var created = await createdResponse.Content.ReadFromJsonAsync<JsonElement>();
var viewerId = created.GetProperty("id").GetInt64();
using var viewer = fixture.Factory.CreateClient();
var login = await viewer.PostAsJsonAsync(
"/api/admin/auth/login",
new { username, password = initialPassword });
login.EnsureSuccessStatusCode();
var loginPayload = await login.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(loginPayload.GetProperty("mustChangePassword").GetBoolean());
var missingCsrf = await viewer.PutAsJsonAsync(
"/api/admin/auth/password",
new { currentPassword = initialPassword, newPassword = permanentPassword });
Assert.Equal(HttpStatusCode.Unauthorized, missingCsrf.StatusCode);
viewer.DefaultRequestHeaders.Add(
AdminSessionService.CsrfHeader,
loginPayload.GetProperty("csrfToken").GetString());
var changed = await viewer.PutAsJsonAsync(
"/api/admin/auth/password",
new { currentPassword = initialPassword, newPassword = permanentPassword });
changed.EnsureSuccessStatusCode();
var changedPayload = await changed.Content.ReadFromJsonAsync<JsonElement>();
viewer.DefaultRequestHeaders.Remove(AdminSessionService.CsrfHeader);
viewer.DefaultRequestHeaders.Add(
AdminSessionService.CsrfHeader,
changedPayload.GetProperty("csrfToken").GetString());
Assert.Equal(
HttpStatusCode.OK,
(await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
var writeDenied = await viewer.PostAsJsonAsync(
"/api/admin/configs",
new { key = $"viewer.denied.{suffix}", value = "no" });
Assert.Equal(HttpStatusCode.Forbidden, writeDenied.StatusCode);
var revoked = await superAdmin.PostAsync(
$"/api/admin/security/accounts/{viewerId}/revoke-sessions",
null);
Assert.Equal(HttpStatusCode.NoContent, revoked.StatusCode);
Assert.Equal(
HttpStatusCode.Unauthorized,
(await viewer.GetAsync("/api/admin/dashboard")).StatusCode);
var audit = await superAdmin.GetFromJsonAsync<JsonElement>(
$"/api/admin/security/audit?username={username}");
Assert.True(audit.GetProperty("total").GetInt32() >= 3);
var entries = audit.GetProperty("list").EnumerateArray().ToList();
Assert.Contains(entries, entry =>
entry.GetProperty("action").GetString() == "post.api.admin.auth.login" &&
entry.GetProperty("success").GetBoolean());
Assert.Contains(entries, entry =>
entry.GetProperty("path").GetString() == "/api/admin/configs" &&
!entry.GetProperty("success").GetBoolean());
}
}
@@ -11,8 +11,6 @@ namespace MiaoJiZhang.Api.Tests;
[Collection(ApiCollection.Name)]
public sealed class PushIntegrationTests(ApiFixture fixture)
{
private const string AdminKey = "test-only-admin-key-at-least-24-characters";
[Fact]
public async Task Preferences_DefaultOff_AndPersistAllCategories()
{
@@ -149,8 +147,7 @@ public sealed class PushIntegrationTests(ApiFixture fixture)
var userId = profile.GetProperty("userId").GetInt64();
await RegisterDeviceAsync(user, Guid.NewGuid().ToString(), "campaign-device-token");
using var admin = fixture.Factory.CreateClient();
admin.DefaultRequestHeaders.Add("X-Admin-Key", AdminKey);
using var admin = await fixture.AdminAsync();
var request = new
{
title = "系统维护通知",