mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 10:04:45 +00:00
test(e2e): fix dead specs and cover log streaming + auth negatives (#4868)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
<mdi:cog class="size-6" />
|
<mdi:cog class="size-6" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<dropdown class="dropdown-end" v-if="config.user">
|
<dropdown class="dropdown-end" data-testid="user-menu" v-if="config.user">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<template v-if="config.disableAvatars || !config.user.email">
|
<template v-if="config.disableAvatars || !config.user.email">
|
||||||
<material-symbols:person class="size-6" />
|
<material-symbols:person class="size-6" />
|
||||||
|
|||||||
+51
-3
@@ -1,3 +1,10 @@
|
|||||||
|
x-dozzle-healthcheck: &dozzle-healthcheck
|
||||||
|
test: ["CMD", "/dozzle", "healthcheck"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
start_interval: 5s
|
||||||
|
|
||||||
services:
|
services:
|
||||||
custom_base:
|
custom_base:
|
||||||
container_name: custom_base
|
container_name: custom_base
|
||||||
@@ -10,6 +17,7 @@ services:
|
|||||||
- DOZZLE_HOSTNAME=custom name
|
- DOZZLE_HOSTNAME=custom name
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
simple-auth:
|
simple-auth:
|
||||||
@@ -20,6 +28,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DOZZLE_AUTH_PROVIDER=simple
|
- DOZZLE_AUTH_PROVIDER=simple
|
||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dozzle:
|
dozzle:
|
||||||
@@ -33,8 +42,33 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 7070:8080
|
- 7070:8080
|
||||||
|
# Deliberately no healthcheck: this container is the subject of the visual
|
||||||
|
# snapshots, and a healthcheck makes a health badge appear in the sidebar
|
||||||
|
# (HostMenu.vue), which shifts the reference images.
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
# Dedicated instance + noisy container so log streaming can be asserted without
|
||||||
|
# perturbing the `name=dozzle` filter the other instances rely on.
|
||||||
|
logs-viewer:
|
||||||
|
container_name: logs-viewer
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
- DOZZLE_FILTER=name=logspam
|
||||||
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
|
- DOZZLE_HOSTNAME=logs
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
depends_on:
|
||||||
|
- logspam
|
||||||
|
logspam:
|
||||||
|
container_name: logspam
|
||||||
|
image: alpine:3
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
|
||||||
remote:
|
remote:
|
||||||
container_name: remote
|
container_name: remote
|
||||||
environment:
|
environment:
|
||||||
@@ -43,6 +77,7 @@ services:
|
|||||||
- DOZZLE_NO_ANALYTICS=1
|
- DOZZLE_NO_ANALYTICS=1
|
||||||
ports:
|
ports:
|
||||||
- 5050:8080
|
- 5050:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -56,6 +91,7 @@ services:
|
|||||||
- DOZZLE_LEVEL=debug
|
- DOZZLE_LEVEL=debug
|
||||||
ports:
|
ports:
|
||||||
- 8082:8080
|
- 8082:8080
|
||||||
|
healthcheck: *dozzle-healthcheck
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -85,6 +121,9 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
environment:
|
environment:
|
||||||
- CONTAINERS=1
|
- CONTAINERS=1
|
||||||
|
# Dozzle calls /info on connect; without it the host never initializes and
|
||||||
|
# lists zero containers.
|
||||||
|
- INFO=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -105,6 +144,15 @@ services:
|
|||||||
- PWTEST_SKIP_TEST_OUTPUT=1
|
- PWTEST_SKIP_TEST_OUTPUT=1
|
||||||
- CI=1
|
- CI=1
|
||||||
depends_on:
|
depends_on:
|
||||||
- dozzle
|
dozzle:
|
||||||
- custom_base
|
condition: service_started
|
||||||
- remote
|
custom_base:
|
||||||
|
condition: service_healthy
|
||||||
|
remote:
|
||||||
|
condition: service_healthy
|
||||||
|
simple-auth:
|
||||||
|
condition: service_healthy
|
||||||
|
dozzle-with-agent:
|
||||||
|
condition: service_healthy
|
||||||
|
logs-viewer:
|
||||||
|
condition: service_healthy
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("http://dozzle-with-agent:8080/");
|
await page.goto("http://dozzle-with-agent:8080/");
|
||||||
});
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto("http://remote:8080/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has right title", async ({ page }) => {
|
|
||||||
await expect(page).toHaveTitle(/.* - Dozzle/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("select running container", async ({ page }) => {
|
|
||||||
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/container/);
|
|
||||||
await expect(page.getByText("Accepting connections")).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Targets a dedicated instance filtered to the `logspam` container, which prints a
|
||||||
|
// numbered line every second. That makes "logs are actually streaming" assertable
|
||||||
|
// without depending on incidental output from Dozzle itself.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("http://logs-viewer:8080/");
|
||||||
|
await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/container\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders historical logs on load", async ({ page }) => {
|
||||||
|
await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("no-logs")).toBeHidden();
|
||||||
|
await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams new logs over SSE without a reload", async ({ page }) => {
|
||||||
|
const entries = page.locator("ul[data-logs] > li");
|
||||||
|
await expect(entries.first()).toBeVisible();
|
||||||
|
|
||||||
|
const initial = await entries.count();
|
||||||
|
// logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
|
||||||
|
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the newest log entry in view while streaming", async ({ page }) => {
|
||||||
|
const entries = page.locator("ul[data-logs] > li");
|
||||||
|
await expect(entries.first()).toBeVisible();
|
||||||
|
|
||||||
|
const initial = await entries.count();
|
||||||
|
await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
|
||||||
|
await expect(entries.last()).toBeInViewport();
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
|
||||||
|
// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("http://remote:8080/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has right title", async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle(/.* - Dozzle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the labeled remote host", async ({ page }) => {
|
||||||
|
// The host column on the dashboard, rather than a bare getByText: the label also
|
||||||
|
// appears in the sidebar and the merge link, and which of them exist depends on
|
||||||
|
// whether containers have loaded yet.
|
||||||
|
await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("select running container", async ({ page }) => {
|
||||||
|
await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/container/);
|
||||||
|
await expect(page.getByText("Accepting connections")).toBeVisible();
|
||||||
|
});
|
||||||
+52
-2
@@ -1,9 +1,59 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test("simple authentication", async ({ page }) => {
|
const BASE = "http://simple-auth:8080";
|
||||||
await page.goto("http://simple-auth:8080/");
|
|
||||||
|
async function login(page: import("@playwright/test").Page) {
|
||||||
await page.locator('input[name="username"]').fill("admin");
|
await page.locator('input[name="username"]').fill("admin");
|
||||||
await page.locator('input[name="password"]').fill("password");
|
await page.locator('input[name="password"]').fill("password");
|
||||||
await page.locator('button[type="submit"]').click();
|
await page.locator('button[type="submit"]').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("simple authentication", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await login(page);
|
||||||
await expect(page.getByTestId("settings")).toBeVisible();
|
await expect(page.getByTestId("settings")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("unauthenticated request redirects to the login page", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the requested url as redirectUrl", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/settings");
|
||||||
|
await expect(page).toHaveURL(/\/login\?redirectUrl=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a wrong password", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await page.locator('input[name="username"]').fill("admin");
|
||||||
|
await page.locator('input[name="password"]').fill("not-the-password");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Username or password are not valid")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("settings")).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("api rejects unauthenticated requests with 401", async ({ request }) => {
|
||||||
|
// A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
|
||||||
|
for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
|
||||||
|
const response = await request.get(BASE + path, { maxRedirects: 0 });
|
||||||
|
expect(response.status(), `${path} should be unauthorized`).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout clears the session", async ({ page }) => {
|
||||||
|
await page.goto(BASE + "/");
|
||||||
|
await login(page);
|
||||||
|
await expect(page.getByTestId("settings")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("user-menu").locator("label").click();
|
||||||
|
await page.getByRole("button", { name: "Logout" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
|
||||||
|
// The cookie must be gone server-side, not just visually logged out.
|
||||||
|
const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user