Skip to content

Commit 9cd8bc6

Browse files
authored
Merge pull request #139 from hotdata-dev/test/cli-scenario-integration-tests
test(cli): add env-gated scenario integration tests
2 parents 3d7b989 + 55d63a9 commit 9cd8bc6

8 files changed

Lines changed: 672 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Integration Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
jobs:
11+
# Parity check runs on every PR and push: confirms every scenario in
12+
# www.hotdata.dev/api/test-scenarios.yaml that is NOT opted out for the CLI
13+
# has a matching test file here. www.hotdata.dev is private, so we fetch the
14+
# manifest via the GitHub App token. hotdata-cli convention: tests/<name>.rs.
15+
# Scenarios listing `cli` in optional_for are skipped (the CLI's surface
16+
# doesn't cover them, e.g. datasets/secrets/saved-queries).
17+
scenario-parity:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Generate GitHub App token
21+
id: app-token
22+
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
23+
with:
24+
app-id: ${{ secrets.HOTDATA_AUTOMATION_APP_ID }}
25+
private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }}
26+
owner: hotdata-dev
27+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
28+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
29+
with:
30+
python-version: '3.12'
31+
- name: Install PyYAML
32+
run: pip install --quiet pyyaml
33+
- name: Fetch scenarios manifest
34+
env:
35+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
36+
run: |
37+
curl -sS -f -L \
38+
-H "Accept: application/vnd.github.v3.raw" \
39+
-H "Authorization: Bearer $GH_TOKEN" \
40+
https://api.github.com/repos/hotdata-dev/www.hotdata.dev/contents/api/test-scenarios.yaml \
41+
-o test-scenarios.yaml
42+
- name: Check parity
43+
run: |
44+
python3 - <<'PY'
45+
import sys, pathlib, yaml
46+
scenarios = yaml.safe_load(pathlib.Path("test-scenarios.yaml").read_text())["scenarios"]
47+
missing = []
48+
required = 0
49+
for s in scenarios:
50+
if "cli" in (s.get("optional_for") or []):
51+
continue
52+
required += 1
53+
expected = pathlib.Path("tests") / f"{s['name']}.rs"
54+
if not expected.exists():
55+
missing.append(str(expected))
56+
if missing:
57+
print(f"::error::hotdata-cli is missing tests for {len(missing)} scenarios:")
58+
for m in missing:
59+
print(f" - {m}")
60+
sys.exit(1)
61+
print(f"All {required} required scenarios have corresponding test files (of {len(scenarios)} total).")
62+
PY
63+
rm -f test-scenarios.yaml
64+
65+
# Integration tests run against production. The shared harness
66+
# (tests/common/mod.rs) skips cleanly when HOTDATA_SDK_TEST_API_KEY /
67+
# HOTDATA_SDK_TEST_WORKSPACE_ID are absent (e.g. PRs from forks where secrets
68+
# aren't injected), so this job stays green without credentials.
69+
integration:
70+
runs-on: ubuntu-latest
71+
steps:
72+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
73+
- name: Install Rust
74+
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
75+
- name: Cache cargo
76+
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
77+
with:
78+
path: |
79+
~/.cargo/registry
80+
~/.cargo/git
81+
target
82+
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
83+
restore-keys: |
84+
${{ runner.os }}-cargo-
85+
- name: Run integration tests
86+
env:
87+
HOTDATA_SDK_TEST_API_URL: ${{ vars.HOTDATA_SDK_TEST_API_URL }}
88+
HOTDATA_SDK_TEST_API_KEY: ${{ secrets.HOTDATA_SDK_TEST_API_KEY }}
89+
HOTDATA_SDK_TEST_WORKSPACE_ID: ${{ vars.HOTDATA_SDK_TEST_WORKSPACE_ID }}
90+
HOTDATA_SDK_TEST_CONNECTION_ID: ${{ vars.HOTDATA_SDK_TEST_CONNECTION_ID }}
91+
# --no-fail-fast runs every scenario binary even after one fails, so a
92+
# red run surfaces all failing scenarios at once.
93+
run: cargo test --test '*' --no-fail-fast -- --nocapture

‎tests/auth_missing_token_401.rs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! Scenario: auth_missing_token_401.
2+
//!
3+
//! A request with no credentials must be denied. The SDKs assert a literal 401
4+
//! from the server because they can construct an unauthenticated client; the
5+
//! CLI instead refuses *client-side* (it has no session, no api key, and
6+
//! nothing to mint a JWT from), so the meaningful CLI equivalent is: an
7+
//! authenticated command run with no credentials exits non-zero, reports an
8+
//! auth/not-configured error, and never prints a workspace listing.
9+
//!
10+
//! Although this scenario sends no credentials, it still gates on the standard
11+
//! test env (like sdk-python's `env` fixture) so `cargo test` with no secrets
12+
//! configured does not run a live, misleading path.
13+
14+
mod common;
15+
16+
#[test]
17+
fn auth_missing_token_401() {
18+
// Gate on creds so offline CI skips cleanly (mirrors the SDK env fixture).
19+
let _cli = skip_if_no_creds!();
20+
let env = common::load_env();
21+
22+
// No api key, no session (isolated empty config), no workspace lock.
23+
let output =
24+
common::unauthenticated_output(&env.api_url, &["workspaces", "list", "-o", "json"]);
25+
26+
assert!(
27+
!output.status.success(),
28+
"workspaces list without credentials must fail; stdout:\n{}",
29+
String::from_utf8_lossy(&output.stdout)
30+
);
31+
32+
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
33+
assert!(
34+
stderr.contains("auth") || stderr.contains("log in") || stderr.contains("not configured"),
35+
"expected an auth/not-configured error on stderr, got:\n{}",
36+
String::from_utf8_lossy(&output.stderr)
37+
);
38+
39+
// Defensive: must not have leaked a successful JSON listing on stdout.
40+
let stdout = String::from_utf8_lossy(&output.stdout);
41+
assert!(
42+
serde_json::from_str::<serde_json::Value>(stdout.trim())
43+
.ok()
44+
.and_then(|v| v.as_array().map(|a| !a.is_empty()))
45+
!= Some(true),
46+
"unauthenticated call leaked a workspace listing:\n{stdout}"
47+
);
48+
}

‎tests/auth_unknown_workspace.rs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//! Scenario: auth_unknown_workspace.
2+
//!
3+
//! A valid api key combined with a fabricated workspace id must be rejected and
4+
//! must never leak data from another workspace. The CLI mints a JWT from the
5+
//! real api key, then sends the fabricated id as the gateway-enforced
6+
//! `X-Workspace-Id`; the server responds 4xx (403/404). We assert the command
7+
//! exits non-zero and never prints a successful listing.
8+
9+
mod common;
10+
11+
#[test]
12+
fn auth_unknown_workspace() {
13+
let cli = skip_if_no_creds!();
14+
15+
let fake_workspace = format!(
16+
"ws_{:08x}{:08x}",
17+
rand::random::<u32>(),
18+
rand::random::<u32>()
19+
);
20+
21+
// Real api key (no HOTDATA_WORKSPACE lock) + fabricated workspace via -w.
22+
let output = cli
23+
.cmd_unlocked_workspace()
24+
.args(["connections", "list", "-w", &fake_workspace, "-o", "json"])
25+
.output()
26+
.expect("failed to spawn hotdata binary");
27+
28+
assert!(
29+
!output.status.success(),
30+
"connections list with fabricated workspace {fake_workspace} must fail \
31+
(potential cross-workspace leak); stdout:\n{}",
32+
String::from_utf8_lossy(&output.stdout)
33+
);
34+
35+
// Defensive: must not have leaked a successful JSON listing on stdout.
36+
let stdout = String::from_utf8_lossy(&output.stdout);
37+
assert!(
38+
serde_json::from_str::<serde_json::Value>(stdout.trim())
39+
.ok()
40+
.and_then(|v| v.as_array().map(|a| !a.is_empty()))
41+
!= Some(true),
42+
"fabricated workspace {fake_workspace} leaked a connection listing:\n{stdout}"
43+
);
44+
}

0 commit comments

Comments
 (0)