fix: harden runtime isolation and regression tests

This commit is contained in:
ajnart committed 2026-07-30 00:38:34 +02:00
1 parent 5d9f05e083
commit 9963ba0274
8 files changed
+151 -100

No files matched your search

+13 -4
View File
@@ -3,10 +3,15 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const registerAsync = async (nodeEnv: string, nextRuntime: string) => {
const loggerLoaded = vi.fn();
const tasksStarted = vi.fn();
const websocketStarted = vi.fn();
vi.stubEnv("NODE_ENV", nodeEnv);
vi.stubEnv("NEXT_RUNTIME", nextRuntime);
vi.doMock("@homarr/core/infrastructure/logs", () => {
loggerLoaded();
return { createLogger: () => ({ error: vi.fn() }) };
});
vi.doMock("@homarr/tasks", () => {
tasksStarted();
return {};
@@ -18,7 +23,7 @@ const registerAsync = async (nodeEnv: string, nextRuntime: string) => {
const { register } = await import("./instrumentation");
await register();
return { tasksStarted, websocketStarted };
return { loggerLoaded, tasksStarted, websocketStarted };
};
describe("Next instrumentation", () => {
@@ -27,28 +32,32 @@ describe("Next instrumentation", () => {
});
afterEach(() => {
vi.doUnmock("@homarr/core/infrastructure/logs");
vi.doUnmock("@homarr/tasks");
vi.doUnmock("@homarr/websocket");
vi.unstubAllEnvs();
});
test("embeds background services in the production Node runtime", async () => {
const { tasksStarted, websocketStarted } = await registerAsync("production", "nodejs");
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "nodejs");
expect(loggerLoaded).toHaveBeenCalledOnce();
expect(tasksStarted).toHaveBeenCalledOnce();
expect(websocketStarted).toHaveBeenCalledOnce();
});
test("does not embed background services in the development server", async () => {
const { tasksStarted, websocketStarted } = await registerAsync("development", "nodejs");
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("development", "nodejs");
expect(loggerLoaded).not.toHaveBeenCalled();
expect(tasksStarted).not.toHaveBeenCalled();
expect(websocketStarted).not.toHaveBeenCalled();
});
test("does not embed Node services in the edge runtime", async () => {
const { tasksStarted, websocketStarted } = await registerAsync("production", "edge");
const { loggerLoaded, tasksStarted, websocketStarted } = await registerAsync("production", "edge");
expect(loggerLoaded).not.toHaveBeenCalled();
expect(tasksStarted).not.toHaveBeenCalled();
expect(websocketStarted).not.toHaveBeenCalled();
});
+2 -4
View File
@@ -1,11 +1,9 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "instrumentation" });
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.NODE_ENV !== "production") return;
const { createLogger } = await import("@homarr/core/infrastructure/logs");
const logger = createLogger({ module: "instrumentation" });
const services = [
{ name: "tasks", start: () => import("@homarr/tasks") },
{ name: "websocket", start: () => import("@homarr/websocket") },
+26 -18
View File
@@ -8,13 +8,17 @@ describe("Health checks", () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
try {
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
test("ready and live should return 200 OK with external redis", async () => {
@@ -29,18 +33,22 @@ describe("Health checks", () => {
},
}).start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
try {
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
} finally {
await Promise.all([homarrContainer.stop(), redisContainer.stop()]);
}
}, 60_000);
});
+8 -4
View File
@@ -7,10 +7,14 @@ describe("Home", () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
try {
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
// Assert
expect(homeResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
});
+34 -34
View File
@@ -37,45 +37,45 @@ describe("LLDAP authorization", () => {
const context = await browser.newContext();
const page = await context.newPage();
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
try {
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.locator("#password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Act
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.locator("#password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Assert
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
// Assert
await page.waitForURL(baseUrl, { timeout: 60_000 });
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
},
},
},
},
});
expect(users).toHaveLength(1);
const user = users[0]!;
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
});
expect(users).toHaveLength(1);
const user = users[0]!;
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
// Cleanup
await browser.close();
await homarrContainer.stop();
await lldapContainer.stop();
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
} finally {
await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]);
}
}, 120_000);
});
+34 -34
View File
@@ -22,25 +22,25 @@ describe("Onboarding", () => {
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
@@ -66,22 +66,22 @@ describe("Onboarding", () => {
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
});
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
}
public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
}
}
@@ -122,6 +122,29 @@ describe("persisted update checker cache", () => {
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("deduplicates concurrent checks across simulated processes", async () => {
let completeGitHubRequest: (() => void) | undefined;
mocks.listReleases.mockImplementation(
async () =>
await new Promise<typeof successfulResponse>((resolve) => {
completeGitHubRequest = () => resolve(successfulResponse);
}),
);
const firstHandler = await loadHandlerAsync();
vi.resetModules();
const secondHandler = await loadHandlerAsync();
const firstRequest = firstHandler.getDataAsync();
await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce());
const secondRequest = secondHandler.getDataAsync();
completeGitHubRequest?.();
const results = await Promise.all([firstRequest, secondRequest]);
expect(results[0].data).toEqual(results[1].data);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("refreshes at the 24-hour boundary, not before it", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
@@ -150,18 +173,27 @@ describe("persisted update checker cache", () => {
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("serves stale data and suppresses retries for 24 hours after a provider failure", async () => {
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable"));
const handler = await loadHandlerAsync();
const fresh = await handler.getDataAsync();
mocks.removeFreshResult();
const stale = await handler.getDataAsync();
now += 24 * 60 * 60 * 1_000 - 1;
const repeated = await handler.getDataAsync();
expect(stale.data).toEqual(fresh.data);
expect(repeated.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
now += 1;
const retried = await handler.getDataAsync();
expect(retried.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(3);
});
test("does not contact GitHub when external connections are disabled", async () => {