New top-level portal/ project, peer to console/ and firmware/. Delivers a .NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no changes to existing console or firmware. Backend (src/Tau.Acuvim.Portal/): - .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout). - Single AppDbContext with identity / app / monitoring schemas. - MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation) + IdentityBootstrapper (seeded admin + branding) on startup. - Pure CostCalculator + DB-backed RateService for tariffs (effective-dated, TOU periods, VAT, fixed charges, per-municipality timezone). - BrandingService with logo upload to mounted volume. - Time-series ingest + bucketed query services (time_bucket aggregates, ON CONFLICT for idempotent re-delivery). - ConfigOverviewService with redaction-by-construction (passwords never in payload). - DataProtection keys persisted to /data/keys volume for cookie survival across container restarts. Frontend (frontend/): - React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query. - BrandingProvider + ThemedRoot for live re-themed white-labelling. - RequireAuth / RequireRole guards. - Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin), Settings tabs (Branding / Rates / Users / Grafana / App config). Infra: - Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose files. Three services per customer; Traefik subdomain + same-origin /grafana path-prefix routing wired with labels. - Grafana 11 with provisioned timescaledb datasource (uid pinned) and starter power-overview.json dashboard with device template variable. - Compose project name documented as lowercase (Compose v2 requirement). Tests (tests/Tau.Acuvim.Portal.Tests/): - xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap, VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl. Production refusal), TariffValidator, DayOfWeekFlag. - All passing locally against .NET 10. Docs: - README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer provisioning, secret rotation, backup, troubleshooting), TESTING.md (manual integration scenarios, frontend test scaffolding recipe). Production safety guards: - Refuses to start if Authentication:DefaultAdminPassword is unchanged default in Production. - Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in Production. - Prod Grafana ships with anonymous off and auth mode unset (three options documented in README Security) so iframe refuses to load until a deliberate prod auth choice is made. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
84 lines
3.0 KiB
C#
84 lines
3.0 KiB
C#
using Tau.Acuvim.Portal.Configuration;
|
|
using Tau.Acuvim.Portal.Tests.Helpers;
|
|
|
|
namespace Tau.Acuvim.Portal.Tests;
|
|
|
|
public class ConnectionStringResolverTests
|
|
{
|
|
[Fact]
|
|
public void ExplicitConnectionString_WinsOverEverything()
|
|
{
|
|
var db = new DatabaseOptions
|
|
{
|
|
ConnectionString = "Host=elsewhere;Database=mydb;Username=u;Password=p",
|
|
AutoProvisionLocalTimescaleDb = true
|
|
};
|
|
var ts = new TimescaleDbOptions { Host = "ignored" };
|
|
var env = new FakeHostEnvironment("Production");
|
|
|
|
var r = ConnectionStringResolver.Resolve(db, ts, env);
|
|
|
|
Assert.Equal("Host=elsewhere;Database=mydb;Username=u;Password=p", r.ConnectionString);
|
|
Assert.Equal("Database:ConnectionString", r.Source);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoProvision_InDev_BuildsFromTimescaleBlock()
|
|
{
|
|
var db = new DatabaseOptions { ConnectionString = "", AutoProvisionLocalTimescaleDb = true };
|
|
var ts = new TimescaleDbOptions
|
|
{
|
|
Host = "timescaledb",
|
|
Port = 5432,
|
|
Database = "power_monitoring",
|
|
Username = "power_user",
|
|
Password = "local_secret"
|
|
};
|
|
var env = new FakeHostEnvironment("Development");
|
|
|
|
var r = ConnectionStringResolver.Resolve(db, ts, env);
|
|
|
|
Assert.Equal(
|
|
"Host=timescaledb;Port=5432;Database=power_monitoring;Username=power_user;Password=local_secret",
|
|
r.ConnectionString);
|
|
Assert.Contains("auto-provision", r.Source, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoProvision_InProduction_Throws()
|
|
{
|
|
var db = new DatabaseOptions { ConnectionString = "", AutoProvisionLocalTimescaleDb = true };
|
|
var ts = new TimescaleDbOptions();
|
|
var env = new FakeHostEnvironment("Production");
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(
|
|
() => ConnectionStringResolver.Resolve(db, ts, env));
|
|
Assert.Contains("not permitted in Production", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoConnectionString_AndAutoProvisionOff_Throws()
|
|
{
|
|
var db = new DatabaseOptions { ConnectionString = "", AutoProvisionLocalTimescaleDb = false };
|
|
var ts = new TimescaleDbOptions();
|
|
var env = new FakeHostEnvironment("Development");
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(
|
|
() => ConnectionStringResolver.Resolve(db, ts, env));
|
|
Assert.Contains("No database connection string available", ex.Message);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Staging")]
|
|
[InlineData("Production")]
|
|
public void AutoProvisionOff_AnyEnvironment_Throws(string envName)
|
|
{
|
|
var db = new DatabaseOptions { ConnectionString = "", AutoProvisionLocalTimescaleDb = false };
|
|
var ts = new TimescaleDbOptions();
|
|
var env = new FakeHostEnvironment(envName);
|
|
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => ConnectionStringResolver.Resolve(db, ts, env));
|
|
}
|
|
}
|