labfy-investigation/tests/test_research_store.c

453 lines
19 KiB
C

#include "core/research_store.h"
#include <glib/gstdio.h>
#include <sqlite3.h>
#define INVESTIGATION_ID "82000000-0000-4000-8000-000000000001"
#define OTHER_INVESTIGATION_ID "82000000-0000-4000-8000-000000000002"
#define PLAN_ID "82000000-0000-4000-8000-000000000010"
#define ACTION_DNS_ID "82000000-0000-4000-8000-000000000011"
#define ACTION_HTTP_ID "82000000-0000-4000-8000-000000000012"
#define GRANT_ID "82000000-0000-4000-8000-000000000020"
#define EXPIRED_GRANT_ID "82000000-0000-4000-8000-000000000021"
#define BAD_GRANT_ID "82000000-0000-4000-8000-000000000022"
#define WAVE_GRANT_ID "82000000-0000-4000-8000-000000000023"
#define REFUSE_GRANT_ID "82000000-0000-4000-8000-000000000024"
#define CAMPAIGN_ID "82000000-0000-4000-8000-000000000030"
#define RESULT_ID "82000000-0000-4000-8000-000000000031"
#define RECEIPT_ID "82000000-0000-4000-8000-000000000032"
#define HASH_A "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
#define HASH_B "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
#define HASH_C "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
#define HASH_D "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
static const ResearchActionDecision grant_decisions[] = {
{ACTION_DNS_ID, RESEARCH_ACTION_AUTHORIZE},
{ACTION_HTTP_ID, RESEARCH_ACTION_AUTHORIZE}};
static const char *const decision_time = "2026-09-27T10:01:00Z";
typedef struct {
char *directory;
char *path;
LocalJobStore *store;
} Fixture;
static void setup(Fixture *fixture, gconstpointer unused) {
(void)unused;
GError *error = NULL;
fixture->directory = g_dir_make_tmp("labfy-research-XXXXXX", &error);
g_assert_no_error(error);
fixture->path = g_build_filename(fixture->directory, "jobs.sqlite", NULL);
fixture->store = local_job_store_create(fixture->path, INVESTIGATION_ID,
&error);
g_assert_no_error(error);
g_assert_nonnull(fixture->store);
}
static void teardown(Fixture *fixture, gconstpointer unused) {
(void)unused;
local_job_store_close(fixture->store);
g_remove(fixture->path);
g_rmdir(fixture->directory);
g_free(fixture->path);
g_free(fixture->directory);
}
static ResearchPlan plan_fixture(ResearchAction actions[2],
ResearchSeed seeds[2]) {
seeds[0] = (ResearchSeed){RESEARCH_SEED_DOMAIN, "specimen.example"};
seeds[1] = (ResearchSeed){RESEARCH_SEED_SEARCH_TERM, "SPECIMEN ALPHA"};
actions[0] = (ResearchAction){
.action_id = ACTION_DNS_ID,
.capability_id = "research.dns.passive",
.provider_id = "specimen.provider",
.endpoint = "https://api.example.test/dns",
.subject = "specimen.example",
.contact = RESEARCH_CONTACT_THIRD_PARTY,
.disclosure = "DOMAIN_TO_PROVIDER",
.max_requests = 2,
.max_response_bytes = 4096,
.max_active_ms = 500};
actions[1] = (ResearchAction){
.action_id = ACTION_HTTP_ID,
.capability_id = "research.http.target",
.provider_id = "direct",
.endpoint = "https://specimen.example/profile",
.subject = "https://specimen.example/profile",
.contact = RESEARCH_CONTACT_TARGET,
.disclosure = "SOURCE_IP_TO_TARGET",
.max_requests = 1,
.max_response_bytes = 8192,
.max_active_ms = 700};
return (ResearchPlan){
.contract = RESEARCH_PLAN_CONTRACT,
.plan_id = PLAN_ID,
.idempotency_key = "plan-key-specimen",
.content_fingerprint = HASH_A,
.input_fingerprint = HASH_B,
.input_revision = 3,
.created_at = "2026-09-27T10:00:00Z",
.seeds = seeds,
.seed_count = 2,
.actions = actions,
.action_count = 2};
}
static ScopeGrant grant_fixture(const char *grant_id, const char *key,
const char *fingerprint,
const char *expires_at,
const char *const *selected,
gsize selected_count) {
static const char *const exclusions[] = {
"https://specimen.example/profile"};
return (ScopeGrant){
.contract = RESEARCH_GRANT_CONTRACT,
.grant_id = grant_id,
.investigation_id = INVESTIGATION_ID,
.idempotency_key = key,
.content_fingerprint = fingerprint,
.plan_fingerprint = HASH_A,
.created_at = "2026-09-27T10:01:00Z",
.expires_at = expires_at,
.selected_action_ids = selected,
.selected_action_count = selected_count,
.excluded_subjects = exclusions,
.excluded_subject_count = G_N_ELEMENTS(exclusions),
.max_requests = 3,
.max_response_bytes = 12288,
.max_active_ms = 1200};
}
static gint64 table_count(const char *path, const char *table) {
sqlite3 *db = NULL;
sqlite3_stmt *statement = NULL;
g_assert_cmpint(sqlite3_open(path, &db), ==, SQLITE_OK);
char *sql = g_strdup_printf("SELECT count(*) FROM %s;", table);
g_assert_cmpint(sqlite3_prepare_v2(db, sql, -1, &statement, NULL), ==,
SQLITE_OK);
g_assert_cmpint(sqlite3_step(statement), ==, SQLITE_ROW);
gint64 value = sqlite3_column_int64(statement, 0);
sqlite3_finalize(statement);
sqlite3_close(db);
g_free(sql);
return value;
}
static void admit_valid_plan(Fixture *fixture, ResearchAction actions[2],
ResearchSeed seeds[2]) {
GError *error = NULL;
ResearchPlan plan = plan_fixture(actions, seeds);
gboolean reused = TRUE;
g_assert_true(
research_store_admit_plan(fixture->store, &plan, &reused, &error));
g_assert_no_error(error);
g_assert_false(reused);
}
static void test_plan_atomic_idempotence(Fixture *fixture,
gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
ResearchPlan plan = plan_fixture(actions, seeds);
actions[1].max_requests = 0;
GError *error = NULL;
g_assert_false(research_store_admit_plan(fixture->store, &plan, NULL,
&error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "research_plans"), ==, 0);
g_assert_cmpint(table_count(fixture->path, "research_actions"), ==, 0);
plan = plan_fixture(actions, seeds);
gboolean reused = FALSE;
g_assert_true(
research_store_admit_plan(fixture->store, &plan, &reused, &error));
g_assert_false(reused);
g_assert_true(
research_store_admit_plan(fixture->store, &plan, &reused, &error));
g_assert_true(reused);
plan.content_fingerprint = HASH_C;
g_assert_false(
research_store_admit_plan(fixture->store, &plan, &reused, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_EXISTS);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "research_plans"), ==, 1);
g_assert_cmpint(table_count(fixture->path, "research_actions"), ==, 2);
}
static ResearchPolicyDecision decide(Fixture *fixture, const char *grant_id,
const ResearchAction *action,
const char *investigation_id,
const char *plan_fingerprint,
const char *now) {
ResearchPolicyRequest request = {
.investigation_id = investigation_id,
.plan_fingerprint = plan_fingerprint,
.action = action,
.now = now,
.requested_requests = 1,
.requested_response_bytes = 1024,
.requested_active_ms = 100};
ResearchPolicyDecision decision = RESEARCH_POLICY_DENY_ACTION;
GError *error = NULL;
g_assert_true(research_store_policy_decide(fixture->store, grant_id,
&request, &decision, &error));
g_assert_no_error(error);
return decision;
}
static void test_grant_policy(Fixture *fixture, gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const selected[] = {ACTION_DNS_ID};
ScopeGrant grant = grant_fixture(GRANT_ID, "grant-key-specimen", HASH_C,
"2026-09-28T00:00:00Z", selected, G_N_ELEMENTS(selected));
GError *error = NULL;
gboolean reused = FALSE;
g_assert_true(
research_store_admit_grant(fixture->store, &grant, grant_decisions,
G_N_ELEMENTS(grant_decisions), decision_time, &reused, &error));
g_assert_false(reused);
g_assert_true(
research_store_admit_grant(fixture->store, &grant, grant_decisions,
G_N_ELEMENTS(grant_decisions), decision_time, &reused, &error));
g_assert_true(reused);
grant.content_fingerprint = HASH_D;
g_assert_false(
research_store_admit_grant(fixture->store, &grant, grant_decisions,
G_N_ELEMENTS(grant_decisions), decision_time, &reused, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_EXISTS);
g_clear_error(&error);
g_assert_cmpint(decide(fixture, GRANT_ID, &actions[0], INVESTIGATION_ID,
HASH_A, "2026-09-27T12:00:00Z"), ==,
RESEARCH_POLICY_ALLOW);
g_assert_cmpint(decide(fixture, GRANT_ID, &actions[0],
OTHER_INVESTIGATION_ID, HASH_A,
"2026-09-27T12:00:00Z"), ==,
RESEARCH_POLICY_DENY_INVESTIGATION);
ResearchAction redirected = actions[0];
redirected.endpoint = "https://redirected.example.test/dns";
g_assert_cmpint(decide(fixture, GRANT_ID, &redirected, INVESTIGATION_ID,
HASH_A, "2026-09-27T12:00:00Z"), ==,
RESEARCH_POLICY_DENY_ACTION);
g_assert_cmpint(decide(fixture, GRANT_ID, &actions[1], INVESTIGATION_ID,
HASH_A, "2026-09-27T12:00:00Z"), ==,
RESEARCH_POLICY_DENY_EXCLUDED);
g_assert_true(research_store_revoke_grant(
fixture->store, GRANT_ID, "2026-09-27T13:00:00Z", &error));
g_assert_cmpint(decide(fixture, GRANT_ID, &actions[1], INVESTIGATION_ID,
HASH_A, "2026-09-27T14:00:00Z"), ==,
RESEARCH_POLICY_DENY_EXCLUDED);
g_assert_cmpint(decide(fixture, GRANT_ID, &actions[0], INVESTIGATION_ID,
HASH_A, "2026-09-27T14:00:00Z"), ==,
RESEARCH_POLICY_DENY_REVOKED);
}
static void test_expiry_and_atomic_bad_action(Fixture *fixture,
gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const selected[] = {ACTION_DNS_ID};
ScopeGrant expired = grant_fixture(EXPIRED_GRANT_ID, "expired-key", HASH_C,
"2026-09-27T11:00:00Z", selected, G_N_ELEMENTS(selected));
GError *error = NULL;
g_assert_true(research_store_admit_grant(fixture->store, &expired,
grant_decisions, G_N_ELEMENTS(grant_decisions), decision_time, NULL,
&error));
g_assert_cmpint(decide(fixture, EXPIRED_GRANT_ID, &actions[0],
INVESTIGATION_ID, HASH_A,
"2026-09-27T11:00:00Z"), ==,
RESEARCH_POLICY_DENY_EXPIRED);
static const char *const unknown[] = {
"82000000-0000-4000-8000-000000000099"};
ScopeGrant bad = grant_fixture(BAD_GRANT_ID, "bad-key", HASH_D,
"2026-09-28T00:00:00Z", unknown, G_N_ELEMENTS(unknown));
g_assert_false(
research_store_admit_grant(fixture->store, &bad, grant_decisions,
G_N_ELEMENTS(grant_decisions), decision_time, NULL, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_FAILED);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "scope_grants"), ==, 1);
g_assert_true(local_job_store_integrity(fixture->store, &error));
g_assert_no_error(error);
}
static void test_cross_investigation_admission(Fixture *fixture,
gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const selected[] = {ACTION_DNS_ID};
ScopeGrant grant = grant_fixture(GRANT_ID, "cross-key", HASH_C,
"2026-09-28T00:00:00Z", selected, G_N_ELEMENTS(selected));
grant.investigation_id = OTHER_INVESTIGATION_ID;
GError *error = NULL;
g_assert_false(
research_store_admit_grant(fixture->store, &grant, grant_decisions,
G_N_ELEMENTS(grant_decisions), decision_time, NULL, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_PERMISSION_DENIED);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "scope_grants"), ==, 0);
}
static void test_grant_decision_coverage(Fixture *fixture,
gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const selected[] = {ACTION_DNS_ID};
ScopeGrant grant = grant_fixture(GRANT_ID, "coverage-key", HASH_C,
"2026-09-28T00:00:00Z", selected, G_N_ELEMENTS(selected));
ResearchActionDecision incomplete[] = {
{ACTION_DNS_ID, RESEARCH_ACTION_AUTHORIZE}};
ResearchActionDecision complete[] = {
{ACTION_DNS_ID, RESEARCH_ACTION_AUTHORIZE},
{ACTION_HTTP_ID, RESEARCH_ACTION_DEFER}};
GError *error = NULL;
g_assert_false(research_store_admit_grant(fixture->store, &grant,
incomplete, G_N_ELEMENTS(incomplete), decision_time, NULL, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "scope_grants"), ==, 0);
g_assert_cmpint(table_count(fixture->path, "research_action_decisions"), ==,
0);
g_assert_true(research_store_admit_grant(fixture->store, &grant,
complete, G_N_ELEMENTS(complete), decision_time, NULL, &error));
g_assert_no_error(error);
g_assert_cmpint(table_count(fixture->path, "scope_grants"), ==, 1);
g_assert_cmpint(table_count(fixture->path, "research_action_decisions"), ==,
2);
}
static void test_refusal_is_terminal(Fixture *fixture, gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const first_selected[] = {ACTION_DNS_ID};
ResearchActionDecision refused[] = {
{ACTION_DNS_ID, RESEARCH_ACTION_AUTHORIZE},
{ACTION_HTTP_ID, RESEARCH_ACTION_REFUSE}};
ScopeGrant first = grant_fixture(GRANT_ID, "refusal-first", HASH_C,
"2026-09-28T00:00:00Z", first_selected, G_N_ELEMENTS(first_selected));
GError *error = NULL;
g_assert_true(research_store_admit_grant(fixture->store, &first, refused,
G_N_ELEMENTS(refused), decision_time, NULL, &error));
static const char *const refused_selected[] = {ACTION_HTTP_ID};
ResearchActionDecision retry[] = {
{ACTION_DNS_ID, RESEARCH_ACTION_AUTHORIZE},
{ACTION_HTTP_ID, RESEARCH_ACTION_AUTHORIZE}};
ScopeGrant second = grant_fixture(REFUSE_GRANT_ID, "refusal-retry", HASH_D,
"2026-09-28T00:00:00Z", refused_selected,
G_N_ELEMENTS(refused_selected));
g_assert_false(research_store_admit_grant(fixture->store, &second, retry,
G_N_ELEMENTS(retry), decision_time, NULL, &error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_FAILED);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "scope_grants"), ==, 1);
}
static void test_campaign_result_receipt(Fixture *fixture,
gconstpointer unused) {
(void)unused;
ResearchAction actions[2];
ResearchSeed seeds[2];
admit_valid_plan(fixture, actions, seeds);
static const char *const wave_two[] = {ACTION_HTTP_ID};
ScopeGrant premature = grant_fixture(WAVE_GRANT_ID, "wave-two-premature",
HASH_C, "2026-09-28T00:00:00Z", wave_two, G_N_ELEMENTS(wave_two));
GError *error = NULL;
g_assert_false(research_store_admit_grant(fixture->store, &premature,
grant_decisions, G_N_ELEMENTS(grant_decisions), decision_time, NULL,
&error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_FAILED);
g_clear_error(&error);
static const char *const selected[] = {ACTION_DNS_ID};
ScopeGrant grant = grant_fixture(GRANT_ID, "campaign-grant", HASH_C,
"2026-09-28T00:00:00Z", selected, G_N_ELEMENTS(selected));
g_assert_true(research_store_admit_grant(fixture->store, &grant,
grant_decisions, G_N_ELEMENTS(grant_decisions), decision_time, NULL,
&error));
ResearchCampaign campaign = {
.contract = RESEARCH_CAMPAIGN_CONTRACT,
.campaign_id = CAMPAIGN_ID,
.investigation_id = INVESTIGATION_ID,
.grant_id = GRANT_ID,
.idempotency_key = "campaign-key",
.content_fingerprint = HASH_D,
.created_at = "2026-09-27T10:02:00Z"};
gboolean reused = FALSE;
g_assert_true(research_store_admit_campaign(fixture->store, &campaign,
&reused, &error));
g_assert_false(reused);
g_assert_true(research_store_admit_campaign(fixture->store, &campaign,
&reused, &error));
g_assert_true(reused);
ResearchResult result = {
.contract = RESEARCH_RESULT_CONTRACT,
.result_id = RESULT_ID,
.campaign_id = CAMPAIGN_ID,
.action_id = ACTION_DNS_ID,
.raw_artifact_relative_path = "research/raw/specimen-dns.json",
.content_sha256 = HASH_B,
.status = "CAPTURED"};
ResearchReceipt receipt = {
.contract = RESEARCH_RECEIPT_CONTRACT,
.receipt_id = RECEIPT_ID,
.result_id = RESULT_ID,
.campaign_id = CAMPAIGN_ID,
.grant_id = GRANT_ID,
.action_id = ACTION_DNS_ID,
.decided_at = "2026-09-27T10:03:00Z",
.decision_code = "ALLOW"};
result.raw_artifact_relative_path = "../outside.json";
g_assert_false(research_store_record_result(fixture->store, &result, &receipt,
&error));
g_assert_error(error, G_IO_ERROR, G_IO_ERROR_INVALID_ARGUMENT);
g_clear_error(&error);
g_assert_cmpint(table_count(fixture->path, "research_results"), ==, 0);
result.raw_artifact_relative_path = "research/raw/specimen-dns.json";
g_assert_true(research_store_record_result(fixture->store, &result, &receipt,
&error));
g_assert_cmpint(table_count(fixture->path, "research_results"), ==, 1);
g_assert_cmpint(table_count(fixture->path, "research_receipts"), ==, 1);
ScopeGrant admitted_wave = grant_fixture(WAVE_GRANT_ID, "wave-two-after-first",
HASH_C, "2026-09-28T00:00:00Z", wave_two, G_N_ELEMENTS(wave_two));
g_assert_true(research_store_admit_grant(fixture->store, &admitted_wave,
grant_decisions, G_N_ELEMENTS(grant_decisions), decision_time, NULL,
&error));
g_assert_true(local_job_store_integrity(fixture->store, &error));
g_assert_no_error(error);
}
int main(int argc, char **argv) {
g_test_init(&argc, &argv, NULL);
g_test_add("/research/plan-atomic-idempotence", Fixture, NULL, setup,
test_plan_atomic_idempotence, teardown);
g_test_add("/research/grant-policy", Fixture, NULL, setup,
test_grant_policy, teardown);
g_test_add("/research/expiry-atomic-bad-action", Fixture, NULL, setup,
test_expiry_and_atomic_bad_action, teardown);
g_test_add("/research/cross-investigation", Fixture, NULL, setup,
test_cross_investigation_admission, teardown);
g_test_add("/research/grant-decision-coverage", Fixture, NULL, setup,
test_grant_decision_coverage, teardown);
g_test_add("/research/refusal-terminal", Fixture, NULL, setup,
test_refusal_is_terminal, teardown);
g_test_add("/research/campaign-result-receipt", Fixture, NULL, setup,
test_campaign_result_receipt, teardown);
return g_test_run();
}