408 lines
18 KiB
Java
408 lines
18 KiB
Java
import java.nio.file.*;
|
|
import java.time.Duration;
|
|
import java.util.*;
|
|
|
|
/** Dependency-free behavior tests: java ci/Ci.java test. */
|
|
class CiTests {
|
|
static final String SHA = "a".repeat(40);
|
|
static int count;
|
|
|
|
interface Test {
|
|
void run() throws Exception;
|
|
}
|
|
|
|
static void test(String name, Test test) throws Exception {
|
|
test.run();
|
|
count++;
|
|
IO.println("PASS " + name);
|
|
}
|
|
|
|
static void equal(Object expected, Object actual) {
|
|
if (!Objects.equals(expected, actual)) throw new AssertionError("Expected " + expected + ", got " + actual);
|
|
}
|
|
|
|
static void failure(Test action) throws Exception {
|
|
try {
|
|
action.run();
|
|
} catch (Exception expected) {
|
|
return;
|
|
}
|
|
throw new AssertionError("Expected failure");
|
|
}
|
|
|
|
static void run() throws Exception {
|
|
count = 0;
|
|
test("shared runner scope and identity are independent of the application", () -> {
|
|
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "first")));
|
|
equal("mini-pc", Setup.runnerName(Map.of("PROJECT_NAME", "second")));
|
|
equal("/api/v1/user/actions/runners/registration-token", Setup.runnerRegistrationPath(Map.of()));
|
|
equal(
|
|
"/api/v1/admin/actions/runners/registration-token",
|
|
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "instance")));
|
|
equal(
|
|
"/api/v1/orgs/team/actions/runners/registration-token",
|
|
Setup.runnerRegistrationPath(
|
|
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "team")));
|
|
equal(
|
|
"/api/v1/repos/owner/app/actions/runners/registration-token",
|
|
Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "repository", "CI_REPOSITORY", "owner/app")));
|
|
failure(() -> Setup.runnerRegistrationPath(Map.of("RUNNER_SCOPE", "unknown")));
|
|
failure(() -> Setup.runnerRegistrationPath(
|
|
Map.of("RUNNER_SCOPE", "organization", "RUNNER_ORGANIZATION", "../unsafe")));
|
|
failure(() -> Setup.runnerName(Map.of("RUNNER_NAME", "../unsafe")));
|
|
});
|
|
test("runner preflight rejects unavailable offline and differently labelled runners", () -> {
|
|
equal(false, Setup.hasRunner(Map.of("runners", List.of()), "mini-pc"));
|
|
equal(
|
|
false,
|
|
Setup.hasRunner(
|
|
Map.of(
|
|
"runners",
|
|
List.of(Map.of("status", "offline", "labels", List.of(Map.of("name", "mini-pc"))))),
|
|
"mini-pc"));
|
|
equal(
|
|
false,
|
|
Setup.hasRunner(
|
|
Map.of(
|
|
"runners",
|
|
List.of(Map.of("status", "online", "labels", List.of(Map.of("name", "other"))))),
|
|
"mini-pc"));
|
|
equal(
|
|
true,
|
|
Setup.hasRunner(
|
|
Map.of(
|
|
"runners",
|
|
List.of(Map.of(
|
|
"status",
|
|
"online",
|
|
"busy",
|
|
true,
|
|
"labels",
|
|
List.of(Map.of("name", "mini-pc"))))),
|
|
"mini-pc"));
|
|
});
|
|
test("recipe supports backend-only and frontend-only projects", () -> {
|
|
equal(List.of("backend"), Recipe.roles(Map.of("BACKEND_MODULE", "server")));
|
|
equal(List.of("frontend"), Recipe.roles(Map.of("FRONTEND_MODULE", "web")));
|
|
failure(() -> Recipe.roles(Map.of()));
|
|
failure(() -> Recipe.project(Map.of("PROJECT_NAME", "../unsafe")));
|
|
});
|
|
test("project configuration is overridden by environment and rejects escaping modules", () -> {
|
|
final Path root = Files.createTempDirectory("recipe-config-");
|
|
try {
|
|
Files.writeString(
|
|
root.resolve("engineering.properties"), "PROJECT_NAME=sample\nBACKEND_MODULE=server\n");
|
|
equal(
|
|
"override",
|
|
Recipe.environment(root, Map.of("PROJECT_NAME", "override"))
|
|
.get("PROJECT_NAME"));
|
|
failure(() -> Recipe.environment(root, Map.of("BACKEND_MODULE", "../outside")));
|
|
} finally {
|
|
Pipeline.deleteTree(root);
|
|
}
|
|
});
|
|
test("JSON preserves configuration values and escaped text", () -> {
|
|
String json = "{\"text\":\"line\\nquote\\\"\\u20ac\",\"values\":[null,true,false,1,-2.5e2],\"nested\":{}}";
|
|
Object data = Json.parse(json);
|
|
equal(data, Json.parse(Json.write(data)));
|
|
equal("line\nquote\"€", Json.object(data).get("text"));
|
|
});
|
|
test("JSON rejects malformed/ambiguous input", () -> {
|
|
for (String value : List.of(
|
|
"{\"a\":null,\"a\":1}", "{} []", "[1,]", "01", "\"\\uZZZZ\"", "\"line\n\"", "truefalse", "+1"))
|
|
failure(() -> Json.parse(value));
|
|
});
|
|
test("Docker configuration merge is idempotent and preserves backup", () -> {
|
|
Path dir = Files.createTempDirectory("ci-docker-test-");
|
|
try {
|
|
Path file = dir.resolve("daemon.json");
|
|
String original = "{\"features\":{\"buildkit\":true},\"insecure-registries\":[\"existing:5000\"]}";
|
|
Files.writeString(file, original);
|
|
Setup.configureDocker(file, "registry.example:3000");
|
|
String first = Files.readString(file);
|
|
Setup.configureDocker(file, "registry.example:3000");
|
|
equal(first, Files.readString(file));
|
|
equal(
|
|
List.of("existing:5000", "registry.example:3000"),
|
|
Json.object(Json.parse(first)).get("insecure-registries"));
|
|
equal(original, Files.readString(dir.resolve("daemon.json.before-engineering")));
|
|
} finally {
|
|
Pipeline.deleteTree(dir);
|
|
}
|
|
});
|
|
test("Invalid Docker configuration is unchanged", () -> {
|
|
Path dir = Files.createTempDirectory("ci-docker-test-");
|
|
try {
|
|
Path file = dir.resolve("daemon.json");
|
|
String original = "{\"insecure-registries\":\"invalid\"}";
|
|
Files.writeString(file, original);
|
|
failure(() -> Setup.configureDocker(file, "registry.example:3000"));
|
|
equal(original, Files.readString(file));
|
|
} finally {
|
|
Pipeline.deleteTree(dir);
|
|
}
|
|
});
|
|
test("Feature branches never contact registry", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.pipeline.env.put("CI_REF", "refs/heads/feature/test");
|
|
f.pipeline.publish();
|
|
equal(0L, f.calls("login"));
|
|
equal(0L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Unverified commit cannot publish", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
Files.delete(f.root.resolve("ci/artifacts/verified-commit"));
|
|
failure(f.pipeline::publish);
|
|
equal(0L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Publication pullback and rerun preserve commit tags", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.pipeline.publish();
|
|
equal(4L, f.calls("push"));
|
|
f.pipeline.publish();
|
|
equal(6L, f.calls("push"));
|
|
equal(8L, f.calls("pull"));
|
|
}
|
|
});
|
|
test("Partial publication is completed without overwriting backend", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.pushFailure = "frontend:" + SHA;
|
|
failure(f.pipeline::publish);
|
|
equal(
|
|
0L,
|
|
f.fake.calls.stream()
|
|
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
|
|
.count());
|
|
f.fake.pushFailure = "";
|
|
f.pipeline.publish();
|
|
equal(
|
|
1L,
|
|
f.fake.calls.stream()
|
|
.filter(a -> a.contains("push") && a.getLast().endsWith("backend:" + SHA))
|
|
.count());
|
|
}
|
|
});
|
|
test("Authentication error is not a missing tag", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.manifestError = true;
|
|
failure(f.pipeline::publish);
|
|
equal(0L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Pull failure blocks moving tags", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.pullFailure = "backend:" + SHA;
|
|
failure(f.pipeline::publish);
|
|
equal(
|
|
0L,
|
|
f.fake.calls.stream()
|
|
.filter(a -> a.contains("push") && a.getLast().endsWith(":main"))
|
|
.count());
|
|
}
|
|
});
|
|
test("Older commit cannot move main", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.head = "c".repeat(40);
|
|
f.pipeline.publish();
|
|
equal(2L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Unavailable Docker fails before build with a concrete cause", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.dockerAvailable = false;
|
|
try {
|
|
f.pipeline.verify();
|
|
throw new AssertionError("Expected failure");
|
|
} catch (IllegalArgumentException error) {
|
|
equal(true, error.getMessage().contains("Docker is unavailable"));
|
|
}
|
|
equal(
|
|
0L,
|
|
f.fake.calls.stream()
|
|
.filter(a -> a.getFirst().equals("mvn"))
|
|
.count());
|
|
}
|
|
});
|
|
test("Architecture mismatch blocks publication", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.pipeline.env.put("TARGET_PLATFORM", "linux/arm64");
|
|
failure(f.pipeline::publish);
|
|
equal(0L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Failed verification removes success marker and preserves exit status", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.mavenFailure = true;
|
|
try {
|
|
f.pipeline.verify();
|
|
throw new AssertionError("Expected failure");
|
|
} catch (Commands.Failure error) {
|
|
equal(42, error.exitCode);
|
|
}
|
|
equal(
|
|
List.of("mvn", "-B", "-ntp", "-Dformat.mode=check", "clean", "verify"),
|
|
f.fake.calls.stream()
|
|
.filter(a -> a.getFirst().equals("mvn"))
|
|
.findFirst()
|
|
.orElseThrow());
|
|
equal(false, Files.exists(f.root.resolve("ci/artifacts/verified-commit")));
|
|
equal(0L, f.calls("build"));
|
|
equal(0L, f.calls("push"));
|
|
}
|
|
});
|
|
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.fake.lintFailure = true;
|
|
try {
|
|
f.pipeline.verifyFrontend();
|
|
throw new AssertionError("Expected failure");
|
|
} catch (Commands.Failure error) {
|
|
equal(1, error.exitCode);
|
|
}
|
|
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
|
|
}
|
|
});
|
|
test("Frontend verification checks before testing and building", () -> {
|
|
try (Fixture f = new Fixture()) {
|
|
f.pipeline.verifyFrontend();
|
|
equal(
|
|
List.of(
|
|
"ci",
|
|
"run lint",
|
|
"test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml",
|
|
"run build"),
|
|
f.fake.calls.stream()
|
|
.map(a -> String.join(" ", a.subList(1, a.size())))
|
|
.toList());
|
|
}
|
|
});
|
|
test("Process execution enforces its deadline", () -> {
|
|
Path dir = Files.createTempDirectory("ci-process-test-");
|
|
try {
|
|
Path file = dir.resolve("Sleep.java");
|
|
Files.writeString(
|
|
file,
|
|
"class Sleep { public static void main(String[] args) throws Exception { Thread.sleep(10000);"
|
|
+ " } }");
|
|
Commands runner = new Commands(Duration.ofMillis(100));
|
|
try {
|
|
runner.execute(
|
|
List.of(System.getProperty("java.home") + "/bin/java", file.toString()),
|
|
dir,
|
|
System.getenv(),
|
|
null,
|
|
false);
|
|
throw new AssertionError("Expected timeout");
|
|
} catch (Commands.Failure error) {
|
|
equal(124, error.exitCode);
|
|
}
|
|
} finally {
|
|
Pipeline.deleteTree(dir);
|
|
}
|
|
});
|
|
IO.println(count + " CI tests passed");
|
|
}
|
|
|
|
static class Fixture implements AutoCloseable {
|
|
final Path root = Files.createTempDirectory("ci-publication-test-");
|
|
final FakeCommands fake = new FakeCommands();
|
|
final Pipeline pipeline;
|
|
|
|
Fixture() throws Exception {
|
|
Map<String, String> env = new HashMap<>(System.getenv());
|
|
env.putAll(Map.of(
|
|
"CI_COMMIT",
|
|
SHA,
|
|
"CI_RUN_ID",
|
|
"test",
|
|
"CI_RUN_ATTEMPT",
|
|
"1",
|
|
"CI_REF",
|
|
"refs/heads/main",
|
|
"CI_REPOSITORY",
|
|
"owner/recipe-demo",
|
|
"TARGET_PLATFORM",
|
|
"linux/amd64",
|
|
"GITEA_SERVER_URL",
|
|
"http://registry.example:3000",
|
|
"REGISTRY_USER",
|
|
"test",
|
|
"REGISTRY_TOKEN",
|
|
"dummy-token",
|
|
"CI_PUBLISH_LOCK_DIR",
|
|
root.resolve("lock").toString()));
|
|
env.put("PROJECT_NAME", "recipe-demo");
|
|
env.put("BACKEND_MODULE", "backend");
|
|
env.put("FRONTEND_MODULE", "frontend");
|
|
pipeline = new Pipeline(root, env, fake) {
|
|
@Override
|
|
String mainHead() {
|
|
return fake.head;
|
|
}
|
|
};
|
|
Files.writeString(root.resolve("ci/artifacts/verified-commit"), SHA + "\n");
|
|
}
|
|
|
|
long calls(String verb) {
|
|
return fake.calls.stream()
|
|
.filter(a -> a.size() > 1
|
|
&& a.getFirst().equals("docker")
|
|
&& a.get(1).equals(verb))
|
|
.count();
|
|
}
|
|
|
|
public void close() throws Exception {
|
|
pipeline.closeDockerConfig();
|
|
Pipeline.deleteTree(root);
|
|
}
|
|
}
|
|
|
|
static class FakeCommands extends Commands {
|
|
final List<List<String>> calls = new ArrayList<>();
|
|
final Map<String, String> images = new HashMap<>(), tags = new HashMap<>();
|
|
String head = SHA, pushFailure = "", pullFailure = "";
|
|
boolean manifestError, mavenFailure, lintFailure;
|
|
boolean dockerAvailable = true;
|
|
|
|
FakeCommands() {
|
|
super(Duration.ofMinutes(1));
|
|
}
|
|
|
|
@Override
|
|
Result execute(List<String> args, Path cwd, Map<String, String> env, String input, boolean echo) {
|
|
calls.add(List.copyOf(args));
|
|
if (args.getFirst().equals("git")) return new Result(0, SHA);
|
|
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42, "deliberate failure");
|
|
if (args.equals(List.of("npm", "run", "lint")) && lintFailure)
|
|
return new Result(1, "deliberate lint failure");
|
|
if (!args.getFirst().equals("docker")) return new Result(0, "");
|
|
String command = args.get(1), ref = args.getLast();
|
|
if (command.equals("info"))
|
|
return new Result(dockerAvailable ? 0 : 1, args.contains("{{.Architecture}}") ? "amd64" : "linux");
|
|
if (command.equals("context")) return new Result(0, "unix:///tmp/test-docker.sock");
|
|
if (command.equals("manifest")) {
|
|
if (manifestError) return new Result(1, "unauthorized");
|
|
return images.containsKey(ref) ? new Result(0, "{}") : new Result(1, "manifest unknown");
|
|
}
|
|
if (command.equals("tag")) tags.put(ref, images.getOrDefault(args.get(2), SHA));
|
|
if (command.equals("push")) {
|
|
if (!pushFailure.isEmpty() && ref.contains(pushFailure)) return new Result(1, "push failed");
|
|
images.put(ref, tags.getOrDefault(ref, SHA));
|
|
}
|
|
if (command.equals("pull") && !pullFailure.isEmpty() && ref.contains(pullFailure))
|
|
return new Result(1, "pull failed");
|
|
if (command.equals("image"))
|
|
return new Result(
|
|
0,
|
|
args.stream().anyMatch(a -> a.contains("RepoDigests"))
|
|
? ref.substring(0, ref.lastIndexOf(':')) + "@sha256:" + "b".repeat(64)
|
|
: images.getOrDefault(ref, "missing"));
|
|
if (command.equals("container") || command.equals("network") && args.contains("inspect"))
|
|
return new Result(1, "");
|
|
return new Result(0, "");
|
|
}
|
|
}
|
|
}
|