Add transfer tracking and secure admin access
This commit is contained in:
@@ -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 = "系统维护通知",
|
||||
|
||||
Reference in New Issue
Block a user