| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- 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<Model.ActionDto> refs { get; set; }
- }
- /** Typed action WITHOUT a `private_type` override — demonstrates S-8 erosion. */
- public class ErodingAction : TypedStatumAction<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();
- }
- }
- /** Typed action WITH the `private_type` override — the supported pattern. */
- public class FixedAction : TypedStatumAction<LoginPrivate> {
- 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<Properties>());
- 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<Model.ActionDto>();
- 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<Properties>();
- 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>(Model.ActionDto.get_mapper());
- var container = new Container();
- container.register_singleton<Statum.Cryptography.SigningProvider>();
- container.register_singleton<Statum.Cryptography.EncryptionProvider>();
- container.register_singleton<StateService>();
- container.register_singleton<HeldSlotResolver>();
- container.register_singleton<ChannelEndpoint>().as<ChannelService>();
- container.register_singleton<ActionRegistry>();
- container.register_singleton<TopicRegistry>();
- container.register_scoped<ErodingAction>();
- container.register_scoped<FixedAction>();
- 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);
- }
- }
- }
|