Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Rule packs (support dates and newest CU per release) are published in a signed feed and hot-loaded without a restart; the verified pack is reloaded at startup. New versions are announced (Notify) or downloaded and staged by the unprivileged agent (Automatic), and installed by a privileged apply-update task that re-verifies signature and hash in an admin-only folder, keeps appsettings/web.config, health-checks the services and rolls back on failure. Stable and preview channels, manifest expiry and rollback protection, https or folder/share feeds for offline sites. Vendor tool gains rulepack and publish; the Hub can serve the feed; the web UI gets a System page with update, rule pack, license and exactly what is sent to the Hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
170 lines
8 KiB
C#
170 lines
8 KiB
C#
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<HubFixture>
|
|
{
|
|
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<HttpResponseMessage> 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<T> QueryAsync<T>(Func<HubDbContext, Task<T>> query)
|
|
{
|
|
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
|
return await query(scope.ServiceProvider.GetRequiredService<HubDbContext>());
|
|
}
|
|
|
|
[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<SubmissionReceipt>())!.Duplicate);
|
|
Assert.Equal(1, (await second.Content.ReadFromJsonAsync<SubmissionReceipt>())!.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<SubmissionReceipt>())!.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);
|
|
}
|
|
}
|
|
}
|