using InvercargillSql.Migrations; using InvercargillSql.Dialects; using Spry.Authentication; using Spry.Authentication.Migrations; namespace Spry.Tests { int failures = 0; int passes = 0; void check(bool condition, string label) { if (condition) { passes++; print("PASS %s\n", label); } else { failures++; print("FAIL %s\n", label); } } UserProjection user_with_epoch(DateTime? epoch) { var user = new UserProjection(); user.id = 42; user.enabled = true; user.sessions_valid_after = epoch; return user; } /** * The epoch comparison at the heart of SA-1/SA-14 revocation: a frame * authored BEFORE the user's sessions_valid_after is revoked; one * authored after (or exactly AT it — the re-mint a password change * issues in the same second) stays valid. */ void test_epoch_comparison() { var epoch = new DateTime.utc(2025, 6, 1, 12, 0, 0.0); var before = epoch.add_seconds(-1); check(!UserService.frame_is_valid_at(user_with_epoch(epoch), before), "SA-1: a frame authored 1s before the epoch is revoked"); var after = epoch.add_seconds(1); check(UserService.frame_is_valid_at(user_with_epoch(epoch), after), "SA-1: a frame authored 1s after the epoch stays valid"); check(UserService.frame_is_valid_at(user_with_epoch(epoch), epoch), "SA-1: a frame authored exactly at the epoch stays valid (same-second re-mint)"); check(!UserService.frame_is_valid_at(user_with_epoch(epoch), epoch.add_seconds(-0.5)), "SA-1: sub-second-older frames are revoked (DateTime.compare uses microseconds)"); // Far-past frame against a bumped epoch — the replay SA-1 describes. var replayed = new DateTime.utc(2020, 1, 1, 0, 0, 0.0); check(!UserService.frame_is_valid_at(user_with_epoch(epoch), replayed), "SA-1: a frame replayed from long before the bump is revoked"); } /** * Pre-M0002 rows read back with a NULL epoch: they must behave as the * Unix epoch so migrating an existing database revokes nothing. */ void test_null_epoch_is_unix_epoch() { var migrated = user_with_epoch(null); check(migrated.effective_sessions_valid_after.to_unix() == 0, "M0002: a NULL epoch reads as the Unix epoch (1970-01-01T00:00:00Z)"); var old_frame = new DateTime.utc(2001, 2, 3, 4, 5, 6.0); check(UserService.frame_is_valid_at(migrated, old_frame), "M0002: a pre-migration frame stays valid against a NULL epoch"); check(UserEntity.EPOCH.to_unix() == 0 && UserEntity.EPOCH.get_timezone_abbreviation() == "UTC", "M0002: UserEntity.EPOCH is Unix epoch zero in UTC"); } /** * The bump source: session_epoch_now must be a fresh UTC instant that a * frame authored immediately after it clears (the re-mint contract). */ void test_epoch_now_is_usable_bump() { var before = new DateTime.now_utc(); var bump = UserService.session_epoch_now(); var after = new DateTime.now_utc(); check(bump.to_unix() >= before.to_unix() && bump.to_unix() <= after.to_unix(), "SA-1: session_epoch_now lands between two surrounding now_utc samples"); var user = user_with_epoch(bump); check(UserService.frame_is_valid_at(user, bump), "SA-1: a frame authored at the bump instant itself stays valid"); check(!UserService.frame_is_valid_at(user, bump.add_seconds(-1)), "SA-1: a frame authored 1s before the bump is revoked"); } /** * M0002 shape: additive-only. One ADD COLUMN, nullable (no NOT NULL), no * DEFAULT (the SQLite dialect cannot render a DateTime default), so * existing rows keep working with a NULL epoch. */ void test_m0002_sql_shape() throws GLib.Error { var migration = new M0002_SessionsValidAfter(); check(migration.serial == 2, "M0002: serial is 2 (ordered after M0001)"); check(migration.migration_namespace == "spry-authentication", "M0002: shares M0001's migration namespace"); var builder = new MigrationBuilder(new SqliteDialect()); migration.up(builder); var operations = builder.get_operations(); check(operations.length == 1, "M0002: up() emits exactly one schema operation"); foreach (var op in operations) { var sql = op.to_sql(new SqliteDialect()); check(sql.has_prefix("ALTER TABLE spry_users ADD COLUMN sessions_valid_after "), @"M0002: SQL alters spry_users and adds sessions_valid_after — got: $sql"); check("INTEGER" in sql, @"M0002: the column is stored INTEGER (unix seconds) — got: $sql"); check(!("NOT NULL" in sql), @"M0002: the column is nullable (additive-only upgrade) — got: $sql"); check(!("DEFAULT" in sql), @"M0002: no DEFAULT clause (DateTime defaults are unrenderable) — got: $sql"); } } /** * M0002 down(): the column is dropped again, so the migration is * reversible (losing only the epoch, never user rows). */ void test_m0002_down_shape() throws GLib.Error { var migration = new M0002_SessionsValidAfter(); var builder = new MigrationBuilder(new SqliteDialect()); migration.down(builder); var operations = builder.get_operations(); check(operations.length == 1, "M0002: down() emits exactly one schema operation"); foreach (var op in operations) { var sql = op.to_sql(new SqliteDialect()); check(sql == "ALTER TABLE spry_users DROP COLUMN sessions_valid_after", @"M0002: down() drops only sessions_valid_after — got: $sql"); } } /** * SpryAuth.permission_matches is re-exercised here because the auth * guards (the enforcement point of this change) sit behind it. */ void test_permission_matching_untouched() { string[] patterns = { "admin" }; check(SpryAuth.permission_matches(patterns, "anything"), "guards: the admin wildcard still matches every permission"); string[] scoped = { "users.*" }; check(SpryAuth.permission_matches(scoped, "users.read"), "guards: a trailing-* pattern still prefix-matches"); check(!SpryAuth.permission_matches(scoped, "other.read"), "guards: a non-matching permission still fails"); } public int main(string[] args) { try { test_m0002_sql_shape(); test_m0002_down_shape(); } catch (GLib.Error e) { failures++; print("FAIL M0002 migration shape threw: %s\n", e.message); } test_epoch_comparison(); test_null_epoch_is_unix_epoch(); test_epoch_now_is_usable_bump(); test_permission_matching_untouched(); print("---- %d passed, %d failed ----\n", passes, failures); if (failures > 0) { return 1; } return 0; } }