Skip to content
Merged
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
5 changes: 5 additions & 0 deletions tests/e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
blob-report/
.playwright/
27 changes: 27 additions & 0 deletions tests/e2e/helpers/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Shared authentication helper for Caldera UI tests.
*
* Caldera's default credentials are admin:admin. Override via env vars
* CALDERA_USER / CALDERA_PASS if the instance uses something else.
*/
const CALDERA_USER = process.env.CALDERA_USER || "admin";
const CALDERA_PASS = process.env.CALDERA_PASS || "admin";

/**
* Log into Caldera through the login page.
* After this resolves the page is authenticated and ready.
*/
async function login(page) {
await page.goto("/");

// If we are already past the login screen, nothing to do.
if (page.url().includes("/login") || (await page.locator('input[name="username"], input#username').count()) > 0) {
await page.locator('input[name="username"], input#username').first().fill(CALDERA_USER);
await page.locator('input[name="password"], input#password').first().fill(CALDERA_PASS);
await page.locator('button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign")').first().click();
// Wait for navigation away from login
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15_000 });
}
}

module.exports = { login, CALDERA_USER, CALDERA_PASS };
22 changes: 22 additions & 0 deletions tests/e2e/helpers/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Navigation helpers for reaching plugin tabs inside Caldera / Magma.
*/

/**
* Navigate to the Atomic plugin tab in the Magma Vue app.
*/
async function navigateToAtomic(page) {
const navItem = page.locator(
'a:has-text("Atomic"), .nav-item:has-text("Atomic"), [data-test="nav-atomic"], button:has-text("Atomic")'
).first();
await navItem.waitFor({ state: "visible", timeout: 15_000 });
await navItem.click();

// Wait for the atomic page content
await page.locator("h2:has-text('Atomic'), .content:has-text('Atomic')").first().waitFor({
state: "visible",
timeout: 15_000,
});
}

module.exports = { navigateToAtomic };
78 changes: 78 additions & 0 deletions tests/e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions tests/e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "atomic-e2e-tests",
"version": "1.0.0",
"private": true,
"description": "Playwright E2E tests for the CALDERA Atomic plugin",
"scripts": {
"test": "npx playwright test",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug",
"test:report": "npx playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.52.0"
}
}
31 changes: 31 additions & 0 deletions tests/e2e/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @ts-check
const { defineConfig, devices } = require("@playwright/test");

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

module.exports = defineConfig({
testDir: "./specs",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [["html", { open: "never" }], ["list"]],
timeout: 60_000,
expect: { timeout: 15_000 },

use: {
baseURL: CALDERA_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
actionTimeout: 10_000,
navigationTimeout: 30_000,
ignoreHTTPSErrors: true,
},

projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
81 changes: 81 additions & 0 deletions tests/e2e/specs/atomic-abilities-display.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @ts-check
const { test, expect } = require("@playwright/test");
const { login } = require("../helpers/auth");
const { navigateToAtomic } = require("../helpers/navigation");

test.describe("Atomic plugin - abilities display", () => {
test.beforeEach(async ({ page }) => {
await login(page);
await navigateToAtomic(page);
});

test("should display the abilities count (numeric or placeholder)", async ({ page }) => {
// The Vue template shows {{ atomicAbilities.length || "---" }}
const countText = page.locator(".is-size-1, h1.is-size-1").first();
await expect(countText).toBeVisible({ timeout: 15_000 });
const text = await countText.textContent();
// Should be either a number or "---"
expect(text?.trim()).toMatch(/^(\d+|---)$/);
});

test("should display the 'abilities' label under the count", async ({ page }) => {
const label = page.locator("p:has-text('abilities')").first();
await expect(label).toBeVisible({ timeout: 15_000 });
});

test("should show the View Abilities button", async ({ page }) => {
const viewBtn = page.locator(
'a:has-text("View Abilities"), button:has-text("View Abilities"), .button:has-text("View Abilities")'
).first();
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
});

test("the View Abilities button should link to the abilities page with atomic filter", async ({ page }) => {
const viewBtn = page.locator(
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
).first();
await expect(viewBtn).toBeVisible({ timeout: 15_000 });

// The router-link should resolve to /abilities?plugin=atomic
const href = await viewBtn.getAttribute("href");
if (href) {
expect(href).toContain("abilities");
expect(href).toContain("atomic");
}
// If it's a router-link, the href might be generated dynamically
// Just check the button text and that it's clickable
await expect(viewBtn).toBeEnabled();
});

test("clicking View Abilities should navigate to the abilities page", async ({ page }) => {
const viewBtn = page.locator(
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
).first();
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
await viewBtn.click();

// Should navigate to abilities page
await page.waitForURL(/abilities/, { timeout: 15_000 });
});

test("abilities count should update to a number after data loads", async ({ page }) => {
const countText = page.locator(".is-size-1, h1.is-size-1").first();
await expect(countText).toBeVisible({ timeout: 15_000 });

// Wait until count text changes from the initial "---" placeholder to a number,
// or confirms it stays "---" if no atomic abilities are ingested.
await expect(countText).not.toHaveText("---", { timeout: 15_000 }).catch(() => {
// It's acceptable for the count to remain "---" when no abilities are ingested.
});
const text = await countText.textContent();
// Should be either a valid number or "---" (if no atomic abilities ingested)
expect(text?.trim()).toMatch(/^(\d+|---)$/);
});

test("the card should be properly centered on the page", async ({ page }) => {
const container = page.locator(
".is-flex.is-align-items-center.is-justify-content-center"
).first();
await expect(container).toBeVisible({ timeout: 15_000 });
});
});
121 changes: 121 additions & 0 deletions tests/e2e/specs/atomic-error-states.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// @ts-check
const { test, expect } = require("@playwright/test");
const { login } = require("../helpers/auth");
const { navigateToAtomic } = require("../helpers/navigation");

test.describe("Atomic plugin - error states", () => {
test.beforeEach(async ({ page }) => {
await login(page);
});

test("should show placeholder count when abilities API fails", async ({ page }) => {
// Intercept abilities API to simulate failure
await page.route("**/api/v2/abilities", (route) => {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal Server Error" }),
});
});
Comment on lines +13 to +19

await navigateToAtomic(page);

// The count should show "---" since no abilities loaded
const countText = page.locator(".is-size-1, h1.is-size-1").first();
await expect(countText).toBeVisible({ timeout: 15_000 });
const text = await countText.textContent();
expect(text?.trim()).toBe("---");
});

test("page should remain functional when abilities API returns empty array", async ({ page }) => {
await page.route("**/api/v2/abilities", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
Comment on lines +31 to +37

await navigateToAtomic(page);

// Count should show "---" for zero abilities
const countText = page.locator(".is-size-1, h1.is-size-1").first();
await expect(countText).toBeVisible({ timeout: 15_000 });
const text = await countText.textContent();
expect(text?.trim()).toBe("---");

// Page heading should still be visible
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
});

test("page should remain functional when adversaries API fails", async ({ page }) => {
await page.route("**/api/v2/adversaries", (route) => {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal Server Error" }),
});
});
Comment on lines +52 to +58

await navigateToAtomic(page);

// The page heading and description should still render
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
await expect(page.locator("p:has-text('Red Canary Atomic')").first()).toBeVisible();
});

test("View Abilities button should still be present when no abilities loaded", async ({ page }) => {
await page.route("**/api/v2/abilities", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
Comment on lines +68 to +74

await navigateToAtomic(page);

const viewBtn = page.locator(
'a:has-text("View Abilities"), .button:has-text("View Abilities")'
).first();
await expect(viewBtn).toBeVisible({ timeout: 15_000 });
});

test("page should handle slow API responses gracefully", async ({ page }) => {
// Simulate slow response
await page.route("**/api/v2/abilities", async (route) => {
await new Promise((r) => setTimeout(r, 5_000));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
Comment on lines +86 to +93

await navigateToAtomic(page);

// While loading, the count should show "---"
const countText = page.locator(".is-size-1, h1.is-size-1").first();
await expect(countText).toBeVisible({ timeout: 15_000 });
const initialText = await countText.textContent();
expect(initialText?.trim()).toBe("---");

// Heading should be visible during loading
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible();
});

test("page should not crash with malformed API response", async ({ page }) => {
await page.route("**/api/v2/abilities", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: "not valid json",
});
});
Comment on lines +108 to +114

await navigateToAtomic(page);

// Page should still show the heading even if parsing fails
await expect(page.locator("h2:has-text('Atomic')").first()).toBeVisible({ timeout: 15_000 });
});
});
Loading
Loading