Skip to content

Commit ef08c87

Browse files
committed
fix(FR-2877): address review findings (insecure-tls gate, --output, --also-include rename, password scrub, detectRole fail-fast, e2e dir check)
1 parent d2d1a46 commit ef08c87

5 files changed

Lines changed: 149 additions & 38 deletions

File tree

packages/backend.ai-webui-smoke-cli/README.md

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,51 @@ for the full spec. The operator-facing README (EN + KO) lands in FR-2883
77

88
## Usage (alpha)
99

10+
Prefer reading the password from stdin or an env var — passing
11+
`--password` on the command line exposes the secret to anything that
12+
can read the host process's argv. The CLI scrubs the argv value at
13+
startup, but the original invocation may still surface in shell history
14+
or process listings before the scrub runs.
15+
1016
```sh
11-
bai-smoke run \
17+
# Option 1 — password from stdin (recommended)
18+
printf '%s' "$BAI_PASSWORD" | bai-smoke run \
19+
--endpoint https://webui.example.com \
20+
--webserver https://webui.example.com \
21+
--email admin@example.com \
22+
--password-stdin \
23+
--output ./smoke-report
24+
25+
# Option 2 — password from env var
26+
BAI_SMOKE_PASSWORD="$BAI_PASSWORD" bai-smoke run \
1227
--endpoint https://webui.example.com \
28+
--webserver https://webui.example.com \
1329
--email admin@example.com \
14-
--password "***" \
1530
--output ./smoke-report
1631
```
1732

18-
This is an alpha MVP. Full operator docs land with FR-2883.
33+
To widen the selection beyond the default `@smoke*` set, use
34+
`--also-include`. To narrow the run, use `--pages`:
35+
36+
```sh
37+
bai-smoke run \
38+
--endpoint https://webui.example.com \
39+
--email admin@example.com \
40+
--password-stdin \
41+
--also-include "@critical" \
42+
--pages session,vfolder
43+
```
44+
45+
## Limitations (alpha MVP)
46+
47+
- Must be run from a `backend.ai-webui` monorepo checkout — the e2e
48+
specs are not bundled yet. Tarball / single-binary distribution is
49+
tracked in FR-2881.
50+
- Air-gap binary, `doctor` command, and rich diagnostic reports → Phase 2.
51+
- `--also-include` widens the smoke set; to narrow it, use `--pages`
52+
instead. The legacy `--include` flag is a deprecated hidden alias and
53+
will be removed in a future release.
54+
- `--insecure-tls` accepts self-signed certs but does not pin
55+
fingerprints — only enable on trusted networks.
56+
57+
This is an alpha MVP. Full operator docs (EN + KO) land with FR-2883.

packages/backend.ai-webui-smoke-cli/playwright.smoke.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineConfig, devices } from '@playwright/test';
2+
import { existsSync } from 'node:fs';
23
import path from 'node:path';
34
import { fileURLToPath } from 'node:url';
45

@@ -31,6 +32,18 @@ const __filename = fileURLToPath(import.meta.url);
3132
const __dirname = path.dirname(__filename);
3233
const E2E_DIR = path.resolve(__dirname, '..', '..', 'e2e');
3334

35+
// FR-2877 MVP limitation: the smoke runner discovers specs from the
36+
// monorepo's e2e/ tree. A bundled tarball / standalone binary distribution
37+
// is tracked in FR-2881. Fail loudly when the directory is missing so the
38+
// user gets a clear message instead of a cryptic Playwright "no tests
39+
// found" output.
40+
if (!existsSync(E2E_DIR)) {
41+
throw new Error(
42+
`bai-smoke MVP requires running from a backend.ai-webui checkout. Expected e2e dir at: ${E2E_DIR}. ` +
43+
`Tarball / single-binary distribution lands in FR-2881.`,
44+
);
45+
}
46+
3447
const reportDir = process.env.BAI_SMOKE_REPORT_DIR ?? path.resolve(process.cwd(), 'smoke-report');
3548

3649
const workersEnv = process.env.BAI_SMOKE_WORKERS;
@@ -75,7 +88,7 @@ export default defineConfig({
7588
trace: 'retain-on-failure',
7689
video: 'retain-on-failure',
7790
headless: !headed,
78-
ignoreHTTPSErrors: true,
91+
ignoreHTTPSErrors: process.env.BAI_SMOKE_INSECURE_TLS === '1',
7992
},
8093
projects: [
8194
{

packages/backend.ai-webui-smoke-cli/src/cli.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,16 @@ program
9696
.choices(['auto', 'admin', 'user'])
9797
.default('auto'),
9898
)
99-
.option('--include <tags>', 'Comma-separated extra tags to include (e.g. "@critical").')
99+
.option(
100+
'--also-include <tags>',
101+
'Additional tag pattern(s) to OR onto the smoke selection (e.g. "@critical"). ' +
102+
'To NARROW the smoke set, use --pages instead.',
103+
)
104+
// Backwards-compat alias for the original `--include` name. Deprecated —
105+
// will be removed in a future release. Kept hidden from the main help.
106+
.addOption(
107+
new Option('--include <tags>', '(deprecated) alias for --also-include.').hideHelp(),
108+
)
100109
.option('--exclude <tags>', 'Comma-separated tags to exclude.')
101110
.option(
102111
'--pages <names>',
@@ -108,12 +117,7 @@ program
108117
'Per-test timeout. Accepts "180s", "3m", or raw ms.',
109118
'180s',
110119
)
111-
.option(
112-
'--output <dir>',
113-
'Output directory for the smoke report.',
114-
() => defaultOutputDir(),
115-
defaultOutputDir(),
116-
)
120+
.option('--output <dir>', 'Output directory for the smoke report.')
117121
.option('--headed', 'Run the browser in headed mode (debugging only).', false)
118122
.option('--insecure-tls', 'Accept self-signed TLS certificates.', false)
119123
.action(async (raw: Record<string, unknown>) => {
@@ -124,6 +128,9 @@ program
124128
);
125129
process.exit(2);
126130
}
131+
// Scrub the password value from process.argv so accidental introspection
132+
// (logs, crash dumps, `ps`-style helpers reading argv) cannot leak it.
133+
scrubPasswordFromArgv();
127134

128135
const endpoint = String(raw.endpoint);
129136
const webserver =
@@ -145,18 +152,25 @@ program
145152
return;
146153
}
147154

155+
// `--also-include` is the canonical flag; `--include` is a hidden alias.
156+
const alsoIncludeRaw =
157+
(raw.alsoInclude as string | string[] | undefined) ??
158+
(raw.include as string | string[] | undefined);
159+
160+
const outputDir = path.resolve(String(raw.output ?? defaultOutputDir()));
161+
148162
const opts: SmokeRunOptions = {
149163
endpoint,
150164
webserver,
151165
email: String(raw.email),
152166
password,
153167
role: (raw.role as SmokeRoleSelection) ?? 'auto',
154-
include: splitCsvArg(raw.include as string | string[] | undefined),
168+
include: splitCsvArg(alsoIncludeRaw),
155169
exclude: splitCsvArg(raw.exclude as string | string[] | undefined),
156170
pages: splitCsvArg(raw.pages as string | string[] | undefined),
157171
workers: typeof raw.workers === 'number' && raw.workers > 0 ? raw.workers : undefined,
158172
timeoutMs,
159-
outputDir: path.resolve(String(raw.output ?? defaultOutputDir())),
173+
outputDir,
160174
headed: raw.headed === true,
161175
insecureTls: raw.insecureTls === true,
162176
};
@@ -203,6 +217,27 @@ async function resolvePassword(raw: Record<string, unknown>): Promise<string | u
203217
return undefined;
204218
}
205219

220+
/**
221+
* Replace the resolved password value in `process.argv` with `***` so
222+
* downstream introspection (crash dumps, logs that dump argv, child
223+
* processes inheriting argv via APIs that read the parent's command
224+
* line) cannot leak the secret. Covers both `--password VALUE` and
225+
* `--password=VALUE` forms.
226+
*/
227+
function scrubPasswordFromArgv(): void {
228+
for (let i = 0; i < process.argv.length; i++) {
229+
const a = process.argv[i];
230+
if (a == null) continue;
231+
if (a.startsWith('--password=')) {
232+
process.argv[i] = '--password=***';
233+
continue;
234+
}
235+
if (a === '--password' && i > 0 && process.argv[i + 1] != null) {
236+
process.argv[i + 1] = '***';
237+
}
238+
}
239+
}
240+
206241
function readStdin(): Promise<string> {
207242
return new Promise((resolve, reject) => {
208243
const chunks: Buffer[] = [];

packages/backend.ai-webui-smoke-cli/src/config.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export interface SmokeRunOptions {
2020
password: string;
2121
/** Role selection mode (`--role`). */
2222
role: SmokeRoleSelection;
23-
/** Extra include tags (`--include @critical,@dashboard` → `['@critical','@dashboard']`). */
23+
/**
24+
* Extra tags to OR onto the smoke selection. Populated from
25+
* `--also-include` (preferred) or the deprecated `--include` alias.
26+
*/
2427
include?: string[];
2528
/** Exclude tags (`--exclude`). */
2629
exclude?: string[];
@@ -52,7 +55,7 @@ export interface SmokeRunOptions {
5255
* so a `user` run picks up `@smoke`, `@smoke-any`, and `@smoke-user` —
5356
* but NOT `@smoke-admin`.
5457
*
55-
* Additional `--include` tags are OR-ed in. `--exclude` tags become
58+
* Additional `--also-include` tags are OR-ed in. `--exclude` tags become
5659
* `grepInvert`.
5760
*/
5861
export function buildGrepExpression(
@@ -122,8 +125,11 @@ export function buildPlaywrightEnv(
122125

123126
if (opts.insecureTls) {
124127
// Forward to both the runner's spawned children and any fetches
125-
// inside the test harness. Only set when explicitly requested.
128+
// inside the test harness. Only set on the spawned child env — we
129+
// intentionally do NOT mutate process.env in the host runner.
126130
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
131+
// Gate Playwright's `ignoreHTTPSErrors` in playwright.smoke.config.ts.
132+
env.BAI_SMOKE_INSECURE_TLS = '1';
127133
}
128134

129135
return env;

packages/backend.ai-webui-smoke-cli/src/runner.ts

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export interface SmokeSummary {
3232
webserver: string;
3333
role: EffectiveRole;
3434
roleSelection: SmokeRunOptions['role'];
35-
include?: string[];
35+
/** Tags OR-ed onto the smoke selection via `--also-include`. */
36+
alsoInclude?: string[];
3637
exclude?: string[];
3738
pages?: string[];
3839
grep?: string;
@@ -56,27 +57,48 @@ export interface SmokeSummary {
5657
/**
5758
* Auto-detect role by hitting the webserver's /server/login endpoint.
5859
*
59-
* On failure we conservatively default to `'user'` — fewer privileged
60-
* specs attempted is safer than running admin-only specs against a
61-
* misclassified account.
60+
* On any failure (network error, non-200 status, malformed JSON,
61+
* `authenticated !== true`) we throw. Silently defaulting to `'user'`
62+
* was masking real misconfiguration — operators ended up with a green
63+
* smoke run that had skipped every admin-tagged spec without saying so.
64+
*
65+
* The body shape we POST here (`{ username, password }`) mirrors what
66+
* the SESSION-mode browser fixture submits to `/server/login`. The
67+
* proper signed-request based detection — using the post-login
68+
* `/func/auth/role` endpoint — is tracked in TODO(FR-2878).
6269
*/
6370
export async function detectRole(opts: SmokeRunOptions): Promise<EffectiveRole> {
6471
const url = `${opts.webserver.replace(/\/$/, '')}/server/login`;
6572
// Honour --insecure-tls for the detection call so self-signed certs
66-
// don't crash detection before we even reach Playwright.
73+
// don't crash detection before we even reach Playwright. We only
74+
// mutate the spawned-process env; the host runner's process.env is
75+
// restored in `finally`.
6776
const previousTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
6877
if (opts.insecureTls) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
6978
try {
70-
const res = await fetch(url, {
71-
method: 'POST',
72-
headers: { 'Content-Type': 'application/json' },
73-
body: JSON.stringify({ username: opts.email, password: opts.password }),
74-
});
79+
let res: Response;
80+
try {
81+
res = await fetch(url, {
82+
method: 'POST',
83+
headers: { 'Content-Type': 'application/json' },
84+
// TODO(FR-2878): switch to signed-request /func/auth/role for a
85+
// proper detection. This naive form matches what the SESSION-mode
86+
// browser fixture submits to /server/login and works against the
87+
// current staging cluster.
88+
body: JSON.stringify({ username: opts.email, password: opts.password }),
89+
});
90+
} catch (err) {
91+
throw new Error(
92+
`Failed to auto-detect role for ${opts.email} against ${opts.webserver}: ` +
93+
`${(err as Error).message}. Pass --role admin or --role user explicitly and retry.`,
94+
);
95+
}
7596
if (!res.ok) {
76-
process.stderr.write(
77-
`[bai-smoke] role detection: webserver returned HTTP ${res.status}; defaulting to "user".\n`,
97+
throw new Error(
98+
`Failed to auto-detect role for ${opts.email} against ${opts.webserver}: ` +
99+
`webserver returned HTTP ${res.status}. ` +
100+
`Pass --role admin or --role user explicitly and retry.`,
78101
);
79-
return 'user';
80102
}
81103
const json = (await res.json().catch(() => null)) as
82104
| {
@@ -85,22 +107,18 @@ export async function detectRole(opts: SmokeRunOptions): Promise<EffectiveRole>
85107
}
86108
| null;
87109
if (!json || json.authenticated !== true) {
88-
process.stderr.write(
89-
'[bai-smoke] role detection: login not authenticated; defaulting to "user".\n',
110+
throw new Error(
111+
`Failed to auto-detect role for ${opts.email} against ${opts.webserver}: ` +
112+
`login not authenticated (unexpected response shape or wrong credentials). ` +
113+
`Pass --role admin or --role user explicitly and retry.`,
90114
);
91-
return 'user';
92115
}
93116
const role = json.data?.role;
94117
const isAdmin = json.data?.is_admin === true;
95118
if (role === 'admin' || role === 'superadmin' || isAdmin) {
96119
return 'admin';
97120
}
98121
return 'user';
99-
} catch (err) {
100-
process.stderr.write(
101-
`[bai-smoke] role detection failed: ${(err as Error).message}; defaulting to "user".\n`,
102-
);
103-
return 'user';
104122
} finally {
105123
if (opts.insecureTls) {
106124
if (previousTlsReject === undefined) {
@@ -175,7 +193,7 @@ export async function runSmoke(opts: SmokeRunOptions): Promise<SmokeRunResult> {
175193
webserver: opts.webserver,
176194
role: effectiveRole,
177195
roleSelection: opts.role,
178-
include: opts.include,
196+
alsoInclude: opts.include,
179197
exclude: opts.exclude,
180198
pages: opts.pages,
181199
grep,

0 commit comments

Comments
 (0)