TestMain.vala 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Mapping;
  4. using InvercargillJson;
  5. using Inversion;
  6. using Astralis;
  7. using Statum;
  8. namespace Statum.Tests {
  9. int failures = 0;
  10. int passes = 0;
  11. void check(bool condition, string label) {
  12. if (condition) {
  13. passes++;
  14. print("PASS %s\n", label);
  15. } else {
  16. failures++;
  17. print("FAIL %s\n", label);
  18. }
  19. }
  20. /**
  21. * Scalar model in the style of Percipio's login private data: the field
  22. * types that previously crashed (int64, bool) or mis-serialised (int) the
  23. * encryption path.
  24. */
  25. public class LoginPrivate : Object {
  26. public int64 user_id { get; set; }
  27. public bool is_admin { get; set; }
  28. public int attempts { get; set; }
  29. public double score { get; set; }
  30. public string username { get; set; }
  31. public long session_no { get; set; }
  32. }
  33. /** List-bearing model: a `string[]` plus a collection of a registered type. */
  34. public class ArrayModel : Object {
  35. public string[] tags { get; set; }
  36. public Series<Model.ActionDto> refs { get; set; }
  37. }
  38. /** Typed action WITHOUT a `private_type` override — demonstrates S-8 erosion. */
  39. public class ErodingAction : TypedStatumAction<LoginPrivate> {
  40. public Type exposed_private_type() {
  41. return private_type;
  42. }
  43. public LoginPrivate? exposed_request_private() {
  44. return request_private;
  45. }
  46. public void feed(Properties props) {
  47. request = new StatumRequest() { action_private = props };
  48. }
  49. public override async DirectiveBuilder handle() throws GLib.Error {
  50. return directives();
  51. }
  52. }
  53. /** Typed action WITH the `private_type` override — the supported pattern. */
  54. public class FixedAction : TypedStatumAction<LoginPrivate> {
  55. protected override Type private_type { get { return typeof(LoginPrivate); } }
  56. public Type exposed_private_type() {
  57. return private_type;
  58. }
  59. public LoginPrivate? exposed_request_private() {
  60. return request_private;
  61. }
  62. public void feed(Properties props) {
  63. request = new StatumRequest() { action_private = props };
  64. }
  65. public override async DirectiveBuilder handle() throws GLib.Error {
  66. return directives();
  67. }
  68. }
  69. LoginPrivate sample_private() {
  70. return new LoginPrivate() {
  71. user_id = (int64)9007199254740993,
  72. is_admin = true,
  73. attempts = 3,
  74. score = 0.5,
  75. username = "bbarrow",
  76. session_no = (long)123456789012345
  77. };
  78. }
  79. void assert_private_matches(LoginPrivate expected, LoginPrivate actual, string label) {
  80. check(actual.user_id == expected.user_id, label + ": user_id int64 round-trip");
  81. check(actual.is_admin == expected.is_admin, label + ": is_admin bool round-trip");
  82. check(actual.attempts == expected.attempts, label + ": attempts int round-trip");
  83. check(actual.score == expected.score, label + ": score double round-trip");
  84. check(actual.username == expected.username, label + ": username string round-trip");
  85. check(actual.session_no == expected.session_no, label + ": session_no long round-trip");
  86. }
  87. void test_scalar_public_path() throws GLib.Error {
  88. var model = sample_private();
  89. var props = GObjectMapping.to_properties(model);
  90. var wire = new JsonElement.from_properties(props).stringify();
  91. check(wire.contains("\"user_id\":9007199254740993"), "public path: int64 serialises as an unquoted JSON number");
  92. check(wire.contains("\"is_admin\":true"), "public path: bool serialises as an unquoted JSON boolean");
  93. check(wire.contains("\"attempts\":3"), "public path: int serialises as an unquoted JSON number, not a string");
  94. check(wire.contains("\"score\":0.5"), "public path: double serialises as an unquoted JSON number");
  95. var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate),
  96. new JsonElement.from_string(wire).as<Properties>());
  97. assert_private_matches(model, back, "public path");
  98. }
  99. void test_scalar_encryption_path(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  100. var model = sample_private();
  101. var blob = encryption.author_properties("statum-tests", GObjectMapping.to_properties(model));
  102. var props = encryption.read_properties("statum-tests", blob);
  103. var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate), props);
  104. assert_private_matches(model, back, "encryption path");
  105. }
  106. void test_arrays(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  107. var first = new Model.ActionDto() { uri = "/_statum/action/aaaa", method = "POST" };
  108. var second = new Model.ActionDto() { uri = "/_statum/action/bbbb", method = "DELETE" };
  109. var refs = new Series<Model.ActionDto>();
  110. refs.add(first);
  111. refs.add(second);
  112. var model = new ArrayModel() { tags = new string[] { "alpha", "beta", "gamma" }, refs = refs };
  113. var props = GObjectMapping.to_properties(model);
  114. var wire = new JsonElement.from_properties(props).stringify();
  115. check(wire.contains("\"tags\":[\"alpha\",\"beta\",\"gamma\"]"), "arrays: string[] serialises as a JSON array");
  116. check(wire.contains("\"uri\":\"/_statum/action/bbbb\"")
  117. && wire.contains("\"method\":\"DELETE\""), "arrays: registered-Object collection serialises as an array of nested objects");
  118. var parsed = new JsonElement.from_string(wire).as<Properties>();
  119. var array_model = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel), parsed);
  120. check(array_model.tags.length == 3 && array_model.tags[0] == "alpha"
  121. && array_model.tags[1] == "beta" && array_model.tags[2] == "gamma", "arrays: string[] read-back from JSON array");
  122. var blob = encryption.author_properties("statum-tests", props);
  123. var decrypted = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel),
  124. encryption.read_properties("statum-tests", blob));
  125. check(decrypted.tags.length == 3 && decrypted.tags[2] == "gamma", "arrays: string[] round-trips through the encryption path");
  126. }
  127. void test_typed_action_private(Inversion.Scope scope, Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  128. var eroding = (ErodingAction) scope.resolve_type(typeof(ErodingAction));
  129. check(eroding.exposed_private_type() == typeof(void), "S-8 repro: typeof(TPrivate) erodes to void for a reflectively-instantiated action");
  130. eroding.feed(new PropertyDictionary());
  131. check(eroding.exposed_request_private() == null, "S-8 repro: un-overridden request_private yields null without crashing");
  132. var fixed_action = (FixedAction) scope.resolve_type(typeof(FixedAction));
  133. check(fixed_action.exposed_private_type() == typeof(LoginPrivate), "S-8 fix: private_type override captures the GType from a foreign namespace");
  134. var model = sample_private();
  135. var blob = encryption.author_properties(fixed_action.get_type().name(), GObjectMapping.to_properties(model));
  136. fixed_action.feed(encryption.read_properties(fixed_action.get_type().name(), blob));
  137. var private_data = fixed_action.exposed_request_private();
  138. check(private_data != null, "S-8 fix: request_private materialises from a sealed blob");
  139. if (private_data != null) {
  140. assert_private_matches(model, (!)private_data, "S-8 fix");
  141. }
  142. }
  143. /**
  144. * SA-4: restore_slot must enforce the snapshot's invalid_after and must
  145. * not refresh last_touched, so replayed frames cannot pin slots forever
  146. * and the cleanup sweep still expires restored slots.
  147. */
  148. void test_restore_slot_expiry(StateService states, Statum.Cryptography.SigningProvider signing) throws GLib.Error {
  149. // A live frame authored now: restorable, keeps its own as_at as
  150. // last_touched, and still expirable by cleanup.
  151. var slot = states.new_slot(Scope.SESSION, new State() {
  152. type_name = "test-sa4",
  153. public_data = new PropertyDictionary(),
  154. private_data = new PropertyDictionary()
  155. });
  156. var live_frame = sign_snapshot(signing, slot.id, "test-sa4", new DateTime.now_utc().add_hours(6));
  157. var before_restore = new DateTime.now_utc();
  158. // Evict the server-side copy first so restore_slot has an empty cache
  159. // to restore into (a non-empty slot makes restore a no-op).
  160. states.clear_slot.begin(slot.id);
  161. check(states.get_slot(slot.id) == null, "SA-4: slot evicted before first restore");
  162. var restored = states.restore_slot(live_frame);
  163. check(restored, "SA-4: a frame inside its invalid_after window restores");
  164. var cached = states.get_slot(slot.id);
  165. check(cached != null, "SA-4: the restored slot is cached");
  166. if (cached != null) {
  167. var touched = ((!)cached).current_state.last_touched;
  168. check(touched.compare(before_restore) <= 0,
  169. "SA-4: restore does not refresh last_touched (the clock is not 'now')");
  170. }
  171. // The same slot re-restored after eviction must still expire through
  172. // cleanup, because last_touched came from the snapshot, not 'now'.
  173. states.clear_slot.begin(slot.id); // remove from cache (async push is a no-op)
  174. check(states.get_slot(slot.id) == null, "SA-4: slot evicted before replay");
  175. // Re-sign a frame with an as_at in the past but still valid.
  176. var old_touch = new DateTime.now_utc().add_days(-6);
  177. var stale_frame = sign_snapshot(signing, slot.id, "test-sa4-old", new DateTime.now_utc().add_hours(1), old_touch);
  178. check(states.restore_slot(stale_frame), "SA-4: stale-but-valid frame restores");
  179. states.cleanup();
  180. check(states.get_slot(slot.id) != null, "SA-4: restored slot within EXPIRY_SECONDS survives cleanup");
  181. // A frame whose as_at predates the 7-day expiry is gone after cleanup.
  182. var ancient_touch = new DateTime.now_utc().add_days(-9);
  183. var ancient_frame = sign_snapshot(signing, slot.id + "-ancient", "test-sa4-ancient",
  184. new DateTime.now_utc().add_hours(1), ancient_touch);
  185. check(states.restore_slot(ancient_frame), "SA-4: ancient-but-valid frame restores (once)");
  186. states.cleanup();
  187. check(states.get_slot(slot.id + "-ancient") == null,
  188. "SA-4: cleanup expires a restored slot (restore no longer resets last_touched to now)");
  189. // A frame past its invalid_after is rejected outright.
  190. var expired_frame = sign_snapshot(signing, slot.id + "-expired", "test-sa4-expired",
  191. new DateTime.now_utc().add_seconds(-5));
  192. bool threw = false;
  193. try {
  194. states.restore_slot(expired_frame);
  195. } catch (StateError e) {
  196. threw = true;
  197. } catch (GLib.Error e) {
  198. threw = false;
  199. }
  200. check(threw, "SA-4: restore_slot rejects a frame past invalid_after with FRAME_EXPIRED");
  201. check(states.get_slot(slot.id + "-expired") == null,
  202. "SA-4: a rejected frame leaves no slot cached");
  203. // SA-4 bonus: a rejected replay also drops any slot already cached
  204. // under the same key, so the stale entry cannot linger.
  205. var cached_key = slot.id + "-drop";
  206. check(states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
  207. new DateTime.now_utc().add_hours(1))), "SA-4: slot cached for the drop test");
  208. check(states.get_slot(cached_key) != null, "SA-4: slot present before expired replay");
  209. try {
  210. states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
  211. new DateTime.now_utc().add_seconds(-5)));
  212. } catch (StateError.FRAME_EXPIRED e) {
  213. // expected
  214. }
  215. check(states.get_slot(cached_key) == null,
  216. "SA-4: an expired replay of the same key invalidates the cached slot");
  217. }
  218. /** Signs a snapshot for `key` valid until `invalid_after`, authored `as_at`. */
  219. Model.FrameDto sign_snapshot(Statum.Cryptography.SigningProvider signing, string key, string type,
  220. DateTime invalid_after, DateTime? as_at = null) throws GLib.Error {
  221. var dto = new Model.SnapshotDto() {
  222. as_at = as_at ?? new DateTime.now_utc(),
  223. slot = new Model.SlotDto() {
  224. key = key,
  225. scope = Scope.SESSION,
  226. slot_type = type
  227. },
  228. transmit_after = new DateTime.now_utc().add_seconds(60),
  229. invalid_after = invalid_after,
  230. @public = new PropertyDictionary()
  231. };
  232. var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(dto)).stringify();
  233. return signing.author_frame(json);
  234. }
  235. /** SA-10: verify_frame must fail closed on a mismatched signer claim. */
  236. void test_verify_frame_signer(Statum.Cryptography.SigningProvider signing,
  237. Statum.Cryptography.SigningProvider other) throws GLib.Error {
  238. var frame = sign_snapshot(signing, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
  239. signing.verify_frame(frame);
  240. check(true, "SA-10: a frame signed and claimed by this provider verifies");
  241. // Same content and signature, forged signer claim.
  242. var forged = new Model.FrameDto() {
  243. content = frame.content,
  244. signer = other.signer_id,
  245. signature = frame.signature
  246. };
  247. bool threw = false;
  248. try {
  249. signing.verify_frame(forged);
  250. } catch (Statum.Cryptography.SigningError e) {
  251. threw = true;
  252. }
  253. check(threw, "SA-10: verify_frame rejects a frame whose signer != signer_id");
  254. // A frame actually signed by another provider is rejected too (its
  255. // signature does not verify against this key).
  256. var foreign = sign_snapshot(other, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
  257. threw = false;
  258. try {
  259. signing.verify_frame(foreign);
  260. } catch (Statum.Cryptography.SigningError e) {
  261. threw = true;
  262. }
  263. check(threw, "SA-10: verify_frame rejects a frame signed by a different key");
  264. }
  265. /** SA-15: global and per-IP channel connection caps. */
  266. void test_connection_caps() {
  267. var tracker = new ConnectionTracker(4, 2);
  268. check(tracker.can_accept("10.0.0.1"), "SA-15: first connection from an IP is accepted");
  269. tracker.acquired("10.0.0.1");
  270. check(tracker.can_accept("10.0.0.1"), "SA-15: second connection from the same IP is accepted");
  271. tracker.acquired("10.0.0.1");
  272. check(!tracker.can_accept("10.0.0.1"), "SA-15: third connection from the same IP is refused (per-IP cap)");
  273. check(tracker.can_accept("10.0.0.2"), "SA-15: another IP is unaffected by the per-IP cap");
  274. tracker.released("10.0.0.1");
  275. check(tracker.can_accept("10.0.0.1"), "SA-15: releasing a connection frees its per-IP slot");
  276. tracker.acquired("10.0.0.2");
  277. tracker.acquired("10.0.0.3");
  278. // total: 1 + 1 + 1 = 3 of 4 — the next is the last.
  279. check(tracker.can_accept("10.0.0.4"), "SA-15: connection accepted up to the global cap");
  280. tracker.acquired("10.0.0.4");
  281. check(!tracker.can_accept("10.0.0.4"), "SA-15: connections beyond the global cap are refused");
  282. check(!tracker.can_accept("192.168.0.1"), "SA-15: the global cap refuses new IPs too");
  283. check(tracker.open_total() == 4, "SA-15: the global count tracks acquisitions");
  284. tracker.released("10.0.0.2");
  285. check(tracker.can_accept("192.168.0.1"), "SA-15: a release frees the global slot");
  286. // Unknown clients count globally but cannot trip the per-IP cap.
  287. var anon = new ConnectionTracker(1, 2);
  288. check(anon.can_accept(null), "SA-15: unknown client is accepted when under the global cap");
  289. anon.acquired(null);
  290. check(!anon.can_accept(null), "SA-15: unknown clients still respect the global cap");
  291. check(anon.open_for(null) == 0, "SA-15: unknown client is not counted per-IP");
  292. }
  293. /** SA-15: subscriptions per channel are bounded. */
  294. void test_subscription_cap(ChannelEndpoint channel) {
  295. // subscribe() only acts on channels with an open connection; without
  296. // one it must simply no-op (returning false) rather than crash.
  297. bool recorded = channel.subscribe("nonexistent-channel", "some-key");
  298. check(!recorded, "SA-15: subscribing to a channel with no connection is a no-op");
  299. }
  300. /** SA-6: the authorize hooks default open and honour installed guards. */
  301. void test_authorization_policy() {
  302. var policy = new AuthorizationPolicy();
  303. check(policy.authorize_entrypoint(null) == null, "SA-6: entrypoint is open when no guard is installed");
  304. check(policy.authorize_channel(null) == null, "SA-6: channel is open when no guard is installed");
  305. check(policy.authorize_channel_subscriptions(null) == null, "SA-6: channel subscriptions are open when no guard is installed");
  306. var denied = new HttpStringResult("login required", StatusCode.UNAUTHORIZED);
  307. policy.guard_channel(ctx => denied);
  308. var decision = policy.authorize_channel(null);
  309. check(decision != null && ((!)decision).status == StatusCode.UNAUTHORIZED,
  310. "SA-6: an installed channel guard short-circuits with its result");
  311. check(policy.authorize_entrypoint(null) == null, "SA-6: the channel guard does not leak into the entrypoint");
  312. policy.guard_entrypoint(ctx => null);
  313. check(policy.authorize_entrypoint(null) == null, "SA-6: a guard returning null allows the request");
  314. }
  315. void main() {
  316. GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
  317. var container = new Container();
  318. container.register_singleton<Statum.Cryptography.SigningProvider>();
  319. container.register_singleton<Statum.Cryptography.EncryptionProvider>();
  320. container.register_singleton<StateService>();
  321. container.register_singleton<HeldSlotResolver>();
  322. container.register_singleton<ChannelEndpoint>().as<ChannelService>();
  323. container.register_singleton<ActionRegistry>();
  324. container.register_singleton<TopicRegistry>();
  325. container.register_scoped<ErodingAction>();
  326. container.register_scoped<FixedAction>();
  327. var scope = container.create_transient_scope();
  328. var encryption = (Statum.Cryptography.EncryptionProvider) scope.resolve_type(typeof(Statum.Cryptography.EncryptionProvider));
  329. var signing = (Statum.Cryptography.SigningProvider) scope.resolve_type(typeof(Statum.Cryptography.SigningProvider));
  330. var states = (StateService) scope.resolve_type(typeof(StateService));
  331. var channel = (ChannelEndpoint) scope.resolve_type(typeof(ChannelEndpoint));
  332. try {
  333. test_scalar_public_path();
  334. } catch (GLib.Error e) {
  335. failures++;
  336. print("FAIL scalar public path threw: %s\n", e.message);
  337. }
  338. try {
  339. test_scalar_encryption_path(encryption);
  340. } catch (GLib.Error e) {
  341. failures++;
  342. print("FAIL scalar encryption path threw: %s\n", e.message);
  343. }
  344. try {
  345. test_arrays(encryption);
  346. } catch (GLib.Error e) {
  347. failures++;
  348. print("FAIL arrays threw: %s\n", e.message);
  349. }
  350. try {
  351. test_typed_action_private(scope, encryption);
  352. } catch (GLib.Error e) {
  353. failures++;
  354. print("FAIL typed action private threw: %s\n", e.message);
  355. }
  356. try {
  357. test_restore_slot_expiry(states, signing);
  358. } catch (GLib.Error e) {
  359. failures++;
  360. print("FAIL restore_slot expiry threw: %s\n", e.message);
  361. }
  362. try {
  363. test_verify_frame_signer(signing, new Statum.Cryptography.SigningProvider());
  364. } catch (GLib.Error e) {
  365. failures++;
  366. print("FAIL verify_frame signer threw: %s\n", e.message);
  367. }
  368. try {
  369. test_connection_caps();
  370. } catch (GLib.Error e) {
  371. failures++;
  372. print("FAIL connection caps threw: %s\n", e.message);
  373. }
  374. try {
  375. test_subscription_cap(channel);
  376. } catch (GLib.Error e) {
  377. failures++;
  378. print("FAIL subscription cap threw: %s\n", e.message);
  379. }
  380. try {
  381. test_authorization_policy();
  382. } catch (GLib.Error e) {
  383. failures++;
  384. print("FAIL authorization policy threw: %s\n", e.message);
  385. }
  386. print("---- %d passed, %d failed ----\n", passes, failures);
  387. if (failures > 0) {
  388. Process.exit(1);
  389. }
  390. }
  391. }