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