TestMain.vala 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. using InvercargillSql.Migrations;
  2. using InvercargillSql.Dialects;
  3. using Spry.Authentication;
  4. using Spry.Authentication.Migrations;
  5. namespace Spry.Tests {
  6. int failures = 0;
  7. int passes = 0;
  8. void check(bool condition, string label) {
  9. if (condition) {
  10. passes++;
  11. print("PASS %s\n", label);
  12. } else {
  13. failures++;
  14. print("FAIL %s\n", label);
  15. }
  16. }
  17. UserProjection user_with_epoch(DateTime? epoch) {
  18. var user = new UserProjection();
  19. user.id = 42;
  20. user.enabled = true;
  21. user.sessions_valid_after = epoch;
  22. return user;
  23. }
  24. /**
  25. * The epoch comparison at the heart of SA-1/SA-14 revocation: a frame
  26. * authored BEFORE the user's sessions_valid_after is revoked; one
  27. * authored after (or exactly AT it — the re-mint a password change
  28. * issues in the same second) stays valid.
  29. */
  30. void test_epoch_comparison() {
  31. var epoch = new DateTime.utc(2025, 6, 1, 12, 0, 0.0);
  32. var before = epoch.add_seconds(-1);
  33. check(!UserService.frame_is_valid_at(user_with_epoch(epoch), before),
  34. "SA-1: a frame authored 1s before the epoch is revoked");
  35. var after = epoch.add_seconds(1);
  36. check(UserService.frame_is_valid_at(user_with_epoch(epoch), after),
  37. "SA-1: a frame authored 1s after the epoch stays valid");
  38. check(UserService.frame_is_valid_at(user_with_epoch(epoch), epoch),
  39. "SA-1: a frame authored exactly at the epoch stays valid (same-second re-mint)");
  40. check(!UserService.frame_is_valid_at(user_with_epoch(epoch), epoch.add_seconds(-0.5)),
  41. "SA-1: sub-second-older frames are revoked (DateTime.compare uses microseconds)");
  42. // Far-past frame against a bumped epoch — the replay SA-1 describes.
  43. var replayed = new DateTime.utc(2020, 1, 1, 0, 0, 0.0);
  44. check(!UserService.frame_is_valid_at(user_with_epoch(epoch), replayed),
  45. "SA-1: a frame replayed from long before the bump is revoked");
  46. }
  47. /**
  48. * Pre-M0002 rows read back with a NULL epoch: they must behave as the
  49. * Unix epoch so migrating an existing database revokes nothing.
  50. */
  51. void test_null_epoch_is_unix_epoch() {
  52. var migrated = user_with_epoch(null);
  53. check(migrated.effective_sessions_valid_after.to_unix() == 0,
  54. "M0002: a NULL epoch reads as the Unix epoch (1970-01-01T00:00:00Z)");
  55. var old_frame = new DateTime.utc(2001, 2, 3, 4, 5, 6.0);
  56. check(UserService.frame_is_valid_at(migrated, old_frame),
  57. "M0002: a pre-migration frame stays valid against a NULL epoch");
  58. check(UserEntity.EPOCH.to_unix() == 0 && UserEntity.EPOCH.get_timezone_abbreviation() == "UTC",
  59. "M0002: UserEntity.EPOCH is Unix epoch zero in UTC");
  60. }
  61. /**
  62. * The bump source: session_epoch_now must be a fresh UTC instant that a
  63. * frame authored immediately after it clears (the re-mint contract).
  64. */
  65. void test_epoch_now_is_usable_bump() {
  66. var before = new DateTime.now_utc();
  67. var bump = UserService.session_epoch_now();
  68. var after = new DateTime.now_utc();
  69. check(bump.to_unix() >= before.to_unix() && bump.to_unix() <= after.to_unix(),
  70. "SA-1: session_epoch_now lands between two surrounding now_utc samples");
  71. var user = user_with_epoch(bump);
  72. check(UserService.frame_is_valid_at(user, bump),
  73. "SA-1: a frame authored at the bump instant itself stays valid");
  74. check(!UserService.frame_is_valid_at(user, bump.add_seconds(-1)),
  75. "SA-1: a frame authored 1s before the bump is revoked");
  76. }
  77. /**
  78. * M0002 shape: additive-only. One ADD COLUMN, nullable (no NOT NULL), no
  79. * DEFAULT (the SQLite dialect cannot render a DateTime default), so
  80. * existing rows keep working with a NULL epoch.
  81. */
  82. void test_m0002_sql_shape() {
  83. var migration = new M0002_SessionsValidAfter();
  84. check(migration.serial == 2, "M0002: serial is 2 (ordered after M0001)");
  85. check(migration.migration_namespace == "spry-authentication",
  86. "M0002: shares M0001's migration namespace");
  87. var builder = new MigrationBuilder(new SqliteDialect());
  88. migration.up(builder);
  89. var operations = builder.get_operations();
  90. check(operations.length == 1, "M0002: up() emits exactly one schema operation");
  91. foreach (var op in operations) {
  92. var sql = op.to_sql(new SqliteDialect());
  93. check(sql.has_prefix("ALTER TABLE spry_users ADD COLUMN sessions_valid_after "),
  94. @"M0002: SQL alters spry_users and adds sessions_valid_after — got: $sql");
  95. check("INTEGER" in sql,
  96. @"M0002: the column is stored INTEGER (unix seconds) — got: $sql");
  97. check(!("NOT NULL" in sql),
  98. @"M0002: the column is nullable (additive-only upgrade) — got: $sql");
  99. check(!("DEFAULT" in sql),
  100. @"M0002: no DEFAULT clause (DateTime defaults are unrenderable) — got: $sql");
  101. }
  102. }
  103. /**
  104. * M0002 down(): the column is dropped again, so the migration is
  105. * reversible (losing only the epoch, never user rows).
  106. */
  107. void test_m0002_down_shape() {
  108. var migration = new M0002_SessionsValidAfter();
  109. var builder = new MigrationBuilder(new SqliteDialect());
  110. migration.down(builder);
  111. var operations = builder.get_operations();
  112. check(operations.length == 1, "M0002: down() emits exactly one schema operation");
  113. foreach (var op in operations) {
  114. var sql = op.to_sql(new SqliteDialect());
  115. check(sql == "ALTER TABLE spry_users DROP COLUMN sessions_valid_after",
  116. @"M0002: down() drops only sessions_valid_after — got: $sql");
  117. }
  118. }
  119. /**
  120. * SpryAuth.permission_matches is re-exercised here because the auth
  121. * guards (the enforcement point of this change) sit behind it.
  122. */
  123. void test_permission_matching_untouched() {
  124. string[] patterns = { "admin" };
  125. check(SpryAuth.permission_matches(patterns, "anything"),
  126. "guards: the admin wildcard still matches every permission");
  127. string[] scoped = { "users.*" };
  128. check(SpryAuth.permission_matches(scoped, "users.read"),
  129. "guards: a trailing-* pattern still prefix-matches");
  130. check(!SpryAuth.permission_matches(scoped, "other.read"),
  131. "guards: a non-matching permission still fails");
  132. }
  133. public int main(string[] args) {
  134. test_epoch_comparison();
  135. test_null_epoch_is_unix_epoch();
  136. test_epoch_now_is_usable_bump();
  137. test_m0002_sql_shape();
  138. test_m0002_down_shape();
  139. test_permission_matching_untouched();
  140. print("---- %d passed, %d failed ----\n", passes, failures);
  141. if (failures > 0) {
  142. return 1;
  143. }
  144. return 0;
  145. }
  146. }