diff --git a/lib/schematic/rules_engine.rb b/lib/schematic/rules_engine.rb index 773c3b6..97c5cfd 100644 --- a/lib/schematic/rules_engine.rb +++ b/lib/schematic/rules_engine.rb @@ -15,6 +15,7 @@ def initialize(logger: nil) @alloc_fn = nil @dealloc_fn = nil @check_flag_fn = nil + @set_time_fn = nil @get_result_json_fn = nil @get_result_json_length_fn = nil @get_version_key_fn = nil @@ -47,6 +48,11 @@ def initialize! @alloc_fn = export_func("alloc") @dealloc_fn = export_func("dealloc") @check_flag_fn = export_func("checkFlagCombined") + # Optional: feed the wasm the current wall-clock time before each check. + # The raw wasm32-unknown-unknown build has no clock under wasmtime + # (SCHY-471); without this, metric-period reset timestamps are omitted. + # Absent on older wasm — hence the safe-navigation call below. + @set_time_fn = @instance.export("setCurrentTimeMillis")&.to_func @get_result_json_fn = export_func("getResultJson") @get_result_json_length_fn = export_func("getResultJsonLength") @get_version_key_fn = export_func("get_version_key_wasm") @@ -80,6 +86,11 @@ def check_flag(flag, company = nil, user = nil) ptr = @alloc_fn.call(json_bytes.bytesize) begin @memory.write(ptr, json_bytes) + + # Supply the host's current time so the engine can compute + # metric-period reset timestamps (SCHY-471). No-op on older wasm. + @set_time_fn&.call((Time.now.to_f * 1000).to_i) + result_len = @check_flag_fn.call(ptr, json_bytes.bytesize) raise "WASM checkFlagCombined returned error" if result_len.negative? diff --git a/test/rules_engine_clock_test.rb b/test/rules_engine_clock_test.rb new file mode 100644 index 0000000..8711b5e --- /dev/null +++ b/test/rules_engine_clock_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "minitest/autorun" + +# Regression for SCHY-471. +# +# A company-override entitlement rule whose metric condition uses a +# calendar/billing metric period drives the engine into the metric-period-reset +# code path, which needs a wall clock. On the raw wasm32-unknown-unknown build +# that path used to trap (+wasm unreachable+); the flag then wrongly fell back to +# its default value for a company that is legitimately entitled. The host now +# injects the current time via +setCurrentTimeMillis+ so this evaluates cleanly. +class RulesEngineClockTest < Minitest::Test + def setup + @engine = Schematic::RulesEngine.new + @engine.initialize! + # The wasm binary is fetched via scripts/download-wasm.sh and may be absent + # (or the wasmtime gem missing) in some environments — skip rather than fail. + skip "WASM rules engine unavailable (wasm binary or wasmtime gem missing)" unless @engine.initialized? + end + + def flag + { + "id" => "flag1", "key" => "mcp-access", "account_id" => "acc_1", + "environment_id" => "env_1", "default_value" => false, "rules" => [] + } + end + + # Usage 40 < allocation 100, so the override grants the feature. + def entitled_company + { + "id" => "co_entitled", "account_id" => "acc_1", "environment_id" => "env_1", + "keys" => { "id" => "co_entitled" }, + "metrics" => [{ + "account_id" => "acc_1", "environment_id" => "env_1", "company_id" => "co_entitled", + "event_subtype" => "api-calls", "period" => "current_month", + "month_reset" => "billing_cycle", "value" => 40, "created_at" => "2023-01-01T00:00:00Z" + }], + "rules" => [{ + "id" => "rule_override", "flag_id" => "flag1", "account_id" => "acc_1", + "environment_id" => "env_1", "name" => "Company Override", + "rule_type" => "company_override", "priority" => 0, "value" => true, + "condition_groups" => [], + "conditions" => [ + { + "id" => "cond_company", "account_id" => "acc_1", "environment_id" => "env_1", + "condition_type" => "company", "operator" => "eq", + "resource_ids" => ["co_entitled"], "trait_value" => "" + }, + { + "id" => "cond_metric", "account_id" => "acc_1", "environment_id" => "env_1", + "condition_type" => "metric", "operator" => "lt", "event_subtype" => "api-calls", + "metric_value" => 100, "metric_period" => "current_month", + "metric_period_month_reset" => "billing_cycle", "trait_value" => "100" + } + ] + }] + } + end + + def test_billing_metric_override_evaluates_without_trapping + result = @engine.check_flag(flag, entitled_company) + + assert result[:value], "company override should grant the flag, not fall back to default false" + assert_match(/override/i, result[:reason].to_s) + end + + def test_billing_metric_override_populates_reset_at + result = @engine.check_flag(flag, entitled_company) + + refute_nil result[:feature_usage_reset_at], + "reset-at should be computed from the injected host time" + end +end