using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SQLInsight.Contracts; using SQLInsight.Hub.Data; using SQLInsight.Licensing; namespace SQLInsight.IntegrationTests; [Trait("Category", "Integration")] public class HubApiTests(HubFixture hub) : IClassFixture { internal static FindingsSubmission Submission(string instance, DateTimeOffset collectedAt, params (string Rule, string Subject, string Severity)[] findings) => new() { SubmissionId = Guid.NewGuid(), AgentVersion = "1.0.0", CollectedAt = collectedAt, Instance = new InstanceSnapshot { InstanceKey = instance, Release = "SQL Server 2019", ProductVersion = "15.0.4490.9", Edition = "Enterprise" }, Rules = findings.Select(f => f.Rule).Distinct().Select(r => new RuleOutcomeDto { RuleId = r, Outcome = "Findings" }).ToList(), Findings = findings.Select(f => new FindingDto { RuleId = f.Rule, Subject = f.Subject, Severity = f.Severity, Title = $"{f.Rule} {f.Subject}", Explanation = "e", Recommendation = "r", FirstSeenAt = collectedAt.AddHours(-1), Evidence = [new EvidenceDto("MÃ¥ling", "42")], }).ToList(), }; private async Task PostAsync(string license, object body) { var client = hub.Factory.CreateClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") { Content = JsonContent.Create(body) }; request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); return await client.SendAsync(request); } private async Task QueryAsync(Func> query) { await using var scope = hub.Factory.Services.CreateAsyncScope(); return await query(scope.ServiceProvider.GetRequiredService()); } [Fact] public async Task Full_state_is_reconciled_idempotently() { var license = hub.License("Reconcile A/S"); var t0 = DateTimeOffset.UtcNow.AddMinutes(-30); var first = Submission("sql01", t0, ("SQLI-CFG-001", "", "High"), ("SQLI-DB-007", "database: Sales", "Critical")); var response = await PostAsync(license, first); var again = await PostAsync(license, first); var second = await PostAsync(license, Submission("sql01", t0.AddMinutes(15), ("SQLI-CFG-001", "", "Medium"))); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.True((await again.Content.ReadFromJsonAsync())!.Duplicate); Assert.Equal(1, (await second.Content.ReadFromJsonAsync())!.ActiveFindings); var findings = await QueryAsync(db => db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer) .Where(f => f.Instance!.Customer!.Name == "Reconcile A/S").ToListAsync()); Assert.Equal(2, findings.Count); var cfg = Assert.Single(findings, f => f.RuleId == "SQLI-CFG-001"); Assert.Null(cfg.ResolvedAt); Assert.Equal(2, cfg.Severity); // Medium now Assert.NotNull(Assert.Single(findings, f => f.RuleId == "SQLI-DB-007").ResolvedAt); } [Fact] public async Task Late_older_submission_is_not_applied() { var license = hub.License("Order A/S"); var now = DateTimeOffset.UtcNow; await PostAsync(license, Submission("sql01", now, ("SQLI-CFG-001", "", "High"))); var late = await PostAsync(license, Submission("sql01", now.AddMinutes(-15))); Assert.True((await late.Content.ReadFromJsonAsync())!.Duplicate); Assert.Equal(1, await QueryAsync(db => db.Findings.CountAsync(f => f.Instance!.Customer!.Name == "Order A/S" && f.ResolvedAt == null))); } [Fact] public async Task Concurrent_submissions_for_one_instance_do_not_conflict() { var license = hub.License("Parallel A/S"); var start = DateTimeOffset.UtcNow.AddMinutes(-10); var responses = await Task.WhenAll(Enumerable.Range(0, 8).Select(i => PostAsync(license, Submission("sql01", start.AddSeconds(i), ("SQLI-CFG-001", "", "High"), ("SQLI-DB-002", $"database: D{i % 2}", "High"))))); Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode)); Assert.Equal(1, await QueryAsync(db => db.Findings.CountAsync(f => f.Instance!.Customer!.Name == "Parallel A/S" && f.RuleId == "SQLI-CFG-001" && f.ResolvedAt == null))); } [Theory] [InlineData("garbage", HttpStatusCode.Unauthorized)] [InlineData("", HttpStatusCode.Unauthorized)] public async Task Invalid_license_is_rejected(string license, HttpStatusCode expected) { Assert.Equal(expected, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode); } [Fact] public async Task Community_and_long_expired_licenses_cannot_sync() { var community = hub.License("Free A/S", LicensePlan.Community); var expired = hub.License("Old A/S", expires: DateTimeOffset.UtcNow.AddDays(-45)); Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(community, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(expired, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode); } [Fact] public async Task Revoked_license_is_rejected() { var id = Guid.NewGuid().ToString("N"); var license = hub.License("Revoked A/S", id: id); Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode); await QueryAsync(async db => { var record = await db.Licenses.SingleAsync(l => l.LicenseId == id); record.RevokedAt = DateTime.UtcNow; record.RevokedReason = "Kontrakt opsagt"; return await db.SaveChangesAsync(); }); var response = await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow.AddMinutes(1))); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Contains("Kontrakt opsagt", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); } [Fact] public async Task Instance_limit_is_enforced() { var license = hub.License("Small A/S", maxInstances: 1); Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode); Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow.AddMinutes(1)))).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(license, Submission("sql02", DateTimeOffset.UtcNow))).StatusCode); } [Fact] public async Task Malformed_submissions_are_rejected() { var license = hub.License("Strict A/S"); var badSeverity = Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "Catastrophic")); Assert.Equal(HttpStatusCode.BadRequest, (await PostAsync(license, new { schemaVersion = 99 })).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await PostAsync(license, badSeverity)).StatusCode); } [Fact] public async Task Hub_serves_the_update_feed() { var feed = Path.Combine(Path.GetTempPath(), "hubfeed-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(feed, "stable")); await File.WriteAllTextAsync(Path.Combine(feed, "stable", "manifest.sqli"), "SQLI1.x.y"); try { await using var factory = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:UpdateFeedPath", feed)); var client = factory.CreateClient(); Assert.Equal("SQLI1.x.y", await client.GetStringAsync("/updates/stable/manifest.sqli")); Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/../HubOptions.cs")).StatusCode); } finally { Directory.Delete(feed, recursive: true); } } }