diff --git a/.github/workflows/habit-checkins.yml b/.github/workflows/habit-checkins.yml new file mode 100644 index 00000000..025d191f --- /dev/null +++ b/.github/workflows/habit-checkins.yml @@ -0,0 +1,33 @@ +name: Habit check-ins checks + +on: + pull_request: + paths: + - 'applications/habit-checkins/**' + - '.github/workflows/habit-checkins.yml' + push: + branches: [main] + paths: + - 'applications/habit-checkins/**' + - '.github/workflows/habit-checkins.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: applications/habit-checkins + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: '10.0.401' + - run: dotnet restore --locked-mode + - run: dotnet tool restore + - run: dotnet build -c Release --no-restore + - run: dotnet test -c Release --no-build --filter 'Category!=Cloud' +# Cloud acceptance is explicit, with a dedicated service and no Cloud secrets in CI. diff --git a/applications/habit-checkins/.config/dotnet-tools.json b/applications/habit-checkins/.config/dotnet-tools.json new file mode 100644 index 00000000..1611eda7 --- /dev/null +++ b/applications/habit-checkins/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.12", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/applications/habit-checkins/.env.example b/applications/habit-checkins/.env.example new file mode 100644 index 00000000..36fc6cc6 --- /dev/null +++ b/applications/habit-checkins/.env.example @@ -0,0 +1,9 @@ +PGHOST=your-cloud-service-hostname +PGPORT=5432 +PGDATABASE=postgres +PGUSER=habit_app +PGPASSWORD=replace-with-runtime-password +PGSSLMODE=verify-full +PGSSLROOTCERT=/absolute/path/cloud-ca.pem +APP_TOKENS=user-a:replace-with-random-token-at-least-32-characters,user-b:another-random-token-at-least-32-characters +APP_URL=http://127.0.0.1:8080 diff --git a/applications/habit-checkins/.gitignore b/applications/habit-checkins/.gitignore new file mode 100644 index 00000000..66ccd933 --- /dev/null +++ b/applications/habit-checkins/.gitignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +.env +*.pem diff --git a/applications/habit-checkins/Directory.Build.props b/applications/habit-checkins/Directory.Build.props new file mode 100644 index 00000000..c20d1030 --- /dev/null +++ b/applications/habit-checkins/Directory.Build.props @@ -0,0 +1,8 @@ + + + enable + enable + true + true + + diff --git a/applications/habit-checkins/HabitApi/CalendarContract.cs b/applications/habit-checkins/HabitApi/CalendarContract.cs new file mode 100644 index 00000000..67cbe281 --- /dev/null +++ b/applications/habit-checkins/HabitApi/CalendarContract.cs @@ -0,0 +1,31 @@ +using System.Globalization; +namespace HabitApi; + +public static class CalendarContract +{ + public static readonly DateOnly FirstDate = new(2000, 1, 1); + public static readonly DateOnly LastDate = new(2100, 12, 31); + + public static DateOnly Date(string input) + { + if (!DateOnly.TryParseExact(input, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + || date < FirstDate || date > LastDate) + throw new ApiProblem(400, "date_must_be_yyyy_mm_dd_between_2000_and_2100"); + return date; + } + + public static (DateOnly Start, DateOnly End) Month(string input) + { + if (input.Length != 7) throw new ApiProblem(400, "month_must_be_yyyy_mm"); + var start = Date(input + "-01"); + return (start, start.AddMonths(1)); + } +} + +public sealed class ApiProblem(int status, string code) : Exception(code) +{ + public int Status + { + get; + } = status; +} diff --git a/applications/habit-checkins/HabitApi/HabitApi.csproj b/applications/habit-checkins/HabitApi/HabitApi.csproj new file mode 100644 index 00000000..e16f37b0 --- /dev/null +++ b/applications/habit-checkins/HabitApi/HabitApi.csproj @@ -0,0 +1,15 @@ + + + net10.0 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/applications/habit-checkins/HabitApi/HabitDb.cs b/applications/habit-checkins/HabitApi/HabitDb.cs new file mode 100644 index 00000000..ed4b6954 --- /dev/null +++ b/applications/habit-checkins/HabitApi/HabitDb.cs @@ -0,0 +1,105 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +namespace HabitApi; + +public sealed class Habit +{ + public Guid Id + { + get; + set; + } = Guid.NewGuid(); + public required string OwnerId + { + get; + set; + } + public required string Name + { + get; + set; + } + public bool Archived + { + get; + set; + } + public DateTime CreatedAt + { + get; + set; + } = DateTime.UtcNow; + public List CheckIns + { + get; + set; + } = []; +} + +public sealed class CheckIn +{ + public Guid Id + { + get; + set; + } = Guid.NewGuid(); + public Guid HabitId + { + get; + set; + } + public Habit Habit + { + get; + set; + } = null!; + public DateOnly CompletedOn + { + get; + set; + } + public DateTime CreatedAt + { + get; + set; + } = DateTime.UtcNow; +} + +public sealed class HabitDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Habits => Set(); + public DbSet CheckIns => Set(); + protected override void OnModelCreating(ModelBuilder model) + { + model.HasDefaultSchema("habits"); + model.Entity(e => + { + e.ToTable("habit", t => t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0")); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever(); + e.Property(x => x.OwnerId).HasColumnName("owner_id").HasMaxLength(64).IsRequired(); + e.Property(x => x.Name).HasColumnName("name").HasMaxLength(100).IsRequired(); + e.Property(x => x.Archived).HasColumnName("archived"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.HasIndex(x => new { x.OwnerId, x.CreatedAt, x.Id }).HasDatabaseName("habit_owner_created"); + }); + model.Entity(e => + { + e.ToTable("check_in", t => t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'")); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id").ValueGeneratedNever(); + e.Property(x => x.HabitId).HasColumnName("habit_id"); + e.Property(x => x.CompletedOn).HasColumnName("completed_on").HasColumnType("date"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.HasOne(x => x.Habit).WithMany(x => x.CheckIns).HasForeignKey(x => x.HabitId).OnDelete(DeleteBehavior.Cascade); + e.HasIndex(x => new { x.HabitId, x.CompletedOn }).IsUnique().HasDatabaseName("uq_check_in_habit_date"); + }); + } +} + +// dotnet-ef uses this explicit design-time path and the credential supplied to its process. +public sealed class MigrationFactory : IDesignTimeDbContextFactory +{ + public HabitDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder() + .UseNpgsql(PgConfig.ConnectionString(), pg => pg.MigrationsHistoryTable("__EFMigrationsHistory", "habits")).Options); +} diff --git a/applications/habit-checkins/HabitApi/HabitService.cs b/applications/habit-checkins/HabitApi/HabitService.cs new file mode 100644 index 00000000..65cf7b81 --- /dev/null +++ b/applications/habit-checkins/HabitApi/HabitService.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +namespace HabitApi; + +public sealed record HabitView(Guid Id, string Name, bool Archived, DateTime CreatedAt); +public sealed record CheckInView(Guid Id, DateOnly CompletedOn, DateTime CreatedAt); + +public sealed class HabitService(HabitDb db) +{ + public async Task Create(string owner, string? name) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 100 || name.Contains('\0')) throw new ApiProblem(400, "invalid_habit_name"); + var habit = new Habit { OwnerId = owner, Name = name.Trim() }; + db.Habits.Add(habit); + await db.SaveChangesAsync(); + return View(habit); + } + + public Task> List(string owner) => db.Habits.AsNoTracking() + .Where(h => h.OwnerId == owner).OrderByDescending(h => h.CreatedAt).ThenByDescending(h => h.Id) + .Take(100).Select(h => new HabitView(h.Id, h.Name, h.Archived, h.CreatedAt)).ToListAsync(); + + private async Task LockedOwned(string owner, Guid id) + { + // All archive/check-in/undo paths lock this row before inspecting state. + var rows = await db.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} AND owner_id={owner} FOR UPDATE").ToListAsync(); + return rows.SingleOrDefault() ?? throw new ApiProblem(404, "habit_not_found"); + } + + public async Task Complete(string owner, Guid id, DateOnly date) + { + await using var tx = await db.Database.BeginTransactionAsync(); + var habit = await LockedOwned(owner, id); + var existing = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date); + if (existing is not null) + { + await tx.CommitAsync(); + return View(existing); + } + if (habit.Archived) throw new ApiProblem(409, "habit_archived"); + var checkIn = new CheckIn { HabitId = id, CompletedOn = date }; + db.CheckIns.Add(checkIn); + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException e) when (e.InnerException is PostgresException + { SqlState: PostgresErrorCodes.UniqueViolation, ConstraintName: "uq_check_in_habit_date" }) + { + // Discard the attempted write and tracking before reading the durable winner. + await tx.RollbackAsync(); + db.ChangeTracker.Clear(); + var winner = await db.CheckIns.AsNoTracking().SingleAsync(c => c.HabitId == id && c.CompletedOn == date); + return View(winner); + } + await tx.CommitAsync(); + return View(checkIn); + } + + public async Task Undo(string owner, Guid id, DateOnly date) + { + await using var tx = await db.Database.BeginTransactionAsync(); + await LockedOwned(owner, id); + // Undo is permitted even after archive. + var checkIn = await db.CheckIns.SingleOrDefaultAsync(c => c.HabitId == id && c.CompletedOn == date); + if (checkIn is not null) + { + db.CheckIns.Remove(checkIn); + await db.SaveChangesAsync(); + } + await tx.CommitAsync(); + } + + public async Task Archive(string owner, Guid id) + { + await using var tx = await db.Database.BeginTransactionAsync(); + var habit = await LockedOwned(owner, id); + habit.Archived = true; + await db.SaveChangesAsync(); + await tx.CommitAsync(); + return View(habit); + } + + public async Task> History(string owner, Guid id, string month) + { + var (start, end) = CalendarContract.Month(month); + if (!await db.Habits.AnyAsync(h => h.Id == id && h.OwnerId == owner)) throw new ApiProblem(404, "habit_not_found"); + return await db.CheckIns.AsNoTracking().Where(c => c.HabitId == id && c.CompletedOn >= start && c.CompletedOn < end) + .OrderBy(c => c.CompletedOn).Take(31).Select(c => new CheckInView(c.Id, c.CompletedOn, c.CreatedAt)).ToListAsync(); + } + + private static HabitView View(Habit h) => new(h.Id, h.Name, h.Archived, h.CreatedAt); + private static CheckInView View(CheckIn c) => new(c.Id, c.CompletedOn, c.CreatedAt); +} diff --git a/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs new file mode 100644 index 00000000..f1e68738 --- /dev/null +++ b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using HabitApi; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace HabitApi.Migrations +{ + [DbContext(typeof(HabitDb))] + [Migration("20261002120012_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("habits") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitApi.CheckIn", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedOn") + .HasColumnType("date") + .HasColumnName("completed_on"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HabitId") + .HasColumnType("uuid") + .HasColumnName("habit_id"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "CompletedOn") + .IsUnique() + .HasDatabaseName("uq_check_in_habit_date"); + + b.ToTable("check_in", "habits", t => + { + t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'"); + }); + }); + + modelBuilder.Entity("HabitApi.Habit", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Archived") + .HasColumnType("boolean") + .HasColumnName("archived"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("owner_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId", "CreatedAt", "Id") + .HasDatabaseName("habit_owner_created"); + + b.ToTable("habit", "habits", t => + { + t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("HabitApi.CheckIn", b => + { + b.HasOne("HabitApi.Habit", "Habit") + .WithMany("CheckIns") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habit"); + }); + + modelBuilder.Entity("HabitApi.Habit", b => + { + b.Navigation("CheckIns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs new file mode 100644 index 00000000..d0ded40d --- /dev/null +++ b/applications/habit-checkins/HabitApi/Migrations/20261002120012_Initial.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HabitApi.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "habits"); + + migrationBuilder.CreateTable( + name: "habit", + schema: "habits", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + owner_id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + archived = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_habit", x => x.id); + table.CheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0"); + }); + + migrationBuilder.CreateTable( + name: "check_in", + schema: "habits", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + habit_id = table.Column(type: "uuid", nullable: false), + completed_on = table.Column(type: "date", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_check_in", x => x.id); + table.CheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'"); + table.ForeignKey( + name: "FK_check_in_habit_habit_id", + column: x => x.habit_id, + principalSchema: "habits", + principalTable: "habit", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "uq_check_in_habit_date", + schema: "habits", + table: "check_in", + columns: new[] { "habit_id", "completed_on" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "habit_owner_created", + schema: "habits", + table: "habit", + columns: new[] { "owner_id", "created_at", "id" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "check_in", + schema: "habits"); + + migrationBuilder.DropTable( + name: "habit", + schema: "habits"); + } + } +} diff --git a/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs b/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs new file mode 100644 index 00000000..efc80fbc --- /dev/null +++ b/applications/habit-checkins/HabitApi/Migrations/HabitDbModelSnapshot.cs @@ -0,0 +1,111 @@ +// +using System; +using HabitApi; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace HabitApi.Migrations +{ + [DbContext(typeof(HabitDb))] + partial class HabitDbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("habits") + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HabitApi.CheckIn", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedOn") + .HasColumnType("date") + .HasColumnName("completed_on"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HabitId") + .HasColumnType("uuid") + .HasColumnName("habit_id"); + + b.HasKey("Id"); + + b.HasIndex("HabitId", "CompletedOn") + .IsUnique() + .HasDatabaseName("uq_check_in_habit_date"); + + b.ToTable("check_in", "habits", t => + { + t.HasCheckConstraint("check_in_date_bound", "completed_on BETWEEN DATE '2000-01-01' AND DATE '2100-12-31'"); + }); + }); + + modelBuilder.Entity("HabitApi.Habit", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Archived") + .HasColumnType("boolean") + .HasColumnName("archived"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("OwnerId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("owner_id"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId", "CreatedAt", "Id") + .HasDatabaseName("habit_owner_created"); + + b.ToTable("habit", "habits", t => + { + t.HasCheckConstraint("habit_name_nonblank", "length(btrim(name)) > 0"); + }); + }); + + modelBuilder.Entity("HabitApi.CheckIn", b => + { + b.HasOne("HabitApi.Habit", "Habit") + .WithMany("CheckIns") + .HasForeignKey("HabitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Habit"); + }); + + modelBuilder.Entity("HabitApi.Habit", b => + { + b.Navigation("CheckIns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/habit-checkins/HabitApi/PgConfig.cs b/applications/habit-checkins/HabitApi/PgConfig.cs new file mode 100644 index 00000000..c1bdd565 --- /dev/null +++ b/applications/habit-checkins/HabitApi/PgConfig.cs @@ -0,0 +1,24 @@ +using Npgsql; +namespace HabitApi; + +public static class PgConfig +{ + public static string Required(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value + ? value : throw new InvalidOperationException($"Missing {name}"); + + public static string ConnectionString() => new NpgsqlConnectionStringBuilder + { + Host = Required("PGHOST"), + Port = int.Parse(Environment.GetEnvironmentVariable("PGPORT") ?? "5432"), + Database = Environment.GetEnvironmentVariable("PGDATABASE") ?? "postgres", + Username = Required("PGUSER"), + Password = Required("PGPASSWORD"), + SslMode = SslMode.VerifyFull, + RootCertificate = Required("PGSSLROOTCERT"), + SearchPath = "habits", + MaxPoolSize = 5, + Timeout = 15, + CommandTimeout = 30, + GssEncryptionMode = GssEncryptionMode.Disable + }.ConnectionString; +} diff --git a/applications/habit-checkins/HabitApi/Program.cs b/applications/habit-checkins/HabitApi/Program.cs new file mode 100644 index 00000000..a29ea560 --- /dev/null +++ b/applications/habit-checkins/HabitApi/Program.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; +using HabitApi; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = 4096); +builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("APP_URL") ?? "http://127.0.0.1:8080"); +builder.Services.AddProblemDetails(); +builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow); +builder.Services.AddSingleton(new Tokens(PgConfig.Required("APP_TOKENS"))); +builder.Services.AddDbContext(options => options.UseNpgsql(PgConfig.ConnectionString(), + pg => pg.MigrationsHistoryTable("__EFMigrationsHistory", "habits"))); +builder.Services.AddScoped(); +var app = builder.Build(); +app.UseExceptionHandler(); +app.Use(async (context, next) => +{ + if (context.Request.Path == "/health" && context.Request.Method == "GET") + { + await next(context); + return; + } + var user = context.RequestServices.GetRequiredService().Resolve(context.Request.Headers.Authorization); + if (user is null) + { + context.Response.StatusCode = 401; + context.Response.Headers.WWWAuthenticate = "Bearer"; + await context.Response.WriteAsJsonAsync(new { error = "authentication_required" }); + return; + } + context.Items["owner"] = user; + try + { + await next(context); + } + catch (ApiProblem e) + { + context.Response.StatusCode = e.Status; + await context.Response.WriteAsJsonAsync(new { error = e.Message }); + } +}); + +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapPost("/habits", async (CreateHabit body, HttpContext ctx, HabitService service) => + Results.Json(await service.Create(Owner(ctx), body.Name), statusCode: 201)); +app.MapGet("/habits", async (HttpContext ctx, HabitService service) => Results.Ok(await service.List(Owner(ctx)))); +app.MapPut("/habits/{id:guid}/check-ins/{date}", async (Guid id, string date, HttpContext ctx, HabitService service) => + Results.Ok(await service.Complete(Owner(ctx), id, CalendarContract.Date(date)))); +app.MapDelete("/habits/{id:guid}/check-ins/{date}", async (Guid id, string date, HttpContext ctx, HabitService service) => +{ + await service.Undo(Owner(ctx), id, CalendarContract.Date(date)); + return Results.NoContent(); +}); +app.MapPost("/habits/{id:guid}/archive", async (Guid id, HttpContext ctx, HabitService service) => + Results.Ok(await service.Archive(Owner(ctx), id))); +app.MapGet("/habits/{id:guid}/history", async (Guid id, string month, HttpContext ctx, HabitService service) => + Results.Ok(await service.History(Owner(ctx), id, month))); +app.Run(); + +static string Owner(HttpContext context) => (string)context.Items["owner"]!; +public sealed record CreateHabit(string? Name); diff --git a/applications/habit-checkins/HabitApi/Tokens.cs b/applications/habit-checkins/HabitApi/Tokens.cs new file mode 100644 index 00000000..0dc16d1a --- /dev/null +++ b/applications/habit-checkins/HabitApi/Tokens.cs @@ -0,0 +1,33 @@ +using System.Security.Cryptography; +using System.Text; +namespace HabitApi; + +public sealed class Tokens +{ + private readonly List<(byte[] Digest, string User)> entries = []; + + public Tokens(string configuration) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var entry in configuration.Split(',')) + { + var fields = entry.Split(':'); + if (fields.Length != 2 || fields[0].Length is < 1 or > 64 + || fields[0].Any(c => !char.IsAsciiLetterOrDigit(c) && c != '-' && c != '_') + || fields[1].Length is < 32 or > 256 || !seen.Add(fields[1])) + throw new InvalidOperationException("Invalid APP_TOKENS mapping"); + entries.Add((SHA256.HashData(Encoding.UTF8.GetBytes(fields[1])), fields[0])); + } + } + + public string? Resolve(string? authorization) + { + if (authorization is null || !authorization.StartsWith("Bearer ", StringComparison.Ordinal) + || authorization.Length > 263) return null; + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(authorization[7..])); + string? user = null; + foreach (var entry in entries) + if (CryptographicOperations.FixedTimeEquals(entry.Digest, digest)) user = entry.User; + return user; + } +} diff --git a/applications/habit-checkins/HabitApi/packages.lock.json b/applications/habit-checkins/HabitApi/packages.lock.json new file mode 100644 index 00000000..4411ad73 --- /dev/null +++ b/applications/habit-checkins/HabitApi/packages.lock.json @@ -0,0 +1,216 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.EntityFrameworkCore": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "I/IwEkUEUoa62UMJHh2+04oAs+gZz4QEunpZ3MGtAf12PZUcTduZLuGZEp3FAZ7wkmkSThZez41P8tgFj8QYKQ==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.12", + "Microsoft.Extensions.DependencyModel": "10.0.12", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.4" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.12" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Build.Framework": { + "type": "Transitive", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "rDPQVTxh/zMTDF7wHlRqL/jjZbwjAP6315ydBxj51pZW7qkAwGwjoSiMutxYI91xmOE3ZXjAGHbYRqzyJV7Urw==" + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/applications/habit-checkins/HabitCheckins.slnx b/applications/habit-checkins/HabitCheckins.slnx new file mode 100644 index 00000000..693f9171 --- /dev/null +++ b/applications/habit-checkins/HabitCheckins.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/applications/habit-checkins/HabitTests/CloudTests.cs b/applications/habit-checkins/HabitTests/CloudTests.cs new file mode 100644 index 00000000..340290c8 --- /dev/null +++ b/applications/habit-checkins/HabitTests/CloudTests.cs @@ -0,0 +1,181 @@ +using System.Security.Authentication; +using HabitApi; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; +using Xunit; + +namespace HabitTests; + +[Trait("Category", "Cloud")] +public sealed class CloudTests +{ + private static HabitDb Db(string? applicationName = null) + { + var connection = new NpgsqlConnectionStringBuilder(PgConfig.ConnectionString()); + if (applicationName is not null) connection.ApplicationName = applicationName; + return new HabitDb(new DbContextOptionsBuilder().UseNpgsql(connection.ConnectionString).Options); + } + + private static async Task Create() + { + await using var db = Db(); + return (await new HabitService(db).Create("user-a", "Cloud fixture")).Id; + } + private static async Task Remove(Guid id) + { + await using var db = Db(); + await db.Habits.Where(h => h.Id == id).ExecuteDeleteAsync(); + } + + [Fact] + public async Task NaturalKeyRejectsDuplicateDatabaseWriters() + { + var id = await Create(); + async Task Insert() + { + await using var db = Db(); + db.CheckIns.Add(new CheckIn { HabitId = id, CompletedOn = new DateOnly(2026, 10, 5) }); + try + { + await db.SaveChangesAsync(); + return true; + } + catch (DbUpdateException error) + { + var pg = Assert.IsType(error.InnerException); + Assert.Equal(PostgresErrorCodes.UniqueViolation, pg.SqlState); + Assert.Equal("uq_check_in_habit_date", pg.ConstraintName); + return false; + } + } + try + { + var results = await Task.WhenAll(Insert(), Insert()); + Assert.Single(results, success => success); + await using var db = Db(); + Assert.Equal(1, await db.CheckIns.CountAsync(c => c.HabitId == id)); + } + finally + { + await Remove(id); + } + } + + private static async Task WaitForRowLock(HabitDb keeper) + { + await using var command = new NpgsqlCommand( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pg_backend_pid() = ANY(pg_blocking_pids(pid)))", + (NpgsqlConnection)keeper.Database.GetDbConnection(), + (NpgsqlTransaction)keeper.Database.CurrentTransaction!.GetDbTransaction()); + + for (var attempt = 0; attempt < 100; attempt++) + { + // Statistics views retain a snapshot within a transaction; refresh before observing the contender. + await using var refresh = new NpgsqlCommand("SELECT pg_stat_clear_snapshot()", + (NpgsqlConnection)keeper.Database.GetDbConnection(), + (NpgsqlTransaction)keeper.Database.CurrentTransaction!.GetDbTransaction()); + await refresh.ExecuteNonQueryAsync(); + if ((bool)(await command.ExecuteScalarAsync())!) return; + await Task.Delay(100); + } + Assert.Fail("Contending service did not reach the parent row lock"); + } + + [Fact] + public async Task ArchiveCommitsBeforeBlockedCheckInRejectsNewCompletion() + { + var id = await Create(); + try + { + await using var keeper = Db(); + await using var tx = await keeper.Database.BeginTransactionAsync(); + var rows = await keeper.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} FOR UPDATE").ToListAsync(); + var contender = Task.Run(async () => + { + await using var db = Db("habit-check-contender"); + return await Assert.ThrowsAsync(() => new HabitService(db).Complete("user-a", id, new DateOnly(2026, 10, 5))); + }); + await WaitForRowLock(keeper); + rows.Single().Archived = true; + await keeper.SaveChangesAsync(); + await tx.CommitAsync(); + Assert.Equal(409, (await contender.WaitAsync(TimeSpan.FromSeconds(30))).Status); + Assert.Equal(0, await keeper.CheckIns.CountAsync(c => c.HabitId == id)); + } + finally + { + await Remove(id); + } + } + + [Fact] + public async Task CheckInCommitsBeforeBlockedArchiveRetainsCompletion() + { + var id = await Create(); + try + { + await using var keeper = Db(); + await using var tx = await keeper.Database.BeginTransactionAsync(); + await keeper.Habits.FromSqlInterpolated($"SELECT * FROM habits.habit WHERE id={id} FOR UPDATE").ToListAsync(); + keeper.CheckIns.Add(new CheckIn { HabitId = id, CompletedOn = new DateOnly(2026, 10, 5) }); + await keeper.SaveChangesAsync(); + var contender = Task.Run(async () => + { + await using var db = Db("habit-archive-contender"); + return await new HabitService(db).Archive("user-a", id); + }); + await WaitForRowLock(keeper); + await tx.CommitAsync(); + Assert.True((await contender.WaitAsync(TimeSpan.FromSeconds(30))).Archived); + Assert.Equal(1, await keeper.CheckIns.CountAsync(c => c.HabitId == id)); + } + finally + { + await Remove(id); + } + } + + [Fact] + public async Task RollbackAfterFlushLeavesNoHabit() + { + var id = Guid.NewGuid(); + await using var db = Db(); + await using (var tx = await db.Database.BeginTransactionAsync()) + { + db.Habits.Add(new Habit { Id = id, OwnerId = "user-a", Name = "Rollback" }); + await db.SaveChangesAsync(); + await tx.RollbackAsync(); + } + db.ChangeTracker.Clear(); + Assert.False(await db.Habits.AnyAsync(h => h.Id == id)); + } + + [Fact] + public async Task RuntimeCannotCreateTablesOrReadMigrationHistory() + { + await using var connection = new NpgsqlConnection(PgConfig.ConnectionString()); + await connection.OpenAsync(); + foreach (var sql in new[] { "CREATE TABLE habits.forbidden (id int)", "SELECT * FROM habits.\"__EFMigrationsHistory\"" }) + { + await using var command = new NpgsqlCommand(sql, connection); + var error = await Assert.ThrowsAsync(async () => await command.ExecuteNonQueryAsync()); + Assert.Equal(PostgresErrorCodes.InsufficientPrivilege, error.SqlState); + } + await using var encrypted = new NpgsqlCommand("SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()", connection); + Assert.True((bool)(await encrypted.ExecuteScalarAsync())!); + } + + [Fact] + public async Task WrongCaFailsCertificateTrustWithPositiveControl() + { + await using var positive = new NpgsqlConnection(PgConfig.ConnectionString()); + await positive.OpenAsync(); + var wrong = new NpgsqlConnectionStringBuilder(PgConfig.ConnectionString()) + { RootCertificate = "/etc/ssl/certs/ca-certificates.crt", Pooling = false }; + await using var negative = new NpgsqlConnection(wrong.ConnectionString); + var error = await Assert.ThrowsAsync(() => negative.OpenAsync()); + Assert.IsType(error.InnerException); + Assert.Contains("certificate", error.ToString(), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/applications/habit-checkins/HabitTests/ContractTests.cs b/applications/habit-checkins/HabitTests/ContractTests.cs new file mode 100644 index 00000000..ea0dfdad --- /dev/null +++ b/applications/habit-checkins/HabitTests/ContractTests.cs @@ -0,0 +1,33 @@ +using HabitApi; +using Xunit; +namespace HabitTests; + +public sealed class ContractTests +{ + [Fact] + public void ExplicitCalendarDatesIncludeLeapDay() + { + Assert.Equal(new DateOnly(2024, 2, 29), CalendarContract.Date("2024-02-29")); + foreach (var invalid in new[] { "2026-02-29", "2026-2-03", "2026-10-01T00:00:00Z", "1999-12-31", "2101-01-01" }) + Assert.Throws(() => CalendarContract.Date(invalid)); + } + [Fact] + public void MonthlyRangeIsHalfOpenAndCrossesYearBoundary() + { + var (start, end) = CalendarContract.Month("2026-12"); + Assert.Equal(new DateOnly(2026, 12, 1), start); + Assert.Equal(new DateOnly(2027, 1, 1), end); + Assert.Throws(() => CalendarContract.Month("2026-2")); + } + [Fact] + public void TokensBindUsersAndRejectAmbiguousMappings() + { + var a = new string('a', 48); + var b = new string('b', 48); + var tokens = new Tokens($"user-a:{a},user-b:{b}"); + Assert.Equal("user-a", tokens.Resolve("Bearer " + a)); + Assert.Equal("user-b", tokens.Resolve("Bearer " + b)); + Assert.Null(tokens.Resolve("Bearer " + new string('c', 48))); + Assert.Throws(() => new Tokens($"user-a:{a},user-b:{a}")); + } +} diff --git a/applications/habit-checkins/HabitTests/HabitTests.csproj b/applications/habit-checkins/HabitTests/HabitTests.csproj new file mode 100644 index 00000000..515093d7 --- /dev/null +++ b/applications/habit-checkins/HabitTests/HabitTests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + false + true + + + + + + all + + + + diff --git a/applications/habit-checkins/HabitTests/packages.lock.json b/applications/habit-checkins/HabitTests/packages.lock.json new file mode 100644 index 00000000..f6fb1e97 --- /dev/null +++ b/applications/habit-checkins/HabitTests/packages.lock.json @@ -0,0 +1,224 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.10.1, )", + "resolved": "18.10.1", + "contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.10.1", + "Microsoft.TestPlatform.TestHost": "18.10.1" + } + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.3, )", + "resolved": "2.9.3", + "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", + "dependencies": { + "xunit.analyzers": "1.18.0", + "xunit.assert": "2.9.3", + "xunit.core": "[2.9.3]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e7OrVN8U5yr4kuwdTHWBHGVQIVsGfMkAr0Ej1/BnrS989Q0XBVANpIzNZ2bc1GvMZ0/XwMt5InTnmKskxGy99Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.12", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "kDrux7T6C3V/YYYe2LXr7x6AEsVyq5Y2YoTE9er1SwbDtap3z1g8RgxNh5eKQVyB4T+DKvBBJ4Ezd/VZzJQJZg==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "Kd4o2oO1A6dfKxjz5LS16QyLtpCFTUiFWwmBABETAXRsSImBdRdWeKjSgbbRhAMiV3a3/nWr95ZtzClT4uaADQ==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "OiHmr8XzX96dbgMsYGe8qoqg4KxibdZG2r0QUVJBGktFInOp8IrABe8jvcbo5hrqeWfbwpK7fCEBiEPbY/ik4A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.12", + "Microsoft.Extensions.Caching.Memory": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging": "10.0.12" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "sI3A6MUAyZwccLnwq0cXuRmC3WBtXieimBWafXSfhRJ4jnM2uGVXrqImDDVyW27u4vVaTOGlJxNJd8d1FGvCNQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "fhC3DHcBcFF4bXvHIxFtXJYNut7q7ZItqNFcgWfrIgmpVImlcEKNHhE7ztpNSPJYMHYYStS5QGND2I5mC26yeA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.10.1", + "contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.10.1" + } + }, + "Npgsql": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Transitive", + "resolved": "10.0.3", + "contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.3" + } + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.18.0", + "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]", + "xunit.extensibility.execution": "[2.9.3]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.3", + "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", + "dependencies": { + "xunit.extensibility.core": "[2.9.3]" + } + }, + "habitapi": { + "type": "Project", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.12, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.12, )", + "Npgsql": "[10.0.3, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" + } + } + } + } +} \ No newline at end of file diff --git a/applications/habit-checkins/README.md b/applications/habit-checkins/README.md new file mode 100644 index 00000000..b7403534 --- /dev/null +++ b/applications/habit-checkins/README.md @@ -0,0 +1,144 @@ +# Habit check-ins with ASP.NET Core and EF Core + +A small account-scoped API backed by ClickHouse Managed Postgres in ClickHouse Cloud. Create habits, mark an explicit calendar date complete, undo it, and archive a habit while keeping history. Repeating a check-in returns the stored completion, including its original ID, instead of inserting a duplicate. There is no frontend, streak calculation or inferred timezone. + +Pinned stack: .NET SDK 10.0.401 / ASP.NET Core 10.0.12, EF Core 10.0.12 (including Relational, Design and dotnet-ef), Npgsql and its EF provider 10.0.3. The provider's NuGet metadata supports EF versions 10.0.4–10.x; all Microsoft EF packages are explicitly aligned to 10.0.12. NuGet lockfiles pin transitive dependencies. `global.json` disables SDK roll-forward and preview SDKs. + +## API and date contract + +Demo bearer tokens in `APP_TOKENS` bind to immutable user IDs. Generate different random tokens with `openssl rand -hex 24` and keep them secret. Retain the same identity when rotating a token. Ownership is derived from the server mapping; a submitted `ownerId` is an unknown JSON property and rejected. Token digests use SHA-256 and constant-time comparison. There is no registration, token issuance or persisted revocation system. + +Every habit operation filters by authenticated owner. An inaccessible/missing habit returns 404. The shared runtime database role is trusted application infrastructure: API ownership checks are not Postgres row-level security, and that database credential can access both users' rows. End users receive API tokens, not database passwords. Remote deployment requires HTTPS at a trusted proxy; the API binds to loopback by default and has no cookie authentication or CORS configuration. + +| Method | Route | Behavior | +|---|---|---| +| GET | `/health` | Anonymous process liveness, not database readiness | +| POST | `/habits` | JSON `name`; returns 201 and habit | +| GET | `/habits` | Newest 100 owned habits, including archived | +| PUT | `/habits/{id}/check-ins/{date}` | 200 on creation/replay; 409 for a new archived completion | +| DELETE | `/habits/{id}/check-ins/{date}` | Undo; repeated undo returns 204 | +| POST | `/habits/{id}/archive` | Repeat-safe archive; retains existing history | +| GET | `/habits/{id}/history?month=2026-10` | Owned monthly completions, ascending date, at most 31 | + +Supply dates exactly as `yyyy-MM-dd`, from 2000-01-01 through 2100-12-31; supply months as `yyyy-MM` in that range. A date is a client-chosen calendar label, represented by `DateOnly` and Postgres `date`. The server does not derive it from a clock, timezone or timestamp. Dates can be in the past or future within the documented range. Names must be nonblank, no embedded NUL, and at most 100 input UTF-16 code units; stored names are trimmed. JSON request bodies are capped at 4096 bytes. + +Archive rejects a **new** completion with 409. An existing habit/date completion can still be replayed with 200. Undo remains allowed after archive; once undone, completing that date again is a new completion and returns 409. There is no unarchive or API habit deletion. Lists order by `createdAt DESC, id DESC`, with no pagination or total count. Errors use 400 for invalid input, 401 for invalid/missing tokens, 404 for inaccessible habits, 409 for new archived completion and 413 for oversized JSON. + +## Build + +Install the pinned .NET SDK, Python 3 and a PostgreSQL client. In this directory: + +```sh +dotnet restore --locked-mode +dotnet tool restore +dotnet build -c Release --no-restore +dotnet test -c Release --no-build --filter 'Category!=Cloud' +``` + +Three database-independent tests cover explicit date/month boundaries and token identity mapping. CI uses these commands without Cloud credentials. Cloud tests do not use EF's in-memory provider or a local database. + +## Create a dedicated Cloud service + +Install [clickhousectl](https://github.com/ClickHouse/clickhousectl) and authenticate using your Cloud API credentials. These visible commands match clickhousectl 0.5.0. Select a supported size/region for your account; the fixture used c6gd.large in us-east-1, Postgres 18, without HA. Creating a service incurs charges. Check current service options in the [ClickHouse Managed Postgres quickstart](https://clickhouse.com/docs/products/managed-postgres/quickstart) and delete your dedicated test service afterward. + +```sh +clickhousectl cloud postgres create --name habit-checkins-demo \ + --provider aws --region us-east-1 --size c6gd.large \ + --pg-version 18 --ha-type none --json > service-private.json +chmod 600 service-private.json +# Set SERVICE_ID to the returned id. Poll until state is running: +clickhousectl cloud postgres get "$SERVICE_ID" --json +clickhousectl cloud postgres certs get "$SERVICE_ID" --output cloud-ca.pem +``` + +Save the receipt outside the repository: it contains the initial administrator password, which later `get` calls do not return. Copy `.env.example` to a private environment file and fill the hostname, absolute CA path, runtime password and generated tokens. Do not paste connection strings or credentials into source/history. + +`PgConfig` builds a connection with `SSL Mode=VerifyFull` and the downloaded `Root Certificate`; there is no arbitrary certificate-validation callback or trust bypass. `PGSSLMODE=verify-full` also protects the visible `psql` commands. The Npgsql 10 GSS encryption preference is disabled explicitly so this example uses the specified TLS path. + +## Bootstrap, migrate, grant and seed + +Use a fresh dedicated service. Set `PGHOST`, `PGPORT=5432`, `PGDATABASE=postgres`, `PGSSLMODE=verify-full`, `PGSSLROOTCERT` and administrator `PGUSER` / `PGPASSWORD`. Privately export two different random database passwords as `MIGRATION_PASSWORD` and `APP_PASSWORD`. + +```sh +psql -X -v ON_ERROR_STOP=1 \ + -v migration_password="$MIGRATION_PASSWORD" \ + -v app_password="$APP_PASSWORD" -f sql/bootstrap.sql +``` + +The administrator creates two roles and a schema `habits` owned by `habit_migration`. Runtime `habit_app` receives schema USAGE. PUBLIC loses CREATE on `public` on this dedicated fixture. No database CREATE grant is needed for migrations in the existing owned schema. psql password arguments can be visible to other local users on a shared machine; keep fixture setup private. + +Run the committed EF migration explicitly with the migration credential: + +```sh +PGUSER=habit_migration PGPASSWORD="$MIGRATION_PASSWORD" \ + dotnet ef database update --project HabitApi --configuration Release --no-build +PGUSER=habit_migration PGPASSWORD="$MIGRATION_PASSWORD" \ + dotnet ef migrations has-pending-model-changes --project HabitApi --configuration Release --no-build +``` + +The design-time factory uses only the credential supplied to that command. The migration creates habits, child check-ins, their foreign key, a named unique habit/date index and an owner/list index. EF history is in `habits.__EFMigrationsHistory`. First migration on an empty schema may log a missing-history SELECT before creating the table; the command then applies the initial migration. Normal API startup never calls `Migrate`, `EnsureCreated` or any DDL method. + +With the administrator credential still in the shell, grant only table CRUD, then seed using runtime: + +```sh +psql -X -v ON_ERROR_STOP=1 -f sql/grants.sql +PGUSER=habit_app PGPASSWORD="$APP_PASSWORD" \ + psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql +``` + +Repeat migration and seed safely: EF reports up-to-date and the seed's fixed IDs/natural key use `ON CONFLICT DO NOTHING`. Runtime cannot create tables or read EF migration history. To add a future migration, change the model, run `dotnet ef migrations add NAME --project HabitApi`, review the generated migration/snapshot, and apply it through the same owner-only path. + +## Start and try a check-in + +Start a fresh shell and load only the runtime environment/tokens. Keep administrator and migration credentials out of the API process. + +```sh +dotnet HabitApi/bin/Release/net10.0/HabitApi.dll +``` + +The default address is `http://127.0.0.1:8080`. An explicit `APP_URL` override supports a trusted deployment. Set `USER_TOKEN` privately to a configured token: + +```sh +curl -sS http://127.0.0.1:8080/habits \ + -H "Authorization: Bearer $USER_TOKEN" -H 'Content-Type: application/json' \ + -d '{"name":"Read a chapter"}' +# Set HABIT_ID to the response id. +curl -sS -X PUT http://127.0.0.1:8080/habits/$HABIT_ID/check-ins/2026-10-05 \ + -H "Authorization: Bearer $USER_TOKEN" +# Repeat the same PUT: 200, the same persisted completion id. +curl -sS 'http://127.0.0.1:8080/habits/'"$HABIT_ID"'/history?month=2026-10' \ + -H "Authorization: Bearer $USER_TOKEN" +``` + +A check-in transaction first locks the owned habit row with parameterized `FOR UPDATE`. Completion, undo and archive all follow this cooperative locking protocol. If completion commits before archive, it stays in history; if archive commits first, the waiting new completion returns 409. Direct writers that ignore this protocol could break archive behavior. The unique index remains a database backstop for duplicate habit/date rows, independent of cooperative locking. + +EF inserts the check-in after checking for an existing row. Only SQLSTATE 23505 for `uq_check_in_habit_date` is treated as a repeat: the transaction is rolled back and the durable winner is read. Other database errors propagate. Monthly history uses a half-open range from the first day to the next month, projects only completion fields, orders by date, and caps at 31. The unique index also supports that range query. + +## Cloud acceptance + +Use a dedicated fixture with the two `.env.example` identities and runtime credentials: + +```sh +dotnet test -c Release --no-build --filter 'Category=Cloud' +python3 scripts/acceptance.py +``` + +Six native Cloud tests exercise independent duplicate writers (one success, one exact named 23505), both archive/check-in lock orderings, real rollback after saving, runtime permission denials/encrypted connection, and wrong-CA certificate failure with a positive same-endpoint control. Ordering tests hold a parent lock, observe a blocked service through `pg_blocking_pids` with refreshed statistics snapshots, then commit and assert the result. + +The HTTP script starts the production DLL on loopback 8081 with only runtime credentials. It checks date/name/body bounds, forged ownership and cross-user reads/writes, eight concurrent duplicate check-ins returning one stored ID, four archive/check-in races, retained history, archived replay/undo behavior, ascending monthly projection and persistence after process restart. Tests remove their generated rows using the runtime credential; do not point them at production data. `APP_LOG` selects a private application log, default `/tmp/habit-api.log`. The wrong-CA control uses the system CA bundle, which must not include the dedicated Cloud CA. + +## Cleanup + +Stop the application. Switch to administrator credentials on your dedicated fixture and optionally remove only the example schema/roles, then delete the dedicated service: + +```sh +psql -X -v ON_ERROR_STOP=1 -f sql/cleanup.sql +clickhousectl cloud postgres delete "$SERVICE_ID" +clickhousectl cloud postgres list --json +``` + +Deletion is asynchronous. Confirm your recorded ID disappears; do not delete an unrelated resource. Remove private local receipts when no longer needed. + +## Sources + +[Npgsql security](https://www.npgsql.org/doc/security.html), [Npgsql EF provider](https://www.npgsql.org/efcore/), [EF concurrency](https://learn.microsoft.com/en-us/ef/core/saving/concurrency), [ASP.NET Core APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/apis?view=aspnetcore-10.0), [EF migration application](https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/applying). diff --git a/applications/habit-checkins/global.json b/applications/habit-checkins/global.json new file mode 100644 index 00000000..1769bc16 --- /dev/null +++ b/applications/habit-checkins/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.401", + "rollForward": "disable", + "allowPrerelease": false + } +} diff --git a/applications/habit-checkins/scripts/acceptance.py b/applications/habit-checkins/scripts/acceptance.py new file mode 100644 index 00000000..4851e92f --- /dev/null +++ b/applications/habit-checkins/scripts/acceptance.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""HTTP checks against the dedicated Cloud fixture; uses only Python's standard library.""" +import os, json, urllib.request, urllib.error, subprocess, time, concurrent.futures, uuid + +BASE = "http://127.0.0.1:8081" +TOKENS = {e.split(":")[0]: e.split(":")[1] for e in os.environ["APP_TOKENS"].split(",")} +IDS = [] + + +def req(method, path, owner="user-a", body=None): + headers = {"Content-Type": "application/json"} + if owner: + headers["Authorization"] = "Bearer " + TOKENS[owner] + data = json.dumps(body).encode() if body is not None else None + try: + with urllib.request.urlopen( + urllib.request.Request(BASE + path, data, headers, method=method), + timeout=60, + ) as r: + s = r.read() + return r.status, json.loads(s) if s else None + except urllib.error.HTTPError as e: + s = e.read() + try: + body = json.loads(s) if s else None + except ValueError: + body = None + return e.code, body + + +def expect(code, result): + assert result[0] == code, (code, result) + return result[1] + + +def create(owner="user-a"): + h = expect(201, req("POST", "/habits", owner, {"name": "Read a chapter"})) + IDS.append(h["id"]) + return "/habits/" + h["id"] + + +def start(): + env = { + k: v + for k, v in os.environ.items() + if k + in { + "PATH", + "HOME", + "DOTNET_ROOT", + "LANG", + "PGHOST", + "PGPORT", + "PGDATABASE", + "PGUSER", + "PGPASSWORD", + "PGSSLROOTCERT", + "APP_TOKENS", + } + } + env["APP_URL"] = BASE + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + f = open(os.environ.get("APP_LOG", "/tmp/habit-api.log"), "a") + p = subprocess.Popen( + ["dotnet", "HabitApi/bin/Release/net10.0/HabitApi.dll"], + env=env, + stdout=f, + stderr=f, + ) + for _ in range(90): + if p.poll() is not None: + raise RuntimeError("Application exited; inspect private log") + try: + if req("GET", "/health", None)[0] == 200: + return p + except OSError: + pass + time.sleep(1) + p.terminate() + raise RuntimeError("Application startup timeout") + + +def stop(p): + p.terminate() + p.wait(timeout=30) + + +p = None +try: + p = start() + expect(401, req("GET", "/habits", None)) + for name in ["", " ", "x" * 101, "Nul\0name"]: + expect(400, req("POST", "/habits", body={"name": name})) + expect(400, req("POST", "/habits", body={"name": "Forged", "ownerId": "user-b"})) + expect(413, req("POST", "/habits", body={"name": "x" * 5000})) + path = create() + for date in [ + "2026-02-29", + "2026-2-03", + "1999-12-31", + "2101-01-01", + "2026-10-03T00:00:00Z", + ]: + expect(400, req("PUT", path + "/check-ins/" + date)) + for month in ["2026-13", "2026-2", "1999-12"]: + expect(400, req("GET", path + "/history?month=" + month)) + for method, suffix in [ + ("PUT", "/check-ins/2026-10-03"), + ("DELETE", "/check-ins/2026-10-03"), + ("POST", "/archive"), + ("GET", "/history?month=2026-10"), + ]: + expect(404, req(method, path + suffix, "user-b")) + assert all( + h["id"] != path.split("/")[-1] + for h in expect(200, req("GET", "/habits", "user-b")) + ) + first = expect(200, req("PUT", path + "/check-ins/2026-10-03")) + again = expect(200, req("PUT", path + "/check-ins/2026-10-03")) + assert first["id"] == again["id"] and again["completedOn"] == "2026-10-03" + expect(200, req("PUT", path + "/check-ins/2026-09-30")) + expect(200, req("PUT", path + "/check-ins/2026-11-01")) + history = expect(200, req("GET", path + "/history?month=2026-10")) + assert len(history) == 1 and history[0]["id"] == first["id"] + print( + "PASS explicit date/month validation, input bounds, forged-owner rejection and cross-user reads/writes", + flush=True, + ) + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + cp = create() + jobs = [pool.submit(req, "PUT", cp + "/check-ins/2026-10-05") for _ in range(8)] + results = [expect(200, j.result()) for j in jobs] + assert len({r["id"] for r in results}) == 1 + assert len(expect(200, req("GET", cp + "/history?month=2026-10"))) == 1 + for _ in range(4): + ap = create() + a = pool.submit(req, "PUT", ap + "/check-ins/2026-10-05") + b = pool.submit(req, "POST", ap + "/archive") + complete = a.result() + expect(200, b.result()) + assert complete[0] in [200, 409], complete + hist = expect(200, req("GET", ap + "/history?month=2026-10")) + assert len(hist) == (1 if complete[0] == 200 else 0) + expect(409, req("PUT", ap + "/check-ins/2026-10-06")) + expect(200, req("POST", path + "/archive")) + expect(200, req("POST", path + "/archive")) + assert expect(200, req("GET", path + "/history?month=2026-10")) == history + assert expect(200, req("PUT", path + "/check-ins/2026-10-03")) == history[0] + expect(409, req("PUT", path + "/check-ins/2026-10-04")) + expect(204, req("DELETE", path + "/check-ins/2026-10-03")) + expect(204, req("DELETE", path + "/check-ins/2026-10-03")) + assert expect(200, req("GET", path + "/history?month=2026-10")) == [] + expect(409, req("PUT", path + "/check-ins/2026-10-03")) + print( + "PASS eight duplicate check-ins share one durable id; four archive/check-in races; archived replay/undo and history retention", + flush=True, + ) + month = create() + for day in [31, 1, 15]: + expect(200, req("PUT", month + "/check-ins/2026-10-" + str(day).zfill(2))) + before = expect(200, req("GET", month + "/history?month=2026-10")) + assert [c["completedOn"] for c in before] == [ + "2026-10-01", + "2026-10-15", + "2026-10-31", + ] and len(before) <= 31 + stop(p) + p = start() + assert expect(200, req("GET", month + "/history?month=2026-10")) == before + print( + "PASS ordered bounded monthly history and production-process restart persistence", + flush=True, + ) +finally: + if p is not None and p.poll() is None: + stop(p) + if IDS: + safe = ",".join("'" + str(uuid.UUID(id)) + "'" for id in IDS) + subprocess.run( + [ + "psql", + "-X", + "-v", + "ON_ERROR_STOP=1", + "-c", + "DELETE FROM habits.habit WHERE id IN (" + safe + ")", + ], + check=True, + stdout=subprocess.DEVNULL, + ) diff --git a/applications/habit-checkins/sql/bootstrap.sql b/applications/habit-checkins/sql/bootstrap.sql new file mode 100644 index 00000000..0c017d5a --- /dev/null +++ b/applications/habit-checkins/sql/bootstrap.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP on +CREATE ROLE habit_migration LOGIN PASSWORD :'migration_password'; +CREATE ROLE habit_app LOGIN PASSWORD :'app_password'; +REVOKE CREATE ON SCHEMA public FROM PUBLIC; +CREATE SCHEMA habits AUTHORIZATION habit_migration; +GRANT USAGE ON SCHEMA habits TO habit_app; diff --git a/applications/habit-checkins/sql/cleanup.sql b/applications/habit-checkins/sql/cleanup.sql new file mode 100644 index 00000000..43cfe92d --- /dev/null +++ b/applications/habit-checkins/sql/cleanup.sql @@ -0,0 +1,4 @@ +\set ON_ERROR_STOP on +DROP SCHEMA IF EXISTS habits CASCADE; +DROP ROLE IF EXISTS habit_app; +DROP ROLE IF EXISTS habit_migration; diff --git a/applications/habit-checkins/sql/grants.sql b/applications/habit-checkins/sql/grants.sql new file mode 100644 index 00000000..d6dcb218 --- /dev/null +++ b/applications/habit-checkins/sql/grants.sql @@ -0,0 +1,2 @@ +\set ON_ERROR_STOP on +GRANT SELECT, INSERT, UPDATE, DELETE ON habits.habit, habits.check_in TO habit_app; diff --git a/applications/habit-checkins/sql/seed.sql b/applications/habit-checkins/sql/seed.sql new file mode 100644 index 00000000..5943dfc6 --- /dev/null +++ b/applications/habit-checkins/sql/seed.sql @@ -0,0 +1,8 @@ +\set ON_ERROR_STOP on +INSERT INTO habits.habit (id, owner_id, name, archived, created_at) +VALUES ('00000000-0000-0000-0000-000000000001', 'user-a', 'Read a chapter', false, now()), + ('00000000-0000-0000-0000-000000000002', 'user-b', 'Stretch', false, now()) +ON CONFLICT (id) DO NOTHING; +INSERT INTO habits.check_in (id, habit_id, completed_on, created_at) +VALUES ('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', DATE '2026-10-01', now()) +ON CONFLICT (habit_id, completed_on) DO NOTHING;