mod support;
use support::{
FIXTURE_USER_CODE, FakeGithub, Reply, file_contains, files_under, fixture_token, run,
runner_manager, runner_manager_against,
};
fn signed_in(data_dir: &std::path::Path, github: &FakeGithub) {
github.with_device_code();
github.with_approval();
github.with_no_installations();
let outcome = run({
let mut command = runner_manager_against(data_dir, github);
command.args(["auth", "login"]);
command
});
assert_eq!(
outcome.code, 0,
"the fixture login must succeed, or every assertion after it is about the wrong \
thing. stdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
}
#[test]
fn a_machine_with_no_credential_reports_not_authenticated() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(outcome.code, 3, "stderr: {}", outcome.stderr);
assert!(
outcome.stdout.contains("Credential: not_authenticated"),
"{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("Nothing has been revoked"),
"an unconfigured machine must not read as a broken one:\n{}",
outcome.stdout
);
assert!(
github.seen().is_empty(),
"with no credential there is nothing to ask GitHub about, and asking anyway would \
spend rate limit to learn nothing: {:?}",
github.seen()
);
}
fn signed_in_with_two_repositories(data_dir: &std::path::Path) -> FakeGithub {
let login = FakeGithub::start();
signed_in(data_dir, &login);
let github = FakeGithub::start();
github.with_installation(
42,
"operator",
"User",
"selected",
&["operator/one", "operator/two"],
);
github
}
#[test]
fn an_accepted_credential_reports_what_it_can_reach() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = signed_in_with_two_repositories(data_dir.path());
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(outcome.code, 0, "stderr: {}", outcome.stderr);
assert!(
outcome.stdout.contains("Credential: authenticated"),
"{}",
outcome.stdout
);
assert!(
outcome
.stdout
.contains("2 repositories and 0 organizations"),
"the summary must count both kinds:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("operator (user, installation 42"),
"every installation is named unconditionally -- it is the account whose grant this \
is, and there are few of them:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("2 reachable"),
"and each installation carries its own count, so the default output still \
distinguishes two repositories from two hundred:\n{}",
outcome.stdout
);
assert!(
!outcome.stdout.contains("ALL repositories"),
"a `selected` installation must not be labelled over-broad:\n{}",
outcome.stdout
);
}
#[test]
fn the_repository_names_are_behind_list_and_the_default_says_where_they_are() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = signed_in_with_two_repositories(data_dir.path());
let quiet = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(quiet.code, 0, "stderr: {}", quiet.stderr);
for repository in ["operator/one", "operator/two"] {
assert!(
!quiet.stdout.contains(repository),
"{repository} must not be named without --list:\n{}",
quiet.stdout
);
}
assert!(
quiet.stdout.contains("--list"),
"a reader who wants the names must be told the flag that prints them, or the \
information is not behind a flag, it is gone:\n{}",
quiet.stdout
);
let listed = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status", "--list"]);
command
});
assert_eq!(listed.code, 0, "stderr: {}", listed.stderr);
for repository in ["operator/one", "operator/two"] {
assert!(
listed.stdout.contains(repository),
"`07-security.md` requires the reachable repositories to be nameable, so that an \
over-broad installation is visible rather than assumed. Missing {repository} \
in:\n{}",
listed.stdout
);
}
}
#[test]
fn the_permission_report_carries_the_whole_grant() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status", "--permissions"]);
command
});
for needle in [
"Administration: Read and write",
"DELETING",
"RENAMING",
"TRANSFERRING",
"collaborators",
"Repository -> Metadata",
"Organization -> Self-hosted runners",
"monitor-only",
] {
assert!(
outcome.stdout.contains(needle),
"`auth status --permissions` is where the grant text lives now, and it must \
carry `{needle}`:\n{}",
outcome.stdout
);
}
assert!(
github.seen().is_empty(),
"the permission set is a property of the published App, not of this host, so \
describing it must cost no request: {:?}",
github.seen()
);
}
#[test]
fn the_permission_report_needs_no_credential() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status", "--permissions"]);
command
});
assert_eq!(
outcome.code, 3,
"the exit code still reports the credential, which is absent here; stdout:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("Administration: Read and write"),
"an operator deciding whether to sign in at all must be able to read the grant \
first:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("Credential: not_authenticated"),
"and the credential answer is still given:\n{}",
outcome.stdout
);
}
#[test]
fn the_permission_table_is_absent_without_the_flag() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = signed_in_with_two_repositories(data_dir.path());
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert!(
!outcome.stdout.contains("Administration: Read and write"),
"the permission table is `--permissions`, not the default:\n{}",
outcome.stdout
);
}
#[test]
fn an_over_broad_installation_is_called_out_rather_than_only_listed() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(data_dir.path(), &login);
let github = FakeGithub::start();
github.with_installation(7, "acme", "Organization", "all", &["acme/one"]);
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(outcome.code, 0, "stderr: {}", outcome.stderr);
assert!(
outcome.stdout.contains("ALL repositories"),
"an installation set to every repository on the account must say so:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("created later"),
"and must say why a list of names cannot show it:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("1 repository and 1 organization"),
"an organization account is a reachable target in its own right (D18), and is \
counted alongside the repositories the installation reaches rather than instead \
of them:\n{}",
outcome.stdout
);
}
#[test]
fn a_revoked_credential_is_reported_as_revoked_and_not_as_a_missing_one() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(data_dir.path(), &login);
let github = FakeGithub::start();
github.with_revoked_credential();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(
outcome.code, 4,
"revoked has its own exit code, distinct from not-authenticated's 3; stderr: {}",
outcome.stderr
);
assert!(
outcome.stdout.contains("Credential: revoked"),
"{}",
outcome.stdout
);
assert!(
outcome.stderr.contains("auth login"),
"the remedy for a revoked credential is a fresh one:\n{}",
outcome.stderr
);
}
#[test]
fn a_lockout_is_reported_distinctly_and_never_advises_signing_in_again() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(data_dir.path(), &login);
let github = FakeGithub::start();
github.with_authentication_lockout(120);
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(
outcome.code, 5,
"the lockout has its own exit code, distinct from revoked's 4; stdout:\n{}\n\
stderr:\n{}",
outcome.stdout, outcome.stderr
);
assert!(
outcome.stdout.contains("Credential: locked_out"),
"{}",
outcome.stdout
);
assert!(
outcome
.stdout
.contains("nothing wrong with the token itself"),
"an operator in a lockout must be told their credential is fine:\n{}",
outcome.stdout
);
assert!(
!outcome.stderr.contains("auth login"),
"and must NOT be told to sign in again -- `03-control-flows.md` flow 4.3: the agent \
backs off without further refresh attempts. Signing in during a lockout extends \
it.\n{}",
outcome.stderr
);
assert!(
outcome.stderr.contains("wait"),
"the remedy is waiting:\n{}",
outcome.stderr
);
}
#[test]
fn a_permissions_refusal_is_not_reported_as_a_lockout() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(data_dir.path(), &login);
let github = FakeGithub::start();
github.route(
"GET",
"/user/installations",
Reply::json(
403,
r#"{"message":"Resource not accessible by integration"}"#,
),
);
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_ne!(
outcome.code, 5,
"a `403` carrying a GitHub message is a permissions answer; latching a back-off on \
it would silence this client for fifteen minutes over a grant that is simply \
missing. stdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
assert_eq!(
outcome.code, 8,
"it belongs in the refused class; stderr: {}",
outcome.stderr
);
}
#[test]
fn an_unreachable_github_is_not_reported_as_a_bad_credential() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(data_dir.path(), &login);
let dead = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let port = listener.local_addr().expect("bound").port();
drop(listener);
format!("http://127.0.0.1:{port}/")
};
let outcome = run({
let mut command = runner_manager(data_dir.path());
command
.env("RUNNER_MANAGER_GITHUB_BASE_URL", &dead)
.env(
"RUNNER_MANAGER_GITHUB_CLIENT_ID",
support::FIXTURE_CLIENT_ID,
)
.env("RUNNER_MANAGER_GITHUB_APP_SLUG", support::FIXTURE_APP_SLUG)
.args(["auth", "status"]);
command
});
assert_eq!(
outcome.code, 7,
"the unreachable class, distinct from revoked (4) and not-authenticated (3); \
stdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
assert!(
outcome.stdout.contains("Credential: unreachable"),
"{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("may be perfectly good"),
"an offline host must not be told its credential is bad:\n{}",
outcome.stdout
);
}
#[test]
fn the_reported_states_reach_distinct_exit_codes() {
let empty = tempfile::tempdir().expect("a temporary directory");
let no_github = FakeGithub::start();
let not_authenticated = run({
let mut command = runner_manager_against(empty.path(), &no_github);
command.args(["auth", "status"]);
command
})
.code;
let signed = tempfile::tempdir().expect("a temporary directory");
let login = FakeGithub::start();
signed_in(signed.path(), &login);
let reachable = FakeGithub::start();
reachable.with_installation(1, "operator", "User", "selected", &["operator/one"]);
let authenticated = run({
let mut command = runner_manager_against(signed.path(), &reachable);
command.args(["auth", "status"]);
command
})
.code;
let revoked_github = FakeGithub::start();
revoked_github.with_revoked_credential();
let revoked = run({
let mut command = runner_manager_against(signed.path(), &revoked_github);
command.args(["auth", "status"]);
command
})
.code;
let lockout_github = FakeGithub::start();
lockout_github.with_authentication_lockout(120);
let locked_out = run({
let mut command = runner_manager_against(signed.path(), &lockout_github);
command.args(["auth", "status"]);
command
})
.code;
let codes = [not_authenticated, authenticated, revoked, locked_out];
let distinct: std::collections::BTreeSet<i32> = codes.iter().copied().collect();
assert_eq!(
distinct.len(),
codes.len(),
"authenticated, not-authenticated, revoked and locked-out must all be tellable \
apart from a script: {codes:?}"
);
assert_eq!(authenticated, 0, "only success exits zero");
}
#[test]
fn logout_leaves_no_token_and_names_the_authoritative_revocation() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
signed_in(data_dir.path(), &github);
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "logout"]);
command
});
assert_eq!(outcome.code, 0, "stderr: {}", outcome.stderr);
assert!(
outcome.stdout.contains("Removed the stored credential"),
"{}",
outcome.stdout
);
assert!(
outcome
.stdout
.contains("Authoritative revocation is uninstalling the App at GitHub."),
"`07-security.md` makes uninstalling the App the authoritative revocation, and a \
logout that did not say so would let an operator read a local purge as one:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains("still valid at GitHub"),
"{}",
outcome.stdout
);
let after = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "status"]);
command
});
assert_eq!(
after.code, 3,
"after a logout the host is back to not-authenticated; stdout:\n{}",
after.stdout
);
assert!(after.stdout.contains("Credential: not_authenticated"));
let planted = fixture_token();
let mut offenders = Vec::new();
for entry in files_under(data_dir.path()) {
if file_contains(&entry, &planted) {
offenders.push(entry.display().to_string());
}
}
assert!(
offenders.is_empty(),
"`auth logout` must leave no token in the store: {offenders:?}"
);
}
#[test]
fn logout_on_a_host_that_was_never_signed_in_succeeds() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "logout"]);
command
});
assert_eq!(
outcome.code, 0,
"a host with nothing to purge has complied with the procedure; stderr: {}",
outcome.stderr
);
assert!(
outcome.stdout.contains("Nothing to remove"),
"and it must say so rather than claiming to have removed something:\n{}",
outcome.stdout
);
assert!(
outcome
.stdout
.contains("Authoritative revocation is uninstalling the App at GitHub."),
"the notice is owed either way:\n{}",
outcome.stdout
);
}
#[test]
fn an_app_override_without_a_fake_github_is_ignored_and_said_to_be_ignored() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let outcome = run({
let mut command = runner_manager(data_dir.path());
command
.env(
"RUNNER_MANAGER_GITHUB_CLIENT_ID",
support::FIXTURE_CLIENT_ID,
)
.env("RUNNER_MANAGER_GITHUB_APP_SLUG", support::FIXTURE_APP_SLUG)
.args(["auth", "status"]);
command
});
assert!(
outcome.stderr.contains("ignoring"),
"a stale override must be reported as IGNORED rather than obeyed in \
silence; stderr:\n{}",
outcome.stderr
);
assert!(
outcome.stderr.contains("runner-manager-scaler"),
"and the warning must name the App the sign-in will actually use; \
stderr:\n{}",
outcome.stderr
);
assert!(
!outcome.stderr.contains(&format!(
"authenticating as the GitHub App `{}`",
support::FIXTURE_APP_SLUG
)),
"the binary must not report authenticating as the overridden App; \
stderr:\n{}",
outcome.stderr
);
}
#[test]
fn a_sign_in_is_not_refused_by_a_store_it_cannot_read() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let github = FakeGithub::start();
signed_in(data_dir.path(), &github);
let store = files_under(data_dir.path())
.into_iter()
.find(|path| support::is_the_secret_store(path))
.expect("the sign-in above wrote a credential somewhere under the store");
std::fs::write(&store, [0xff_u8, 0xfe, 0x00, 0xff]).expect("the store file is writable");
github.with_device_code();
github.with_approval();
github.with_no_installations();
let outcome = run({
let mut command = runner_manager_against(data_dir.path(), &github);
command.args(["auth", "login"]);
command
});
assert!(
outcome.stdout.contains("could not be read"),
"the operator is told which of the two things happened -- resumed, or replaced. \
stdout:\n{}",
outcome.stdout
);
assert!(
outcome.stdout.contains(FIXTURE_USER_CODE),
"an unreadable credential is the ordinary condition of a host about to sign in, not a \
reason to refuse, so the device flow must be reached. Before the fix this command \
ended on the read and printed no code at all. stdout:\n{}\nstderr:\n{}",
outcome.stdout,
outcome.stderr
);
#[cfg(not(target_os = "macos"))]
assert_eq!(
outcome.code, 0,
"and it finishes. stdout:\n{}\nstderr:\n{}",
outcome.stdout, outcome.stderr
);
}