Skip to content

Commit ef9428b

Browse files
mpecanclaude
andauthored
feat(server,cli): allow updating test suites for published filters (#119) (#192)
## Summary - **PUT /api/filters/{hash}/tests** — new endpoint for replacing the test suite of a published filter. Uses a DB transaction with `SELECT ... FOR UPDATE` row lock. Uploads new storage objects before DB commit; deletes old ones after (best-effort cleanup). - **Server-side test validation** — all test files are validated via `tokf_common::test_case::validate()` on both publish and update paths. Rejects invalid TOML, empty names, missing `[[expect]]` blocks, and bad regexes. - **`tokf publish --update-tests`** — CLI flag to update test files for an already-published filter. Includes client-side validation, stdlib guard, and explicit 403/404 error messages. - **Shared `TestCase`/`Expectation` types** — moved to `tokf-common` with a `validation` feature gate for `toml` + `regex` deps. - **`StorageClient::delete()`** — new trait method with R2, in-memory mock (with `delete_calls` counter), and no-op implementations. - **`AppError::Forbidden`** — new 403 variant for authorization errors. - **10 new e2e tests** — publish lifecycle, update round-trips, non-author rejection, invalid content rejection. - **13 new server unit tests** — hash validation, semantic validation, zero-to-N update, double update, storage cleanup verification. - **3 new validation unit tests** — whitespace-only names, multiple expect blocks, mixed valid/invalid regexes. ## Test plan - [x] `cargo build --workspace` — clean - [x] `cargo clippy --workspace --all-targets -- -D warnings` — no warnings - [x] `cargo fmt -- --check` — formatted - [x] `cargo test --workspace` — 1126 tests pass - [x] `cargo test -p tokf-common --features validation -- test_case` — 13 validation tests pass - [x] `cargo test -p tokf-server -- storage` — 25 storage tests pass - [x] `cargo test -p e2e-tests --no-run` — e2e tests compile (require DATABASE_URL to run) - [ ] Manual: `tokf publish --update-tests <filter>` against staging server - [ ] E2E: run with `DATABASE_URL` against CockroachDB 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 3253f2f commit ef9428b

30 files changed

Lines changed: 1871 additions & 181 deletions

Cargo.lock

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

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,39 @@ Test filenames are validated to prevent path traversal attacks.
854854

855855
---
856856

857+
## Updating Test Suites
858+
859+
After publishing a filter, the filter TOML itself is immutable (same content = same hash), but you
860+
can replace the bundled test suite at any time:
861+
862+
```sh
863+
tokf publish --update-tests <filter-name>
864+
```
865+
866+
This replaces the **entire** test suite in the registry with the current local `_test/` directory
867+
contents. Only the original author can update tests.
868+
869+
### Options
870+
871+
| Flag | Description |
872+
|------|-------------|
873+
| `--dry-run` | Preview which test files would be uploaded without making changes |
874+
875+
### Examples
876+
877+
```sh
878+
tokf publish --update-tests git/push # replace test suite for git/push
879+
tokf publish --update-tests git/push --dry-run # preview only
880+
```
881+
882+
### Notes
883+
884+
- The filter's identity (content hash) does not change.
885+
- The old test suite is deleted and fully replaced by the new one.
886+
- You must be the original author of the filter.
887+
888+
---
889+
857890
## Publishing a Filter
858891

859892
See [Publishing Filters](./publishing-filters.md) for how to share your own filters.

crates/e2e-tests/tests/harness/mod.rs

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ use tokio::task::JoinHandle;
1717

1818
use tokf::auth::credentials::LoadedAuth;
1919
use tokf::remote::client::{MachineInfo, RegisteredMachine};
20+
use tokf::remote::filter_client::{self, DownloadedFilter, FilterDetails, FilterSummary};
2021
use tokf::remote::gain_client::{GainResponse, GlobalGainResponse};
2122
use tokf::remote::machine::StoredMachine;
23+
use tokf::remote::publish_client::{self, PublishResponse, UpdateTestsResponse};
2224
use tokf::remote::sync_client::{self, SyncEvent, SyncRequest, SyncResponse};
2325
use tokf::tracking;
2426
use tokf_server::auth::github::GitHubClient;
2527
use tokf_server::auth::mock::{NoOpGitHubClient, SuccessGitHubClient};
2628
use tokf_server::routes::{create_router, test_helpers};
29+
use tokf_server::storage::mock::InMemoryStorageClient;
2730

2831
/// Reusable test harness that spins up an in-process axum server
2932
/// backed by a real `CockroachDB` pool and provides helpers for
@@ -35,6 +38,7 @@ pub struct TestHarness {
3538
pub user_id: i64,
3639
pub machine_id: uuid::Uuid,
3740
pub sqlite_path: PathBuf,
41+
pub pool: PgPool,
3842
_temp_dir: tempfile::TempDir,
3943
server_handle: JoinHandle<()>,
4044
}
@@ -52,20 +56,45 @@ impl TestHarness {
5256
Self::with_github(pool, Arc::new(NoOpGitHubClient)).await
5357
}
5458

59+
/// Create a harness with `InMemoryStorageClient` so filter publish/download
60+
/// operations actually persist bytes in memory (unlike `NoOpStorageClient`).
61+
pub async fn with_storage(pool: PgPool) -> Self {
62+
Self::with_github_and_storage(
63+
pool,
64+
Arc::new(NoOpGitHubClient),
65+
Arc::new(InMemoryStorageClient::new()),
66+
)
67+
.await
68+
}
69+
5570
/// Create a harness with `SuccessGitHubClient` (device flow completes
5671
/// immediately, useful for auth E2E tests).
5772
pub async fn with_github_mock(pool: PgPool) -> Self {
5873
Self::with_github(pool, Arc::new(SuccessGitHubClient)).await
5974
}
6075

6176
async fn with_github(pool: PgPool, github: Arc<dyn GitHubClient>) -> Self {
77+
Self::with_github_and_storage(
78+
pool,
79+
github,
80+
Arc::new(tokf_server::storage::noop::NoOpStorageClient),
81+
)
82+
.await
83+
}
84+
85+
async fn with_github_and_storage(
86+
pool: PgPool,
87+
github: Arc<dyn GitHubClient>,
88+
storage: Arc<dyn tokf_server::storage::StorageClient>,
89+
) -> Self {
6290
// Create user, token, and machine in DB
6391
let (user_id, token) = test_helpers::create_user_and_token(&pool).await;
6492
let machine_id = test_helpers::create_machine(&pool, user_id).await;
6593

66-
// Build state — override the github client
67-
let mut state = test_helpers::make_state(pool);
94+
// Build state — override the github client and storage
95+
let mut state = test_helpers::make_state(pool.clone());
6896
state.github = github;
97+
state.storage = storage;
6998

7099
let app = create_router(state);
71100

@@ -109,11 +138,18 @@ impl TestHarness {
109138
user_id,
110139
machine_id,
111140
sqlite_path,
141+
pool,
112142
_temp_dir: temp_dir,
113143
server_handle,
114144
}
115145
}
116146

147+
/// Create a second user+token pair (for testing authorization).
148+
pub async fn create_other_user_token(&self) -> String {
149+
let (_, token) = test_helpers::create_user_and_token(&self.pool).await;
150+
token
151+
}
152+
117153
/// Open (or create) the `SQLite` tracking database.
118154
pub fn open_tracking_db(&self) -> rusqlite::Connection {
119155
tracking::open_db(&self.sqlite_path).unwrap()
@@ -310,4 +346,130 @@ impl TestHarness {
310346
.await
311347
.unwrap()
312348
}
349+
350+
// ── Filter helpers ──────────────────────────────────────────
351+
352+
/// Try to publish a filter (returns Result for error-path tests).
353+
pub async fn try_publish(
354+
&self,
355+
filter_bytes: Vec<u8>,
356+
test_files: Vec<(String, Vec<u8>)>,
357+
) -> anyhow::Result<(bool, PublishResponse)> {
358+
let base_url = self.base_url.clone();
359+
let token = self.token.clone();
360+
tokio::task::spawn_blocking(move || {
361+
let client = Self::http_client();
362+
publish_client::publish_filter(&client, &base_url, &token, &filter_bytes, &test_files)
363+
})
364+
.await
365+
.unwrap()
366+
}
367+
368+
/// Publish a filter with optional test files. Returns `(is_new, response)`.
369+
pub async fn blocking_publish(
370+
&self,
371+
filter_bytes: Vec<u8>,
372+
test_files: Vec<(String, Vec<u8>)>,
373+
) -> (bool, PublishResponse) {
374+
let base_url = self.base_url.clone();
375+
let token = self.token.clone();
376+
tokio::task::spawn_blocking(move || {
377+
let client = Self::http_client();
378+
publish_client::publish_filter(&client, &base_url, &token, &filter_bytes, &test_files)
379+
.unwrap()
380+
})
381+
.await
382+
.unwrap()
383+
}
384+
385+
/// Update the test suite for a published filter.
386+
pub async fn blocking_update_tests(
387+
&self,
388+
hash: &str,
389+
test_files: Vec<(String, Vec<u8>)>,
390+
) -> UpdateTestsResponse {
391+
let base_url = self.base_url.clone();
392+
let token = self.token.clone();
393+
let hash = hash.to_string();
394+
tokio::task::spawn_blocking(move || {
395+
let client = Self::http_client();
396+
publish_client::update_tests(&client, &base_url, &token, &hash, &test_files).unwrap()
397+
})
398+
.await
399+
.unwrap()
400+
}
401+
402+
/// Try to update tests (returns Result for error-path tests).
403+
pub async fn try_update_tests(
404+
&self,
405+
hash: &str,
406+
test_files: Vec<(String, Vec<u8>)>,
407+
) -> anyhow::Result<UpdateTestsResponse> {
408+
let base_url = self.base_url.clone();
409+
let token = self.token.clone();
410+
let hash = hash.to_string();
411+
tokio::task::spawn_blocking(move || {
412+
let client = Self::http_client();
413+
publish_client::update_tests(&client, &base_url, &token, &hash, &test_files)
414+
})
415+
.await
416+
.unwrap()
417+
}
418+
419+
/// Try to update tests with a custom token (for auth tests).
420+
pub async fn try_update_tests_with_token(
421+
&self,
422+
hash: &str,
423+
test_files: Vec<(String, Vec<u8>)>,
424+
token: &str,
425+
) -> anyhow::Result<UpdateTestsResponse> {
426+
let base_url = self.base_url.clone();
427+
let token = token.to_string();
428+
let hash = hash.to_string();
429+
tokio::task::spawn_blocking(move || {
430+
let client = Self::http_client();
431+
publish_client::update_tests(&client, &base_url, &token, &hash, &test_files)
432+
})
433+
.await
434+
.unwrap()
435+
}
436+
437+
/// Search the filter registry.
438+
pub async fn blocking_search_filters(&self, query: &str, limit: usize) -> Vec<FilterSummary> {
439+
let base_url = self.base_url.clone();
440+
let token = self.token.clone();
441+
let query = query.to_string();
442+
tokio::task::spawn_blocking(move || {
443+
let client = Self::http_client();
444+
filter_client::search_filters(&client, &base_url, &token, &query, limit).unwrap()
445+
})
446+
.await
447+
.unwrap()
448+
}
449+
450+
/// Get filter details by hash.
451+
pub async fn blocking_get_filter(&self, hash: &str) -> FilterDetails {
452+
let base_url = self.base_url.clone();
453+
let token = self.token.clone();
454+
let hash = hash.to_string();
455+
tokio::task::spawn_blocking(move || {
456+
let client = Self::http_client();
457+
filter_client::get_filter(&client, &base_url, &token, &hash).unwrap()
458+
})
459+
.await
460+
.unwrap()
461+
}
462+
463+
/// Download filter TOML + test files by hash.
464+
pub async fn blocking_download_filter(&self, hash: &str) -> DownloadedFilter {
465+
let base_url = self.base_url.clone();
466+
let token = self.token.clone();
467+
let hash = hash.to_string();
468+
tokio::task::spawn_blocking(move || {
469+
let client = Self::http_client();
470+
filter_client::download_filter(&client, &base_url, &token, &hash).unwrap()
471+
})
472+
.await
473+
.unwrap()
474+
}
313475
}

0 commit comments

Comments
 (0)