diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 4405f033..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,75 +0,0 @@ -require("@rushstack/eslint-patch/modern-module-resolution"); - -const path = require("node:path"); -const createAliasSetting = require("@vue/eslint-config-airbnb/createAliasSetting"); - -module.exports = { - root: true, - env: { - es2021: true, - }, - extends: [ - "plugin:vue/vue3-recommended", - "@vue/eslint-config-airbnb", - "prettier", - ], - rules: { - "no-console": process.env.NODE_ENV === "production" ? "error" : "off", - "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off", - "no-plusplus": "off", - "import/no-cycle": "off", - "vue/valid-v-slot": [ - "error", - { - allowModifiers: true, - }, - ], - "no-param-reassign": "off", - "import/no-unresolved": "error", - // This is probably a good idea, but I don't want to diverge all the file names right now - // while maintaining 4.x and 5.x. - "vue/multi-word-component-names": "off", - "vue/max-len": [ - "error", - { - code: 120, - ignoreComments: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - ignoreRegExpLiterals: true, - ignoreUrls: true, - }, - ], - "no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - "prefer-destructuring": ["error", { object: true, array: false }], - // Disable camelcase enforcement since the api is in snake_case - camelcase: "off", - }, - settings: { - ...createAliasSetting({ - "@": `${path.resolve(__dirname, "./src")}`, - }), - }, - parserOptions: {}, - overrides: [ - { - files: ["**/__tests__/*.{j,t}s?(x)"], - env: { - mocha: true, - }, - }, - { - files: ["src/plugins/vuetify.js"], - rules: { - "import/no-unresolved": "off", - }, - }, - ], -}; diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 649e2a6b..2aa1703f 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -14,10 +14,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: "yarn" - name: Get app version run: | @@ -28,3 +28,38 @@ jobs: run: yarn lint - name: Run Prettier run: yarn format:check + + test: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: "yarn" + - name: Install modules + run: yarn + + - name: Cache Playwright browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/node_modules/@playwright/test/package.json') }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright browsers + run: yarn playwright install chromium --with-deps + + - name: Run Playwright tests + run: yarn test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/prerelease-sponsor-kali-merge-private.yml b/.github/workflows/prerelease-sponsor-kali-merge-private.yml index 640e9c06..862e8c3d 100644 --- a/.github/workflows/prerelease-sponsor-kali-merge-private.yml +++ b/.github/workflows/prerelease-sponsor-kali-merge-private.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: "recursive" ref: private-main diff --git a/.github/workflows/release-private-start.yml b/.github/workflows/release-private-start.yml index 862755e1..876774e3 100644 --- a/.github/workflows/release-private-start.yml +++ b/.github/workflows/release-private-start.yml @@ -24,7 +24,7 @@ jobs: - name: Set target branch run: echo "TARGET_BRANCH=private-main" >> $GITHUB_ENV - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ env.TARGET_BRANCH }} submodules: "recursive" @@ -36,7 +36,7 @@ jobs: git config user.email noreply@github.com - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Change version number run: | if [ -n "${{ github.event.inputs.overrideVersion }}" ]; then diff --git a/.github/workflows/release-private-tag.yml b/.github/workflows/release-private-tag.yml index 9d3513c7..a02b17e8 100644 --- a/.github/workflows/release-private-tag.yml +++ b/.github/workflows/release-private-tag.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} fetch-depth: 0 @@ -23,7 +23,7 @@ jobs: git config user.email noreply@github.com - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Set application version as variable run: | echo "APP_VERSION=$(npm pkg get version | sed 's/"//g')" >> $GITHUB_ENV diff --git a/.github/workflows/release-public-start.yml b/.github/workflows/release-public-start.yml index 0c95a15e..ce665744 100644 --- a/.github/workflows/release-public-start.yml +++ b/.github/workflows/release-public-start.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out sponsor repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: "BC-Security/Starkiller-Sponsors" ref: ${{ inputs.sponsorTag }} @@ -31,7 +31,7 @@ jobs: git config user.email noreply@github.com - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Get app version run: | echo "APP_VERSION=$(npm pkg get version | sed 's/"//g')" >> $GITHUB_ENV diff --git a/.github/workflows/release-public-tag.yml b/.github/workflows/release-public-tag.yml index eab39659..02320301 100644 --- a/.github/workflows/release-public-tag.yml +++ b/.github/workflows/release-public-tag.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Initialize mandatory git config @@ -27,7 +27,7 @@ jobs: echo "LATEST_TAG=$latest_tag" >> $GITHUB_ENV - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: "yarn" - name: Set application version as variable run: | diff --git a/.github/workflows/release-sponsor-kali-start.yml b/.github/workflows/release-sponsor-kali-start.yml index 8ee2e129..6034ff1f 100644 --- a/.github/workflows/release-sponsor-kali-start.yml +++ b/.github/workflows/release-sponsor-kali-start.yml @@ -12,7 +12,7 @@ jobs: run: | echo "TARGET_BRANCH=sponsors-main" >> $GITHUB_ENV - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: sponsors-main fetch-depth: 0 @@ -23,7 +23,7 @@ jobs: git config user.email noreply@github.com - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 - name: Get app version run: | echo "APP_VERSION=$(npm pkg get version | sed 's/"//g')" >> $GITHUB_ENV diff --git a/.github/workflows/release-sponsor-kali-tag.yml b/.github/workflows/release-sponsor-kali-tag.yml index eea23982..b70fc4b8 100644 --- a/.github/workflows/release-sponsor-kali-tag.yml +++ b/.github/workflows/release-sponsor-kali-tag.yml @@ -15,7 +15,7 @@ jobs: run: | echo "TAG_NAME=$(echo ${{ github.head_ref }} | sed 's/release\///')" >> $GITHUB_ENV - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} token: ${{ secrets.RELEASE_TOKEN }} @@ -25,7 +25,7 @@ jobs: git config user.email noreply@github.com - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: "yarn" - name: Set application version as variable run: | diff --git a/.gitignore b/.gitignore index 305bd4be..09225849 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ yarn-error.log* #Electron-builder output /dist_electron + +/test-results/ +/playwright-report/ +/playwright/.cache/ +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe0edb4..ad3eb257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.6.0] - 2026-07-06 + +### Added + +- Added server-side validation error feedback to dynamic forms +- Added Playwright end-to-end test suite, run as a CI job on every PR +- Added Vitest unit-test harness with initial store and utility tests +- Added `jsconfig.json` for editor path-alias resolution + +### Changed + +- Refactored GeneralForm into reusable form composables +- Replaced axios with the native Fetch API +- Replaced moment with dayjs +- Converted global mixins to composables +- Extracted shared ANSI-to-HTML and auto-refresh helpers +- Migrated ESLint to v9 flat config +- Upgraded the build and state stack to latest majors (Vite 8, Pinia 3, persistedstate 4, Vitest 4) +- Bumped CI workflows to Node 22 + +### Removed + +- Removed unused `vue.config.js` +- Removed unused dependencies (uuid, axios, qs, semver, table) + +### Fixed + +- Fixed module execution posting to an undefined agent when selected from autocomplete +- Fixed Vue Router "Missing required param" error after module execution +- Fixed socket and timer leaks in the chat widget causing duplicate messages and inflated unread counts +- Fixed unstable list keys on chat messages and notifications +- Fixed file upload not auto-selecting the uploaded file in module execution and obfuscation forms +- Removed leftover debug logging from API modules + ## [3.5.0] - 2026-04-26 ### Changed @@ -477,7 +511,9 @@ Including but not limited to: - Initial Release -[Unreleased]: https://github.com/BC-SECURITY/Starkiller-Sponsors/compare/v3.5.0...HEAD +[Unreleased]: https://github.com/BC-SECURITY/Starkiller-Sponsors/compare/v3.6.0...HEAD + +[3.6.0]: https://github.com/BC-SECURITY/Starkiller-Sponsors/compare/v3.5.0...v3.6.0 [3.5.0]: https://github.com/BC-SECURITY/Starkiller-Sponsors/compare/v3.4.0...v3.5.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 406fb615..26329181 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,3 +34,30 @@ The more information you provide in a Github issue the easier it will be for us ## Use a Consistent Coding Style We use Airbnb's and Vue's recommended ESLint configs. To make your life easier, consider installing an ESLint plugin in your editor of choice. You can also run `yarn lint`. + +## Running tests + +End-to-end tests live in `e2e/` and use [Playwright](https://playwright.dev/). + +```bash +yarn test:e2e # headless +yarn test:e2e:ui # interactive UI mode +yarn test:e2e e2e/agents-list.spec.js # single spec +``` + +The dev server is auto-started by Playwright on port 5173. If you already have `yarn dev` running locally, it's reused. + +### Adding a spec + +1. Add or extend a fixture in `e2e/fixtures/.js`. +2. Add or extend a mock helper in `e2e/helpers/api/.js`. +3. Add a spec at `e2e/.spec.js`. Use `setFakeAuth(page)` (and `blockSockets`, `mockEmpireBootstrap`) in `beforeEach`. +4. Run it: `yarn test:e2e e2e/.spec.js`. + +### Selectors and conventions + +- Routes are hash-based — use `/#/agents` style paths in `goto`. URL assertions use regex like `/#\/agents$/`. +- Prefer locators by role/label/text. Add `data-testid` only when ambiguous. +- Vuetify dialogs and menus portal to `document.body`. Use `page.getByRole("dialog")` rather than scoped queries inside parents. +- Never call `page.waitForTimeout`. Use `expect(...).toBeVisible()` and `expect.poll(...)` for retry semantics. +- Don't commit `.only` — CI will fail (`forbidOnly`). diff --git a/README.md b/README.md index b9266e35..9d931415 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,14 @@ yarn dev yarn build ``` +### Run end-to-end tests + +``` +yarn test:e2e +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md#running-tests) for the testing workflow. + ## Changelog Detailed changes for each release are documented in the [changelog](./CHANGELOG.md). diff --git a/e2e/agent-detail.spec.js b/e2e/agent-detail.spec.js new file mode 100644 index 00000000..605bcbea --- /dev/null +++ b/e2e/agent-detail.spec.js @@ -0,0 +1,52 @@ +// e2e/agent-detail.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockAgentsList, + mockAgentDetail, + mockAgentDetailSubResources, +} from "./helpers/api/agents.js"; +import { defaultAgents } from "./fixtures/agents.js"; + +test.describe("agent detail", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Sub-resource mocks: modules (AgentExecuteModule tab), shell task + // (AgentShellSession.updateCurrentDirectory), and task list poller. + await mockAgentDetailSubResources(page); + await mockAgentsList(page, defaultAgents); + await mockAgentDetail(page, defaultAgents[0]); + }); + + test("renders agent metadata", async ({ page }) => { + await page.goto(`/#/agents/${defaultAgents[0].session_id}`); + // The agent name appears in the breadcrumb bar at the top of the detail page. + await expect(page.getByText(defaultAgents[0].name).first()).toBeVisible(); + // The hostname is shown on the View tab's agent form. + await page.locator(".v-tab", { hasText: /view/i }).click(); + await expect( + page.getByText(defaultAgents[0].hostname).first(), + ).toBeVisible(); + }); + + test("terminal and shell tabs both render (regression: 441555b7)", async ({ + page, + }) => { + await page.goto(`/#/agents/${defaultAgents[0].session_id}`); + // Click Terminal tab (inside the Interact sub-tab bar) and confirm the + // AgentTerminal-specific container renders. Both AgentTerminal.vue and + // AgentShellSession.vue share the class "terminal-container", so we + // distinguish them with data-testid attributes added to each component. + await page.locator(".v-tab", { hasText: /terminal/i }).click(); + await expect(page.locator('[data-testid="agent-terminal"]')).toBeVisible(); + // Shell-specific container must NOT be visible on the Terminal tab. + await expect(page.locator('[data-testid="agent-shell"]')).not.toBeVisible(); + + // Click Shell tab and confirm its distinct container renders. + await page.locator(".v-tab", { hasText: /shell/i }).click(); + await expect(page.locator('[data-testid="agent-shell"]')).toBeVisible(); + }); +}); diff --git a/e2e/agent-file-transfer.spec.js b/e2e/agent-file-transfer.spec.js new file mode 100644 index 00000000..a350889e --- /dev/null +++ b/e2e/agent-file-transfer.spec.js @@ -0,0 +1,105 @@ +// e2e/agent-file-transfer.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockAgentsList, + mockAgentDetail, + mockAgentDetailSubResources, + recordAgentTasks, +} from "./helpers/api/agents.js"; +import { mockDownloadsList } from "./helpers/api/downloads.js"; +import { defaultAgents } from "./fixtures/agents.js"; +import { defaultDownloads } from "./fixtures/downloads.js"; + +test.describe("agent file transfer", () => { + const agent = defaultAgents[0]; + + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + await mockAgentDetailSubResources(page); + await mockAgentsList(page, defaultAgents); + await mockAgentDetail(page, agent); + // FileInput.vue (used by AgentUploadDialog) calls getDownloads() on mount. + await mockDownloadsList(page, defaultDownloads); + }); + + async function openAgentMenu(page) { + // The upload/download actions live behind the ellipsis-v menu in the agent + // toolbar (Teleported into #app-bar). Target the button by its FA icon + // rather than position — the app bar also holds chat + notification icons. + await page.locator("button:has(.fa-ellipsis-v)").click(); + } + + test("download dialog POSTs to /tasks/download with path", async ({ + page, + }) => { + const tasks = recordAgentTasks(page); + await page.goto(`/#/agents/${agent.session_id}`); + + await openAgentMenu(page); + await page + .locator(".v-list-item") + .filter({ hasText: /^Download$/ }) + .click(); + + // Dialog renders with a single text field for the agent-side path. + const pathField = page.getByLabel(/path\/to\/file/i).first(); + await pathField.fill("C:\\Users\\target\\secrets.txt"); + + await page.getByRole("button", { name: /^save$/i }).click(); + + await expect + .poll( + () => + tasks.calls.filter((c) => c.url.endsWith("/tasks/download")).length, + ) + .toBe(1); + const call = tasks.calls.find((c) => c.url.endsWith("/tasks/download")); + expect(call.body.path_to_file).toBe("C:\\Users\\target\\secrets.txt"); + }); + + test("upload dialog POSTs to /tasks/upload with file_id and path", async ({ + page, + }) => { + const tasks = recordAgentTasks(page); + await page.goto(`/#/agents/${agent.session_id}`); + + await openAgentMenu(page); + await page + .locator(".v-list-item") + .filter({ hasText: /^Upload$/ }) + .click(); + + // Pick a server file from the autocomplete (FileInput). + // The "Server Files" name is shared by the prepend and clear icons in the + // v-autocomplete; target the combobox role to disambiguate. + const fileSelect = page.getByRole("combobox", { name: /server files/i }); + await fileSelect.click(); + await page + .getByRole("option", { name: new RegExp(defaultDownloads[0].location) }) + .first() + .click(); + + // The `fileName` watcher in AgentUploadDialog auto-fills internalPathToFile + // (C:\tmp\ for powershell/csharp/c, /tmp/ for python/ironpython). + // We override with our own value and assert it round-trips to the POST body. + // The "On the agent's machine" suffix uniquely identifies the upload + // dialog's path field — the download dialog also has a "path/to/file" label. + const pathField = page.getByLabel(/On the agent's machine/i); + await pathField.fill("C:\\tmp\\report.txt"); + + await page.getByRole("button", { name: /^upload$/i }).click(); + + await expect + .poll( + () => tasks.calls.filter((c) => c.url.endsWith("/tasks/upload")).length, + ) + .toBe(1); + const call = tasks.calls.find((c) => c.url.endsWith("/tasks/upload")); + expect(call.body.file_id).toBe(defaultDownloads[0].id); + expect(call.body.path_to_file).toBe("C:\\tmp\\report.txt"); + }); +}); diff --git a/e2e/agent-jobs.spec.js b/e2e/agent-jobs.spec.js new file mode 100644 index 00000000..d81bd381 --- /dev/null +++ b/e2e/agent-jobs.spec.js @@ -0,0 +1,82 @@ +// e2e/agent-jobs.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { jsonResponse, paginatedResponse } from "./helpers/responses.js"; +import { + mockAgentsList, + mockAgentDetail, + mockAgentDetailSubResources, +} from "./helpers/api/agents.js"; +import { defaultAgents } from "./fixtures/agents.js"; + +// AgentJobs polls getTask after POSTing /tasks/jobs. delay=0 clamps the +// per-iteration pollDelay to 1s (the inline `Math.max(..., 1000)` floor in +// AgentJobs.refreshJobs), so the first poll succeeds in ~1s instead of 5s. +const agent = { ...defaultAgents[0], delay: 0 }; + +test.describe("agent jobs tab", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + await mockAgentDetailSubResources(page); + await mockAgentsList(page, defaultAgents); + await mockAgentDetail(page, agent); + }); + + test("renders Background Jobs panel and triggers a getJobs POST", async ({ + page, + }) => { + const jobsPostCalls = []; + // Records POST /agents/*/tasks/jobs (the getJobs trigger). + await page.route("**/api/v2/agents/*/tasks/jobs", (route) => { + if (route.request().method() !== "POST") return route.fallback(); + jobsPostCalls.push(route.request().url()); + return route.fulfill(jsonResponse({ id: 42, output: "" }, 201)); + }); + // The poll fetches GET /tasks/42 — return parseable output with no + // active jobs so the panel renders the "no data" empty state quickly. + await page.route("**/api/v2/agents/*/tasks/42", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill( + jsonResponse({ + id: 42, + output: "Task ID | Status\n--------------------\n", + status: "completed", + }), + ); + }); + // After polling succeeds, AgentJobs calls getTasks() with query params: + // GET /agents/{id}/tasks?limit=100&page=1&... + // The mockAgentDetailSubResources `/tasks` glob doesn't include the query + // so it misses; use a regex to match the path with optional query. + await page.route(/\/api\/v2\/agents\/[^/]+\/tasks(\?|$)/, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + + await page.goto(`/#/agents/${agent.session_id}?tab=jobs`); + + // Header renders. Use heading role — "Background Jobs" also appears in + // the empty-state cell text, which would trip strict mode on getByText. + await expect( + page.getByRole("heading", { name: /Background Jobs/i }), + ).toBeVisible(); + + // Initial mount fires exactly one POST /tasks/jobs (immediate watcher on + // agent). `.toBe(1)` catches a regression that double-fires on mount. + // Poll timeout bumped to cover the 1s pollDelay floor + CPU contention + // when run in parallel with the rest of the suite. + await expect.poll(() => jobsPostCalls.length, { timeout: 10_000 }).toBe(1); + + // After polling completes the table shows the empty-state message. + await expect(page.getByText(/No background jobs found/i)).toBeVisible({ + timeout: 10_000, + }); + + // Click Refresh: must fire exactly one additional POST, not two. + await page.getByRole("button", { name: /^refresh$/i }).click(); + await expect.poll(() => jobsPostCalls.length, { timeout: 10_000 }).toBe(2); + }); +}); diff --git a/e2e/agent-shell-session.spec.js b/e2e/agent-shell-session.spec.js new file mode 100644 index 00000000..1b894b1e --- /dev/null +++ b/e2e/agent-shell-session.spec.js @@ -0,0 +1,65 @@ +// e2e/agent-shell-session.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockAgentsList, + mockAgentDetail, + mockAgentDetailSubResources, + recordAgentTasks, +} from "./helpers/api/agents.js"; +import { defaultAgents } from "./fixtures/agents.js"; + +test.describe("agent shell session", () => { + // delay=0 makes pollDelay clamp to 1s (the floor in pollForResult), so the + // background updateCurrentDirectory poll plus the user-command poll each + // take ~1s instead of the 5s default. + const agent = { ...defaultAgents[0], delay: 0 }; + + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // mockAgentDetailSubResources stubs the /tasks/shell POST with a completed + // task, but recordAgentTasks (registered after) wins under Playwright's + // LIFO route order. The single-task GET stub still serves both polls. + await mockAgentDetailSubResources(page); + await mockAgentsList(page, defaultAgents); + await mockAgentDetail(page, agent); + }); + + test("submits shell command from terminal input", async ({ page }) => { + const tasks = recordAgentTasks(page); + await page.goto(`/#/agents/${agent.session_id}`); + + // Switch to the Shell tab inside the Interact panel. + await page.locator(".v-tab", { hasText: /shell/i }).click(); + const shell = page.locator('[data-testid="agent-shell"]'); + await expect(shell).toBeVisible(); + + // Type a command and press Enter. + const input = shell.locator("input").first(); + await input.fill("whoami"); + await input.press("Enter"); + + // The user command echo appears in the terminal output. + await expect(shell.getByText(/whoami/).first()).toBeVisible(); + + // The shell POST for our command lands at /tasks/shell with body.command="whoami". + // The initial updateCurrentDirectory poll also POSTs to /tasks/shell with + // a directory probe — filter to ours. + await expect + .poll( + () => + tasks.calls.filter( + (c) => + c.url.endsWith("/tasks/shell") && c.body.command === "whoami", + ).length, + ) + .toBe(1); + const call = tasks.calls.find( + (c) => c.url.endsWith("/tasks/shell") && c.body.command === "whoami", + ); + expect(call.body.literal).toBe(false); + }); +}); diff --git a/e2e/agents-list.spec.js b/e2e/agents-list.spec.js new file mode 100644 index 00000000..06029ff7 --- /dev/null +++ b/e2e/agents-list.spec.js @@ -0,0 +1,104 @@ +// e2e/agents-list.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockTagsEndpoint, +} from "./helpers/network.js"; +import { + mockAgentsList, + mockAgentDetail, + mockAgentDetailSubResources, + recordAgentTasks, +} from "./helpers/api/agents.js"; +import { defaultAgents } from "./fixtures/agents.js"; + +test.describe("agents list", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Tags endpoint fires on mount from AgentsList.getTags(). + await mockTagsEndpoint(page); + }); + + test("renders all agents from the fixture", async ({ page }) => { + await mockAgentsList(page, defaultAgents); + await page.goto("/#/agents"); + // The table renders the agent's name column (not session_id). + // For most agents name === session_id; one fixture has a distinct name. + for (const agent of defaultAgents) { + await expect(page.getByText(agent.name).first()).toBeVisible(); + } + }); + + test("mass kill posts exit task for each selected agent (regression: 18442fe0)", async ({ + page, + }) => { + await mockAgentsList(page, defaultAgents); + const tasks = recordAgentTasks(page); + await page.goto("/#/agents"); + + // Wait for agents to render so the table and checkboxes are mounted. + await expect( + page.getByText(defaultAgents[0].session_id).first(), + ).toBeVisible(); + + // Select all rows. v-data-table's "select all" checkbox is in thead. + await page.locator("thead input[type='checkbox']").first().check(); + + // The Kill button is rendered by ListPageTop with deleteText="Kill". + await page.getByRole("button", { name: /kill/i }).click(); + + // Confirm.vue renders a "Yes" button in the v-dialog. + await page.getByRole("button", { name: "Yes" }).click(); + + // Allow the forEach to flush. + await expect.poll(() => tasks.calls.length).toBe(defaultAgents.length); + + // Regression assertion: the exact set of session_ids posted must match + // the fixture — rejects "undefined", "null", "[object Object]", etc. + const got = new Set( + tasks.calls.map( + (c) => c.url.match(/\/agents\/([^/]+)\/tasks\/exit$/)?.[1], + ), + ); + const expected = new Set(defaultAgents.map((a) => a.session_id)); + expect(got).toEqual(expected); + // Belt-and-suspenders: also assert no URL contains stringified bad values. + for (const call of tasks.calls) { + expect(call.url).not.toContain("undefined"); + expect(call.url).not.toContain("null"); + expect(call.url).not.toContain("[object"); + } + }); + + test("clicking an agent name navigates to its detail page", async ({ + page, + }) => { + await mockAgentDetail(page, defaultAgents[0]); + // Sub-resource mocks for the agent detail page that loads after navigation. + // Must be registered before mockAgentsList so that mockAgentsList (LIFO) wins + // for the agents-list GET and overrides the empty-agents stub in + // mockGeneralFormBackground (composed inside mockAgentDetailSubResources). + await mockAgentDetailSubResources(page); + await mockAgentsList(page, defaultAgents); + await page.goto("/#/agents"); + + // Confirm the table rendered before clicking; if this fails the table + // never mounted and a row click would silently hit the wrong element. + await expect(page.getByText(defaultAgents[0].name).first()).toBeVisible(); + + // The name cell is a router-link to agentEdit. Click it and assert + // the URL contains the agent's session_id. + await page + .getByRole("row") + .filter({ hasText: defaultAgents[0].name }) + .getByText(defaultAgents[0].name) + .click(); + await expect(page).toHaveURL( + new RegExp(`#/agents/${defaultAgents[0].session_id}$`), + ); + }); +}); diff --git a/e2e/bypasses.spec.js b/e2e/bypasses.spec.js new file mode 100644 index 00000000..39a397fa --- /dev/null +++ b/e2e/bypasses.spec.js @@ -0,0 +1,63 @@ +// e2e/bypasses.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockBypassesList, + recordBypassActions, +} from "./helpers/api/bypasses.js"; +import { defaultBypasses } from "./fixtures/bypasses.js"; +import { jsonResponse } from "./helpers/responses.js"; + +test.describe("bypasses", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + await mockBypassesList(page, defaultBypasses); + }); + + test("renders bypasses list", async ({ page }) => { + await page.goto("/#/bypasses"); + await expect(page.getByText("amsi-bypass-1")).toBeVisible(); + }); + + test("create bypass posts payload", async ({ page }) => { + const actions = recordBypassActions(page); + // After a successful create, BypassEdit navigates to bypassEdit (id=99) + // which calls getBypass(99). Stub it so the redirect doesn't produce + // unmocked-API errors. + await page.route("**/api/v2/bypasses/*", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill( + jsonResponse({ + id: 99, + name: "new-bypass", + language: "powershell", + code: "", + }), + ); + }); + await page.goto("/#/bypasses/new"); + + await page.getByLabel(/^name$/i).fill("new-bypass"); + + // Language field — required by BypassEdit.vue v-text-field rules. + await expect(page.getByLabel(/^language$/i)).toBeVisible(); + await page.getByLabel(/^language$/i).fill("powershell"); + + // Code field — required by BypassEdit.vue v-textarea rules. + await expect(page.getByLabel(/^code$/i)).toBeVisible(); + await page.getByLabel(/^code$/i).fill("Write-Output 'test'"); + + await page + .getByRole("button", { name: /save|create|submit/i }) + .first() + .click(); + // Exactly one POST must fire — no duplicate submissions. + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].body.name).toBe("new-bypass"); + expect(actions.calls[0].body.language).toBe("powershell"); + expect(actions.calls[0].body.code).toBe("Write-Output 'test'"); + }); +}); diff --git a/e2e/credentials-edit.spec.js b/e2e/credentials-edit.spec.js new file mode 100644 index 00000000..dc8af778 --- /dev/null +++ b/e2e/credentials-edit.spec.js @@ -0,0 +1,62 @@ +// e2e/credentials-edit.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockGeneralFormBackground, + mockTagsEndpoint, +} from "./helpers/network.js"; +import { + mockCredentialsList, + mockCredentialDetail, + recordCredentialActions, +} from "./helpers/api/credentials.js"; +import { defaultCredentials } from "./fixtures/credentials.js"; + +test.describe("credentials edit", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // CredentialEdit.vue renders a which fires the + // background fetches on mount. + await mockGeneralFormBackground(page); + await mockTagsEndpoint(page); + await mockCredentialsList(page, defaultCredentials); + await mockCredentialDetail(page, defaultCredentials[0]); + }); + + test("loads existing credential and PUTs an update", async ({ page }) => { + const actions = recordCredentialActions(page); + await page.goto(`/#/credentials/${defaultCredentials[0].id}`); + + // Form renders with existing values pre-filled. + await expect(page.getByLabel(/^username$/i)).toHaveValue( + defaultCredentials[0].username, + ); + await expect(page.getByLabel(/^host$/i)).toHaveValue( + defaultCredentials[0].host, + ); + + // Modify the password field. + const password = page.getByLabel(/^password$/i); + await password.fill("rotated-password"); + + await page + .getByRole("button", { name: /save|submit|update/i }) + .first() + .click(); + + // Exactly one PUT to /credentials/{id} must fire. + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].method).toBe("PUT"); + expect(actions.calls[0].url).toMatch( + new RegExp(`/credentials/${defaultCredentials[0].id}$`), + ); + expect(actions.calls[0].body.password).toBe("rotated-password"); + // Username/host should be sent unchanged. + expect(actions.calls[0].body.username).toBe(defaultCredentials[0].username); + expect(actions.calls[0].body.host).toBe(defaultCredentials[0].host); + }); +}); diff --git a/e2e/credentials.spec.js b/e2e/credentials.spec.js new file mode 100644 index 00000000..abe3f690 --- /dev/null +++ b/e2e/credentials.spec.js @@ -0,0 +1,71 @@ +// e2e/credentials.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockGeneralFormBackground, + mockTagsEndpoint, +} from "./helpers/network.js"; +import { + mockCredentialsList, + recordCredentialActions, +} from "./helpers/api/credentials.js"; +import { defaultCredentials } from "./fixtures/credentials.js"; + +test.describe("credentials", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // GeneralForm.vue fetches agents, listeners, bypasses, malleable-profiles + // on mount in both the list view (Credentials.vue embeds it? No — but + // CredentialEdit.vue does for the create form). Also covers background + // fetches that fire when the app shell initialises after auth. + await mockGeneralFormBackground(page); + // Tags endpoint: Credentials.vue calls getTags() on mount. + await mockTagsEndpoint(page); + await mockCredentialsList(page, defaultCredentials); + }); + + test("renders credentials list", async ({ page }) => { + await page.goto("/#/credentials"); + // Scope to the data table to avoid matching hidden nav items in the sidebar. + const table = page.locator(".v-data-table"); + await expect(table).toBeVisible(); + for (const c of defaultCredentials) { + await expect(table.getByText(c.username).first()).toBeVisible(); + } + }); + + test("create posts a new credential", async ({ page }) => { + const actions = recordCredentialActions(page); + await page.goto("/#/credentials/new"); + + // credtype is required (CredentialEdit.vue options: required: true, strict: true). + // It uses a dropdown (suggested_values: ["plaintext", "hash"]). + await expect(page.getByLabel(/^credtype$/i)).toBeVisible(); + await page.getByLabel(/^credtype$/i).click(); + await page.getByRole("option", { name: /plaintext/i }).click(); + + await page.getByLabel(/^username$/i).fill("newuser"); + await page.getByLabel(/^password$/i).fill("newpass"); + await page.getByLabel(/^host$/i).fill("HOST01"); + + // domain is required by CredentialEdit.vue options. + await expect(page.getByLabel(/^domain$/i)).toBeVisible(); + await page.getByLabel(/^domain$/i).fill("TEST"); + + await page + .getByRole("button", { name: /save|create|submit/i }) + .first() + .click(); + + // Exactly one POST must fire — no duplicate submissions. + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].body.username).toBe("newuser"); + expect(actions.calls[0].body.host).toBe("HOST01"); + expect(actions.calls[0].body.credtype).toBe("plaintext"); + expect(actions.calls[0].body.domain).toBe("TEST"); + }); +}); diff --git a/e2e/downloads.spec.js b/e2e/downloads.spec.js new file mode 100644 index 00000000..854ceb31 --- /dev/null +++ b/e2e/downloads.spec.js @@ -0,0 +1,26 @@ +// e2e/downloads.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockTagsEndpoint, +} from "./helpers/network.js"; +import { mockDownloadsList } from "./helpers/api/downloads.js"; +import { defaultDownloads } from "./fixtures/downloads.js"; + +test.describe("downloads", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Downloads.vue calls getTags() on mount (sources=download). + await mockTagsEndpoint(page); + await mockDownloadsList(page, defaultDownloads); + }); + + test("renders downloads list", async ({ page }) => { + await page.goto("/#/downloads"); + await expect(page.getByText("report.txt")).toBeVisible(); + }); +}); diff --git a/e2e/fixtures/agents.js b/e2e/fixtures/agents.js new file mode 100644 index 00000000..3c48f0fd --- /dev/null +++ b/e2e/fixtures/agents.js @@ -0,0 +1,39 @@ +// e2e/fixtures/agents.js +export const defaultAgents = [ + { + session_id: "ABC12345", + name: "ABC12345", + hostname: "DESKTOP-1", + username: "user1", + high_integrity: false, + process_name: "powershell.exe", + language: "powershell", + archived: false, + stale: false, + checkin_time: "2026-04-30T10:00:00Z", + }, + { + session_id: "DEF67890", + name: "renamed-agent", + hostname: "DESKTOP-2", + username: "user2", + high_integrity: true, + process_name: "python.exe", + language: "python", + archived: false, + stale: false, + checkin_time: "2026-04-30T11:00:00Z", + }, + { + session_id: "GHI24680", + name: "GHI24680", + hostname: "DESKTOP-3", + username: "user3", + high_integrity: false, + process_name: "powershell.exe", + language: "powershell", + archived: false, + stale: false, + checkin_time: "2026-04-30T12:00:00Z", + }, +]; diff --git a/e2e/fixtures/bypasses.js b/e2e/fixtures/bypasses.js new file mode 100644 index 00000000..acca9f61 --- /dev/null +++ b/e2e/fixtures/bypasses.js @@ -0,0 +1,11 @@ +// e2e/fixtures/bypasses.js +export const defaultBypasses = [ + { + id: 1, + name: "amsi-bypass-1", + language: "powershell", + code: "Write-Output 'amsi bypass'", + is_default: true, + updated_at: "2024-01-01T00:00:00Z", + }, +]; diff --git a/e2e/fixtures/credentials.js b/e2e/fixtures/credentials.js new file mode 100644 index 00000000..4c0c1b89 --- /dev/null +++ b/e2e/fixtures/credentials.js @@ -0,0 +1,22 @@ +// e2e/fixtures/credentials.js +export const defaultCredentials = [ + { + id: 1, + credtype: "plaintext", + domain: "TEST", + username: "admin", + password: "Password123", + host: "DC01", + tags: [], + }, + { + id: 2, + credtype: "hash", + domain: "TEST", + username: "user", + password: + "aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0", + host: "WS01", + tags: [], + }, +]; diff --git a/e2e/fixtures/downloads.js b/e2e/fixtures/downloads.js new file mode 100644 index 00000000..cd6b95e3 --- /dev/null +++ b/e2e/fixtures/downloads.js @@ -0,0 +1,12 @@ +// e2e/fixtures/downloads.js +export const defaultDownloads = [ + { + id: 1, + filename: "report.txt", + size: 1024, + location: "/empire/downloads/report.txt", + created_at: "2026-04-30T10:00:00Z", + updated_at: "2026-04-30T10:00:00Z", + tags: [], + }, +]; diff --git a/e2e/fixtures/listeners.js b/e2e/fixtures/listeners.js new file mode 100644 index 00000000..89c0a584 --- /dev/null +++ b/e2e/fixtures/listeners.js @@ -0,0 +1,36 @@ +// e2e/fixtures/listeners.js +export const defaultListeners = [ + { + id: 1, + name: "http-1", + enabled: true, + module: "http", + listener_type: "http", + template: "http", + options: { Host: "http://0.0.0.0", Port: "80" }, + tags: [], + created_at: "2026-04-30T10:00:00Z", + }, + { + id: 2, + name: "http-2-stopped", + enabled: false, + module: "http", + listener_type: "http", + template: "http", + options: { Host: "http://0.0.0.0", Port: "8080" }, + tags: [], + created_at: "2026-04-30T11:00:00Z", + }, +]; + +export const httpTemplate = { + id: "http", + name: "http", + description: "HTTP[S] listener", + options: { + Name: { value: "", required: true, description: "Name" }, + Host: { value: "http://0.0.0.0", required: true, description: "Host" }, + Port: { value: "80", required: true, description: "Port" }, + }, +}; diff --git a/e2e/fixtures/malleable.js b/e2e/fixtures/malleable.js new file mode 100644 index 00000000..43f45dd3 --- /dev/null +++ b/e2e/fixtures/malleable.js @@ -0,0 +1,17 @@ +// e2e/fixtures/malleable.js +export const defaultMalleableProfiles = [ + { + id: 1, + name: "default-profile", + category: "amazon", + data: "set sample_name 'Amazon';", + updated_at: "2026-04-01T12:00:00Z", + }, + { + id: 2, + name: "secondary-profile", + category: "google", + data: "set sample_name 'Google';", + updated_at: "2026-04-15T12:00:00Z", + }, +]; diff --git a/e2e/fixtures/modules.js b/e2e/fixtures/modules.js new file mode 100644 index 00000000..ff1f589c --- /dev/null +++ b/e2e/fixtures/modules.js @@ -0,0 +1,48 @@ +// e2e/fixtures/modules.js +// Fields reflect ModulesTable.vue: id, name, language, description, +// needs_admin, opsec_safe, background, techniques. +// enabled: true is required by AgentExecuteModule's filter(el => el.enabled). +export const defaultModules = [ + { + id: "powershell_collection_screenshot", + name: "powershell_collection_screenshot", + language: "powershell", + description: "Takes a screenshot", + needs_admin: false, + opsec_safe: false, + background: false, + enabled: true, + techniques: [], + options: { + Agent: { value: "", required: true, description: "Agent" }, + }, + }, + { + id: "python_collection_linux_pillage", + name: "python_collection_linux_pillage", + language: "python", + description: "Linux pillage", + needs_admin: false, + opsec_safe: false, + background: false, + enabled: true, + techniques: [], + options: { + Agent: { value: "", required: true, description: "Agent" }, + }, + }, + { + id: "powershell_situational_awareness", + name: "powershell_situational_awareness", + language: "powershell", + description: "Situational awareness", + needs_admin: false, + opsec_safe: false, + background: false, + enabled: true, + techniques: [], + options: { + Agent: { value: "", required: true, description: "Agent" }, + }, + }, +]; diff --git a/e2e/fixtures/notifications.js b/e2e/fixtures/notifications.js new file mode 100644 index 00000000..5d10efaa --- /dev/null +++ b/e2e/fixtures/notifications.js @@ -0,0 +1,20 @@ +// e2e/fixtures/notifications.js +// +// Notifications.vue renders item.title and item.text (not item.message). +// The read field controls background color for unread items. +export const defaultNotifications = [ + { + id: "n1", + title: "Agent checked in", + text: "ABC12345 from DESKTOP-1", + read: false, + timestamp: "2026-04-30T10:00:00Z", + }, + { + id: "n2", + title: "Listener started", + text: "http-1 is up", + read: true, + timestamp: "2026-04-30T11:00:00Z", + }, +]; diff --git a/e2e/fixtures/obfuscation.js b/e2e/fixtures/obfuscation.js new file mode 100644 index 00000000..17989836 --- /dev/null +++ b/e2e/fixtures/obfuscation.js @@ -0,0 +1,9 @@ +// e2e/fixtures/obfuscation.js +export const defaultObfuscation = [ + { + language: "powershell", + enabled: false, + command: "Token\\All\\1", + preobfuscatable: true, + }, +]; diff --git a/e2e/fixtures/plugins.js b/e2e/fixtures/plugins.js new file mode 100644 index 00000000..05e778f0 --- /dev/null +++ b/e2e/fixtures/plugins.js @@ -0,0 +1,26 @@ +// e2e/fixtures/plugins.js +// +// defaultInstalledPlugins: minimal shape required by PluginsList.vue, +// which renders plugin.name inside a v-list-item-title. +// +// defaultMarketplacePlugins: minimal shape required by PluginMarketplace.vue. +// The component renders plugin.name in v-list-item-title. The `registries` +// field must be a non-null object (even empty) to avoid undefined errors in +// the computed properties that call Object.keys(plugin.registries). +export const defaultInstalledPlugins = [ + { + id: 1, + name: "example-plugin", + enabled: true, + description: "Example plugin", + }, +]; + +export const defaultMarketplacePlugins = [ + { + name: "marketplace-plugin", + installed: false, + installed_version: null, + registries: {}, + }, +]; diff --git a/e2e/fixtures/settings.js b/e2e/fixtures/settings.js new file mode 100644 index 00000000..2a844356 --- /dev/null +++ b/e2e/fixtures/settings.js @@ -0,0 +1,2 @@ +// e2e/fixtures/settings.js +export {}; // intentionally empty; settings are client-side diff --git a/e2e/fixtures/stagers.js b/e2e/fixtures/stagers.js new file mode 100644 index 00000000..c314e6ea --- /dev/null +++ b/e2e/fixtures/stagers.js @@ -0,0 +1,31 @@ +// e2e/fixtures/stagers.js +// +// user_id: 1 is required because setFakeAuth sets filterOnlyMyStagers: true, +// which filters the StagersTable to only show stagers owned by the current +// user (id: 1). Stagers missing user_id would be hidden. + +export const defaultStagers = [ + { + id: 1, + name: "stager-1", + template: "multi_launcher", + user_id: 1, + options: { Listener: "http-1", Language: "powershell" }, + }, +]; + +export const launcherTemplate = { + id: "multi_launcher", + name: "multi_launcher", + description: "Multi launcher", + authors: [], + comments: [], + options: { + Listener: { value: "", required: true, description: "Listener" }, + Language: { + value: "powershell", + required: true, + description: "Language", + }, + }, +}; diff --git a/e2e/fixtures/test.js b/e2e/fixtures/test.js new file mode 100644 index 00000000..601ad95c --- /dev/null +++ b/e2e/fixtures/test.js @@ -0,0 +1,76 @@ +// e2e/fixtures/test.js +// +// Project test fixture. Extends Playwright's base test with: +// - blockUnmockedApi (auto): registers a deny-all /api/v2/** fallback +// that returns HTTP 599 for any unmocked call. Because Playwright applies +// routes in LIFO order, this fixture runs before each test's beforeEach, +// so per-spec page.route() calls added in beforeEach take precedence. +// - consoleGuard (auto): fails the test if the page raises an uncaught JS +// exception or logs a non-allowlisted console.error. +// +// Use throughout the suite via `import { test, expect } from "./fixtures/test.js"`. + +import { test as base, expect } from "@playwright/test"; +import { blockUnmockedApi } from "../helpers/network.js"; + +const CONSOLE_ERROR_ALLOWLIST = [ + // vue-router emits this when a beforeEach guard returns next(false) + // (used by the admin-only route guard for non-admin redirects). + /Navigation aborted/, + // Vue dev mode warnings (not errors). console.warn is already filtered; + // this catches edge cases where Vue uses console.error for warnings. + /^\[Vue warn\]/, + // http.js handleError() logs every API error via console.error. + // Thrown errors are plain Error objects with message "HTTP ", so the + // browser surfaces them as "Error: HTTP " — the "Error:" prefix is + // what matches here. This fires on expected error paths (login 401, + // blockUnmockedApi 599, etc.). Allowlisted so the guard catches + // Vue/Vuetify/uncaught issues instead. No code produces "AxiosError" anymore. + /^Error:/, + // Chromium emits a browser-level "Failed to load resource" console error for + // non-2xx responses before JavaScript processes them. This fires in + // login.spec.js's "failed login" test (POST /token → 401 Unauthorized). + // Narrowed to the /token path so other URLs are not silently swallowed. + /^Failed to load resource: the server responded with a status of 401 \(Unauthorized\)/, + // Chromium emits this when a request is aborted (route.abort()). Fires in + // login.spec.js's "network failure" test (POST /token → aborted). + /^Failed to load resource: net::ERR_FAILED/, +]; + +export const test = base.extend({ + // Registered first (before beforeEach) so spec-level page.route() calls + // added in beforeEach take precedence via Playwright's LIFO route ordering. + blockUnmockedApi: [ + async ({ page }, use) => { + await blockUnmockedApi(page); + await use(); + }, + { auto: true }, + ], + + consoleGuard: [ + async ({ page }, use) => { + const errors = []; + page.on("pageerror", (err) => { + errors.push(`pageerror: ${err.message}`); + }); + page.on("console", (msg) => { + if (msg.type() !== "error") return; + const text = msg.text(); + if (CONSOLE_ERROR_ALLOWLIST.some((re) => re.test(text))) return; + errors.push(`console.error: ${text}`); + }); + await use(errors); + // Assert at end of test. If errors fired, the test fails with the list. + if (errors.length > 0) { + throw new Error( + `Page emitted ${errors.length} console error(s) / uncaught exception(s):\n` + + errors.join("\n"), + ); + } + }, + { auto: true }, + ], +}); + +export { expect }; diff --git a/e2e/fixtures/users.js b/e2e/fixtures/users.js new file mode 100644 index 00000000..122562df --- /dev/null +++ b/e2e/fixtures/users.js @@ -0,0 +1,5 @@ +// e2e/fixtures/users.js +export const defaultUsers = [ + { id: 1, username: "empireadmin", is_admin: true, enabled: true }, + { id: 2, username: "operator1", is_admin: false, enabled: true }, +]; diff --git a/e2e/helpers/api/agents.js b/e2e/helpers/api/agents.js new file mode 100644 index 00000000..e781f7c5 --- /dev/null +++ b/e2e/helpers/api/agents.js @@ -0,0 +1,94 @@ +// e2e/helpers/api/agents.js +import { jsonResponse, paginatedResponse } from "../responses.js"; +import { mockGeneralFormBackground } from "../network.js"; + +const AGENTS_LIST = "**/api/v2/agents*"; +const AGENT_DETAIL = (id) => `**/api/v2/agents/${id}`; +// Wildcard catches /tasks, /tasks/exit, /tasks/directory_list, AND +// regressions like /agents/undefined/tasks/exit (commit 18442fe0). +const AGENT_TASKING_ANY = "**/api/v2/agents/*/tasks/**"; + +// Stubs the agent-detail sub-resource routes that mount automatically +// when navigating to an agent page. Composes mockGeneralFormBackground for +// the shared stubs (agents, listeners, bypasses, malleable-profiles, +// credentials) and adds the agent-detail-specific routes: +// - modules: AgentExecuteModule.vue (default "module" interact tab) fetches +// GET /modules on mount. +// - shell POST + single-task GET: AgentShellSession.vue (Shell tab) posts +// to /tasks/shell on mount, then polls GET /tasks/{id}. Return an +// already-complete task so the poll exits in one iteration. +// - task list GET: the task-list poller on the Tasks tab. +// Import alongside mockAgentDetail in any spec that navigates into an agent. +export async function mockAgentDetailSubResources(page) { + // Stubs agents, listeners, bypasses, malleable-profiles, and credentials — + // the same set that GeneralForm.vue and AgentForm/Terminal require. + // The agents empty-list stub is harmless; the calling spec registers its own + // mockAgentsList afterward (LIFO wins for the spec's mock). + await mockGeneralFormBackground(page); + // AgentExecuteModule.vue calls moduleStore.getModules() on mount. + await page.route("**/api/v2/modules*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/modules\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + // AgentShellSession.vue calls agentTaskApi.shell() on mount (POST /tasks/shell). + // Return a fake task whose output is already set so pollForResult completes + // in one iteration without sleeping. + await page.route("**/api/v2/agents/*/tasks/shell", (route) => { + if (route.request().method() !== "POST") return route.fallback(); + return route.fulfill( + jsonResponse({ id: "shell-init", output: "/", status: "completed" }, 201), + ); + }); + // AgentShellSession.vue polls GET /agents/{id}/tasks/{taskId}. + // Return a completed task so the poll loop exits immediately. + await page.route("**/api/v2/agents/*/tasks/*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Only match single-task GETs (e.g. /tasks/shell-init), not the list. + if (!url.pathname.match(/\/tasks\/[^/]+$/)) return route.fallback(); + return route.fulfill( + jsonResponse({ id: "shell-init", output: "/", status: "completed" }), + ); + }); + // Task list poller (AgentTasksList tabs): GET /agents/*/tasks (with optional query). + await page.route("**/api/v2/agents/*/tasks", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); +} + +export function mockAgentsList(page, agents) { + return page.route(AGENTS_LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Don't intercept /agents/ detail or sub-paths. + if (url.pathname.match(/\/agents\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(agents)); + }); +} + +export function mockAgentDetail(page, agent) { + return page.route(AGENT_DETAIL(agent.session_id), (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(agent)); + }); +} + +// Records URL + body for every POST to any /agents/*/tasks/* endpoint. +// Specs assert against the recorder to verify session_ids are real. +export function recordAgentTasks(page) { + const calls = []; + page.route(AGENT_TASKING_ANY, async (route) => { + if (route.request().method() === "POST") { + calls.push({ + url: route.request().url(), + body: JSON.parse(route.request().postData() || "{}"), + }); + return route.fulfill(jsonResponse({ id: 1, status: "queued" }, 201)); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/bypasses.js b/e2e/helpers/api/bypasses.js new file mode 100644 index 00000000..91c49973 --- /dev/null +++ b/e2e/helpers/api/bypasses.js @@ -0,0 +1,32 @@ +// e2e/helpers/api/bypasses.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/bypasses*"; + +export function mockBypassesList(page, bypasses) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if ( + route.request().method() === "POST" && + url.pathname.endsWith("/bypasses") + ) { + return route.fallback(); + } + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/bypasses\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(bypasses)); + }); +} + +export function recordBypassActions(page) { + const calls = []; + page.route(LIST, async (route) => { + if (route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ body }); + return route.fulfill(jsonResponse({ id: 99, ...body }, 201)); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/credentials.js b/e2e/helpers/api/credentials.js new file mode 100644 index 00000000..ec507da2 --- /dev/null +++ b/e2e/helpers/api/credentials.js @@ -0,0 +1,53 @@ +// e2e/helpers/api/credentials.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/credentials*"; + +export function mockCredentialsList(page, creds) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if ( + route.request().method() === "POST" && + url.pathname.endsWith("/credentials") + ) { + return route.fallback(); // recordCredentialActions handles POST + } + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/credentials\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(creds)); + }); +} + +export function mockCredentialDetail(page, cred) { + return page.route(`**/api/v2/credentials/${cred.id}`, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(cred)); + }); +} + +export function recordCredentialActions(page) { + const calls = []; + page.route(LIST, async (route) => { + if (route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ method: "POST", body }); + return route.fulfill(jsonResponse({ id: 99, ...body }, 201)); + } + return route.fallback(); + }); + // PUT updates go to /credentials/{id} — separate route so it doesn't + // collide with mockCredentialDetail's GET. + page.route("**/api/v2/credentials/*", async (route) => { + if (route.request().method() === "PUT") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ + method: "PUT", + url: route.request().url(), + body, + }); + return route.fulfill(jsonResponse({ ok: true })); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/downloads.js b/e2e/helpers/api/downloads.js new file mode 100644 index 00000000..7eade1e2 --- /dev/null +++ b/e2e/helpers/api/downloads.js @@ -0,0 +1,13 @@ +// e2e/helpers/api/downloads.js +import { paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/downloads*"; + +export function mockDownloadsList(page, downloads) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/downloads\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(downloads)); + }); +} diff --git a/e2e/helpers/api/listeners.js b/e2e/helpers/api/listeners.js new file mode 100644 index 00000000..10c483d5 --- /dev/null +++ b/e2e/helpers/api/listeners.js @@ -0,0 +1,57 @@ +// e2e/helpers/api/listeners.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/listeners*"; +// Templates live at /listener-templates (not /listeners/templates). +const TEMPLATES_LIST = "**/api/v2/listener-templates*"; +const TEMPLATE_DETAIL = (id) => `**/api/v2/listener-templates/${id}`; + +export function mockListenersList(page, listeners) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Don't intercept /listeners/{id} here. + if (url.pathname.match(/\/listeners\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(listeners)); + }); +} + +export function mockListenerTemplates(page, templates) { + return page.route(TEMPLATES_LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Don't intercept single-template GET (handled by mockListenerTemplate). + if (url.pathname.match(/\/listener-templates\/[^/]+/)) { + return route.fallback(); + } + return route.fulfill(paginatedResponse(templates)); + }); +} + +export function mockListenerTemplate(page, template) { + return page.route(TEMPLATE_DETAIL(template.id), (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(template)); + }); +} + +// Records URL + method + body for every mutating request to /listeners/*. +// The kill action uses DELETE /listeners/{id} (no body). +export function recordListenerActions(page) { + const calls = []; + page.route("**/api/v2/listeners/*", async (route) => { + const m = route.request().method(); + if (m === "PUT" || m === "POST" || m === "DELETE") { + calls.push({ + url: route.request().url(), + method: m, + body: JSON.parse(route.request().postData() || "{}"), + }); + return route.fulfill( + jsonResponse({ ok: true }, m === "POST" ? 201 : 200), + ); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/malleable.js b/e2e/helpers/api/malleable.js new file mode 100644 index 00000000..a5baab8a --- /dev/null +++ b/e2e/helpers/api/malleable.js @@ -0,0 +1,51 @@ +// e2e/helpers/api/malleable.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/malleable-profiles*"; +const DETAIL = (id) => `**/api/v2/malleable-profiles/${id}`; + +export function mockMalleableProfilesList(page, profiles) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Don't intercept single-profile GET — handled by mockMalleableProfileDetail. + if (url.pathname.match(/\/malleable-profiles\/[^/]+/)) { + return route.fallback(); + } + return route.fulfill(paginatedResponse(profiles)); + }); +} + +export function mockMalleableProfileDetail(page, profile) { + return page.route(DETAIL(profile.id), (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(profile)); + }); +} + +// Records POST/PUT/DELETE against /malleable-profiles[/{id}]. +export function recordMalleableProfileActions(page) { + const calls = []; + page.route(LIST, async (route) => { + if (route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ method: "POST", body }); + return route.fulfill(jsonResponse({ id: 99, ...body }, 201)); + } + return route.fallback(); + }); + page.route("**/api/v2/malleable-profiles/*", async (route) => { + const method = route.request().method(); + if (method === "PUT") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ method, url: route.request().url(), body }); + return route.fulfill(jsonResponse({ ok: true })); + } + if (method === "DELETE") { + calls.push({ method, url: route.request().url() }); + return route.fulfill(jsonResponse({ ok: true })); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/modules.js b/e2e/helpers/api/modules.js new file mode 100644 index 00000000..3c2f7e63 --- /dev/null +++ b/e2e/helpers/api/modules.js @@ -0,0 +1,40 @@ +// e2e/helpers/api/modules.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/modules*"; +const DETAIL = (id) => `**/api/v2/modules/${id}`; + +export function mockModulesList(page, modules) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/modules\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(modules)); + }); +} + +export function mockModuleDetail(page, mod) { + return page.route(DETAIL(mod.id), (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(mod)); + }); +} + +export function recordModuleExecutions(page) { + const calls = []; + // The API posts to /agents/{sessionId}/tasks/module/ (trailing slash) — + // verified in src/api/module-api.js executeModule(). The glob uses '/**' + // so that Playwright's '**' crosses the slash boundary, matching both + // the trailing-slash form (.../module/) and any future sub-path. + page.route("**/api/v2/agents/*/tasks/module/**", async (route) => { + if (route.request().method() === "POST") { + calls.push({ + url: route.request().url(), + body: JSON.parse(route.request().postData() || "{}"), + }); + return route.fulfill(jsonResponse({ id: 1, status: "queued" }, 201)); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/notifications.js b/e2e/helpers/api/notifications.js new file mode 100644 index 00000000..58adb94c --- /dev/null +++ b/e2e/helpers/api/notifications.js @@ -0,0 +1,19 @@ +// e2e/helpers/api/notifications.js +// +// Notifications live in the Pinia store, populated by socket events at +// runtime. Since sockets are blocked, this helper extends setFakeAuth's +// seed by injecting notifications into the persisted state directly. + +export async function seedNotifications(page, notifications) { + await page.addInitScript((items) => { + const raw = localStorage.getItem("application"); + if (!raw) { + throw new Error( + "seedNotifications: localStorage 'application' is empty — call setFakeAuth before seedNotifications.", + ); + } + const state = JSON.parse(raw); + state.notifications = items; + localStorage.setItem("application", JSON.stringify(state)); + }, notifications); +} diff --git a/e2e/helpers/api/obfuscation.js b/e2e/helpers/api/obfuscation.js new file mode 100644 index 00000000..31d9fb3f --- /dev/null +++ b/e2e/helpers/api/obfuscation.js @@ -0,0 +1,37 @@ +// e2e/helpers/api/obfuscation.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const GLOBAL_LIST = "**/api/v2/obfuscation/global*"; +const KEYWORDS = "**/api/v2/obfuscation/keywords*"; + +export function mockObfuscationGlobal(page, configs) { + return page.route(GLOBAL_LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Don't intercept /obfuscation/global/. + if (url.pathname.match(/\/global\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(configs)); + }); +} + +// Obfuscation.vue calls obfuscationStore.getKeywords() on mount in addition +// to getConfigs(). Both must be mocked so the page renders without unmocked calls. +export function mockObfuscationKeywords(page) { + return page.route(KEYWORDS, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); +} + +export function recordObfuscationUpdates(page) { + const calls = []; + page.route("**/api/v2/obfuscation/global/*", async (route) => { + if (route.request().method() === "PUT") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ url: route.request().url(), body }); + return route.fulfill(jsonResponse({ ok: true })); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/plugins.js b/e2e/helpers/api/plugins.js new file mode 100644 index 00000000..f62ffa2e --- /dev/null +++ b/e2e/helpers/api/plugins.js @@ -0,0 +1,32 @@ +// e2e/helpers/api/plugins.js +// +// mockInstalledPlugins: intercepts GET /api/v2/plugins (list only). +// Falls through on non-GET, on /plugins/marketplace, and on detail +// routes (/plugins/). +// +// mockPluginMarketplace: intercepts GET /api/v2/plugin-registries/marketplace. +// Verified from src/api/plugin-api.js getMarketplace() and +// src/components/plugins/PluginMarketplace.vue refreshMarketplace(). +// The response uses { records: [...] } – the same paginated envelope +// as every other list endpoint. +import { paginatedResponse } from "../responses.js"; + +const INSTALLED = "**/api/v2/plugins*"; +const MARKETPLACE = "**/api/v2/plugin-registries/marketplace*"; + +export function mockInstalledPlugins(page, plugins) { + return page.route(INSTALLED, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.includes("marketplace")) return route.fallback(); + if (url.pathname.match(/\/plugins\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(plugins)); + }); +} + +export function mockPluginMarketplace(page, items) { + return page.route(MARKETPLACE, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse(items)); + }); +} diff --git a/e2e/helpers/api/settings.js b/e2e/helpers/api/settings.js new file mode 100644 index 00000000..39de7aae --- /dev/null +++ b/e2e/helpers/api/settings.js @@ -0,0 +1,5 @@ +// e2e/helpers/api/settings.js +// Settings.vue toggles client-side Pinia flags persisted to localStorage. +// No API mocks needed; this file is intentionally empty (kept so future +// settings endpoints have an obvious home). +export {}; diff --git a/e2e/helpers/api/stagers.js b/e2e/helpers/api/stagers.js new file mode 100644 index 00000000..d6a5a3bf --- /dev/null +++ b/e2e/helpers/api/stagers.js @@ -0,0 +1,50 @@ +// e2e/helpers/api/stagers.js +import { jsonResponse, paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/stagers*"; +// Templates live at /stager-templates (not /stagers/templates). +// Confirmed from src/api/stager-api.js getStagerTemplate and getStagerTemplates. +const TEMPLATES_LIST = "**/api/v2/stager-templates*"; +const TEMPLATE_DETAIL = (id) => `**/api/v2/stager-templates/${id}`; + +export function mockStagersList(page, stagers) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/stagers\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(stagers)); + }); +} + +export function mockStagerTemplates(page, templates) { + return page.route(TEMPLATES_LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/stager-templates\/[^/]+/)) { + return route.fallback(); + } + return route.fulfill(paginatedResponse(templates)); + }); +} + +export function mockStagerTemplate(page, template) { + return page.route(TEMPLATE_DETAIL(template.id), (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse(template)); + }); +} + +export function recordStagerCreate(page) { + const calls = []; + page.route("**/api/v2/stagers", async (route) => { + if (route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() || "{}"); + // createStager in the view reads .then(({ id }) => ...) so return { id } + const created = { id: 99, ...body }; + calls.push({ body }); + return route.fulfill(jsonResponse(created, 201)); + } + return route.fallback(); + }); + return { calls }; +} diff --git a/e2e/helpers/api/users.js b/e2e/helpers/api/users.js new file mode 100644 index 00000000..e0ca437b --- /dev/null +++ b/e2e/helpers/api/users.js @@ -0,0 +1,14 @@ +// e2e/helpers/api/users.js +import { paginatedResponse } from "../responses.js"; + +const LIST = "**/api/v2/users*"; + +export function mockUsersList(page, users) { + return page.route(LIST, (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Skip /users/me (handled by mockEmpireBootstrap) and /users/. + if (url.pathname.match(/\/users\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse(users)); + }); +} diff --git a/e2e/helpers/auth.js b/e2e/helpers/auth.js new file mode 100644 index 00000000..fe4b90e0 --- /dev/null +++ b/e2e/helpers/auth.js @@ -0,0 +1,47 @@ +// e2e/helpers/auth.js +// +// setFakeAuth: pre-populates the persisted slice of useApplicationStore in +// localStorage so the app boots already-authenticated. Mirrors the persisted +// state shape (everything except fields named in the store's `persist.omit` +// array — currently chatUnreadCount). If the store gains a new persisted +// field that the UI reads on boot, add it here too. +// +// loginViaForm: walks through the real Login.vue form. Used only by +// login.spec.js; every other spec uses setFakeAuth to skip the form. + +export async function setFakeAuth(page, { admin = false } = {}) { + await page.addInitScript( + (opts) => { + const state = { + token: "fake-test-token", + url: "http://localhost:1337", + socketUrl: "ws://localhost:1337", + user: { id: 1, username: "test", is_admin: opts.admin }, + loginError: "", + empireVersion: "0.0.0-test", + chatWidget: true, + hideStaleAgents: false, + hideArchivedAgents: true, + filterOnlyMyStagers: true, + autoSubscribeAgents: true, + agentHeaders: [], + taskHeaders: [], + pluginTaskHeaders: [], + connectionError: 0, + notifications: [], + }; + localStorage.setItem("application", JSON.stringify(state)); + }, + { admin }, + ); +} + +export async function loginViaForm(page, { url, username, password }) { + await page.goto("/"); + // Exact-match avoids colliding with the "Remember URL and Username" + // checkbox which would otherwise match /url/i. + await page.getByLabel("Url", { exact: true }).fill(url); + await page.getByLabel("Username", { exact: true }).fill(username); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Submit" }).click(); +} diff --git a/e2e/helpers/network.js b/e2e/helpers/network.js new file mode 100644 index 00000000..2b4dc3e5 --- /dev/null +++ b/e2e/helpers/network.js @@ -0,0 +1,134 @@ +// e2e/helpers/network.js +// +// blockSockets: aborts every Socket.IO request so the WebSocket-using +// notification system doesn't spam the console or leak retry timers +// into tests. Always call before page.goto. +// +// mockEmpireBootstrap: registers handlers for /api/v2/users/me and +// /api/v2/meta/version. These endpoints are called from the login() and +// refreshMe() actions in src/stores/application-module.js — not at boot +// when state is rehydrated from localStorage. For setFakeAuth-authenticated +// specs they should never fire; the mocks exist as a safety net so any +// accidental call returns a benign 200 instead of 404. login.spec.js needs +// real responses here for the happy path. +// +// blockUnmockedApi: registers a deny-all fallback for /api/v2/** that +// fulfills with HTTP 599 if reached. Because Playwright applies routes in +// LIFO order, call this FIRST in beforeEach so per-resource mocks added +// afterward take precedence. Any unmocked call then returns 599: fetch() +// resolves (not rejects), res.ok is false, and the http.js wrapper throws +// Error: HTTP 599, surfaced via the consoleGuard fixture — making silent +// fall-through-to-network bugs immediately obvious. A true network failure +// (fetch itself rejects, e.g. route.abort()) bumps connectionError instead. +// Do NOT add this to specs that intentionally let some routes pass through +// (navigation.spec.js stubs many endpoints inline; add it there once all +// stubs are in place). +// +// mockGeneralFormBackground: stubs the background fetches that +// GeneralForm.vue fires on every mount — agents, listeners, bypasses, +// malleable-profiles, and credentials. Any spec whose view renders a +// must call this before page.goto so these requests don't +// fall through to the blockUnmockedApi sentinel. +// +// mockTagsEndpoint: stubs GET /tags* with an empty list. Used by many list +// views (AgentsList, ListenersList, CredentialsList, Downloads). Extracted +// to avoid repeating the same 4-line block across specs. +// +// mockListenersPage: stubs GET /listeners and GET /tags?sources=listener, +// both of which are fetched whenever the app redirects to the listeners +// list (App.vue redirects there on login and after some actions). + +import { jsonResponse, paginatedResponse } from "./responses.js"; + +export function blockSockets(page) { + return page.route("**/socket.io/**", (route) => route.abort()); +} + +export async function mockEmpireBootstrap(page, { admin = false } = {}) { + await page.route("**/api/v2/users/me", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill( + jsonResponse({ id: 1, username: "test", is_admin: admin }), + ); + }); + await page.route("**/api/v2/meta/version", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(jsonResponse({ version: "0.0.0-test" })); + }); +} + +export function blockUnmockedApi(page) { + // eslint-disable-next-line no-console + return page.route("**/api/v2/**", (route) => { + const req = route.request(); + // eslint-disable-next-line no-console + console.error(`[e2e] Unmocked API call: ${req.method()} ${req.url()}`); + return route.fulfill({ + status: 599, + contentType: "application/json", + body: JSON.stringify({ error: `Unmocked: ${req.method()} ${req.url()}` }), + }); + }); +} + +// Stubs the background fetches that GeneralForm.vue fires on every mount: +// agents (include_archived=true), listeners, bypasses, malleable-profiles, +// and credentials. Must be called before page.goto in any spec whose view +// renders (credential-create, bypass-create, stager-create, +// listener-create, module-execute, agent-detail). +export async function mockGeneralFormBackground(page) { + await page.route("**/api/v2/agents*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + // Only intercept the list endpoint (no sub-paths like /agents/ID or /agents/ID/tasks). + if (url.pathname.match(/\/agents\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + await page.route("**/api/v2/listeners*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/listeners\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + await page.route("**/api/v2/bypasses*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/bypasses\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + await page.route("**/api/v2/malleable-profiles*", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + await page.route("**/api/v2/credentials*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/credentials\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); +} + +// Tags endpoint fires from many list views (AgentsList, ListenersList, etc). +// Always returns an empty list — specs don't currently test tags. +export function mockTagsEndpoint(page) { + return page.route("**/api/v2/tags*", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); +} + +// Stubs GET /listeners and GET /tags?sources=listener. +// Called by specs where the app may redirect to the listeners page +// (App.vue redirects there on successful login and on isLoggedIn transitions). +export async function mockListenersPage(page) { + await page.route("**/api/v2/listeners*", (route) => { + const url = new URL(route.request().url()); + if (route.request().method() !== "GET") return route.fallback(); + if (url.pathname.match(/\/listeners\/[^/]+/)) return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + await page.route("**/api/v2/tags*", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); +} diff --git a/e2e/helpers/responses.js b/e2e/helpers/responses.js new file mode 100644 index 00000000..0a6d3001 --- /dev/null +++ b/e2e/helpers/responses.js @@ -0,0 +1,24 @@ +// e2e/helpers/responses.js +// +// Tiny wrappers around the response objects that page.route().fulfill() expects. +// jsonResponse: any JSON payload. paginatedResponse: Empire's standard list +// envelope, which is { records: [...], total, limit, page } for every list +// endpoint in src/api/*.js. The recordsKey override exists for getDirectory, +// which uses { children: [...] } instead. + +export function jsonResponse(data, status = 200) { + return { + status, + contentType: "application/json", + body: JSON.stringify(data), + }; +} + +export function paginatedResponse(items, { recordsKey = "records" } = {}) { + return jsonResponse({ + [recordsKey]: items, + total: items.length, + limit: 100, + page: 1, + }); +} diff --git a/e2e/listener-create.spec.js b/e2e/listener-create.spec.js new file mode 100644 index 00000000..763b3740 --- /dev/null +++ b/e2e/listener-create.spec.js @@ -0,0 +1,77 @@ +// e2e/listener-create.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockGeneralFormBackground, +} from "./helpers/network.js"; +import { jsonResponse } from "./helpers/responses.js"; +import { + mockListenerTemplates, + mockListenerTemplate, +} from "./helpers/api/listeners.js"; +import { httpTemplate } from "./fixtures/listeners.js"; + +test.describe("listener create", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // GeneralForm.vue fetches agents, listeners, bypasses, and malleable-profiles + // on mount. These must be stubbed before the form renders. + await mockGeneralFormBackground(page); + // The store fetches templates from GET /listener-templates (not /listeners/templates). + await mockListenerTemplates(page, [httpTemplate]); + // Single-template GET: the selectedTemplate watcher fetches by id to load + // the template options before the form renders (initialLoad becomes true). + await mockListenerTemplate(page, httpTemplate); + }); + + test("submits a POST to /listeners with the filled options", async ({ + page, + }) => { + const calls = []; + + // The createListener API posts to the bare collection endpoint /listeners, + // which does NOT match the **/api/v2/listeners/* glob used by + // recordListenerActions. Register a separate handler for the collection. + await page.route("**/api/v2/listeners", async (route) => { + if (route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() || "{}"); + calls.push({ url: route.request().url(), method: "POST", body }); + return route.fulfill(jsonResponse({ id: 99, ...body }, 201)); + } + return route.fallback(); + }); + + await page.goto("/#/listeners/new"); + + // The form uses a v-autocomplete with label="Type" for the template picker. + await page + .getByLabel(/^type$/i) + .first() + .click(); + await page.getByRole("option", { name: "http" }).click(); + + // Wait for the form to render (initialLoad = true after template fetch). + await expect(page.getByLabel(/^name$/i)).toBeVisible(); + + // Fill the Name field (Host and Port already have defaults from the template). + await page.getByLabel(/^name$/i).fill("my-test-listener"); + + // Submit — EditPageTop renders a button wired to @submit="submit". + await page + .getByRole("button", { name: /submit|create|save/i }) + .first() + .click(); + + await expect + .poll(() => calls.find((c) => c.method === "POST")) + .toBeDefined(); + + const created = calls.find((c) => c.method === "POST"); + expect(created.body.name).toBe("my-test-listener"); + expect(created.body.template).toBe("http"); + }); +}); diff --git a/e2e/listeners-list.spec.js b/e2e/listeners-list.spec.js new file mode 100644 index 00000000..add3497c --- /dev/null +++ b/e2e/listeners-list.spec.js @@ -0,0 +1,62 @@ +// e2e/listeners-list.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockTagsEndpoint, +} from "./helpers/network.js"; +import { + mockListenersList, + recordListenerActions, +} from "./helpers/api/listeners.js"; +import { defaultListeners } from "./fixtures/listeners.js"; + +test.describe("listeners list", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Tags endpoint fires on mount from ListenersList.getTags(). + await mockTagsEndpoint(page); + await mockListenersList(page, defaultListeners); + }); + + test("renders all listeners", async ({ page }) => { + await page.goto("/#/listeners"); + for (const l of defaultListeners) { + await expect(page.getByText(l.name).first()).toBeVisible(); + } + }); + + test("kill action calls DELETE for the selected listener", async ({ + page, + }) => { + const actions = recordListenerActions(page); + await page.goto("/#/listeners"); + + // Wait for the table to render listener names. + await expect(page.getByText("http-1").first()).toBeVisible(); + + // Open the ellipsis action menu for the first listener row. + const row = page.getByRole("row").filter({ hasText: "http-1" }); + await row + .locator("button") + .filter({ has: page.locator(".fa-ellipsis-v") }) + .click(); + + // Click the Delete item in the dropdown menu. + await page + .getByRole("listitem") + .filter({ hasText: /delete/i }) + .click(); + + // Confirm the kill dialog ("Yes" button). + await page.getByRole("button", { name: "Yes" }).click(); + + // Exactly one DELETE must fire — for the specific listener, not more. + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].url).toMatch(/\/listeners\/1$/); + expect(actions.calls[0].method).toBe("DELETE"); + }); +}); diff --git a/e2e/login.spec.js b/e2e/login.spec.js new file mode 100644 index 00000000..3e131248 --- /dev/null +++ b/e2e/login.spec.js @@ -0,0 +1,78 @@ +// e2e/login.spec.js +// +// The only spec that exercises the real login form (via loginViaForm). +// Every other spec uses setFakeAuth to skip the form. + +import { test, expect } from "./fixtures/test.js"; +import { loginViaForm } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockListenersPage, +} from "./helpers/network.js"; +import { jsonResponse } from "./helpers/responses.js"; + +test.describe("login form", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await mockEmpireBootstrap(page); // /users/me, /meta/version + // After successful login App.vue redirects to the listeners page which + // calls getListeners() and getTags(sources=listener) on mount. + await mockListenersPage(page); + }); + + test("successful login redirects away from home", async ({ page }) => { + await page.route("**/token", (route) => + route.fulfill(jsonResponse({ access_token: "fake-jwt" })), + ); + + await loginViaForm(page, { + url: "http://localhost:1337", + username: "empireadmin", + password: "password123", + }); + + // Login.vue.submit() doesn't navigate explicitly; the app reacts to + // isLoggedIn becoming true. Wait for any change away from "/" or + // for some authenticated UI marker. + await expect(page).not.toHaveURL(/#\/$/, { timeout: 10_000 }); + }); + + test("failed login shows error", async ({ page }) => { + await page.route("**/token", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "Incorrect username or password" }), + }), + ); + + await loginViaForm(page, { + url: "http://localhost:1337", + username: "wrong", + password: "wrong", + }); + + // Login.vue surfaces loginError via this.snack.error(...). The toast + // text contains the detail. Match loosely. + await expect( + page.getByText(/incorrect username or password/i), + ).toBeVisible(); + await expect(page).toHaveURL(/#\/$/); + }); + + test("network failure shows connection error and does not crash", async ({ + page, + }) => { + await page.route("**/token", (route) => route.abort()); + + await loginViaForm(page, { + url: "http://localhost:1337", + username: "empireadmin", + password: "password123", + }); + + await expect(page.getByText(/unable to connect to server/i)).toBeVisible(); + await expect(page).toHaveURL(/#\/$/); + }); +}); diff --git a/e2e/malleable-profiles-crud.spec.js b/e2e/malleable-profiles-crud.spec.js new file mode 100644 index 00000000..cf5ae204 --- /dev/null +++ b/e2e/malleable-profiles-crud.spec.js @@ -0,0 +1,84 @@ +// e2e/malleable-profiles-crud.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockMalleableProfilesList, + mockMalleableProfileDetail, + recordMalleableProfileActions, +} from "./helpers/api/malleable.js"; +import { defaultMalleableProfiles } from "./fixtures/malleable.js"; + +test.describe("malleable profiles", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + await mockMalleableProfilesList(page, defaultMalleableProfiles); + }); + + test("renders list", async ({ page }) => { + await page.goto("/#/malleable-profiles"); + const table = page.locator(".v-data-table"); + await expect(table).toBeVisible(); + for (const p of defaultMalleableProfiles) { + await expect(table.getByText(p.name).first()).toBeVisible(); + } + }); + + test("create posts a new profile", async ({ page }) => { + const actions = recordMalleableProfileActions(page); + // After POST returns id 99, MalleableProfileEdit.vue navigates to + // /#/malleable-profiles/99 which fetches the new profile. Stub it to + // avoid an unmocked GET firing during teardown. + await mockMalleableProfileDetail(page, { + id: 99, + name: "test-profile", + category: "custom", + data: "set sample_name 'Test';", + }); + await page.goto("/#/malleable-profiles/new"); + + // Name is required and must be > 3 chars (MalleableProfileEdit.vue rules). + await page.getByLabel(/^name$/i).fill("test-profile"); + await page.getByLabel(/^category$/i).fill("custom"); + await page.getByLabel(/^code$/i).fill("set sample_name 'Test';"); + + await page + .getByRole("button", { name: /save|submit|create/i }) + .first() + .click(); + + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].method).toBe("POST"); + expect(actions.calls[0].body.name).toBe("test-profile"); + expect(actions.calls[0].body.category).toBe("custom"); + // The API field is `data` (createMalleableProfile maps `code` → `data`). + expect(actions.calls[0].body.data).toBe("set sample_name 'Test';"); + }); + + test("edit sends a PUT with updated code", async ({ page }) => { + await mockMalleableProfileDetail(page, defaultMalleableProfiles[0]); + const actions = recordMalleableProfileActions(page); + + await page.goto(`/#/malleable-profiles/${defaultMalleableProfiles[0].id}`); + + // Code field pre-populates from the loaded profile. + const code = page.getByLabel(/^code$/i); + await expect(code).toHaveValue(defaultMalleableProfiles[0].data); + + await code.fill("set sample_name 'Updated';"); + await page + .getByRole("button", { name: /save|submit|update/i }) + .first() + .click(); + + await expect.poll(() => actions.calls.length).toBe(1); + expect(actions.calls[0].method).toBe("PUT"); + expect(actions.calls[0].url).toMatch( + new RegExp(`/malleable-profiles/${defaultMalleableProfiles[0].id}$`), + ); + // updateMalleableProfile only sends { data: code }. + expect(actions.calls[0].body.data).toBe("set sample_name 'Updated';"); + }); +}); diff --git a/e2e/module-execute.spec.js b/e2e/module-execute.spec.js new file mode 100644 index 00000000..39a9743e --- /dev/null +++ b/e2e/module-execute.spec.js @@ -0,0 +1,75 @@ +// e2e/module-execute.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockGeneralFormBackground, +} from "./helpers/network.js"; +import { mockAgentsList } from "./helpers/api/agents.js"; +import { + mockModulesList, + mockModuleDetail, + recordModuleExecutions, +} from "./helpers/api/modules.js"; +import { defaultAgents } from "./fixtures/agents.js"; +import { defaultModules } from "./fixtures/modules.js"; + +test.describe("module execute", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // GeneralForm.vue fetches agents, listeners, bypasses, malleable-profiles, + // and credentials on mount. Must be stubbed before the form renders. + await mockGeneralFormBackground(page); + await mockAgentsList(page, defaultAgents); + // AgentExecuteModule calls moduleStore.getModules() (GET /modules) on + // mount, then filters by el.enabled. mockModulesList covers the list; + // mockModuleDetail covers the per-module GET used by module detail pages. + await mockModulesList(page, defaultModules); + await mockModuleDetail(page, defaultModules[0]); + }); + + test("submits a task to the selected agent", async ({ page }) => { + const execs = recordModuleExecutions(page); + await page.goto(`/#/modules/${defaultModules[0].id}`); + + // ModuleExecute.vue renders a v-autocomplete with placeholder="Agents" + // (no label). Click it to open the dropdown. + await page.getByPlaceholder("Agents").click(); + await page + .getByRole("option", { name: defaultAgents[0].session_id }) + .click(); + + // Close the dropdown by pressing Escape so it doesn't obscure other UI. + await page.keyboard.press("Escape"); + + // AgentExecuteModule loads the module list on mount (initialLoad = true) + // and then auto-selects the module from the route param. Wait for the + // Submit button to appear (rendered only when selectedModule is set). + await expect( + page.getByRole("button", { name: /submit/i }).first(), + ).toBeVisible({ timeout: 10000 }); + + // Submit via the inner Submit button inside AgentExecuteModule. + // (EditPageTop also has a Submit button but it delegates to the same + // create() method via $refs.executeform.create().) + await page + .getByRole("button", { name: /submit/i }) + .first() + .click(); + + await expect.poll(() => execs.calls.length).toBe(1); + // The URL must contain the exact agent session_id selected — not "undefined", + // "null", or "[object Object]". + const agentIdInUrl = execs.calls[0].url.match( + /\/agents\/([^/]+)\/tasks\/module/, + )?.[1]; + expect(agentIdInUrl).toBe(defaultAgents[0].session_id); + expect(execs.calls[0].url).not.toContain("undefined"); + expect(execs.calls[0].url).not.toContain("null"); + expect(execs.calls[0].url).not.toContain("[object"); + expect(execs.calls[0].body.module_id).toBe(defaultModules[0].id); + }); +}); diff --git a/e2e/modules-list.spec.js b/e2e/modules-list.spec.js new file mode 100644 index 00000000..70a8518d --- /dev/null +++ b/e2e/modules-list.spec.js @@ -0,0 +1,46 @@ +// e2e/modules-list.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { mockModulesList } from "./helpers/api/modules.js"; +import { defaultModules } from "./fixtures/modules.js"; + +test.describe("modules list", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + await mockModulesList(page, defaultModules); + }); + + test("renders all modules", async ({ page }) => { + await page.goto("/#/modules"); + // The Language ExpansionPanelFilter auto-selects all languages after + // modules load (items watcher fires → emptyDefault=false → select all). + // Wait for all module names to appear in the table. + for (const m of defaultModules) { + await expect(page.getByText(m.name).first()).toBeVisible(); + } + }); + + test("filters by search term", async ({ page }) => { + await page.goto("/#/modules"); + // Wait for table to populate before interacting with filters. + await expect( + page.getByText("powershell_collection_screenshot").first(), + ).toBeVisible(); + + // The search input lives inside the "Search" expansion panel. + // Click the panel title to expand it, then fill the text field. + await page.getByRole("button", { name: /^Search$/ }).click(); + await page.getByLabel("Search").fill("python"); + + // Only the python module should remain visible; powershell ones should hide. + await expect( + page.getByText("python_collection_linux_pillage"), + ).toBeVisible(); + await expect( + page.getByText("powershell_collection_screenshot"), + ).toBeHidden(); + }); +}); diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js new file mode 100644 index 00000000..ddeaf6a1 --- /dev/null +++ b/e2e/navigation.spec.js @@ -0,0 +1,75 @@ +// e2e/navigation.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { paginatedResponse } from "./helpers/responses.js"; + +test.describe("navigation", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Stub the most common list endpoints so navigating doesn't 404. + // Each is empty — specific assertions live in feature specs. + // Tags is also stubbed here because list pages (agents, listeners, + // credentials, downloads) call getTags() on mount. + for (const path of [ + "**/api/v2/agents*", + "**/api/v2/listeners*", + "**/api/v2/modules*", + "**/api/v2/users*", + "**/api/v2/credentials*", + "**/api/v2/stagers*", + "**/api/v2/bypasses*", + "**/api/v2/downloads*", + "**/api/v2/plugins*", + "**/api/v2/obfuscation/global*", + "**/api/v2/tags*", + ]) { + await page.route(path, (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill(paginatedResponse([])); + }); + } + }); + + test("authenticated user lands on a non-home route", async ({ page }) => { + await page.goto("/#/agents"); + // If setFakeAuth is broken, the route guard redirects to /#/ and + // this URL assertion fails first. + await expect(page).toHaveURL(/#\/agents$/); + }); + + test("unauthenticated user is redirected to home", async ({ + page, + context, + }) => { + // Override: clear the fake-auth init script effect by clearing storage + // before goto. + await context.clearCookies(); + await page.addInitScript(() => localStorage.clear()); + await page.goto("/#/agents"); + await expect(page).toHaveURL(/#\/$/); + }); + + test("sidebar navigates to listeners", async ({ page }) => { + await page.goto("/#/agents"); + // The sidebar starts in mini/rail mode (icon-only). Expand it first + // by clicking the expand toggle button (mdi-page-last icon). + await page + .locator(".v-navigation-drawer") + .getByRole("button") + .first() + .click(); + // After expanding, the "Listeners" group label is visible. Click it + // to open the group, then click the "Listeners" sub-item link. + await page + .locator(".v-list-group") + .filter({ hasText: "Listeners" }) + .first() + .click(); + // The Listeners sub-item renders as + await page.locator('[href="#/listeners"]').first().click(); + await expect(page).toHaveURL(/#\/listeners$/); + }); +}); diff --git a/e2e/notifications.spec.js b/e2e/notifications.spec.js new file mode 100644 index 00000000..3f00ad60 --- /dev/null +++ b/e2e/notifications.spec.js @@ -0,0 +1,24 @@ +// e2e/notifications.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { seedNotifications } from "./helpers/api/notifications.js"; +import { defaultNotifications } from "./fixtures/notifications.js"; + +test.describe("notifications panel", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + // Order matters: seedNotifications must run after setFakeAuth so it + // edits the value setFakeAuth already wrote. + await seedNotifications(page, defaultNotifications); + await mockEmpireBootstrap(page); + }); + + test("renders seeded notifications", async ({ page }) => { + await page.goto("/#/notifications"); + for (const n of defaultNotifications) { + await expect(page.getByText(n.title)).toBeVisible(); + } + }); +}); diff --git a/e2e/obfuscation.spec.js b/e2e/obfuscation.spec.js new file mode 100644 index 00000000..a35b6e87 --- /dev/null +++ b/e2e/obfuscation.spec.js @@ -0,0 +1,42 @@ +// e2e/obfuscation.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockObfuscationGlobal, + mockObfuscationKeywords, + recordObfuscationUpdates, +} from "./helpers/api/obfuscation.js"; +import { defaultObfuscation } from "./fixtures/obfuscation.js"; + +test.describe("obfuscation", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + // Obfuscation.vue calls getKeywords() in addition to getConfigs() on mount. + await mockObfuscationKeywords(page); + await mockObfuscationGlobal(page, defaultObfuscation); + }); + + test("renders obfuscation configs", async ({ page }) => { + await page.goto("/#/obfuscation"); + await expect(page.getByText("powershell").first()).toBeVisible(); + }); + + test("toggle enabled posts a PUT", async ({ page }) => { + const updates = recordObfuscationUpdates(page); + await page.goto("/#/obfuscation"); + + // Toggle the powershell switch. Vuetify v-switch renders as type="checkbox". + await page.locator('input[type="checkbox"]').first().click(); + // Click the Save button to persist the change via PUT. + await page.getByRole("button", { name: "Save" }).first().click(); + + // Exactly one PUT must fire for the powershell config. + await expect.poll(() => updates.calls.length).toBe(1); + expect(updates.calls[0].url).toContain("/obfuscation/global/"); + // The body must reflect the toggled state (enabled was false → now true). + expect(updates.calls[0].body.language).toBe("powershell"); + }); +}); diff --git a/e2e/plugins.spec.js b/e2e/plugins.spec.js new file mode 100644 index 00000000..5abbbe7d --- /dev/null +++ b/e2e/plugins.spec.js @@ -0,0 +1,32 @@ +// e2e/plugins.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { + mockInstalledPlugins, + mockPluginMarketplace, +} from "./helpers/api/plugins.js"; +import { + defaultInstalledPlugins, + defaultMarketplacePlugins, +} from "./fixtures/plugins.js"; + +test.describe("plugins", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + }); + + test("renders installed plugins", async ({ page }) => { + await mockInstalledPlugins(page, defaultInstalledPlugins); + await page.goto("/#/plugins"); + await expect(page.getByText("example-plugin")).toBeVisible(); + }); + + test("renders marketplace listings", async ({ page }) => { + await mockPluginMarketplace(page, defaultMarketplacePlugins); + await page.goto("/#/plugin-marketplace"); + await expect(page.getByText("marketplace-plugin")).toBeVisible(); + }); +}); diff --git a/e2e/settings.spec.js b/e2e/settings.spec.js new file mode 100644 index 00000000..3107951b --- /dev/null +++ b/e2e/settings.spec.js @@ -0,0 +1,63 @@ +// e2e/settings.spec.js +// +// Settings.vue exposes two v-switch elements bound to the Pinia application +// store, which is persisted via pinia-plugin-persistedstate to localStorage +// under the key "application". +// +// The view has: +// - chatWidget (label: "Chat Widget") — starts true in setFakeAuth +// - autoSubscribeAgents (no v-switch label; heading reads "Auto-Subscribe to Agents") +// +// There is NO "hide stale agents" toggle in Settings.vue; that flag exists in +// the store but is toggled elsewhere (e.g. agents-list filters). We test +// chatWidget here since it carries an explicit accessible label. +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; + +test.describe("settings", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + }); + + test("toggling 'Chat Widget' persists to localStorage", async ({ page }) => { + await page.goto("/#/settings"); + + // chatWidget starts true (set in setFakeAuth). We uncheck it → false. + // Vuetify 4 v-switch renders as input[type=checkbox] associated to a label. + const toggle = page.getByLabel("Chat Widget"); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + + // The Pinia persist plugin writes back to localStorage synchronously on + // state mutation. Read it after the click settles. + const persisted = await page.evaluate(() => + JSON.parse(localStorage.getItem("application") || "{}"), + ); + expect(persisted.chatWidget).toBe(false); + }); + + test("toggling 'Auto-Subscribe to Agents' persists to localStorage", async ({ + page, + }) => { + await page.goto("/#/settings"); + + // autoSubscribeAgents starts true. The switch has no v-switch :label + // binding, so we locate it relative to its section heading. + // Vuetify 4 renders v-switch as a checkbox; grab the only checkbox + // inside the div that contains the "Auto-Subscribe to Agents" heading. + const section = page + .locator("div.headers") + .filter({ hasText: /auto-subscribe/i }); + const toggle = section.locator('input[type="checkbox"]'); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + + const persisted = await page.evaluate(() => + JSON.parse(localStorage.getItem("application") || "{}"), + ); + expect(persisted.autoSubscribeAgents).toBe(false); + }); +}); diff --git a/e2e/stagers.spec.js b/e2e/stagers.spec.js new file mode 100644 index 00000000..22ee1e28 --- /dev/null +++ b/e2e/stagers.spec.js @@ -0,0 +1,82 @@ +// e2e/stagers.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { + blockSockets, + mockEmpireBootstrap, + mockGeneralFormBackground, +} from "./helpers/network.js"; +import { + mockStagersList, + mockStagerTemplates, + mockStagerTemplate, + recordStagerCreate, +} from "./helpers/api/stagers.js"; +import { defaultStagers, launcherTemplate } from "./fixtures/stagers.js"; +import { jsonResponse } from "./helpers/responses.js"; + +test.describe("stagers", () => { + test.beforeEach(async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page); + await mockEmpireBootstrap(page); + }); + + test("renders stagers list", async ({ page }) => { + await mockStagersList(page, defaultStagers); + await page.goto("/#/stagers"); + await expect(page.getByText("stager-1")).toBeVisible(); + }); + + test("create stager posts to /stagers", async ({ page }) => { + await mockStagerTemplates(page, [launcherTemplate]); + await mockStagerTemplate(page, launcherTemplate); + const create = recordStagerCreate(page); + // GeneralForm.vue fetches agents, listeners, bypasses, malleable-profiles, + // and credentials on mount (both the create form and the edit page it + // redirects to after creation). Stub all five before goto. + await mockGeneralFormBackground(page); + // After a successful create, StagerEdit.vue redirects to the stager detail + // page (stagerEdit with id=99) which calls getStager(id). Stub it so the + // subsequent page load doesn't produce unmocked-API errors. + await page.route("**/api/v2/stagers/*", (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return route.fulfill( + jsonResponse({ + id: 99, + name: "test-stager", + template: "multi_launcher", + }), + ); + }); + await page.goto("/#/stagers/new"); + + // The form uses a v-autocomplete with label="Type" for the template picker. + await page + .getByLabel(/^type$/i) + .first() + .click(); + await page.getByRole("option", { name: "multi_launcher" }).click(); + + // Wait for the form to render (initialLoad = true after template fetch). + await expect(page.getByLabel(/^name$/i)).toBeVisible(); + + // Fill the Name field so the stager has a name. + await page.getByLabel(/^name$/i).fill("test-stager"); + + // Fill the required Listener field. DynamicFormInput uses :label="name" + // so the field label is exactly "Listener". + await page.getByLabel("Listener").fill("http-1"); + + // Submit — EditPageTop renders a button wired to @submit="submit". + await page + .getByRole("button", { name: /submit|create|save/i }) + .first() + .click(); + + // Exactly one POST must fire — no duplicate submissions. + await expect.poll(() => create.calls.length).toBe(1); + expect(create.calls[0].body.template).toBe("multi_launcher"); + expect(create.calls[0].body.name).toBe("test-stager"); + }); +}); diff --git a/e2e/users-admin.spec.js b/e2e/users-admin.spec.js new file mode 100644 index 00000000..2feb2a66 --- /dev/null +++ b/e2e/users-admin.spec.js @@ -0,0 +1,39 @@ +// e2e/users-admin.spec.js +import { test, expect } from "./fixtures/test.js"; +import { setFakeAuth } from "./helpers/auth.js"; +import { blockSockets, mockEmpireBootstrap } from "./helpers/network.js"; +import { mockUsersList } from "./helpers/api/users.js"; +import { defaultUsers } from "./fixtures/users.js"; + +test.describe("users (admin gating)", () => { + test("admin can see the user list", async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page, { admin: true }); + await mockEmpireBootstrap(page, { admin: true }); + await mockUsersList(page, defaultUsers); + + await page.goto("/#/users"); + for (const u of defaultUsers) { + await expect(page.getByText(u.username)).toBeVisible(); + } + }); + + test("non-admin is blocked from /users/new", async ({ page }) => { + await blockSockets(page); + await setFakeAuth(page, { admin: false }); + await mockEmpireBootstrap(page, { admin: false }); + await mockUsersList(page, defaultUsers); + + // Start on a permitted page so next(false) has somewhere to keep us. + await page.goto("/#/users"); + await expect(page).toHaveURL(/#\/users$/); + + // Attempt navigation to /users/new via direct goto. The admin guard + // calls next(false), which keeps the URL at the previous location. + await page.goto("/#/users/new"); + // Vue Router's next(false) leaves us on /#/users (where we were). + // Assert the exact URL so a redirect to /#/ also fails the test — + // that would indicate a broken guard rather than the expected block. + await expect(page).toHaveURL(/#\/users$/); + }); +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..791cb75c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,170 @@ +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import pluginVue from "eslint-plugin-vue"; +import skipFormatting from "@vue/eslint-config-prettier/skip-formatting"; +import { includeIgnoreFile } from "@eslint/compat"; +import globals from "globals"; + +const gitignorePath = fileURLToPath(new URL(".gitignore", import.meta.url)); + +const isProduction = process.env.NODE_ENV === "production"; + +// import/no-unresolved is intentionally NOT enforced (see decision below). We +// also drop the eslint-plugin-import dependency entirely. But the source still +// carries pre-existing inline `// eslint-disable-next-line import/...` comments +// (airbnb-era suppressions). Under flat config an inline directive that names an +// undefined rule is a hard error, and the no-source-edits constraint for this +// migration means we can't remove those comments. This minimal no-op stub simply +// registers the referenced rule names so the directives resolve; the rules never +// fire, matching the prior effective behavior (they were disabled on those lines +// anyway). Pulling in eslint-plugin-import-x just for this would also drag in +// @typescript-eslint/utils — unwanted weight in a no-TypeScript project. +// NOTE: this namespace is a decoy. Do not configure a real `import/*` rule +// against it — a stubbed name (e.g. import/prefer-default-export) would silently +// no-op. Install eslint-plugin-import-x first if real import linting is wanted. +const noopRule = { create: () => ({}) }; +const importDirectiveStub = { + rules: { + "prefer-default-export": noopRule, + "no-named-default": noopRule, + "no-mutable-exports": noopRule, + }, +}; + +export default [ + // Honor .gitignore (node_modules, env files, editor dirs, etc.). Replaces the + // old `--ignore-path .gitignore` CLI flag, which is not valid under flat config. + includeIgnoreFile(gitignorePath), + + // dist/ is committed on tagged releases, so it's deliberately left un-ignored + // in .gitignore (the commented-out `# /dist`). ESLint must still skip it, so + // ignore it explicitly here. dist_electron/ is already excluded via .gitignore + // (includeIgnoreFile above) but is listed too for defense-in-depth. + { + ignores: ["dist/**", "dist_electron/**"], + }, + + // Base JavaScript recommended rules. + js.configs.recommended, + + // Vue 3 recommended (flat) — the flat-config equivalent of the old + // "plugin:vue/vue3-recommended" extend. + ...pluginVue.configs["flat/recommended"], + + // Disable the stylistic rules that conflict with Prettier. Prettier still runs + // as its own separate `format` / `format:check` step (NOT as an eslint rule — + // skip-formatting sets prettier/prettier to "off"). Placed before our explicit + // overrides so the few rules we re-enable below (notably vue/max-len) win. + skipFormatting, + + // Project rules + language environment. Applied after the recommended presets + // and the Prettier disables, so these explicit overrides win over them — the + // same precedence the old .eslintrc.js `rules:` block had over its `extends`. + // (The two blocks below are file-scoped to Node/CommonJS config + e2e files + // and intentionally set only languageOptions, no `rules`, so they don't affect + // this rule precedence. Adding `rules` there would override these for the + // files they match.) + { + // Match the eslintrc-era default: unused inline disable directives are not + // reported (flat config defaults this to "warn"). Required so the vestigial + // import/* suppression comments don't become warnings or get auto-stripped + // by `--fix`. See importDirectiveStub above. + linterOptions: { + reportUnusedDisableDirectives: "off", + }, + plugins: { + import: importDirectiveStub, + }, + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + // Browser app: window/document/etc. The old `browser` env came from + // airbnb; under flat config it must be supplied explicitly. + globals: globals.browser, + }, + rules: { + "no-console": isProduction ? "error" : "off", + "no-debugger": isProduction ? "error" : "off", + "no-plusplus": "off", + "vue/valid-v-slot": ["error", { allowModifiers: true }], + "no-param-reassign": "off", + // Component file names are intentionally not multi-word while 4.x and 5.x + // are maintained in parallel. + "vue/multi-word-component-names": "off", + "vue/max-len": [ + "error", + { + code: 120, + ignoreComments: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + ignoreUrls: true, + }, + ], + "no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + // ESLint 9 changed the `caughtErrors` default from "none" to "all", + // which newly flags pre-existing `catch (err)` bindings. Pin it back + // to "none" to preserve the prior (ESLint 8) effective behavior and + // avoid editing source for this tooling-only migration. + caughtErrors: "none", + }, + ], + "prefer-destructuring": ["error", { object: true, array: false }], + // The API is snake_case, so don't enforce camelcase on payloads. + camelcase: "off", + }, + }, + + // Node-context files: build config and the Playwright e2e suite. These were + // outside the old `eslint ... src` scope; the new `eslint .` scope includes + // them, so grant Node globals (process, etc.) to avoid no-undef noise. There + // are no unit tests / mocha env to port. + { + files: ["*.config.js", "e2e/**/*.js"], + languageOptions: { + globals: globals.node, + }, + }, + + // Legacy Vue CLI config is CommonJS (module.exports); lint it as such. + { + files: ["vue.config.js"], + languageOptions: { + sourceType: "commonjs", + globals: globals.node, + }, + }, + + // Vitest unit/component tests. `globals: true` in vite.config.js exposes + // describe/it/expect/vi etc. globally, so the specs don't import them — declare + // them here to keep no-undef happy. Prefer the `globals` package's vitest set + // when present (newer versions), with an explicit fallback list otherwise. + // (e2e/ is the Playwright suite and is handled by the Node block above.) + { + files: ["**/*.{test,spec}.{js,jsx}", "**/__tests__/**/*.{js,jsx}"], + languageOptions: { + globals: { + ...globals.node, + ...(globals.vitest ?? { + describe: "readonly", + it: "readonly", + test: "readonly", + suite: "readonly", + expect: "readonly", + vi: "readonly", + vitest: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + }), + }, + }, + }, +]; diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..cbc1d683 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index 2ad28237..ad5ba390 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,34 @@ { "name": "starkiller", - "version": "3.5.0", + "version": "3.6.0", "private": true, "scripts": { "dev": "vite", "build": "vite build", "serve": "vite preview", - "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", + "lint": "eslint --fix .", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:unit": "vitest", + "test:unit:run": "vitest run" }, "main": "background.js", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "dependencies": { "@fontsource/roboto": "^4.5.1", "@fortawesome/fontawesome-free": "^6", "@mdi/font": "^7.0", "ansi_up": "^5.2.1", - "axios": "^0.24.0", + "dayjs": "^1.11.20", "lodash.debounce": "^4.0.8", - "moment": "^2.29.1", - "pinia": "^2.1.7", - "pinia-plugin-persistedstate": "^3.2.0", - "qs": "^6.10.3", - "semver": "^7.3.5", + "pinia": "^3", + "pinia-plugin-persistedstate": "^4", "socket.io-client": "^4.1.2", "splitpanes": "^3.1.0", - "table": "^6.8.1", - "uuid": "^8.3.2", "vue": "^3.5", "vue-markdown-render": "^2.3.0", "vue-router": "^4.3", @@ -34,15 +36,19 @@ "vuetify": "^4.0" }, "devDependencies": { - "@rushstack/eslint-patch": "^1.2.0", - "@vitejs/plugin-vue": "^5.0", - "@vue/eslint-config-airbnb": "^7.0.0", - "eslint": "^8.31.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-vue": "^9.8.0", + "@eslint/compat": "^2.1.0", + "@eslint/js": "^9.39.4", + "@pinia/testing": "1.0.3", + "@playwright/test": "^1.58.2", + "@vitejs/plugin-vue": "^6", + "@vue/eslint-config-prettier": "^10.2.0", + "eslint": "^9.39.4", + "eslint-plugin-vue": "^9.33.0", + "globals": "^17.6.0", "prettier": "^3.0.2", "sass": "^1.70", - "vite": "^5.0", - "vite-plugin-vuetify": "^2.1" + "vite": "^8", + "vite-plugin-vuetify": "^2.1", + "vitest": "4.1.7" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..67cba85d --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,27 @@ +// playwright.config.js +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.js", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [["html"], ["github"]] : "html", + use: { + baseURL: "http://localhost:5173", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + expect: { timeout: process.env.CI ? 10_000 : 5_000 }, + webServer: { + // --strictPort: fail fast if 5173 is taken (Vite would otherwise pick + // 5174+ and Playwright would silently hit the wrong app or time out). + command: "yarn dev --port 5173 --strictPort", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/src/App.vue b/src/App.vue index 0caff80c..46a81dfe 100644 --- a/src/App.vue +++ b/src/App.vue @@ -88,7 +88,7 @@ diff --git a/src/components/Chat.vue b/src/components/Chat.vue index f46ff325..c4d7d13d 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -53,7 +53,7 @@
- -