fabro/test/twin/github/src/fixtures.rs
Bryan Helmkamp 19939c5f07
lint(clippy): disallow blocking std::fs on Tokio paths
Phase 2/3 of the std::fs lint initiative (Phase 1 refactors landed in
commit 9d1c0d98c).

clippy.toml additions (appended to disallowed-methods):
  std::fs::read, read_to_string, write, read_dir, copy, canonicalize
  std::fs::File::open, File::create, File::create_new
  std::fs::OpenOptions::open

File::options was deliberately excluded — it returns an OpenOptions
builder with no syscall. OpenOptions::open is where the block happens.
Non-blocking std::fs items (metadata, exists, create_dir_all, remove_*,
rename, and all std::fs types) remain legal.

Annotation policy (per updated plan):
  - Mixed async/sync production source: function- or statement-scoped
    #[expect(...)] so future accidental Tokio-path regressions in the
    same file still fire.
  - Fully-sync production source, test modules, integration tests,
    build.rs: file-level #![expect(...)].
  - Every #[expect] has a specific reason identifying the sync context.

Annotations added in ~90 files across the workspace. Notable narrow
placements: fabro-server server.rs current_server_target,
build_disk_usage_response, create_test_app_state_with_session_key;
fabro-server install.rs read_to_string rollback snapshot;
fabro-sandbox local.rs list_recursive; fabro-agent cli.rs FOLLOW-UP on
the JSON-stdout writer; fabro-llm providers/common.rs FOLLOW-UP for
load_file_as_base64 (7 translator call sites; revisit if file:// URL
usage grows).

build.rs blanket allows: fabro-api/build.rs, fabro-util/build.rs.

Pre-existing unrelated nightly-clippy warnings fixed under scope:
fabro-sandbox sandbox_spec.rs (unused_imports, unused_async),
reconnect.rs (unused_variables, unused_async).

Verified: cargo +nightly-2026-04-14 clippy --workspace --all-targets
-- -D warnings passes; fmt clean; 4129/4131 tests pass (two known
flakes under parallel nextest load, both pass individually and are
unrelated to this change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 17:22:21 -04:00

539 lines
17 KiB
Rust

use std::collections::HashMap;
use std::fs;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::Path;
use serde::Deserialize;
use crate::state::{
AppOptions, AppState, Comment, Installation, IssueContent, ManifestConversion, OwnerType,
Project, ProjectItem, PullRequest, Release, Repository, StatusOption, TokenInfo,
WebhookOptions,
};
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FixtureState {
#[serde(default)]
pub apps: Vec<FixtureApp>,
#[serde(default)]
pub installations: Vec<FixtureInstallation>,
#[serde(default)]
pub repositories: Vec<FixtureRepository>,
#[serde(default)]
pub pull_requests: Vec<FixturePullRequest>,
#[serde(default)]
pub active_tokens: Vec<FixtureActiveToken>,
#[serde(default)]
pub projects: Vec<FixtureProject>,
#[serde(default)]
pub releases: Vec<FixtureRelease>,
#[serde(default)]
pub manifest_conversions: Vec<FixtureManifestConversion>,
#[serde(default)]
pub comments: Vec<FixtureComment>,
#[serde(default)]
pub webhook_config: FixtureWebhookOptions,
pub next_installation_id: Option<u64>,
pub next_pr_number: Option<u64>,
pub viewer_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureApp {
pub app_id: String,
pub slug: String,
pub owner_login: String,
pub public: bool,
pub private_key_pem: String,
pub webhook_secret: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureInstallation {
pub id: u64,
pub app_id: String,
pub owner: String,
#[serde(default)]
pub repositories: Vec<String>,
#[serde(default)]
pub suspended: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureRepository {
pub owner: String,
pub name: String,
#[serde(default)]
pub branches: Vec<String>,
pub default_branch: Option<String>,
#[serde(default)]
pub private: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixturePullRequest {
pub owner: String,
pub repo: String,
#[serde(flatten)]
pub pull_request: PullRequest,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureActiveToken {
pub token: String,
pub app_id: String,
pub installation_id: u64,
#[serde(default)]
pub repositories: Vec<String>,
#[serde(default = "empty_json_object")]
pub permissions: serde_json::Value,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureProject {
pub node_id: String,
pub number: u64,
pub owner: String,
pub owner_type: FixtureOwnerType,
pub status_field_id: String,
#[serde(default)]
pub status_options: Vec<FixtureStatusOption>,
#[serde(default)]
pub items: Vec<FixtureProjectItem>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FixtureOwnerType {
Organization,
User,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureStatusOption {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureProjectItem {
pub id: String,
pub status: String,
pub content: FixtureIssueContent,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureIssueContent {
pub id: String,
pub number: u64,
pub title: String,
pub body: String,
pub url: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub assignee_ids: Vec<String>,
#[serde(default)]
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureRelease {
pub owner: String,
pub repo: String,
pub tag_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureManifestConversion {
pub code: String,
pub app_id: i64,
pub slug: String,
pub client_id: String,
pub client_secret: String,
pub webhook_secret: Option<String>,
pub pem: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FixtureComment {
pub issue_node_id: String,
pub body: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct FixtureWebhookOptions {
pub url: Option<String>,
pub content_type: Option<String>,
}
impl FixtureState {
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
#[expect(
clippy::disallowed_methods,
reason = "twin test harness: sync fixture load from disk; not on a Tokio hot path"
)]
let contents = fs::read_to_string(path)
.map_err(|err| format!("failed to read fixture {}: {err}", path.display()))?;
serde_json::from_str(&contents)
.map_err(|err| format!("failed to parse fixture {}: {err}", path.display()))
}
pub fn into_app_state(self) -> Result<AppState, String> {
let mut state = AppState::new();
for app in self.apps {
let app_id = app.app_id.clone();
let config = AppOptions {
app_id: app.app_id,
slug: app.slug,
owner_login: app.owner_login,
public: app.public,
private_key_pem: app.private_key_pem,
webhook_secret: app.webhook_secret,
};
catch_unwind(AssertUnwindSafe(|| state.register_app(config)))
.map_err(|_| format!("invalid RSA private key PEM for fixture app {app_id}"))?;
}
state.installations = self
.installations
.into_iter()
.map(|installation| Installation {
id: installation.id,
app_id: installation.app_id,
owner: installation.owner,
repositories: installation.repositories,
suspended: installation.suspended,
})
.collect();
state.repositories = self
.repositories
.into_iter()
.map(|repository| Repository {
owner: repository.owner,
name: repository.name,
branches: repository.branches,
default_branch: repository
.default_branch
.unwrap_or_else(|| "main".to_string()),
private: repository.private,
git_dir: None,
})
.collect();
for pull_request in self.pull_requests {
state
.pull_requests
.entry((pull_request.owner, pull_request.repo))
.or_default()
.push(pull_request.pull_request);
}
state.active_tokens = self
.active_tokens
.into_iter()
.map(|token| {
(token.token, TokenInfo {
app_id: token.app_id,
installation_id: token.installation_id,
repositories: token.repositories,
permissions: token.permissions,
})
})
.collect();
state.projects = self
.projects
.into_iter()
.map(|project| Project {
node_id: project.node_id,
number: project.number,
owner: project.owner,
owner_type: match project.owner_type {
FixtureOwnerType::Organization => OwnerType::Organization,
FixtureOwnerType::User => OwnerType::User,
},
status_field_id: project.status_field_id,
status_options: project
.status_options
.into_iter()
.map(|option| StatusOption {
id: option.id,
name: option.name,
})
.collect(),
items: project
.items
.into_iter()
.map(|item| ProjectItem {
id: item.id,
status: item.status,
content: IssueContent {
id: item.content.id,
number: item.content.number,
title: item.content.title,
body: item.content.body,
url: item.content.url,
created_at: item.content.created_at,
updated_at: item.content.updated_at,
assignee_ids: item.content.assignee_ids,
labels: item.content.labels,
},
})
.collect(),
})
.collect();
state.releases = self
.releases
.into_iter()
.map(|release| {
((release.owner, release.repo), Release {
tag_name: release.tag_name,
})
})
.collect::<HashMap<_, _>>();
state.manifest_conversions = self
.manifest_conversions
.into_iter()
.map(|conversion| {
let code = conversion.code.clone();
(code, ManifestConversion {
code: conversion.code,
app_id: conversion.app_id,
slug: conversion.slug,
client_id: conversion.client_id,
client_secret: conversion.client_secret,
webhook_secret: conversion.webhook_secret,
pem: conversion.pem,
})
})
.collect::<HashMap<_, _>>();
state.comments = self
.comments
.into_iter()
.map(|comment| Comment {
issue_node_id: comment.issue_node_id,
body: comment.body,
})
.collect();
state.webhook_config = WebhookOptions {
url: self.webhook_config.url,
content_type: self.webhook_config.content_type,
};
state.next_installation_id = self
.next_installation_id
.unwrap_or_else(|| next_installation_id(&state.installations));
state.next_pr_number = self
.next_pr_number
.unwrap_or_else(|| next_pr_number(&state.pull_requests));
if let Some(viewer_id) = self.viewer_id {
state.viewer_id = viewer_id;
}
Ok(state)
}
}
fn empty_json_object() -> serde_json::Value {
serde_json::json!({})
}
fn next_installation_id(installations: &[Installation]) -> u64 {
installations
.iter()
.map(|installation| installation.id)
.max()
.unwrap_or(0)
+ 1
}
fn next_pr_number(pull_requests: &HashMap<(String, String), Vec<PullRequest>>) -> u64 {
pull_requests
.values()
.flat_map(|prs| prs.iter().map(|pr| pr.number))
.max()
.unwrap_or(0)
+ 1
}
#[cfg(test)]
impl FixtureState {
fn single_app_fixture_for_test() -> Self {
Self {
apps: vec![FixtureApp {
app_id: "100".to_string(),
slug: "fixture-app".to_string(),
owner_login: "acme".to_string(),
public: true,
private_key_pem: test_rsa_key().to_string(),
webhook_secret: Some("whsec".to_string()),
}],
..Self::default()
}
}
}
#[cfg(test)]
fn test_rsa_key() -> &'static str {
crate::test_support::test_rsa_private_key()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixture_state_loads_full_server_state() {
let private_key_pem = test_rsa_key().replace('\n', "\\n");
let fixture_json = r#"{
"apps": [{
"app_id": "100",
"slug": "ops-app",
"owner_login": "acme",
"public": true,
"private_key_pem": "__PRIVATE_KEY__",
"webhook_secret": "whsec"
}],
"installations": [{
"id": 7,
"app_id": "100",
"owner": "acme",
"repositories": ["widgets"],
"suspended": false
}],
"repositories": [{
"owner": "acme",
"name": "widgets",
"branches": ["main", "feature"],
"default_branch": "main"
}],
"pull_requests": [{
"owner": "acme",
"repo": "widgets",
"number": 1,
"node_id": "PR_1",
"title": "Ship it",
"body": "Ready",
"state": "open",
"draft": false,
"mergeable": true,
"additions": 10,
"deletions": 5,
"changed_files": 2,
"html_url": "https://github.com/acme/widgets/pull/1",
"user_login": "bot",
"head_ref": "feature",
"base_ref": "main",
"created_at": "2026-03-27T00:00:00Z",
"updated_at": "2026-03-27T00:00:00Z",
"auto_merge": null
}],
"active_tokens": [{
"token": "ghs_exampletoken",
"app_id": "100",
"installation_id": 7,
"repositories": ["widgets"],
"permissions": {"contents": "write"}
}],
"projects": [{
"node_id": "PVT_kwDOB",
"number": 3,
"owner": "acme",
"owner_type": "organization",
"status_field_id": "PVTSSF_1",
"status_options": [{
"id": "opt_todo",
"name": "Todo"
}],
"items": [{
"id": "PVTI_1",
"status": "Todo",
"content": {
"id": "ISSUE_1",
"number": 42,
"title": "Track launch",
"body": "Ship it",
"url": "https://github.com/acme/widgets/issues/42",
"created_at": "2026-03-27T00:00:00Z",
"updated_at": "2026-03-27T00:00:00Z",
"assignee_ids": ["U_1"],
"labels": ["priority"]
}
}]
}],
"releases": [{
"owner": "acme",
"repo": "widgets",
"tag_name": "v1.2.3"
}],
"manifest_conversions": [{
"code": "manifest-code",
"app_id": 100,
"slug": "ops-app-dev",
"client_id": "Iv1.123",
"client_secret": "manifest-secret",
"webhook_secret": "manifest-whsec",
"pem": "manifest pem"
}],
"comments": [{
"issue_node_id": "ISSUE_1",
"body": "Looks good"
}],
"webhook_config": {
"url": "https://example.com/webhooks/github",
"content_type": "json"
},
"next_installation_id": 99,
"next_pr_number": 77,
"viewer_id": "U_seeded"
}"#
.replace("__PRIVATE_KEY__", &private_key_pem);
let fixture: FixtureState = serde_json::from_str(&fixture_json).unwrap();
let state = fixture.into_app_state().unwrap();
assert_eq!(state.apps["100"].config.slug, "ops-app");
assert_eq!(state.installations.len(), 1);
assert_eq!(state.repositories.len(), 1);
assert_eq!(
state.pull_requests[&("acme".into(), "widgets".into())].len(),
1
);
assert_eq!(state.active_tokens.len(), 1);
assert_eq!(state.projects.len(), 1);
assert_eq!(
state.releases[&("acme".into(), "widgets".into())].tag_name,
"v1.2.3"
);
assert_eq!(
state.manifest_conversions["manifest-code"].client_secret,
"manifest-secret"
);
assert_eq!(state.comments.len(), 1);
assert_eq!(
state.webhook_config.url.as_deref(),
Some("https://example.com/webhooks/github")
);
assert_eq!(state.webhook_config.content_type.as_deref(), Some("json"));
assert_eq!(state.next_installation_id, 99);
assert_eq!(state.next_pr_number, 77);
assert_eq!(state.viewer_id, "U_seeded");
}
#[test]
fn fixture_state_derives_public_keys_for_registered_apps() {
let fixture = FixtureState::single_app_fixture_for_test();
let state = fixture.into_app_state().unwrap();
assert!(state.apps["100"].public_key_pem.contains("PUBLIC KEY"));
}
}