using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Growth; using Tiku.Domain.Growth; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class CrmEndpointTests { [Fact] public async Task Anonymous_crm_request_returns_401() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/crm/config"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Admin_can_upsert_config_without_secret_leak() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); using var client = factory.CreateClient(); await LoginAsync(client, seed); var response = await client.PutAsJsonAsync( "/api/crm/config", new UpsertCrmConfigDto { Enabled = true, Url = "https://crm.example.test/webhook", Secret = "super-secret", AssignmentMode = "round_robin", AssignmentPool = JsonSerializer.SerializeToElement(new[] { seed.UserId }), FormName = "题库线索", TimeoutSeconds = 10, DelaySeconds = 3 }); var body = await response.Content.ReadAsStringAsync(); var config = JsonSerializer.Deserialize(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.DoesNotContain("super-secret", body, StringComparison.OrdinalIgnoreCase); Assert.Equal("tenant_secrets:crm:webhook:default", config!.SecretRef); Assert.Equal("RoundRobin", config.AssignmentMode); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Contains(dbContext.TenantSecrets, item => item.TenantId == seed.TenantId && item.SecretRef == "tenant_secrets:crm:webhook:default"); } [Fact] public async Task Admin_can_query_and_retry_dead_letter_with_redacted_payload() { await using var factory = new ApiTestFactory(); var seed = await SeedAdminAsync(factory); var queueId = Guid.NewGuid(); await factory.SeedAsync( new CrmWebhookQueueItem { Id = queueId, TenantId = seed.TenantId, RecordId = "lead-1", LeadId = "lead-1", Source = "referral.bind", Provider = "webhook", Status = CrmWebhookQueueStatus.Failed, Attempts = 3, LastError = "token expired", Payload = JsonSerializer.SerializeToElement(new { name = "student", secret = "hidden-value" }) }, new CrmWebhookLog { TenantId = seed.TenantId, RecordId = "lead-1", LeadId = "lead-1", Outcome = "failed", ErrorMessage = "password leaked", RequestPayload = JsonSerializer.SerializeToElement(new { token = "abc" }), ResponseSummary = "failed" }); using var client = factory.CreateClient(); await LoginAsync(client, seed); var queue = await client.GetAsync("/api/crm/queue?status=failed"); var deadLetters = await client.GetAsync("/api/crm/dead-letters"); var logs = await client.GetAsync($"/api/crm/queue/logs?queueId={queueId}"); var retry = await client.PostAsJsonAsync( "/api/crm/queue/action", new CrmQueueActionDto { QueueId = queueId, Action = "retry", Note = "again" }); var queueBody = await queue.Content.ReadAsStringAsync(); var logsBody = await logs.Content.ReadAsStringAsync(); var retried = await retry.Content.ReadFromJsonAsync(); Assert.Equal(HttpStatusCode.OK, queue.StatusCode); Assert.Equal(HttpStatusCode.OK, deadLetters.StatusCode); Assert.Equal(HttpStatusCode.OK, logs.StatusCode); Assert.DoesNotContain("hidden-value", queueBody, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("abc", logsBody, StringComparison.OrdinalIgnoreCase); Assert.Equal(HttpStatusCode.OK, retry.StatusCode); Assert.Equal("Pending", retried!.Status); } private static async Task SeedAdminAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var phone = "13800002001"; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CRM Tenant" }, new User { Id = userId, Phone = phone, Name = "CRM Admin" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(new PasswordHasher().Hash("passw0rd!")) }); return new LoginSeed(tenantId, userId, phone); } private static async Task LoginAsync(HttpClient client, LoginSeed seed) { var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); loginResponse.EnsureSuccessStatusCode(); using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync()); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } private sealed record LoginSeed(Guid TenantId, Guid UserId, string Phone); }