Skip to content

Commit bc2e313

Browse files
authored
add env validation (#522)
1 parent 2214c8c commit bc2e313

File tree

32 files changed

+1233
-68
lines changed

32 files changed

+1233
-68
lines changed

packages/cli/src/commands/qualityGate.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { realpath } from "node:fs/promises";
33
import { exit, cwd as processCwd } from "node:process";
44

55
import { AllureReport, QualityGateState, readConfig, stringifyQualityGateResults } from "@allurereport/core";
6-
import type { TestResult } from "@allurereport/core-api";
7-
import { Command, Option } from "clipanion";
6+
import { type TestResult, validateEnvironmentName } from "@allurereport/core-api";
7+
import { Command, Option, UsageError } from "clipanion";
88
import { glob } from "glob";
99
import * as typanion from "typanion";
1010
import { red } from "yoctocolors";
@@ -66,9 +66,23 @@ export class QualityGateCommand extends Command {
6666
});
6767

6868
async execute() {
69+
let normalizedEnvironment = this.environment;
70+
71+
if (typeof this.environment === "string") {
72+
const envValidationResult = validateEnvironmentName(this.environment);
73+
74+
if (!envValidationResult.valid) {
75+
throw new UsageError(
76+
`Invalid --environment value ${JSON.stringify(this.environment)}: ${envValidationResult.reason}`,
77+
);
78+
}
79+
80+
normalizedEnvironment = envValidationResult.normalized;
81+
}
82+
6983
const cwd = await realpath(this.cwd ?? processCwd());
7084
const resultsDir = (this.resultsDir ?? "./**/allure-results").replace(/[\\/]$/, "");
71-
const { maxFailures, minTestsCount, successRate, fastFail, knownIssues: knownIssuesPath, environment } = this;
85+
const { maxFailures, minTestsCount, successRate, fastFail, knownIssues: knownIssuesPath } = this;
7286
const config = await readConfig(cwd, this.config, {
7387
knownIssuesPath,
7488
});
@@ -139,7 +153,7 @@ export class QualityGateCommand extends Command {
139153
const notHiddenTrs = (trs as TestResult[]).filter((tr) => !tr.hidden);
140154
const { results, fastFailed } = await allureReport.validate({
141155
trs: notHiddenTrs,
142-
environment,
156+
environment: normalizedEnvironment,
143157
knownIssues,
144158
state,
145159
});
@@ -166,7 +180,7 @@ export class QualityGateCommand extends Command {
166180
const validationResults = await allureReport.validate({
167181
trs: allTrs,
168182
knownIssues,
169-
environment,
183+
environment: normalizedEnvironment,
170184
});
171185

172186
if (validationResults.results.length === 0) {

packages/cli/src/commands/run.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
readConfig,
1313
stringifyQualityGateResults,
1414
} from "@allurereport/core";
15-
import { type KnownTestFailure, createTestPlan } from "@allurereport/core-api";
15+
import { type KnownTestFailure, createTestPlan, validateEnvironmentName } from "@allurereport/core-api";
1616
import type { Watcher } from "@allurereport/directory-watcher";
1717
import {
1818
allureResultsDirectoriesWatcher,
@@ -24,7 +24,7 @@ import Awesome from "@allurereport/plugin-awesome";
2424
import { BufferResultFile, PathResultFile } from "@allurereport/reader-api";
2525
import { KnownError } from "@allurereport/service";
2626
import { serve } from "@allurereport/static-server";
27-
import { Command, Option } from "clipanion";
27+
import { Command, Option, UsageError } from "clipanion";
2828
import { red } from "yoctocolors";
2929

3030
import { logTests, runProcess, terminationOf } from "../utils/index.js";
@@ -297,7 +297,21 @@ export class RunCommand extends Command {
297297
const args = this.commandToRun.filter((arg) => arg !== "--") as string[] | undefined;
298298

299299
if (!args || !args.length) {
300-
throw new Error("expecting command to be specified after --, e.g. allure run -- npm run test");
300+
throw new UsageError("expecting command to be specified after --, e.g. allure run -- npm run test");
301+
}
302+
303+
let normalizedEnvironment = this.environment;
304+
305+
if (this.environment !== undefined) {
306+
const envValidationResult = validateEnvironmentName(this.environment);
307+
308+
if (!envValidationResult.valid) {
309+
throw new UsageError(
310+
`invalid --environment value ${JSON.stringify(this.environment)}: ${envValidationResult.reason}`,
311+
);
312+
}
313+
314+
normalizedEnvironment = envValidationResult.normalized;
301315
}
302316

303317
const before = new Date().getTime();
@@ -322,6 +336,7 @@ export class RunCommand extends Command {
322336
port: this.port,
323337
historyLimit: this.historyLimit ? parseInt(this.historyLimit, 10) : undefined,
324338
});
339+
const resolvedEnvironment = normalizedEnvironment ?? config.environment;
325340
const withQualityGate = !!config.qualityGate;
326341
const withRerun = !!this.rerun;
327342

@@ -340,7 +355,7 @@ export class RunCommand extends Command {
340355
}
341356
const allureReport = new AllureReport({
342357
...config,
343-
environment: this.environment,
358+
environment: resolvedEnvironment,
344359
dump: this.dump,
345360
realTime: false,
346361
plugins: [
@@ -378,7 +393,7 @@ export class RunCommand extends Command {
378393
cwd,
379394
command,
380395
commandArgs,
381-
environment: this.environment,
396+
environment: resolvedEnvironment,
382397
environmentVariables: {},
383398
withQualityGate,
384399
});
@@ -409,7 +424,7 @@ export class RunCommand extends Command {
409424
cwd,
410425
command,
411426
commandArgs,
412-
environment: this.environment,
427+
environment: resolvedEnvironment,
413428
environmentVariables: {
414429
ALLURE_TESTPLAN_PATH: testPlanPath,
415430
ALLURE_RERUN: `${rerun}`,
@@ -429,7 +444,7 @@ export class RunCommand extends Command {
429444
const { results } = await allureReport.validate({
430445
trs,
431446
knownIssues,
432-
environment: this.environment,
447+
environment: resolvedEnvironment,
433448
});
434449

435450
qualityGateResults = results;

packages/cli/test/commands/qualityGate.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as console from "node:console";
22
import { exit } from "node:process";
33

44
import { readConfig, stringifyQualityGateResults } from "@allurereport/core";
5-
import { run } from "clipanion";
5+
import { UsageError, run } from "clipanion";
66
import { glob } from "glob";
77
import { type Mock, beforeEach, describe, expect, it, vi } from "vitest";
88

@@ -244,4 +244,13 @@ describe("quality-gate command", () => {
244244
knownIssuesPath: undefined,
245245
});
246246
});
247+
248+
it("should fail with usage error for invalid --environment value", async () => {
249+
const command = new QualityGateCommand();
250+
251+
command.environment = "foo\nbar";
252+
253+
await expect(command.execute()).rejects.toBeInstanceOf(UsageError);
254+
expect(readConfig).not.toHaveBeenCalled();
255+
});
247256
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { UsageError } from "clipanion";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { RunCommand } from "../../src/commands/run.js";
5+
6+
describe("run command", () => {
7+
it("should fail with usage error when command to run is missing", async () => {
8+
const command = new RunCommand();
9+
10+
command.commandToRun = [];
11+
12+
await expect(command.execute()).rejects.toBeInstanceOf(UsageError);
13+
});
14+
15+
it("should fail with usage error for newline in --environment", async () => {
16+
const command = new RunCommand();
17+
18+
command.commandToRun = ["--", "echo", "hi"];
19+
command.environment = "foo\nbar";
20+
21+
await expect(command.execute()).rejects.toBeInstanceOf(UsageError);
22+
});
23+
});

packages/core-api/src/utils/environment.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,65 @@ import type { EnvironmentsConfig } from "../environment.js";
22
import type { TestEnvGroup, TestResult } from "../model.js";
33

44
export const DEFAULT_ENVIRONMENT = "default";
5+
export const MAX_ENVIRONMENT_NAME_LENGTH = 64;
6+
7+
const hasControlChars = (value: string): boolean => {
8+
for (let i = 0; i < value.length; i++) {
9+
const code = value.charCodeAt(i);
10+
11+
// Reject ASCII control ranges: C0 (U+0000..U+001F), DEL (U+007F), and C1 (U+0080..U+009F).
12+
// Common examples: \u0000 (NUL), \t (TAB), \n (LF), \r (CR), \u009F.
13+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) {
14+
return true;
15+
}
16+
}
17+
18+
return false;
19+
};
20+
21+
export type EnvironmentValidationResult = { valid: true; normalized: string } | { valid: false; reason: string };
22+
23+
export const validateEnvironmentName = (name: unknown): EnvironmentValidationResult => {
24+
if (typeof name !== "string") {
25+
return { valid: false, reason: "name must be a string" };
26+
}
27+
28+
const normalized = name.trim();
29+
30+
if (normalized.length === 0) {
31+
return { valid: false, reason: "name must not be empty" };
32+
}
33+
34+
if (normalized.length > MAX_ENVIRONMENT_NAME_LENGTH) {
35+
return {
36+
valid: false,
37+
reason: `name must not exceed ${MAX_ENVIRONMENT_NAME_LENGTH} characters`,
38+
};
39+
}
40+
41+
if (hasControlChars(normalized)) {
42+
return { valid: false, reason: "name must not contain control characters" };
43+
}
44+
45+
return { valid: true, normalized };
46+
};
47+
48+
export const assertValidEnvironmentName = (name: unknown, source: string = "environment name"): string => {
49+
const validationResult = validateEnvironmentName(name);
50+
51+
if (!validationResult.valid) {
52+
throw new Error(`Invalid ${source} ${JSON.stringify(name)}: ${validationResult.reason}`);
53+
}
54+
55+
return validationResult.normalized;
56+
};
57+
58+
export const formatNormalizedEnvironmentCollision = (
59+
sourcePath: string,
60+
normalized: string,
61+
originalKeys: string[],
62+
): string =>
63+
`${sourcePath}: normalized key ${JSON.stringify(normalized)} is produced by original keys [${originalKeys.map((key) => JSON.stringify(key)).join(",")}]`;
564

665
export const matchEnvironment = (envConfig: EnvironmentsConfig, tr: Pick<TestResult, "labels">): string => {
766
return (

packages/core-api/test/utils/environment.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";
22

33
import type { EnvironmentsConfig } from "../../src/index.js";
44
import type { TestResult } from "../../src/model.js";
5-
import { matchEnvironment } from "../../src/utils/environment.js";
5+
import {
6+
MAX_ENVIRONMENT_NAME_LENGTH,
7+
assertValidEnvironmentName,
8+
formatNormalizedEnvironmentCollision,
9+
matchEnvironment,
10+
validateEnvironmentName,
11+
} from "../../src/utils/environment.js";
612

713
const fixtures = {
814
envConfig: {
@@ -42,3 +48,99 @@ describe("matchEnvironment", () => {
4248
expect(result).toEqual("default");
4349
});
4450
});
51+
52+
describe("validateEnvironmentName", () => {
53+
it("accepts valid names and returns normalized value", () => {
54+
const validBoundaryName = "a".repeat(MAX_ENVIRONMENT_NAME_LENGTH);
55+
56+
expect(validateEnvironmentName("foo")).toEqual({ valid: true, normalized: "foo" });
57+
expect(validateEnvironmentName("__proto__")).toEqual({ valid: true, normalized: "__proto__" });
58+
expect(validateEnvironmentName("прод")).toEqual({ valid: true, normalized: "прод" });
59+
expect(validateEnvironmentName(validBoundaryName)).toEqual({ valid: true, normalized: validBoundaryName });
60+
expect(validateEnvironmentName(" foo ")).toEqual({ valid: true, normalized: "foo" });
61+
expect(validateEnvironmentName(" default ")).toEqual({ valid: true, normalized: "default" });
62+
});
63+
64+
it("accepts names previously blocked by filesystem-style checks", () => {
65+
expect(validateEnvironmentName("foo/bar")).toEqual({ valid: true, normalized: "foo/bar" });
66+
expect(validateEnvironmentName("foo#bar")).toEqual({ valid: true, normalized: "foo#bar" });
67+
expect(validateEnvironmentName("foo%bar")).toEqual({ valid: true, normalized: "foo%bar" });
68+
expect(validateEnvironmentName("foo:bar")).toEqual({ valid: true, normalized: "foo:bar" });
69+
expect(validateEnvironmentName(".")).toEqual({ valid: true, normalized: "." });
70+
expect(validateEnvironmentName("..")).toEqual({ valid: true, normalized: ".." });
71+
});
72+
73+
it("rejects empty and whitespace-only names", () => {
74+
expect(validateEnvironmentName("")).toEqual({
75+
valid: false,
76+
reason: "name must not be empty",
77+
});
78+
expect(validateEnvironmentName(" ")).toEqual({
79+
valid: false,
80+
reason: "name must not be empty",
81+
});
82+
});
83+
84+
it("rejects too long names after trim", () => {
85+
const tooLongName = ` ${"a".repeat(MAX_ENVIRONMENT_NAME_LENGTH + 1)} `;
86+
87+
expect(validateEnvironmentName(tooLongName)).toEqual({
88+
valid: false,
89+
reason: `name must not exceed ${MAX_ENVIRONMENT_NAME_LENGTH} characters`,
90+
});
91+
});
92+
93+
it("rejects control characters", () => {
94+
expect(validateEnvironmentName("foo\nbar")).toEqual({
95+
valid: false,
96+
reason: "name must not contain control characters",
97+
});
98+
expect(validateEnvironmentName("foo\tbar")).toEqual({
99+
valid: false,
100+
reason: "name must not contain control characters",
101+
});
102+
expect(validateEnvironmentName("foo\u0000bar")).toEqual({
103+
valid: false,
104+
reason: "name must not contain control characters",
105+
});
106+
expect(validateEnvironmentName("foo\u009Fbar")).toEqual({
107+
valid: false,
108+
reason: "name must not contain control characters",
109+
});
110+
expect(validateEnvironmentName("foo\rbar")).toEqual({
111+
valid: false,
112+
reason: "name must not contain control characters",
113+
});
114+
expect(validateEnvironmentName("foo\r\nbar")).toEqual({
115+
valid: false,
116+
reason: "name must not contain control characters",
117+
});
118+
});
119+
120+
it("rejects non-string input", () => {
121+
expect(validateEnvironmentName(1)).toEqual({
122+
valid: false,
123+
reason: "name must be a string",
124+
});
125+
});
126+
});
127+
128+
describe("assertValidEnvironmentName", () => {
129+
it("returns normalized value", () => {
130+
expect(assertValidEnvironmentName(" foo ")).toBe("foo");
131+
});
132+
133+
it("throws with source details", () => {
134+
expect(() => assertValidEnvironmentName("", "config.environment")).toThrow(
135+
'Invalid config.environment "": name must not be empty',
136+
);
137+
});
138+
});
139+
140+
describe("formatNormalizedEnvironmentCollision", () => {
141+
it("formats stable collision message", () => {
142+
expect(formatNormalizedEnvironmentCollision("config.environments", "foo", ["foo", " foo "])).toBe(
143+
'config.environments: normalized key "foo" is produced by original keys ["foo"," foo "]',
144+
);
145+
});
146+
});

0 commit comments

Comments
 (0)