|
|
@@ -0,0 +1,171 @@
|
|
|
+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() {
|
|
|
+ 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() {
|
|
|
+ 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) {
|
|
|
+ test_epoch_comparison();
|
|
|
+ test_null_epoch_is_unix_epoch();
|
|
|
+ test_epoch_now_is_usable_bump();
|
|
|
+ test_m0002_sql_shape();
|
|
|
+ test_m0002_down_shape();
|
|
|
+ test_permission_matching_untouched();
|
|
|
+
|
|
|
+ print("---- %d passed, %d failed ----\n", passes, failures);
|
|
|
+ if (failures > 0) {
|
|
|
+ return 1;
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+}
|