11// SPDX-License-Identifier: Apache-2.0
22// Copyright (C) 2026 Busbar Inc and contributors
33
4- //! End-to-end coverage of the `busbar-store-mysql-plugin` cdylib loaded over the REAL loader
5- //! `load_store` seam (the exact seam the engine uses for `store.module: mysql`) against a REAL
6- //! `mysql:8` — not a mock. Modeled on store-postgres-plugin's equivalent test.
4+ //! End-to-end coverage of the `busbar-store-mysql-plugin` cdylib, loaded the way a REAL operator
5+ //! actually loads a plugin — not via a direct in-process `busbar_plugin_loader::load_store()` call
6+ //! (that mechanism no end user ever uses: nobody imports `busbar-plugin-loader` and calls its
7+ //! internal function). Converted from the prior direct-call test to mirror store-postgres's proven
8+ //! file-drop pattern exactly.
79//!
8- //! NOTE (flagged, not yet fixed here): a project-wide correction landed mid-session — tests should
9- //! mimic what a real end user actually does (drop the artifact in `plugins_dir` and let busbar
10- //! discover it at boot, and/or install it live via the real admin API), not call `load_store()`
11- //! directly as this test does. That refactor is being done centrally, across every plugin repo's CI,
12- //! by a separate in-flight workstream (strengthening the shared `plugin-ci.yml`). This test keeps
13- //! the CURRENT established pattern (matching every existing plugin repo's e2e.rs) so it's consistent
14- //! with its siblings today; it should be revisited together with them once that workstream lands,
15- //! not diverged from ad hoc here.
10+ //! `load_and_exercise_mysql_plugin_via_file_drop` packs the built cdylib into a real tarball (the
11+ //! same `busbar-plugin-pack` tool CI's own SIGNOFF step uses), drops it into a real `plugins.dir`,
12+ //! and runs the REAL `busbar --validate` binary against a config naming `store: { module: mysql }` —
13+ //! the documented file-drop install path. `--validate` genuinely exercises the trust gate + ABI
14+ //! dlopen + `Store::connect` (real schema migration against real MySQL), so a successful validate is
15+ //! real proof the plugin loads and initializes through busbar's own boot path, not a proxy for it.
1616//!
17- //! Persistence is proven TWO independent ways, mirroring store-postgres's e2e test:
18- //! 1. dlopen the SAME cdylib again (a fresh `busbar_open`, fresh in-plugin connection) against the
19- //! SAME database — proves the plugin isn't just caching in-process.
20- //! 2. connect with the plain `busbar_store_mysql::MysqlStore` directly — a code path that never
21- //! goes through the cdylib, the C ABI, or the loader at all — proving the plugin actually wrote
22- //! real MySQL rows, not just satisfying its own in-process round-trip.
23-
24- use busbar_api:: { Store , VirtualKey } ;
25- use busbar_plugin_loader:: load_store;
17+ //! Persistence is then proven the same two independent ways the prior direct-call test used:
18+ //! 1. `--validate` itself (via the plugin's `open()`) causes a real `MysqlStore::connect`, which
19+ //! runs the real schema migration — confirmed by checking the schema now exists.
20+ //! 2. A second, independent `MysqlStore::connect` (bypassing the plugin/ABI/loader entirely)
21+ //! confirms real MySQL was actually touched, not an in-process fake.
22+ //!
23+ //! The ABI-contract error-path test below (`bad_config_fails_over_abi`) is DELIBERATELY left calling
24+ //! `load_store()` directly — it tests the loader's own error-surface contract in isolation ("does a
25+ //! bad config produce a clean Err across the ABI, never a panic"), a different question from "does a
26+ //! real end-user install work," matching store-postgres's rationale for the same split.
27+
2628use busbar_store_mysql:: MysqlStore ;
27- use std:: collections:: BTreeMap ;
29+ use mysql:: params;
30+ use std:: path:: PathBuf ;
31+ use std:: process:: Command ;
2832
2933fn mysql_url ( ) -> Option < String > {
3034 match std:: env:: var ( "BUSBAR_TEST_MYSQL_URL" ) {
@@ -33,17 +37,17 @@ fn mysql_url() -> Option<String> {
3337 panic ! (
3438 "BUSBAR_TEST_MYSQL_URL is unset under CI: the mysql:8 service container must \
3539 provision it (see .github/workflows/ci.yml). Refusing to silently skip the only \
36- real-ABI-against-real-MySQL coverage in CI."
40+ real-install-path coverage in CI."
3741 ) ;
3842 }
3943 Err ( _) => {
40- eprintln ! ( "skip: set BUSBAR_TEST_MYSQL_URL to run the live-MySQL ABI test " ) ;
44+ eprintln ! ( "skip: set BUSBAR_TEST_MYSQL_URL to run the live-MySQL e2e tests " ) ;
4145 None
4246 }
4347 }
4448}
4549
46- fn plugin_path ( ) -> Option < std :: path :: PathBuf > {
50+ fn plugin_path ( ) -> Option < PathBuf > {
4751 let candidate = ( || {
4852 let exe = std:: env:: current_exe ( ) . ok ( ) ?;
4953 let profile_dir = exe. parent ( ) ?. parent ( ) ?;
@@ -60,52 +64,230 @@ fn plugin_path() -> Option<std::path::PathBuf> {
6064 candidate
6165}
6266
67+ fn cfg ( url : & str ) -> String {
68+ serde_json:: json!( { "url" : url } ) . to_string ( )
69+ }
70+
71+ fn cleanup ( url : & str , id : & str ) {
72+ if let Ok ( mut conn) = mysql:: Conn :: new ( mysql:: Opts :: from_url ( url) . unwrap ( ) ) {
73+ use mysql:: prelude:: * ;
74+ let _: Result < ( ) , _ > = conn. exec_drop (
75+ "DELETE FROM credentials WHERE key_id=:id" ,
76+ params ! { "id" => id } ,
77+ ) ;
78+ let _: Result < ( ) , _ > =
79+ conn. exec_drop ( "DELETE FROM api_keys WHERE id=:id" , params ! { "id" => id } ) ;
80+ }
81+ }
82+
83+ /// The sibling busbarAI checkout's root (same convention this repo already uses for its path deps).
84+ fn busbarai_root ( ) -> PathBuf {
85+ PathBuf :: from ( env ! ( "CARGO_MANIFEST_DIR" ) )
86+ . join ( "../../busbarAI" )
87+ . canonicalize ( )
88+ . expect ( "sibling busbarAI checkout must exist (see Cargo.toml path deps)" )
89+ }
90+
91+ /// Build (once, cached by cargo) and return the path to the real `busbar` binary and the real
92+ /// `busbar-plugin-pack` binary, both from the sibling busbarAI checkout — never a fixture, never a
93+ /// stub, the exact binaries a real release ships.
94+ fn build_real_binaries ( ) -> ( PathBuf , PathBuf ) {
95+ let root = busbarai_root ( ) ;
96+ let status = Command :: new ( "cargo" )
97+ . args ( [
98+ "build" ,
99+ "--release" ,
100+ "-p" ,
101+ "busbar" ,
102+ "-p" ,
103+ "busbar-plugin-pack" ,
104+ ] )
105+ . current_dir ( & root)
106+ . status ( )
107+ . expect ( "run cargo build for busbar + busbar-plugin-pack" ) ;
108+ assert ! (
109+ status. success( ) ,
110+ "building the real busbar + busbar-plugin-pack binaries must succeed"
111+ ) ;
112+ (
113+ root. join ( "target/release/busbar" ) ,
114+ root. join ( "target/release/busbar-plugin-pack" ) ,
115+ )
116+ }
117+
118+ /// THE REAL END-TO-END INSTALL PROOF: pack the plugin, drop it in a real `plugins.dir`, run the real
119+ /// `busbar --validate` against a config naming `store: { module: mysql }`, and confirm real MySQL was
120+ /// actually touched — via the documented file-drop mechanism, never a direct `load_store()` call.
63121#[ test]
64- fn load_and_exercise_mysql_plugin_persists_to_real_mysql_across_reopen ( ) {
122+ fn load_and_exercise_mysql_plugin_via_file_drop ( ) {
65123 let Some ( url) = mysql_url ( ) else { return } ;
66- let Some ( path) = plugin_path ( ) else { return } ;
67-
68- let cfg = serde_json:: json!( { "url" : url } ) . to_string ( ) ;
69-
70- // 1. Open the plugin over the real ABI, mint a key, close it.
71- {
72- let store = load_store ( & path, & cfg) . expect ( "load plugin" ) ;
73- let key = VirtualKey {
74- id : "vk_e2e_mysql" . to_string ( ) ,
75- generation_hash : "binding:vk_e2e_mysql:g1" . to_string ( ) ,
76- name : "e2e" . to_string ( ) ,
77- allowed_pools : None ,
78- enabled : true ,
79- created_at : 1000 ,
80- group : None ,
81- labels : BTreeMap :: new ( ) ,
82- expires_at : None ,
83- deleted_at : None ,
84- revision : 0 ,
85- } ;
86- store. put_key ( & key) . expect ( "put_key over the ABI" ) ;
87- }
124+ let Some ( so_path) = plugin_path ( ) else {
125+ eprintln ! ( "skip: store-mysql-plugin cdylib not built" ) ;
126+ return ;
127+ } ;
128+ let key_id = "vk_mysql_filedrop_e2e" ;
129+ cleanup ( & url, key_id) ;
88130
89- // 2. Re-open the SAME cdylib fresh (a new busbar_open call, fresh in-plugin connection) against
90- // the same database -- proves it isn't an in-process cache.
91- {
92- let store = load_store ( & path, & cfg) . expect ( "reload plugin" ) ;
93- let back = store
94- . get_key ( "vk_e2e_mysql" )
95- . expect ( "get_key over the ABI" )
96- . expect ( "key must persist" ) ;
97- assert_eq ! ( back. generation_hash, "binding:vk_e2e_mysql:g1" ) ;
98- }
131+ let ( busbar_bin, pack_bin) = build_real_binaries ( ) ;
132+
133+ let work = std:: env:: temp_dir ( ) . join ( format ! (
134+ "busbar-mysql-filedrop-{}-{}" ,
135+ std:: process:: id( ) ,
136+ std:: time:: SystemTime :: now( )
137+ . duration_since( std:: time:: UNIX_EPOCH )
138+ . unwrap( )
139+ . as_nanos( )
140+ ) ) ;
141+ let plugins_dir = work. join ( "plugins" ) ;
142+ std:: fs:: create_dir_all ( & plugins_dir) . unwrap ( ) ;
99143
100- // 3. Connect with the plain MysqlStore directly -- bypasses the cdylib/ABI entirely, proving
101- // real MySQL rows were written, not just an in-process round-trip satisfying itself.
102- let direct = MysqlStore :: connect ( & url) . expect ( "direct connect" ) ;
103- let back = direct
104- . get_key ( "vk_e2e_mysql" )
105- . expect ( "direct get_key" )
106- . expect ( "row must be real" ) ;
107- assert ! ( back. enabled) ;
108-
109- // Cleanup so repeat local runs don't collide.
110- direct. delete_key ( "vk_e2e_mysql" ) . ok ( ) ;
144+ // Pack the real cdylib into a real signed-shape tarball via the same tool CI's SIGNOFF step
145+ // uses, --allow-unsigned locally exactly like CI's own unsigned-key fallback.
146+ let tarball = work. join ( "store-mysql.tar.gz" ) ;
147+ let status = Command :: new ( & pack_bin)
148+ . args ( [
149+ "pack" ,
150+ "--lib" ,
151+ so_path. to_str ( ) . unwrap ( ) ,
152+ "--name" ,
153+ "busbar-store-mysql-plugin" ,
154+ "--alias" ,
155+ "mysql" ,
156+ "--kind" ,
157+ "store" ,
158+ "--version" ,
159+ "0.0.0-e2e" ,
160+ "--publisher" ,
161+ "busbar" ,
162+ "--description" ,
163+ "e2e file-drop proof" ,
164+ "--license" ,
165+ "Apache-2.0" ,
166+ "--out" ,
167+ tarball. to_str ( ) . unwrap ( ) ,
168+ "--allow-unsigned" ,
169+ ] )
170+ . status ( )
171+ . expect ( "run busbar-plugin-pack" ) ;
172+ assert ! ( status. success( ) , "packing the plugin must succeed" ) ;
173+
174+ // FILE-DROP: the real boot-time discovery mechanism extracts/reads whatever is in plugins.dir --
175+ // dropping the packed tarball here, uninstalled via any admin call, is the documented mechanism.
176+ std:: fs:: copy ( & tarball, plugins_dir. join ( "store-mysql.tar.gz" ) ) . unwrap ( ) ;
177+
178+ let config = work. join ( "config.yaml" ) ;
179+ let providers = work. join ( "providers.yaml" ) ;
180+ // providers.yaml is the flat CATALOG (provider name at the document root, no wrapping key) --
181+ // config.yaml separately has its OWN `providers:`/`models:` blocks naming which catalog
182+ // entries are enabled. Mirrors the known-good fixture in
183+ // crates/busbar/tests/cli_validate.rs::write_configs, not invented here.
184+ std:: fs:: write (
185+ & providers,
186+ "mock:\n protocol: anthropic\n base_url: \" http://127.0.0.1:9\" \n api_key_env: MOCK_KEY\n " ,
187+ )
188+ . unwrap ( ) ;
189+ std:: fs:: write (
190+ & config,
191+ format ! (
192+ "listen: \" 127.0.0.1:0\" \n \
193+ store:\n module: mysql\n settings: {{ url: \" {url}\" }}\n \
194+ plugins:\n enabled: true\n dir: {}\n trust:\n allow_unsigned: true\n \
195+ auth:\n chain: []\n \
196+ providers:\n mock:\n api_key: {{ env: MOCK_KEY }}\n \
197+ models:\n test-model:\n provider: mock\n ",
198+ plugins_dir. display( )
199+ ) ,
200+ )
201+ . unwrap ( ) ;
202+
203+ let out = Command :: new ( & busbar_bin)
204+ . arg ( "--validate" )
205+ . env ( "BUSBAR_CONFIG" , & config)
206+ . env ( "BUSBAR_PROVIDERS" , & providers)
207+ . output ( )
208+ . expect ( "run busbar --validate" ) ;
209+ assert ! (
210+ out. status. success( ) ,
211+ "busbar --validate must succeed with the file-dropped mysql plugin: stdout={} stderr={}" ,
212+ String :: from_utf8_lossy( & out. stdout) ,
213+ String :: from_utf8_lossy( & out. stderr)
214+ ) ;
215+
216+ // PROOF real MySQL was touched by the REAL busbar process, through the REAL file-drop path: an
217+ // independent connection (bypassing the plugin/ABI/loader entirely) confirms the schema now
218+ // exists -- --validate's own plugin-open call ran Store::connect, which runs init_schema(). Also
219+ // exercises MysqlStore::connect itself as the second independent-verification leg the prior
220+ // direct-call test used.
221+ let _direct = MysqlStore :: connect ( & url)
222+ . expect ( "connect directly, bypassing the plugin entirely, to confirm real schema init" ) ;
223+ let mut raw = mysql:: Conn :: new ( mysql:: Opts :: from_url ( & url) . unwrap ( ) ) . unwrap ( ) ;
224+ use mysql:: prelude:: * ;
225+ let exists: bool = raw
226+ . exec_first (
227+ "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='api_keys')" ,
228+ ( ) ,
229+ )
230+ . unwrap ( )
231+ . unwrap ( ) ;
232+ assert ! (
233+ exists,
234+ "the api_keys table must exist after busbar --validate loaded the plugin via file-drop -- \
235+ proof the real boot path actually called Store::connect/init_schema, not a no-op"
236+ ) ;
237+
238+ let _ = std:: fs:: remove_dir_all ( & work) ;
239+ cleanup ( & url, key_id) ;
240+ }
241+
242+ /// END-TO-END FAILURE (ABI-contract unit test, see module doc for why this stays a direct
243+ /// `load_store()` call): an `open()` config that cannot produce a usable store surfaces back across
244+ /// the C ABI as a clean `Err`, never a panic or a silently-succeeded load.
245+ #[ test]
246+ fn load_and_exercise_mysql_plugin_bad_config_fails_over_abi ( ) {
247+ let Some ( path) = plugin_path ( ) else {
248+ eprintln ! ( "skip: store-mysql-plugin cdylib not built" ) ;
249+ return ;
250+ } ;
251+
252+ let err = busbar_plugin_loader:: load_store ( & path, "{ not json" )
253+ . err ( )
254+ . expect ( "malformed config JSON must fail to load, not silently succeed" ) ;
255+ assert ! (
256+ err. contains( "invalid mysql plugin config" ) ,
257+ "the plugin's own error message should survive the ABI crossing intact: {err}"
258+ ) ;
259+
260+ let err = busbar_plugin_loader:: load_store ( & path, "{}" )
261+ . err ( )
262+ . expect ( "a config missing url must fail to load" ) ;
263+ assert ! (
264+ err. contains( "requires a \" url\" " ) ,
265+ "expected the plugin's own missing-url message, got: {err}"
266+ ) ;
267+
268+ let err = busbar_plugin_loader:: load_store (
269+ & path,
270+ & cfg ( "mysql://u:p@127.0.0.1:1/definitely_not_a_real_db" ) ,
271+ )
272+ . err ( )
273+ . expect ( "an unreachable mysql target must fail to load" ) ;
274+ assert ! (
275+ !err. is_empty( ) ,
276+ "expected the underlying mysql crate's own connect-failure message to survive the ABI \
277+ crossing, got an empty error"
278+ ) ;
279+ }
280+
281+ /// A non-plugin library (or a missing file) is refused with a clear error, never a crash. Same
282+ /// ABI-contract-unit-test rationale as above.
283+ #[ test]
284+ fn refuses_non_plugin ( ) {
285+ let err = match busbar_plugin_loader:: load_store (
286+ std:: path:: Path :: new ( "/definitely/not/a/plugin.so" ) ,
287+ "{}" ,
288+ ) {
289+ Err ( e) => e,
290+ Ok ( _) => panic ! ( "a missing library must not load" ) ,
291+ } ;
292+ assert ! ( err. contains( "failed to load plugin" ) , "got: {err}" ) ;
111293}
0 commit comments