fabro/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs
Bryan Helmkamp 84b75f29f1
docs(api): document additional github repository access
- Add `additional_repositories` to the RunIntegrationsGithubSettings
  OpenAPI schema and reuse the canonical Rust settings types through
  `with_replacement`, with type-identity witnesses and JSON parity
  tests for populated and empty repository sets.
- Regenerate the TypeScript API client.
- Document the feature in the GitHub integration and run-configuration
  guides: exact layer replacement rules, single-token scope, gh/API
  support, App-versus-PAT scope, the same-owner/same-installation
  requirement, validation errors, supported Git URL forms, hard-failure
  semantics for declared repositories, GH_TOKEN precedence, and the
  security boundary (no second server-side repository intersection;
  contents = "write" lets any stage push to any declared repository).
  Correct the earlier claim that injecting GITHUB_TOKEN alone makes
  arbitrary additional private clones work.
- Add a dated changelog entry and an opt-in live GitHub App e2e test
  that verifies a scoped multi-repository token reads every declared
  repository (and that a primary-only token cannot), with repositories
  supplied through the test environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:12:23 -04:00

119 lines
4.6 KiB
Rust

//! JSON parity and type-identity tests for `RunIntegrationsGithubSettings`.
//!
//! Asserts that the API-side `RunIntegrationsGithubSettings` is the
//! canonical Rust resolved type (via `with_replacement` in `build.rs`) and
//! that both names round-trip through the same JSON shape. Covers the
//! populated and empty permissions cases as well as populated and empty
//! `additional_repositories` sets.
use fabro_api::types::{
RunIntegrationsGithubSettings as ApiRunIntegrationsGithubSettings,
RunIntegrationsSettings as ApiRunIntegrationsSettings,
};
use fabro_types::settings::run::{RunIntegrationsGithubSettings, RunIntegrationsSettings};
use serde_json::json;
/// Type-identity witnesses: the generated API names are the canonical Rust
/// types, not parallel DTOs. Compiles only when they are the same type.
#[expect(dead_code, reason = "compile-time type-identity witness")]
fn github_settings_type_identity(
value: ApiRunIntegrationsGithubSettings,
) -> RunIntegrationsGithubSettings {
value
}
#[expect(dead_code, reason = "compile-time type-identity witness")]
fn integrations_settings_type_identity(
value: ApiRunIntegrationsSettings,
) -> RunIntegrationsSettings {
value
}
#[test]
fn run_integrations_github_settings_round_trips_with_permissions() {
let json_value = json!({
"permissions": {
"issues": "read",
"contents": "write",
}
});
let api: ApiRunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("api type should parse");
let canonical: RunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("canonical type should parse");
assert_eq!(serde_json::to_value(&api).unwrap(), json_value);
assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value);
}
#[test]
fn run_integrations_github_settings_round_trips_empty_permissions() {
// Empty map is the resolved form of "no token requested" — must
// serialize as an object, not omitted.
let json_value = json!({ "permissions": {} });
let api: ApiRunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("api type should parse empty");
let canonical: RunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("canonical type should parse empty");
assert_eq!(serde_json::to_value(&api).unwrap(), json_value);
assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value);
}
#[test]
fn run_integrations_github_settings_round_trips_additional_repositories() {
let json_value = json!({
"permissions": { "contents": "read" },
"additional_repositories": ["fabro-sh/arc", "fabro-sh/keystone"],
});
let api: ApiRunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("api type should parse repositories");
let canonical: RunIntegrationsGithubSettings =
serde_json::from_value(json_value.clone()).expect("canonical type should parse");
assert_eq!(serde_json::to_value(&api).unwrap(), json_value);
assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value);
}
#[test]
fn run_integrations_github_settings_omits_an_empty_repository_set() {
// An absent field and an explicit empty array both deserialize to the
// empty set, and the empty set serializes back with the field omitted —
// keeping single-repository settings byte-identical to older releases.
let empty_array = json!({
"permissions": {},
"additional_repositories": [],
});
let omitted = json!({ "permissions": {} });
let api: ApiRunIntegrationsGithubSettings =
serde_json::from_value(empty_array).expect("api type should parse an empty array");
let canonical: RunIntegrationsGithubSettings =
serde_json::from_value(omitted.clone()).expect("canonical type should parse");
assert!(api.additional_repositories.is_empty());
assert_eq!(serde_json::to_value(&api).unwrap(), omitted);
assert_eq!(serde_json::to_value(&canonical).unwrap(), omitted);
}
#[test]
fn run_integrations_settings_round_trips() {
let json_value = json!({
"github": {
"permissions": {
"issues": "read",
}
}
});
let api: ApiRunIntegrationsSettings =
serde_json::from_value(json_value.clone()).expect("api wrapper should parse");
let canonical: RunIntegrationsSettings =
serde_json::from_value(json_value.clone()).expect("canonical wrapper should parse");
assert_eq!(serde_json::to_value(&api).unwrap(), json_value);
assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value);
}