| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- 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;
- }
- }
|