using InvercargillSql.Migrations; using InvercargillSql.Dialects; using Spry.Authentication; using Spry.Authentication.Migrations; using Spry.Actions; 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"); } /** * SA-8 password policy: the length bounds (8 minimum, 128 maximum, * both inclusive) and the deny-list, evaluated exactly as the three * password-setting actions evaluate them. Multi-byte input counts * code points, not UTF-8 bytes. */ void test_password_policy_bounds() { check(ProfileValidation.PasswordPolicy.assess("1234567") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT, "SA-8: a 7-character password is rejected as too short"); check(ProfileValidation.PasswordPolicy.assess("1234567a") == null, "SA-8: an 8-character password is accepted (minimum is inclusive)"); check(ProfileValidation.PasswordPolicy.assess(string.nfill(128, 'x')) == null, "SA-8: a 128-character password is accepted (maximum is inclusive)"); check(ProfileValidation.PasswordPolicy.assess(string.nfill(129, 'x')) == ProfileValidation.PasswordPolicy.Rejection.TOO_LONG, "SA-8: a 129-character password is rejected as too long"); check(ProfileValidation.PasswordPolicy.assess("ßßßßßß") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT, "SA-8: length counts code points (6 two-byte characters read as 6, not 12)"); check(ProfileValidation.PasswordPolicy.assess("ßßßßßßß") == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT, "SA-8: 7 two-byte characters still count as 7 code points, not 14"); check(ProfileValidation.PasswordPolicy.assess("ßßßßßßßß") == null, "SA-8: 8 multi-byte code points pass the minimum"); } /** SA-8 deny-list: exact (case-insensitive) matches are refused. */ void test_password_policy_deny_list() { foreach (string weak in ProfileValidation.PasswordPolicy.DENY_LIST) { // Some deny-list entries are shorter than the 8-character // minimum and so report TOO_SHORT first — both refuse the // password, which is what the actions rely on. var verdict = ProfileValidation.PasswordPolicy.assess(weak); check(verdict == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON || verdict == ProfileValidation.PasswordPolicy.Rejection.TOO_SHORT, @"SA-8: deny-list entry '$weak' is refused"); } check(ProfileValidation.PasswordPolicy.assess("password") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON, "SA-8: 'password' is refused as too common"); check(ProfileValidation.PasswordPolicy.assess("PASSWORD") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON, "SA-8: the deny-list comparison is case-insensitive"); check(ProfileValidation.PasswordPolicy.assess("Change-Me") == ProfileValidation.PasswordPolicy.Rejection.TOO_COMMON, "SA-8: 'Change-Me' (the old README example) is refused"); check(ProfileValidation.PasswordPolicy.assess("Xk7-prod-seed-9f2mQv") == null, "SA-8: a generated-looking password passes the deny-list"); check(ProfileValidation.PasswordPolicy.assess("password-with-more-text") == null, "SA-8: the deny-list matches exactly, not by substring"); check(ProfileValidation.PasswordPolicy.is_denied("qwerty123") == true, "SA-7: is_denied answers true for a long-enough well-known entry"); check(ProfileValidation.PasswordPolicy.is_denied("password-with-more-text") == false, "SA-7: is_denied answers false for a merely similar password"); check(ProfileValidation.PasswordPolicy.MIN_LENGTH == 8 && ProfileValidation.PasswordPolicy.MAX_LENGTH == 128, "SA-8: the policy bounds are 8 and 128"); } /** * SA-16 PHC-string parsing: m/t/p extraction from a libsodium * hash, PHC default costs for a bare string, and malformed input. */ void test_hash_parameter_parsing() { var parsed = UserService.parse_hash_parameters( "$argon2id$v=19$m=262144,t=3,p=1$BJRNHzAq5K1sJMZfNZZFWA$915y7WkAuE67mZ9QLS402OnnDwpSZsqE+ZipbdJIIuw"); check(parsed != null, "SA-16: a real libsodium hash string parses"); var p = (!)parsed; check(p.algorithm == "argon2id", "SA-16: the algorithm reads as argon2id"); check(p.version == 19, "SA-16: the version reads as 19"); check(p.memory == 262144, "SA-16: m= reads as 262144 KiB (256 MiB)"); check(p.passes == 3, "SA-16: t= reads as 3"); check(p.parallelism == 1, "SA-16: p= reads as 1"); var old = UserService.parse_hash_parameters("$argon2id$v=19$m=65536,t=2,p=1$c2FsdA$aGFzaA"); check(old != null && ((!)old).memory == 65536 && ((!)old).passes == 2, "SA-16: lower m/t values parse out individually"); var defaults = UserService.parse_hash_parameters("$argon2id$v=19$c2FsdA"); check(defaults != null && ((!)defaults).memory == 32 && ((!)defaults).passes == 3 && ((!)defaults).parallelism == 1, "SA-16: a hash without a cost field parses with the PHC defaults (m=32,t=3,p=1)"); check(UserService.parse_hash_parameters("not-a-phc-string") == null, "SA-16: a plain string does not parse"); check(UserService.parse_hash_parameters("$argon2id$c2FsdA") == null, "SA-16: a string with no v= field does not parse"); check(UserService.parse_hash_parameters("$argon2id$v=x$m=1,t=1,p=1$a$b") == null, "SA-16: a non-numeric version does not parse"); check(UserService.parse_hash_parameters("$argon2id$v=19$m=abc$t=1$a$b") == null, "SA-16: an unparsable cost value does not parse"); check(UserService.parse_hash_parameters("$argon2id$v=19$m=0,t=1,p=1$a$b") == null, "SA-16: a zero cost value does not parse"); check(UserService.parse_hash_parameters("") == null, "SA-16: the empty string does not parse"); check(UserService.parse_hash_parameters("$bcrypt$v=19$m=1024$a$b") == null, "SA-16: the parser is Argon2-specific — a bcrypt-style string does not parse"); check(UserService.hash_needs_rehash("$bcrypt$v=19$m=1024$a$b"), "SA-16: a non-argon2 PHC string still needs a rehash"); } /** * SA-16 rehash decision: a hash at today's parameters needs no * rewrite; anything older, weaker or malformed does. */ void test_rehash_decision() { string current = "$argon2id$v=19$m=262144,t=3,p=1$BJRNHzAq5K1sJMZfNZZFWA$915y7WkAuE67mZ9QLS402OnnDwpSZsqE+ZipbdJIIuw"; check(!UserService.hash_needs_rehash(current), "SA-16: a hash at the current MODERATE parameters needs no rehash"); check(UserService.hash_needs_rehash("$argon2id$v=19$m=65536,t=2,p=1$c2FsdA$aGFzaA"), "SA-16: a hash at old (weaker) parameters needs a rehash"); check(UserService.hash_needs_rehash("$argon2id$v=19$m=262144,t=3,p=2$c2FsdA$aGFzaA"), "SA-16: a hash with different parallelism needs a rehash"); check(UserService.hash_needs_rehash("$argon2id$v=16$m=262144,t=3,p=1$c2FsdA$aGFzaA"), "SA-16: a hash at another PHC version needs a rehash"); check(UserService.hash_needs_rehash("$argon2i$v=19$m=262144,t=3,p=1$c2FsdA$aGFzaA"), "SA-16: a hash from another Argon2 flavour needs a rehash"); check(UserService.hash_needs_rehash("$argon2id$v=19$c2FsdA"), "SA-16: a hash relying on PHC default costs needs a rehash (m=32 default)"); check(UserService.hash_needs_rehash("garbage"), "SA-16: a malformed stored hash needs a rehash (only migration path)"); // Cross-check against the parameters hash() actually writes: // whatever libsodium emits must read back as not-needing-rehash. string fresh = Sodium.PasswordHashing.hash("correct horse battery staple"); check(fresh != null && !UserService.hash_needs_rehash((!)fresh), "SA-16: a freshly minted libsodium hash is recognised as current"); // The upgrade path itself: a hash written at non-default limits // needs a rehash, and the SAME password verifies against both // the old and the rewritten hash (rehash_password never changes // the credential, only its storage parameters). string password = "correct horse battery staple"; string weaker = Sodium.PasswordHashing.hash(password, Sodium.PasswordHashing.OPSLIMIT_INTERACTIVE, Sodium.PasswordHashing.MEMLIMIT_INTERACTIVE); check(weaker != null && UserService.hash_needs_rehash((!)weaker), "SA-16: a hash minted at INTERACTIVE limits is detected as stale"); check(Sodium.PasswordHashing.check((!)weaker, password), "SA-16: the password still verifies against the stale hash"); check(Sodium.PasswordHashing.check((!)fresh, password), "SA-16: the password also verifies against the fresh (upgraded-shape) hash"); check(!UserService.hash_needs_rehash((!)fresh), "SA-16: rewriting at current parameters clears the rehash flag"); } /** * SA-17 generic client error text: {@link * Spry.Actions.ProfileValidation.internal_error_message} returns the * generic text unchanged whatever the error carries (full detail goes * to the server log), and the notification constants of the seven * affected actions carry no %s placeholder, so no interpolated * internal detail can leak even if a future call site reintroduces * printf-style formatting. */ void test_generic_error_messages() { var noisy = new DummyError.NOISY( "UNIQUE constraint failed: /var/lib/app/store.sqlite INSERT INTO spry_users ..."); string generic = "Registration failed. Please try again later."; string shown = ProfileValidation.internal_error_message(noisy, generic); check(shown == generic, "SA-17: internal_error_message returns the generic text unchanged"); check(!shown.contains("sqlite") && !shown.contains("INSERT") && !shown.contains("/var/lib"), "SA-17: no file path or SQL fragment reaches the client text"); foreach (string constant in new string[] { "Registration failed. Please try again later.", "Could not change your password. Please try again later.", "Could not update the user. Please try again later.", "Could not delete the user. Please try again later.", "Could not grant the permission. Please try again later.", "Could not revoke the permission. Please try again later."}) { check(!constant.contains("%s") && !constant.contains("%d"), @"SA-17: the notification constant '$constant' has no format placeholder"); } } /** Minimal Error for feeding internal_error_message real text. */ private errordomain DummyError { NOISY } /** * SA-7 advisory: the admin minimum is 12 characters and is_denied * powers the seeding warning (warn-only, never blocking). */ void test_admin_seed_thresholds() { check(ProfileValidation.PasswordPolicy.ADMIN_MIN_LENGTH == 12, "SA-7: the admin seeding warning threshold is 12 characters"); check(ProfileValidation.PasswordPolicy.is_denied("change-me"), "SA-7: the README's old 'change-me' example trips the deny-list warning"); check(!ProfileValidation.PasswordPolicy.is_denied("Xk7-prod-seed-9f2mQv"), "SA-7: the README's generated-style example does not trip it"); } 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(); test_password_policy_bounds(); test_password_policy_deny_list(); test_hash_parameter_parsing(); test_rehash_decision(); test_generic_error_messages(); test_admin_seed_thresholds(); print("---- %d passed, %d failed ----\n", passes, failures); if (failures > 0) { return 1; } return 0; } }