|
|
@@ -3,6 +3,7 @@ using Invercargill.DataStructures;
|
|
|
using Invercargill.Mapping;
|
|
|
using InvercargillJson;
|
|
|
using Inversion;
|
|
|
+using Astralis;
|
|
|
using Statum;
|
|
|
|
|
|
namespace Statum.Tests {
|
|
|
@@ -167,6 +168,198 @@ namespace Statum.Tests {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 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());
|
|
|
|
|
|
@@ -183,6 +376,9 @@ namespace Statum.Tests {
|
|
|
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();
|
|
|
@@ -208,6 +404,36 @@ namespace Statum.Tests {
|
|
|
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) {
|