From 90ebb6a22b56db55f387b5af76d754f8d7b3fbee Mon Sep 17 00:00:00 2001 From: Case Wylie Date: Tue, 4 Feb 2025 15:47:36 -0500 Subject: [PATCH] chore: add more testing around asset class (#1753) ## Description The assets class does not have unit tests. The purpose of this PR is to add coverage to ensure the class is working as expected. Due to the use of dependency injection we are loosely helper functions with the method and functions on the assets class that rely on members of the assets class. Due to that, the intent of this PR is to ensure those injected functions are being with the correct members of the assets class and to hedge against accidentally change by ensuring certain functions are called a certain number of times. ## Related Issue Fixes #1655 Relates to # ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [x] Other (security config, docs update, etc) ## Checklist before merging - [x] Unit, [Journey](https://github.com/defenseunicorns/pepr/tree/main/journey), [E2E Tests](https://github.com/defenseunicorns/pepr-excellent-examples), [docs](https://github.com/defenseunicorns/pepr/tree/main/docs), [adr](https://github.com/defenseunicorns/pepr/tree/main/adr) added or updated as needed - [x] [Contributor Guide Steps](https://docs.pepr.dev/main/contribute/#submitting-a-pull-request) followed --------- Signed-off-by: Case Wylie Co-authored-by: Sam Mayer --- src/lib/assets/assets.test.ts | 301 ++++++++++++++++++++++++++++++++++ src/lib/assets/assets.ts | 2 +- src/lib/assets/index.ts | 1 - 3 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/lib/assets/assets.test.ts diff --git a/src/lib/assets/assets.test.ts b/src/lib/assets/assets.test.ts new file mode 100644 index 000000000..231b555de --- /dev/null +++ b/src/lib/assets/assets.test.ts @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2023-Present The Pepr Authors +import { ModuleConfig } from "../core/module"; +import { Assets } from "./assets"; +import { expect, describe, it, jest, afterAll } from "@jest/globals"; +import { CapabilityExport } from "../types"; +import { kind } from "kubernetes-fluent-client"; +import { createDirectoryIfNotExists } from "../filesystemService"; +import { promises as fs } from "fs"; +import { + V1Deployment, + V1MutatingWebhookConfiguration, + V1Secret, + V1ValidatingWebhookConfiguration, +} from "@kubernetes/client-node/dist/gen"; +import { WebhookType } from "../enums"; +import { helmLayout } from "./index"; + +jest.mock("../filesystemService", () => ({ + createDirectoryIfNotExists: jest.fn<() => Promise>().mockResolvedValue(undefined), +})); +jest.mock("./yaml/overridesFile", () => ({ + overridesFile: jest.fn<() => Promise>().mockResolvedValue(undefined), +})); +jest.mock("fs", () => ({ + ...(jest.requireActual("fs") as object), + promises: { + readFile: jest.fn<() => Promise>().mockResolvedValue("mocked"), + writeFile: jest.fn(), + access: jest.fn(), + }, +})); + +jest.mock("./loader", () => ({ + loadCapabilities: jest.fn<() => Promise>().mockResolvedValue([ + { + name: "capability-1", + description: "A test capability", + namespaces: ["custom-ns", "custom-two"], + bindings: [], + hasSchedule: false, + }, + ]), +})); + +jest.mock("./pods", () => ({ + getWatcher: jest.fn<() => V1Deployment>().mockReturnValue({ + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: `test-module-watcher`, + namespace: "pepr-system", + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: "test-module-watcher", + }, + }, + template: { + metadata: { + labels: { + app: "test-module-watcher", + }, + }, + spec: { + containers: [ + { + name: "test-module-watcher", + image: "test-image", + ports: [ + { + containerPort: 8080, + }, + ], + }, + ], + }, + }, + }, + }), + getModuleSecret: jest.fn<() => V1Secret>().mockReturnValue({ + apiVersion: "v1", + kind: "Secret", + metadata: { + name: `test-module`, + namespace: "pepr-system", + }, + type: "Opaque", + data: { + "module-hash.js.gz": "aGVsbG8=", + }, + }), + getDeployment: jest.fn<() => V1Deployment>().mockReturnValue({ + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: `test-module`, + namespace: "pepr-system", + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: "test-module", + }, + }, + template: { + metadata: { + labels: { + app: "test-module", + }, + }, + spec: { + containers: [ + { + name: "test-module", + image: "test-image", + ports: [ + { + containerPort: 8080, + }, + ], + }, + ], + }, + }, + }, + }), +})); + +jest.mock("./index", () => ({ + toYaml: jest.fn<() => string>().mockReturnValue("mocked-yaml"), + helmLayout: jest.fn(() => ({ + files: { + chartYaml: "/tmp/chart.yaml", + namespaceYaml: "/tmp/namespace.yaml", + watcherServiceYaml: "/tmp/watcher-service.yaml", + admissionServiceYaml: "/tmp/admission-service.yaml", + tlsSecretYaml: "/tmp/tls-secret.yaml", + apiTokenSecretYaml: "/tmp/api-token-secret.yaml", + storeRoleYaml: "/tmp/store-role.yaml", + storeRoleBindingYaml: "/tmp/store-role-binding.yaml", + clusterRoleYaml: "/tmp/cluster-role.yaml", + clusterRoleBindingYaml: "/tmp/cluster-role-binding.yaml", + serviceAccountYaml: "/tmp/service-account.yaml", + moduleSecretYaml: "/tmp/module-secret.yaml", + valuesYaml: "/tmp/values.yaml", + watcherDeploymentYaml: "/tmp/watcher-deployment.yaml", + watcherServiceMonitorYaml: "/tmp/watcher-service-monitor.yaml", + }, + dirs: { + templates: "/tmp/templates", + charts: "/tmp/charts", + }, + })), + createWebhookYaml: jest.fn( + ( + name: string, + config: ModuleConfig, + webhook: kind.MutatingWebhookConfiguration | kind.ValidatingWebhookConfiguration, + ) => `mocked-yaml-${name}-${webhook ? "ok" : config.webhookTimeout}`, + ), +})); + +describe("Assets", () => { + const moduleConfig: ModuleConfig = { + uuid: "test-uuid", + alwaysIgnore: { + namespaces: ["zarf"], + }, + peprVersion: "0.0.1", + appVersion: "0.0.1", + description: "A test module", + webhookTimeout: 10, + onError: "reject", + logLevel: "info", + env: {}, + rbac: [], + rbacMode: "scoped", + customLabels: {}, + }; + const assets = new Assets(moduleConfig, "/tmp", ["secret1", "secret2"], "localhost"); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it("should call deploy function that calls deployFunction with assets, force and webhookTimeout", async () => { + const deployFunction = jest.fn<() => Promise>().mockResolvedValue(undefined); + await assets.deploy(deployFunction, true, assets.config.webhookTimeout); + + expect(deployFunction).toHaveBeenCalledWith(assets, true, assets.config.webhookTimeout); + expect(deployFunction).toHaveBeenCalledTimes(1); + }); + + it("should call zarfYaml that calls zarfYamlGenerator with assets, path, and manifests type", () => { + const zarfYamlGenerator = jest.fn<() => string>().mockReturnValue(""); + assets.zarfYaml(zarfYamlGenerator, "/tmp"); + + expect(zarfYamlGenerator).toHaveBeenCalledWith(assets, "/tmp", "manifests"); + expect(zarfYamlGenerator).toHaveBeenCalledTimes(1); + }); + + it("should call zarfYamlChart that calls zarfYamlGenerator with assets, path, and charts type", () => { + const zarfYamlGenerator = jest.fn<() => string>().mockReturnValue(""); + assets.zarfYamlChart(zarfYamlGenerator, "/tmp"); + + expect(zarfYamlGenerator).toHaveBeenCalledWith(assets, "/tmp", "charts"); + expect(zarfYamlGenerator).toHaveBeenCalledTimes(1); + }); + + it("should call allYaml that calls yamlGenerationFunction with assets deployments", async () => { + const yamlGenerationFunction = jest.fn<() => Promise>().mockResolvedValue(""); + await assets.allYaml(yamlGenerationFunction); + const expectedDeployments = { + default: expect.any(Object), + watch: expect.any(Object), + }; + expect(yamlGenerationFunction).toHaveBeenCalledTimes(1); + expect(yamlGenerationFunction).toHaveBeenCalledWith(assets, expectedDeployments); + }); + + it("should call writeWebhookFiles and write admissionController Deployment, ServiceMonitor, and WebhookConfigs", async () => { + const mockHelm = { + files: { + admissionDeploymentYaml: "/tmp/admission-deployment.yaml", + admissionServiceMonitorYaml: "/tmp/admission-service-monitor.yaml", + mutationWebhookYaml: "/tmp/mutation-webhook.yaml", + validationWebhookYaml: "/tmp/validation-webhook.yaml", + }, + }; + const validateWebhook: V1ValidatingWebhookConfiguration = new kind.ValidatingWebhookConfiguration(); + const mutateWebhook: V1MutatingWebhookConfiguration = new kind.MutatingWebhookConfiguration(); + await assets.writeWebhookFiles(validateWebhook, mutateWebhook, mockHelm); + + expect(fs.writeFile).toHaveBeenCalledTimes(4); + }); + + it("should call generateHelmChart which should call createDirectoryIfNotExists twice for templates and charts", async () => { + const webhookGeneratorFunction = jest + .fn<() => Promise>() + .mockResolvedValue(new kind.MutatingWebhookConfiguration()); + await assets.generateHelmChart(webhookGeneratorFunction, "/tmp"); + expect(createDirectoryIfNotExists).toHaveBeenCalledTimes(2); + }); + + it("should call generateHelmChart which should write file 40 times for built Kubernetes Manifests and helm chart generation", async () => { + const webhookGeneratorFunction = jest + .fn<() => Promise>() + .mockResolvedValue(new kind.MutatingWebhookConfiguration()); + await assets.generateHelmChart(webhookGeneratorFunction, "/tmp"); + expect(fs.writeFile).toHaveBeenCalledTimes(40); + }); + + it("should call generateHelmChart and get no error", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const webhookGeneratorFunction = jest + .fn<() => Promise>() + .mockResolvedValue(new kind.MutatingWebhookConfiguration()); + await assets.generateHelmChart(webhookGeneratorFunction, "/tmp"); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("should call generateHelmChart without an error when asset class instance is correct", async () => { + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + + const webhookGeneratorFunction = jest + .fn<() => Promise>() + .mockResolvedValue(new kind.MutatingWebhookConfiguration()); + await assets.generateHelmChart(webhookGeneratorFunction, "/tmp"); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("should call generateHelmChart and throw an error when config is incorrect", async () => { + const exitString = "Mock console.exit call"; + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const processExitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error(exitString); + }); + + (helmLayout as jest.Mock).mockReturnValue(null); + + const webhookGeneratorFunction = jest + .fn< + ( + assets: Assets, + mutateOrValidate: WebhookType, + timeoutSeconds: number | undefined, + ) => Promise + >() + .mockResolvedValue(new kind.ValidatingWebhookConfiguration()); + + await expect(assets.generateHelmChart(webhookGeneratorFunction, "/tmp")).rejects.toThrow(exitString); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index b97e915b9..ead8d0e4c 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -81,7 +81,7 @@ export class Assets { allYaml = async ( yamlGenerationFunction: ( - assyts: Assets, + assets: Assets, deployments: { default: V1Deployment; watch: V1Deployment | null }, ) => Promise, imagePullSecret?: string, diff --git a/src/lib/assets/index.ts b/src/lib/assets/index.ts index c91a98432..19ced2c30 100644 --- a/src/lib/assets/index.ts +++ b/src/lib/assets/index.ts @@ -12,7 +12,6 @@ export function toYaml(obj: any): string { return dumpYaml(obj, { noRefs: true }); } -// Unit Test Me!! export function createWebhookYaml( name: string, config: ModuleConfig,