use anyhow::{Result, ensure};
use rstest::rstest;
use super::{pull_request_cases::parse, rules};
#[rstest]
#[case::main_only("on:\n push:\n branches: [main]\njobs: {}\n", true)]
#[case::unfiltered("on:\n push:\njobs: {}\n", false)]
#[case::scalar("on: push\njobs: {}\n", false)]
#[case::tags("on:\n push:\n tags: ['v*']\njobs: {}\n", false)]
#[case::another_branch("on:\n push:\n branches: [develop]\njobs: {}\n", false)]
#[case::main_and_another("on:\n push:\n branches: [main, develop]\njobs: {}\n", false)]
fn only_a_push_restricted_to_main_is_the_publisher(
#[case] source: &str,
#[case] expected: bool,
) -> Result<()> {
ensure!(
rules::publishes_from_main(&parse(source)?) == expected,
"expected {expected} for {source:?}"
);
Ok(())
}
const PUBLISHER: &str = r#"
on:
push:
branches: [main]
workflow_dispatch:
@CONCURRENCY@
jobs:
coverage:
steps:
- uses: leynos/shared-actions/.github/actions/generate-coverage@abc
with:
with-ratchet: 'true'
@EXTRA_STEP@
- env:
CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}
if: "@UPLOAD_IF@"
uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@abc
"#;
fn publisher(concurrency: &str, upload_if: &str, extra_step: &str) -> String {
PUBLISHER
.replace("@CONCURRENCY@", concurrency)
.replace("@UPLOAD_IF@", upload_if)
.replace("@EXTRA_STEP@", extra_step)
}
const NEVER_CANCEL: &str = "concurrency:\n group: pub\n cancel-in-progress: false";
const GUARD: &str = "${{ env.CS_ACCESS_TOKEN != '' && github.ref == 'refs/heads/main' }}";
#[rstest]
#[case::complies(NEVER_CANCEL, GUARD, "", None)]
#[case::disjunction_appended(
NEVER_CANCEL,
"${{ env.CS_ACCESS_TOKEN != '' && github.ref == 'refs/heads/main' || github.event_name == \
'workflow_dispatch' }}",
"",
Some("not guarded")
)]
#[case::disjunction_prepended(
NEVER_CANCEL,
"${{ github.event_name == 'workflow_dispatch' || env.CS_ACCESS_TOKEN != '' && github.ref == \
'refs/heads/main' }}",
"",
Some("not guarded")
)]
#[case::no_ref_guard(
NEVER_CANCEL,
"${{ env.CS_ACCESS_TOKEN != '' }}",
"",
Some("not guarded")
)]
#[case::cancels(
"concurrency:\n group: pub\n cancel-in-progress: true",
GUARD,
"",
Some("cancel")
)]
#[case::cancels_by_expression(
"concurrency:\n group: pub\n cancel-in-progress: ${{ true }}",
GUARD,
"",
Some("cancel")
)]
#[case::no_group("", GUARD, "", Some("no concurrency group"))]
#[case::token_elsewhere(
NEVER_CANCEL,
GUARD,
" - run: echo ${{ secrets.CS_ACCESS_TOKEN }}",
Some("other than the upload")
)]
fn the_publisher_rule_names_the_clause_broken(
#[case] concurrency: &str,
#[case] upload_if: &str,
#[case] extra_step: &str,
#[case] expected: Option<&str>,
) -> Result<()> {
let source = publisher(concurrency, upload_if, extra_step);
let findings = rules::publisher_findings(&parse(&source)?);
match expected {
None => ensure!(findings.is_empty(), "unexpected findings: {findings:?}"),
Some(clause) => ensure!(
findings.len() == 1 && findings[0].contains(clause),
"expected one finding naming {clause:?}, saw {findings:?}"
),
}
Ok(())
}
const UPLOAD_TOKEN: &str =
" - env:\n CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}\n";
const WIDE_TOKEN: &str = "env:\n CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}\n";
const REUSABLE: &str = " forward:\n uses: ./.github/workflows/elsewhere.yml\n";
#[rstest]
#[case::moved_to_coverage(
|source: String| source
.replace(UPLOAD_TOKEN, " - env: {}\n")
.replace(" with:\n with-ratchet", " env:\n CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}\n with:\n with-ratchet"),
&["does not receive", "other than the upload"][..],
)]
#[case::workflow_env(|source: String| source.replace("jobs:\n", &format!("{WIDE_TOKEN}jobs:\n")), &["for every job"][..])]
#[case::job_env(
|source: String| source.replace(" steps:\n", &format!(" {} steps:\n", WIDE_TOKEN.replace("\n ", "\n "))),
&["for every step"][..],
)]
#[case::forwarded_as_an_input(
|source: String| source.replace("jobs:\n", &format!("jobs:\n{REUSABLE} with:\n token: ${{{{ secrets.CS_ACCESS_TOKEN }}}}\n")),
&["to a reusable workflow"][..],
)]
#[case::forwarded_by_name(
|source: String| source.replace("jobs:\n", &format!("jobs:\n{REUSABLE} secrets:\n CS_ACCESS_TOKEN: ${{{{ secrets.CS_ACCESS_TOKEN }}}}\n")),
&["to a reusable workflow"][..],
)]
#[case::forwarded_by_inheritance(
|source: String| source.replace("jobs:\n", &format!("jobs:\n{REUSABLE} secrets: inherit\n")),
&["to a reusable workflow"][..],
)]
#[case::computed_elsewhere(
|source: String| source.replace(" with:\n with-ratchet", " env:\n T: ${{ secrets['CS_ACCESS_TOKEN'] }}\n with:\n with-ratchet"),
&["computed name"][..],
)]
fn the_token_sits_on_the_upload_alone(
#[case] vary: fn(String) -> String,
#[case] expected: &[&str],
) -> Result<()> {
let source = vary(publisher(NEVER_CANCEL, GUARD, ""));
let findings = rules::publisher_findings(&parse(&source)?);
ensure!(
findings.len() == expected.len()
&& expected
.iter()
.all(|clause| findings.iter().any(|f| f.contains(clause))),
"expected findings naming {expected:?}, saw {findings:?}"
);
Ok(())
}
#[test]
fn a_continued_cli_upload_is_still_an_upload() -> Result<()> {
let extra = " - run: |\n cs-coverage \\\n upload --format lcov\n";
let findings = rules::publisher_findings(&parse(&publisher(NEVER_CANCEL, GUARD, extra))?);
ensure!(
findings.iter().any(|f| f.contains("not guarded")),
"the continued upload was not judged: {findings:?}"
);
Ok(())
}
#[test]
fn check_mode_is_not_an_upload() -> Result<()> {
let source = publisher(NEVER_CANCEL, GUARD, "").replace(
"upload-codescene-coverage@abc\n",
"upload-codescene-coverage@abc\n with:\n mode: check\n",
);
let findings = rules::publisher_findings(&parse(&source)?);
ensure!(
findings.iter().any(|f| f.contains("uploads nothing")),
"check mode read as an upload: {findings:?}"
);
Ok(())
}
#[rstest]
#[case::quoted_or("${{ github.ref == 'refs/heads/main' && env.X != 'a||b' }}", Some(2))]
#[case::quoted_and("${{ github.ref == 'refs/heads/main' && env.X != 'a&&b' }}", Some(2))]
#[case::bare_or("github.ref == 'refs/heads/main' || true", None)]
fn quoted_operators_are_not_operators(#[case] condition: &str, #[case] expected: Option<usize>) {
assert_eq!(
rules::conjuncts(condition).map(|parts| parts.len()),
expected,
"for {condition}"
);
}
const WIRED: &str = r"
on:
push:
branches: [main]
jobs:
coverage:
steps:
- uses: leynos/shared-actions/.github/actions/generate-coverage@abc
with:
output-path: lcov.info
format: lcov
- env:
CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }}
uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@abc
with:
path: lcov.info
format: lcov
access-token: ${{ env.CS_ACCESS_TOKEN }}
";
#[rstest]
#[case::wired("", "", None)]
#[case::other_path(
"path: lcov.info",
"path: other.info",
Some("which no coverage step writes")
)]
#[case::other_format(
" format: lcov\n access",
" format: cobertura\n access",
Some("which no coverage step writes")
)]
#[case::no_token(
" access-token: ${{ env.CS_ACCESS_TOKEN }}\n",
"",
Some("not the token it was given")
)]
#[case::other_token(
"${{ env.CS_ACCESS_TOKEN }}",
"${{ env.OTHER }}",
Some("not the token it was given")
)]
fn the_upload_sends_what_was_measured(
#[case] from: &str,
#[case] to: &str,
#[case] expected: Option<&str>,
) -> Result<()> {
let source = if from.is_empty() {
WIRED.to_owned()
} else {
WIRED.replacen(from, to, 1)
};
ensure!(
source != WIRED || from.is_empty(),
"the case changed nothing"
);
let findings = rules::wiring_findings(&parse(&source)?);
match expected {
None => ensure!(findings.is_empty(), "unexpected findings: {findings:?}"),
Some(clause) => ensure!(
findings.len() == 1 && findings[0].contains(clause),
"expected one finding naming {clause:?}, saw {findings:?}"
),
}
Ok(())
}