using Invercargill; using Invercargill.DataStructures; using Invercargill.Mapping; using InvercargillJson; using Inversion; using Astralis; using Statum; namespace Statum.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); } } /** * Scalar model in the style of Percipio's login private data: the field * types that previously crashed (int64, bool) or mis-serialised (int) the * encryption path. */ public class LoginPrivate : Object { public int64 user_id { get; set; } public bool is_admin { get; set; } public int attempts { get; set; } public double score { get; set; } public string username { get; set; } public long session_no { get; set; } } /** List-bearing model: a `string[]` plus a collection of a registered type. */ public class ArrayModel : Object { public string[] tags { get; set; } public Series refs { get; set; } } /** Typed action WITHOUT a `private_type` override — demonstrates S-8 erosion. */ public class ErodingAction : TypedStatumAction { public Type exposed_private_type() { return private_type; } public LoginPrivate? exposed_request_private() { return request_private; } public void feed(Properties props) { request = new StatumRequest() { action_private = props }; } public override async DirectiveBuilder handle() throws GLib.Error { return directives(); } } /** Typed action WITH the `private_type` override — the supported pattern. */ public class FixedAction : TypedStatumAction { protected override Type private_type { get { return typeof(LoginPrivate); } } public Type exposed_private_type() { return private_type; } public LoginPrivate? exposed_request_private() { return request_private; } public void feed(Properties props) { request = new StatumRequest() { action_private = props }; } public override async DirectiveBuilder handle() throws GLib.Error { return directives(); } } LoginPrivate sample_private() { return new LoginPrivate() { user_id = (int64)9007199254740993, is_admin = true, attempts = 3, score = 0.5, username = "bbarrow", session_no = (long)123456789012345 }; } void assert_private_matches(LoginPrivate expected, LoginPrivate actual, string label) { check(actual.user_id == expected.user_id, label + ": user_id int64 round-trip"); check(actual.is_admin == expected.is_admin, label + ": is_admin bool round-trip"); check(actual.attempts == expected.attempts, label + ": attempts int round-trip"); check(actual.score == expected.score, label + ": score double round-trip"); check(actual.username == expected.username, label + ": username string round-trip"); check(actual.session_no == expected.session_no, label + ": session_no long round-trip"); } void test_scalar_public_path() throws GLib.Error { var model = sample_private(); var props = GObjectMapping.to_properties(model); var wire = new JsonElement.from_properties(props).stringify(); check(wire.contains("\"user_id\":9007199254740993"), "public path: int64 serialises as an unquoted JSON number"); check(wire.contains("\"is_admin\":true"), "public path: bool serialises as an unquoted JSON boolean"); check(wire.contains("\"attempts\":3"), "public path: int serialises as an unquoted JSON number, not a string"); check(wire.contains("\"score\":0.5"), "public path: double serialises as an unquoted JSON number"); var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate), new JsonElement.from_string(wire).as()); assert_private_matches(model, back, "public path"); } void test_scalar_encryption_path(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error { var model = sample_private(); var blob = encryption.author_properties("statum-tests", GObjectMapping.to_properties(model)); var props = encryption.read_properties("statum-tests", blob); var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate), props); assert_private_matches(model, back, "encryption path"); } void test_arrays(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error { var first = new Model.ActionDto() { uri = "/_statum/action/aaaa", method = "POST" }; var second = new Model.ActionDto() { uri = "/_statum/action/bbbb", method = "DELETE" }; var refs = new Series(); refs.add(first); refs.add(second); var model = new ArrayModel() { tags = new string[] { "alpha", "beta", "gamma" }, refs = refs }; var props = GObjectMapping.to_properties(model); var wire = new JsonElement.from_properties(props).stringify(); check(wire.contains("\"tags\":[\"alpha\",\"beta\",\"gamma\"]"), "arrays: string[] serialises as a JSON array"); check(wire.contains("\"uri\":\"/_statum/action/bbbb\"") && wire.contains("\"method\":\"DELETE\""), "arrays: registered-Object collection serialises as an array of nested objects"); var parsed = new JsonElement.from_string(wire).as(); var array_model = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel), parsed); check(array_model.tags.length == 3 && array_model.tags[0] == "alpha" && array_model.tags[1] == "beta" && array_model.tags[2] == "gamma", "arrays: string[] read-back from JSON array"); var blob = encryption.author_properties("statum-tests", props); var decrypted = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel), encryption.read_properties("statum-tests", blob)); check(decrypted.tags.length == 3 && decrypted.tags[2] == "gamma", "arrays: string[] round-trips through the encryption path"); } void test_typed_action_private(Inversion.Scope scope, Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error { var eroding = (ErodingAction) scope.resolve_type(typeof(ErodingAction)); check(eroding.exposed_private_type() == typeof(void), "S-8 repro: typeof(TPrivate) erodes to void for a reflectively-instantiated action"); eroding.feed(new PropertyDictionary()); check(eroding.exposed_request_private() == null, "S-8 repro: un-overridden request_private yields null without crashing"); var fixed_action = (FixedAction) scope.resolve_type(typeof(FixedAction)); check(fixed_action.exposed_private_type() == typeof(LoginPrivate), "S-8 fix: private_type override captures the GType from a foreign namespace"); var model = sample_private(); var blob = encryption.author_properties(fixed_action.get_type().name(), GObjectMapping.to_properties(model)); fixed_action.feed(encryption.read_properties(fixed_action.get_type().name(), blob)); var private_data = fixed_action.exposed_request_private(); check(private_data != null, "S-8 fix: request_private materialises from a sealed blob"); if (private_data != null) { assert_private_matches(model, (!)private_data, "S-8 fix"); } } /** * SA-4: restore_slot must enforce the snapshot's invalid_after and must * not refresh last_touched, so replayed frames cannot pin slots forever * and the cleanup sweep still expires restored slots. */ void test_restore_slot_expiry(StateService states, Statum.Cryptography.SigningProvider signing) throws GLib.Error { // A live frame authored now: restorable, keeps its own as_at as // last_touched, and still expirable by cleanup. var slot = states.new_slot(Scope.SESSION, new State() { type_name = "test-sa4", public_data = new PropertyDictionary(), private_data = new PropertyDictionary() }); var live_frame = sign_snapshot(signing, slot.id, "test-sa4", new DateTime.now_utc().add_hours(6)); var before_restore = new DateTime.now_utc(); // Evict the server-side copy first so restore_slot has an empty cache // to restore into (a non-empty slot makes restore a no-op). states.clear_slot.begin(slot.id); check(states.get_slot(slot.id) == null, "SA-4: slot evicted before first restore"); var restored = states.restore_slot(live_frame); check(restored, "SA-4: a frame inside its invalid_after window restores"); var cached = states.get_slot(slot.id); check(cached != null, "SA-4: the restored slot is cached"); if (cached != null) { var touched = ((!)cached).current_state.last_touched; check(touched.compare(before_restore) <= 0, "SA-4: restore does not refresh last_touched (the clock is not 'now')"); } // The same slot re-restored after eviction must still expire through // cleanup, because last_touched came from the snapshot, not 'now'. states.clear_slot.begin(slot.id); // remove from cache (async push is a no-op) check(states.get_slot(slot.id) == null, "SA-4: slot evicted before replay"); // Re-sign a frame with an as_at in the past but still valid. var old_touch = new DateTime.now_utc().add_days(-6); var stale_frame = sign_snapshot(signing, slot.id, "test-sa4-old", new DateTime.now_utc().add_hours(1), old_touch); check(states.restore_slot(stale_frame), "SA-4: stale-but-valid frame restores"); states.cleanup(); check(states.get_slot(slot.id) != null, "SA-4: restored slot within EXPIRY_SECONDS survives cleanup"); // A frame whose as_at predates the 7-day expiry is gone after cleanup. var ancient_touch = new DateTime.now_utc().add_days(-9); var ancient_frame = sign_snapshot(signing, slot.id + "-ancient", "test-sa4-ancient", new DateTime.now_utc().add_hours(1), ancient_touch); check(states.restore_slot(ancient_frame), "SA-4: ancient-but-valid frame restores (once)"); states.cleanup(); check(states.get_slot(slot.id + "-ancient") == null, "SA-4: cleanup expires a restored slot (restore no longer resets last_touched to now)"); // A frame past its invalid_after is rejected outright. var expired_frame = sign_snapshot(signing, slot.id + "-expired", "test-sa4-expired", new DateTime.now_utc().add_seconds(-5)); bool threw = false; try { states.restore_slot(expired_frame); } catch (StateError e) { threw = true; } catch (GLib.Error e) { threw = false; } check(threw, "SA-4: restore_slot rejects a frame past invalid_after with FRAME_EXPIRED"); check(states.get_slot(slot.id + "-expired") == null, "SA-4: a rejected frame leaves no slot cached"); // SA-4 bonus: a rejected replay also drops any slot already cached // under the same key, so the stale entry cannot linger. var cached_key = slot.id + "-drop"; check(states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop", new DateTime.now_utc().add_hours(1))), "SA-4: slot cached for the drop test"); check(states.get_slot(cached_key) != null, "SA-4: slot present before expired replay"); try { states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop", new DateTime.now_utc().add_seconds(-5))); } catch (StateError.FRAME_EXPIRED e) { // expected } check(states.get_slot(cached_key) == null, "SA-4: an expired replay of the same key invalidates the cached slot"); } /** Signs a snapshot for `key` valid until `invalid_after`, authored `as_at`. */ Model.FrameDto sign_snapshot(Statum.Cryptography.SigningProvider signing, string key, string type, DateTime invalid_after, DateTime? as_at = null) throws GLib.Error { var dto = new Model.SnapshotDto() { as_at = as_at ?? new DateTime.now_utc(), slot = new Model.SlotDto() { key = key, scope = Scope.SESSION, slot_type = type }, transmit_after = new DateTime.now_utc().add_seconds(60), invalid_after = invalid_after, @public = new PropertyDictionary() }; var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(dto)).stringify(); return signing.author_frame(json); } /** SA-10: verify_frame must fail closed on a mismatched signer claim. */ void test_verify_frame_signer(Statum.Cryptography.SigningProvider signing, Statum.Cryptography.SigningProvider other) throws GLib.Error { var frame = sign_snapshot(signing, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1)); signing.verify_frame(frame); check(true, "SA-10: a frame signed and claimed by this provider verifies"); // Same content and signature, forged signer claim. var forged = new Model.FrameDto() { content = frame.content, signer = other.signer_id, signature = frame.signature }; bool threw = false; try { signing.verify_frame(forged); } catch (Statum.Cryptography.SigningError e) { threw = true; } check(threw, "SA-10: verify_frame rejects a frame whose signer != signer_id"); // A frame actually signed by another provider is rejected too (its // signature does not verify against this key). var foreign = sign_snapshot(other, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1)); threw = false; try { signing.verify_frame(foreign); } catch (Statum.Cryptography.SigningError e) { threw = true; } check(threw, "SA-10: verify_frame rejects a frame signed by a different key"); } /** SA-15: global and per-IP channel connection caps. */ void test_connection_caps() { var tracker = new ConnectionTracker(4, 2); check(tracker.can_accept("10.0.0.1"), "SA-15: first connection from an IP is accepted"); tracker.acquired("10.0.0.1"); check(tracker.can_accept("10.0.0.1"), "SA-15: second connection from the same IP is accepted"); tracker.acquired("10.0.0.1"); check(!tracker.can_accept("10.0.0.1"), "SA-15: third connection from the same IP is refused (per-IP cap)"); check(tracker.can_accept("10.0.0.2"), "SA-15: another IP is unaffected by the per-IP cap"); tracker.released("10.0.0.1"); check(tracker.can_accept("10.0.0.1"), "SA-15: releasing a connection frees its per-IP slot"); tracker.acquired("10.0.0.2"); tracker.acquired("10.0.0.3"); // total: 1 + 1 + 1 = 3 of 4 — the next is the last. check(tracker.can_accept("10.0.0.4"), "SA-15: connection accepted up to the global cap"); tracker.acquired("10.0.0.4"); check(!tracker.can_accept("10.0.0.4"), "SA-15: connections beyond the global cap are refused"); check(!tracker.can_accept("192.168.0.1"), "SA-15: the global cap refuses new IPs too"); check(tracker.open_total() == 4, "SA-15: the global count tracks acquisitions"); tracker.released("10.0.0.2"); check(tracker.can_accept("192.168.0.1"), "SA-15: a release frees the global slot"); // Unknown clients count globally but cannot trip the per-IP cap. var anon = new ConnectionTracker(1, 2); check(anon.can_accept(null), "SA-15: unknown client is accepted when under the global cap"); anon.acquired(null); check(!anon.can_accept(null), "SA-15: unknown clients still respect the global cap"); check(anon.open_for(null) == 0, "SA-15: unknown client is not counted per-IP"); } /** SA-15: subscriptions per channel are bounded. */ void test_subscription_cap(ChannelEndpoint channel) { // subscribe() only acts on channels with an open connection; without // one it must simply no-op (returning false) rather than crash. bool recorded = channel.subscribe("nonexistent-channel", "some-key"); check(!recorded, "SA-15: subscribing to a channel with no connection is a no-op"); } /** SA-6: the authorize hooks default open and honour installed guards. */ void test_authorization_policy() { var policy = new AuthorizationPolicy(); check(policy.authorize_entrypoint(null) == null, "SA-6: entrypoint is open when no guard is installed"); check(policy.authorize_channel(null) == null, "SA-6: channel is open when no guard is installed"); check(policy.authorize_channel_subscriptions(null) == null, "SA-6: channel subscriptions are open when no guard is installed"); var denied = new HttpStringResult("login required", StatusCode.UNAUTHORIZED); policy.guard_channel(ctx => denied); var decision = policy.authorize_channel(null); check(decision != null && ((!)decision).status == StatusCode.UNAUTHORIZED, "SA-6: an installed channel guard short-circuits with its result"); check(policy.authorize_entrypoint(null) == null, "SA-6: the channel guard does not leak into the entrypoint"); policy.guard_entrypoint(ctx => null); check(policy.authorize_entrypoint(null) == null, "SA-6: a guard returning null allows the request"); } void main() { GObjectMapping.register_mapper(Model.ActionDto.get_mapper()); var container = new Container(); container.register_singleton(); container.register_singleton(); container.register_singleton(); container.register_singleton(); container.register_singleton().as(); container.register_singleton(); container.register_singleton(); container.register_scoped(); container.register_scoped(); var scope = container.create_transient_scope(); var encryption = (Statum.Cryptography.EncryptionProvider) scope.resolve_type(typeof(Statum.Cryptography.EncryptionProvider)); var signing = (Statum.Cryptography.SigningProvider) scope.resolve_type(typeof(Statum.Cryptography.SigningProvider)); var states = (StateService) scope.resolve_type(typeof(StateService)); var channel = (ChannelEndpoint) scope.resolve_type(typeof(ChannelEndpoint)); try { test_scalar_public_path(); } catch (GLib.Error e) { failures++; print("FAIL scalar public path threw: %s\n", e.message); } try { test_scalar_encryption_path(encryption); } catch (GLib.Error e) { failures++; print("FAIL scalar encryption path threw: %s\n", e.message); } try { test_arrays(encryption); } catch (GLib.Error e) { failures++; print("FAIL arrays threw: %s\n", e.message); } try { test_typed_action_private(scope, encryption); } catch (GLib.Error e) { failures++; print("FAIL typed action private threw: %s\n", e.message); } try { test_restore_slot_expiry(states, signing); } catch (GLib.Error e) { failures++; print("FAIL restore_slot expiry threw: %s\n", e.message); } try { test_verify_frame_signer(signing, new Statum.Cryptography.SigningProvider()); } catch (GLib.Error e) { failures++; print("FAIL verify_frame signer threw: %s\n", e.message); } try { test_connection_caps(); } catch (GLib.Error e) { failures++; print("FAIL connection caps threw: %s\n", e.message); } try { test_subscription_cap(channel); } catch (GLib.Error e) { failures++; print("FAIL subscription cap threw: %s\n", e.message); } try { test_authorization_policy(); } catch (GLib.Error e) { failures++; print("FAIL authorization policy threw: %s\n", e.message); } print("---- %d passed, %d failed ----\n", passes, failures); if (failures > 0) { Process.exit(1); } } }