Skip to content

Commit 9c9a61e

Browse files
Add workspace-level integration tests against the public API
Issue #18 §E3. `AGENTS.md` mandates a `tests/` directory exercising only the public API; the repository had none at the root, and `core/tests/` holds fixtures with no test files. Four targets, matching the four §E3 names: `driver_selection.rs` pins admission: reserved ids resolve to fixed classes, an external driver with no entry is refused fail-closed, an untrusted external driver is refused even with one, a reserved id's class cannot be overridden by config, and a class typo is echoed back so the operator can find the line. `capability_negotiation.rs` covers both directions of the bind-time negotiation, including a deliberately lying provider that advertises a summary tree it has no accessor for — the failure `audit_provider` exists to catch, and which was previously asserted only in unit tests inside the contract crate. `taint_end_to_end.rs` drives provenance through store, get, list, recall, and the export/import round trip, at every driver this workspace ships. It also pins the fail-closed reading of an unknown persisted value, which is the one direction that cannot be undone. `null_provider.rs` asserts the compiled-out configuration is genuinely usable: every mandatory method answers rather than panicking, it reports Ready rather than a fault, and no optional family is either advertised or reachable. Two of the four are deliberately narrower than §E3 describes, and both say so in their module docs. `driver_selection.rs` cannot yet assert that a bound provider's `driver_id()` matches configuration, because nothing selects an engine from config — that is §A5. `taint_end_to_end.rs` cannot drive the sync path, because sync is welded to the engine until §B. Both are written against current behaviour, per the sequencing note in the issue, and each names where its missing leg joins. Refs #18 (§E3)
1 parent 59178d2 commit 9c9a61e

6 files changed

Lines changed: 688 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ serde = { version = "1", features = ["derive"] }
7272
[dev-dependencies]
7373
# The mandatory-family tests are async.
7474
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
75+
# The reference driver and the behavioural suite, for the workspace-level
76+
# integration tests. A dev-dependency only: the facade must not carry a test
77+
# harness into a consumer's dependency graph.
78+
tinymemory-conformance = { path = "conformance" }
7579

7680
[features]
7781
default = []

tests/capability_negotiation.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//! Capability negotiation: what a host may trust a driver's advertisement for,
2+
//! and what happens when the advertisement is wrong.
3+
//!
4+
//! The contract's premise is that a host negotiates once at bind time and then
5+
//! filters its own surface from the cached set. That is only safe if the set is
6+
//! honest, which is what `audit_provider` is for — so these tests pin both the
7+
//! honest path and the dishonest one.
8+
9+
// A failing assertion in a test *is* a panic; the crate-wide `expect_used` /
10+
// `unwrap_used` / `panic` lints exist to keep the library from panicking, not
11+
// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`.
12+
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
13+
14+
use std::sync::Arc;
15+
16+
use tinymemory::api::capabilities::{Capabilities, Capability};
17+
use tinymemory::api::health::MemoryHealth;
18+
use tinymemory::api::null::NullMemoryProvider;
19+
use tinymemory::api::provider::{audit_provider, MemoryProvider, MemoryTree};
20+
use tinymemory_conformance::InMemoryProvider;
21+
22+
#[test]
23+
fn the_reference_drivers_advertise_exactly_what_they_reach() {
24+
for provider in [
25+
Arc::new(InMemoryProvider::new()) as Arc<dyn MemoryProvider>,
26+
Arc::new(NullMemoryProvider::new()),
27+
] {
28+
assert!(
29+
audit_provider(provider.as_ref()).is_ok(),
30+
"driver `{}` failed its audit",
31+
provider.driver_id()
32+
);
33+
}
34+
}
35+
36+
#[test]
37+
fn a_host_can_filter_its_surface_from_the_cached_capability_set() {
38+
// This is the whole point of negotiating once: a host reads the set at bind
39+
// time and never asks again, so the set has to answer both directions.
40+
let provider = InMemoryProvider::new();
41+
let caps = provider.capabilities();
42+
43+
for mandatory in Capability::MANDATORY {
44+
assert!(
45+
caps.contains(mandatory),
46+
"{} must be advertised",
47+
mandatory.as_str()
48+
);
49+
assert!(
50+
provider.provides(mandatory),
51+
"{} must be reachable",
52+
mandatory.as_str()
53+
);
54+
}
55+
56+
// An optional family this driver does not serve is absent from the set AND
57+
// unreachable through its accessor. A host that registered an RPC method
58+
// from the set alone would otherwise expose a method that answers errors.
59+
assert!(!caps.contains(Capability::Tree));
60+
assert!(provider.as_tree().is_none());
61+
assert!(!provider.provides(Capability::Tree));
62+
}
63+
64+
/// A driver that claims a family it cannot serve.
65+
///
66+
/// Exists to prove the audit catches it. This is the failure mode the audit was
67+
/// written for: the claim is cheap to make and, without a check, only surfaces
68+
/// on the first call — which for a memory family may be days later, on a path
69+
/// nobody is watching.
70+
#[derive(Debug, Default)]
71+
struct LyingProvider(InMemoryProvider);
72+
73+
#[async_trait::async_trait]
74+
impl tinymemory::api::provider::MemoryCore for LyingProvider {
75+
async fn store(
76+
&self,
77+
namespace: &str,
78+
key: &str,
79+
content: &str,
80+
category: tinymemory::types::MemoryCategory,
81+
session_id: Option<&str>,
82+
taint: tinymemory::types::MemoryTaint,
83+
) -> Result<(), tinymemory::error::MemoryError> {
84+
self.0
85+
.store(namespace, key, content, category, session_id, taint)
86+
.await
87+
}
88+
async fn get(
89+
&self,
90+
namespace: &str,
91+
key: &str,
92+
) -> Result<Option<tinymemory::types::MemoryEntry>, tinymemory::error::MemoryError> {
93+
self.0.get(namespace, key).await
94+
}
95+
async fn forget(
96+
&self,
97+
namespace: &str,
98+
key: &str,
99+
) -> Result<bool, tinymemory::error::MemoryError> {
100+
self.0.forget(namespace, key).await
101+
}
102+
async fn list(
103+
&self,
104+
namespace: Option<&str>,
105+
category: Option<&tinymemory::types::MemoryCategory>,
106+
session_id: Option<&str>,
107+
) -> Result<Vec<tinymemory::types::MemoryEntry>, tinymemory::error::MemoryError> {
108+
self.0.list(namespace, category, session_id).await
109+
}
110+
async fn namespaces(
111+
&self,
112+
) -> Result<Vec<tinymemory::types::NamespaceSummary>, tinymemory::error::MemoryError> {
113+
self.0.namespaces().await
114+
}
115+
}
116+
117+
#[async_trait::async_trait]
118+
impl tinymemory::api::provider::MemoryRecall for LyingProvider {
119+
async fn recall(
120+
&self,
121+
query: &str,
122+
limit: usize,
123+
opts: &tinymemory::recall::OwnedRecallOpts,
124+
scope: Option<&tinymemory::api::provider::SourceScope>,
125+
) -> Result<Vec<tinymemory::types::MemoryEntry>, tinymemory::error::MemoryError> {
126+
self.0.recall(query, limit, opts, scope).await
127+
}
128+
}
129+
130+
#[async_trait::async_trait]
131+
impl tinymemory::api::provider::MemoryPortability for LyingProvider {
132+
async fn export_page(
133+
&self,
134+
cursor: Option<&str>,
135+
limit: usize,
136+
) -> Result<tinymemory::api::provider::ExportPage, tinymemory::error::MemoryError> {
137+
self.0.export_page(cursor, limit).await
138+
}
139+
async fn import_records(
140+
&self,
141+
records: Vec<tinymemory::api::provider::ExportRecord>,
142+
) -> Result<tinymemory::api::provider::ImportOutcome, tinymemory::error::MemoryError> {
143+
self.0.import_records(records).await
144+
}
145+
}
146+
147+
#[async_trait::async_trait]
148+
impl MemoryProvider for LyingProvider {
149+
fn driver_id(&self) -> &'static str {
150+
"liar"
151+
}
152+
153+
fn capabilities(&self) -> Capabilities {
154+
// Claims a summary tree it has no accessor for.
155+
Capabilities::mandatory().with(Capability::Tree)
156+
}
157+
158+
async fn health(&self) -> MemoryHealth {
159+
MemoryHealth::Ready
160+
}
161+
162+
// `as_tree` deliberately left at its `None` default.
163+
}
164+
165+
#[test]
166+
fn a_driver_that_advertises_a_family_it_cannot_serve_fails_the_audit() {
167+
let liar = LyingProvider::default();
168+
let audit = audit_provider(&liar).expect_err("the audit must catch an overstated capability");
169+
assert!(
170+
audit.advertised_but_absent.contains(&Capability::Tree),
171+
"the audit should name the family: {audit:?}"
172+
);
173+
assert!(
174+
audit.present_but_unadvertised.is_empty(),
175+
"nothing was under-advertised here: {audit:?}"
176+
);
177+
}
178+
179+
#[test]
180+
fn the_audit_failure_renders_something_an_operator_can_act_on() {
181+
let audit = audit_provider(&LyingProvider::default())
182+
.expect_err("the audit must fail")
183+
.to_string();
184+
assert!(
185+
audit.contains("tree"),
186+
"the message should name the family: {audit}"
187+
);
188+
}
189+
190+
/// Compile-time proof that `as_tree` returning `Some` is what "reachable"
191+
/// means, so the audit is checking the accessor and not a second declaration.
192+
#[test]
193+
fn reachability_is_the_accessor_not_a_second_declaration() {
194+
let provider = InMemoryProvider::new();
195+
let tree: Option<&dyn MemoryTree> = provider.as_tree();
196+
assert!(tree.is_none());
197+
}

tests/driver_selection.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//! Driver admission: which ids exist, what class each binds as, and what is
2+
//! refused.
3+
//!
4+
//! Exercises only the public surface of the `tinymemory` facade.
5+
//!
6+
//! # Scope note
7+
//!
8+
//! Issue #18 §E3 describes this file as also asserting that "the bound
9+
//! provider's `driver_id()` matches" the configured id. That step needs
10+
//! `MemoryHostConfig::memory_provider()` to actually select an engine, which is
11+
//! §A5 and does not exist yet — `create_memory_client_with_local_ai` still
12+
//! constructs TinyCortex unconditionally. The issue's own sequencing says to
13+
//! write these tests "against the *current* behaviour first", so this file
14+
//! pins what admission does today. The binding half joins it when §A5 lands,
15+
//! and this file is where it goes.
16+
17+
// A failing assertion in a test *is* a panic; the crate-wide `expect_used` /
18+
// `unwrap_used` / `panic` lints exist to keep the library from panicking, not
19+
// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`.
20+
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
21+
22+
use tinymemory::registry::{
23+
ConfigLabels, DriverClass, DriverEntry, DriverRegistry, COGNEE_DRIVER_ID, MEM0_DRIVER_ID,
24+
SUPERMEMORY_DRIVER_ID, TINYCORTEX_DRIVER_ID, TRUSTED,
25+
};
26+
27+
fn labels() -> ConfigLabels<'static> {
28+
ConfigLabels {
29+
section: "[memory]",
30+
drivers: "[memory.drivers]",
31+
driver_entry: "[memory.drivers.<id>]",
32+
}
33+
}
34+
35+
fn trusted_external() -> DriverEntry<'static> {
36+
DriverEntry {
37+
class: None,
38+
trust_state: TRUSTED,
39+
}
40+
}
41+
42+
#[test]
43+
fn a_reserved_embedded_id_is_admitted_without_any_config_entry() {
44+
// The embedded default's options live in the host's own config blocks, so
45+
// it must not require a `drivers` entry to be selectable at all.
46+
let admission = DriverRegistry::builtin()
47+
.admit(TINYCORTEX_DRIVER_ID, None, labels())
48+
.expect("the built-in embedded engine is admitted");
49+
assert_eq!(admission.id, TINYCORTEX_DRIVER_ID);
50+
assert_eq!(admission.class, DriverClass::Embedded);
51+
}
52+
53+
#[test]
54+
fn the_null_driver_is_admitted_and_is_class_null() {
55+
let admission = DriverRegistry::builtin()
56+
.admit(tinymemory::registry::NULL_DRIVER_ID, None, labels())
57+
.expect("the null driver is admitted");
58+
assert_eq!(admission.class, DriverClass::Null);
59+
}
60+
61+
#[test]
62+
fn every_reserved_external_id_resolves_to_the_external_class() {
63+
let registry = DriverRegistry::builtin();
64+
for id in [SUPERMEMORY_DRIVER_ID, MEM0_DRIVER_ID, COGNEE_DRIVER_ID] {
65+
let admission = registry
66+
.admit(id, Some(trusted_external()), labels())
67+
.unwrap_or_else(|reason| panic!("{id} was refused: {}", reason.reason));
68+
assert_eq!(admission.class, DriverClass::External, "{id}");
69+
assert_eq!(admission.id, id);
70+
}
71+
}
72+
73+
#[test]
74+
fn an_external_driver_without_an_entry_is_refused_fail_closed() {
75+
// The fail-closed half: an external engine needs endpoint, credential and
76+
// trust configuration, so admitting it implicitly would bind an
77+
// out-of-process backend nobody configured.
78+
let reason = DriverRegistry::builtin()
79+
.admit(SUPERMEMORY_DRIVER_ID, None, labels())
80+
.expect_err("an external driver with no entry must be refused");
81+
assert_eq!(reason.configured_driver, SUPERMEMORY_DRIVER_ID);
82+
assert!(
83+
reason.reason.contains("external"),
84+
"the refusal should say why: {}",
85+
reason.reason
86+
);
87+
}
88+
89+
#[test]
90+
fn an_untrusted_external_driver_is_refused_even_with_an_entry() {
91+
let entry = DriverEntry {
92+
class: None,
93+
trust_state: "untrusted",
94+
};
95+
let reason = DriverRegistry::builtin()
96+
.admit(SUPERMEMORY_DRIVER_ID, Some(entry), labels())
97+
.expect_err("trust must be raised explicitly before an external bind");
98+
assert!(
99+
reason.reason.contains(TRUSTED),
100+
"the refusal should name the value to set: {}",
101+
reason.reason
102+
);
103+
}
104+
105+
#[test]
106+
fn a_reserved_id_cannot_have_its_class_overridden_by_config() {
107+
// A reserved id names a fixed implementation. An explicit `class` line may
108+
// confirm it but never override it — otherwise config could run the
109+
// embedded engine under the checks meant for an external one.
110+
let entry = DriverEntry {
111+
class: Some("external"),
112+
trust_state: TRUSTED,
113+
};
114+
let reason = DriverRegistry::builtin()
115+
.admit(TINYCORTEX_DRIVER_ID, Some(entry), labels())
116+
.expect_err("a reserved id's class must not be overridable");
117+
assert!(
118+
reason.reason.contains("built in"),
119+
"the refusal should explain why: {}",
120+
reason.reason
121+
);
122+
}
123+
124+
#[test]
125+
fn an_unknown_driver_id_is_refused_rather_than_defaulted() {
126+
let reason = DriverRegistry::builtin()
127+
.admit("not-an-engine", None, labels())
128+
.expect_err("an unreserved id with no entry must be refused");
129+
assert_eq!(reason.configured_driver, "not-an-engine");
130+
}
131+
132+
#[test]
133+
fn an_empty_driver_id_is_refused() {
134+
let reason = DriverRegistry::builtin()
135+
.admit(" ", None, labels())
136+
.expect_err("a blank driver id must be refused");
137+
assert!(
138+
reason.reason.contains("empty"),
139+
"the refusal should name the problem: {}",
140+
reason.reason
141+
);
142+
}
143+
144+
#[test]
145+
fn a_config_class_typo_is_echoed_back_to_the_operator() {
146+
// The offending value comes from the host's own config file, not from a
147+
// driver or the network, so echoing it discloses nothing the reader did not
148+
// write — and without it the message cannot point at the line to fix.
149+
let entry = DriverEntry {
150+
class: Some("embeded"),
151+
trust_state: TRUSTED,
152+
};
153+
let reason = DriverRegistry::builtin()
154+
.admit("some-driver", Some(entry), labels())
155+
.expect_err("an unparseable class must be refused");
156+
assert!(
157+
reason.reason.contains("embeded"),
158+
"the refusal should quote the typo: {}",
159+
reason.reason
160+
);
161+
}

0 commit comments

Comments
 (0)