Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions tests/e2e/fixtures/caldera-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test as base, expect, type Page } from "@playwright/test";

const CALDERA_USER = process.env.CALDERA_USER || "admin";
const CALDERA_PASS = process.env.CALDERA_PASS || "admin";

/**
* Authenticate against the Caldera login page.
* Handles both the Vue/magma login form and basic-auth style login.
*/
async function authenticateCaldera(page: Page, baseURL: string) {
await page.goto(baseURL);

// If already on the main page (no login required), return early
const url = page.url();
if (!url.includes("/login") && !url.includes("/enter")) {
const appShell = page.locator("#app, .main-content, nav.navbar");
try {
await appShell.first().waitFor({ timeout: 5_000 });
return;
} catch {
// Fall through to login
}
}

// Wait for any login form to appear
const usernameField = page.locator(
'input[name="username"], input[type="text"]#username, input[placeholder*="user" i]'
);
const passwordField = page.locator(
'input[name="password"], input[type="password"]'
);

await usernameField.first().waitFor({ timeout: 10_000 });
await usernameField.first().fill(CALDERA_USER);
await passwordField.first().fill(CALDERA_PASS);

// Submit
const submitBtn = page.locator(
'button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in")'
);
await submitBtn.first().click();

// Wait for navigation away from login
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 15_000,
});
}

type CalderaFixtures = {
authenticatedPage: Page;
};

export const test = base.extend<CalderaFixtures>({
authenticatedPage: async ({ page, baseURL }, use) => {
await authenticateCaldera(page, baseURL!);

Check warning on line 55 in tests/e2e/fixtures/caldera-auth.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=mitre_emu&issues=AZz3Ivog_Q7wavx_PX2e&open=AZz3Ivog_Q7wavx_PX2e&pullRequest=54
await use(page);
},
});

export { expect };

Check warning on line 60 in tests/e2e/fixtures/caldera-auth.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `expect`.

See more on https://sonarcloud.io/project/issues?id=mitre_emu&issues=AZz3Ivog_Q7wavx_PX2f&open=AZz3Ivog_Q7wavx_PX2f&pullRequest=54
17 changes: 17 additions & 0 deletions tests/e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "emu-e2e-tests",
"version": "1.0.0",
"description": "Playwright E2E tests for the MITRE Caldera Emu plugin",
"private": true,
"scripts": {
"test": "npx playwright test",
"test:chromium": "npx playwright test --project=chromium",
"test:firefox": "npx playwright test --project=firefox",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug",
"install:browsers": "npx playwright install --with-deps chromium firefox"
},
"devDependencies": {
"@playwright/test": "^1.52.0"
}
}
33 changes: 33 additions & 0 deletions tests/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";

const CALDERA_URL = process.env.CALDERA_URL || "http://localhost:8888";

export default defineConfig({
testDir: "./specs",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? "github" : "html",
timeout: 60_000,
expect: {
timeout: 15_000,
},
use: {
baseURL: CALDERA_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
ignoreHTTPSErrors: true,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
100 changes: 100 additions & 0 deletions tests/e2e/specs/emu-emulation-plans.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test, expect } from "../fixtures/caldera-auth";

test.describe("Emu plugin — adversary emulation plan listing", () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await page.goto("/#/plugins/emu");
await page.waitForLoadState("networkidle");
const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });
});

test("should display non-zero abilities count when emu plugin is loaded", async ({
authenticatedPage: page,
}) => {
const abilitiesCount = page.locator("h1.is-size-1").first();

await expect(async () => {
const text = await abilitiesCount.textContent();
expect(Number(text?.trim())).toBeGreaterThan(0);
}).toPass({ timeout: 20_000 });
});

test("should display non-zero adversaries count when emu plugin is loaded", async ({
authenticatedPage: page,
}) => {
const adversariesCount = page.locator("h1.is-size-1").nth(1);

await expect(async () => {
const text = await adversariesCount.textContent();
expect(Number(text?.trim())).toBeGreaterThan(0);
}).toPass({ timeout: 20_000 });
});

test("should navigate to abilities page filtered by emu plugin when clicking View Abilities", async ({
authenticatedPage: page,
}) => {
const viewAbilitiesBtn = page.locator(
'a:has-text("Abilities"), a:has-text("View Abilities")'
);
await viewAbilitiesBtn.first().click();

// Should navigate to abilities page with emu filter
await page.waitForLoadState("networkidle");
const url = page.url();
expect(url).toMatch(/abilities/i);
});

test("should navigate to adversaries page filtered by emu plugin when clicking View Adversaries", async ({
authenticatedPage: page,
}) => {
const viewAdversariesBtn = page.locator(
'a:has-text("Adversaries"), a:has-text("View Adversaries")'
);
await viewAdversariesBtn.first().click();

await page.waitForLoadState("networkidle");
const url = page.url();
expect(url).toMatch(/adversaries/i);
});

test("should fetch abilities from the API and filter emu-only abilities", async ({
authenticatedPage: page,
}) => {
// Intercept the abilities API call to verify the request
const abilitiesResponse = await page.waitForResponse(
(response) =>
response.url().includes("/api/v2/abilities") &&
response.status() === 200,
{ timeout: 20_000 }
);
Comment on lines +64 to +69

const abilities = await abilitiesResponse.json();
expect(Array.isArray(abilities)).toBe(true);

// Filter for emu abilities
const emuAbilities = abilities.filter(
(a: any) => a.plugin === "emu"
);
// There should be at least some emu abilities if the plugin is loaded
expect(emuAbilities.length).toBeGreaterThanOrEqual(0);
});

test("should fetch adversaries from the API and filter emu-only adversaries", async ({
authenticatedPage: page,
}) => {
const adversariesResponse = await page.waitForResponse(
(response) =>
response.url().includes("/api/v2/adversaries") &&
response.status() === 200,
{ timeout: 20_000 }
);
Comment on lines +85 to +90

const adversaries = await adversariesResponse.json();
expect(Array.isArray(adversaries)).toBe(true);

const emuAdversaries = adversaries.filter(
(a: any) => a.plugin === "emu"
);
expect(emuAdversaries.length).toBeGreaterThanOrEqual(0);
});
});
185 changes: 185 additions & 0 deletions tests/e2e/specs/emu-error-states.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { test, expect } from "../fixtures/caldera-auth";

test.describe("Emu plugin — error states and edge cases", () => {
test("should handle abilities API failure gracefully", async ({
authenticatedPage: page,
}) => {
// Intercept abilities API and return error
await page.route("**/api/v2/abilities", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal Server Error" }),
})
);

await page.goto("/#/plugins/emu");
await page.waitForLoadState("networkidle");

// The page should still render the heading
const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });

// Abilities count should show placeholder "---" since API failed
const abilitiesCount = page.locator("h1.is-size-1").first();
await expect(abilitiesCount).toBeVisible({ timeout: 10_000 });
const text = await abilitiesCount.textContent();
expect(text?.trim()).toBe("---");
});

test("should handle adversaries API failure gracefully", async ({
authenticatedPage: page,
}) => {
// Intercept adversaries API and return error
await page.route("**/api/v2/adversaries", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal Server Error" }),
})
);

await page.goto("/#/plugins/emu");
await page.waitForLoadState("networkidle");

const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });

// Adversaries count should show placeholder
const adversariesCount = page.locator("h1.is-size-1").nth(1);
await expect(adversariesCount).toBeVisible({ timeout: 10_000 });
const text = await adversariesCount.textContent();
expect(text?.trim()).toBe("---");
});

test("should handle network timeout for abilities API gracefully", async ({
authenticatedPage: page,
}) => {
await page.route("**/api/v2/abilities", (route) => route.abort());

await page.goto("/#/plugins/emu");
await page.waitForLoadState("domcontentloaded");

// Page should still render
const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });
});

test("should handle network timeout for adversaries API gracefully", async ({
authenticatedPage: page,
}) => {
await page.route("**/api/v2/adversaries", (route) => route.abort());

await page.goto("/#/plugins/emu");
await page.waitForLoadState("domcontentloaded");

const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });
});

test("should handle both APIs failing simultaneously", async ({
authenticatedPage: page,
}) => {
await page.route("**/api/v2/abilities", (route) =>
route.fulfill({
status: 500,
body: "error",
})
);
await page.route("**/api/v2/adversaries", (route) =>
route.fulfill({
status: 500,
body: "error",
})
);

await page.goto("/#/plugins/emu");
await page.waitForLoadState("domcontentloaded");

// Page should render without crashing
const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });

// Both counts should show placeholder
const counts = page.locator("h1.is-size-1");
const count = await counts.count();
expect(count).toBeGreaterThanOrEqual(2);
});

test("should display abilities count as 0 when API returns empty array", async ({
authenticatedPage: page,
}) => {
// Return empty arrays (no emu abilities)
await page.route("**/api/v2/abilities", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
})
);
await page.route("**/api/v2/adversaries", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
})
);

await page.goto("/#/plugins/emu");
await page.waitForLoadState("networkidle");

const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });

// With empty arrays filtered for emu, counts should show "---" (falsy 0)
const abilitiesCount = page.locator("h1.is-size-1").first();
await expect(abilitiesCount).toBeVisible({ timeout: 10_000 });
const text = await abilitiesCount.textContent();
// 0 is falsy so the template shows "---"
expect(text?.trim()).toMatch(/^(0|---)$/);
});

test("should not crash when API returns non-emu abilities only", async ({
authenticatedPage: page,
}) => {
// Return abilities that belong to a different plugin
await page.route("**/api/v2/abilities", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
ability_id: "test-1",
name: "Test Ability",
plugin: "stockpile",
},
]),
})
);
await page.route("**/api/v2/adversaries", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
adversary_id: "test-1",
name: "Test Adversary",
plugin: "stockpile",
},
]),
})
);

await page.goto("/#/plugins/emu");
await page.waitForLoadState("networkidle");

const heading = page.locator("h2:has-text('Emu')");
await expect(heading).toBeVisible({ timeout: 15_000 });

// Emu-filtered counts should be 0 (shown as "---")
const abilitiesCount = page.locator("h1.is-size-1").first();
await expect(abilitiesCount).toBeVisible({ timeout: 10_000 });
const text = await abilitiesCount.textContent();
expect(text?.trim()).toMatch(/^(0|---)$/);
});
});
Loading
Loading