mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-07-03 05:12:57 -07:00
Release 2.36.0 (#4127)
## Release Notes Thanks to: @cgillinger, @khassel, @KristjanESPERANTO, @sonnyb9 > ⚠️ This release needs nodejs version >=22.21.1 <23 || >=24 (no change to previous release) [Compare to previous Release v2.35.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.35.0...v2.36.0) This release falls outside the quarterly schedule. We opted for an early release due to: - Security fix for the internal cors proxy - API change of the weather provider smi - Several bug fixes ### Breaking Changes The cors proxy is now disabled by default. If required, it must be explicitly enabled in the `config.js` file. See the [documentation](https://docs.magicmirror.builders/configuration/cors.html). ### ⚠️ Security You can find several publicly accessible MagicMirror² instances. This should never be done. Doing so makes your entire configuration, including secrets and API keys, publicly visible. Furthermore, it allows attackers to target the host; this is only prevented beginning with this release. Public MagicMirror² instances should always run behind a reverse proxy with authentication. ### [core] - Prepare Release 2.36.0 (#4126) - Allow HTTPFetcher to pass through 304 responses (#4120) - fix(http-fetcher): fall back to reloadInterval after retries exhausted (#4113) - config endpoint must handle functions in module configs (#4106) - fix replaceSecretPlaceholder (#4104) - restrict replaceSecretPlaceholder to cors with allowWhitelist (#4102) - fix: prevent crash when config is undefined in socket handler (#4096) - fix cors function for alpine linux (#4091) - fix(cors): prevent SSRF via DNS rebinding (#4090) - add option to disable or restrict cors endpoint (#4087) - fix: prevent SSRF via /cors endpoint by blocking private/reserved IPs (#4084) - chore: add permissions section to enforce pull-request rules workflow (#4079) - update version for develop ### [dependencies] - update dependencies (#4124) - chore: update dependencies (#4088) - refactor: enable ESLint rule "no-unused-vars" and handle related issues (#4080) ### [modules/newsfeed] - fix(newsfeed): prevent duplicate parse error callback when using pipeline (#4083) ### [modules/updatenotification] - fix(updatenotification): harden git command execution + simplify checkUpdates (#4115) - fix(tests): correct import path for git_helper module in updatenotification tests (#4078) ### [modules/weather] - fix(weather): use nearest openmeteo hourly data (#4123) - fix(weather): avoid loading state after reconnect (#4121) - weather: fix UV index display and add WeatherFlow precipitation (#4108) - fix(weather): restore OpenWeatherMap v2.5 support (#4101) - fix(weather): use stable instanceId to prevent duplicate fetchers (#4092) - SMHI: migrate to SNOW1gv1 API (replace deprecated PMP3gv2) (#4082) ### [testing] - ci(actions): set explicit token permissions (#4114) - fix(http_fetcher): use undici.fetch when dispatcher is present (#4097) - ci(codeql): also scan develop branch on push and PR (#4086) - refactor: replace implicit global config with explicit global.config (#4085) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Co-authored-by: Veeck <github@veeck.de> Co-authored-by: veeck <gitkraken@veeck.de> Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com> Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com> Co-authored-by: Nathan <n8nyoung@gmail.com> Co-authored-by: mixasgr <mixasgr@users.noreply.github.com> Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr> Co-authored-by: Konstantinos <geraki@gmail.com> Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com> Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com> Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com> Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr> Co-authored-by: Koen Konst <koenspero@gmail.com> Co-authored-by: Koen Konst <c.h.konst@avisi.nl> Co-authored-by: dathbe <github@beffa.us> Co-authored-by: Marcel <m-idler@users.noreply.github.com> Co-authored-by: Kevin G. <crazylegstoo@gmail.com> Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com> Co-authored-by: Jboucly <contact@jboucly.fr> Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com> Co-authored-by: Jordan Welch <JordanHWelch@gmail.com> Co-authored-by: Blackspirits <blackspirits@gmail.com> Co-authored-by: Samed Ozdemir <samed@xsor.io> Co-authored-by: in-voker <58696565+in-voker@users.noreply.github.com> Co-authored-by: Andrés Vanegas Jiménez <142350+angeldeejay@users.noreply.github.com> Co-authored-by: cgillinger <christian.gillinger@gmail.com> Co-authored-by: Sonny B <43247590+sonnyb9@users.noreply.github.com> Co-authored-by: sonnyb9 <sonnyb9@users.noreply.github.com>
This commit is contained in:
@@ -51,6 +51,31 @@ describe("HTTPFetcher", () => {
|
||||
expect(text).toBe(responseData);
|
||||
});
|
||||
|
||||
it("should treat 304 responses as successful and reset error counters", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 304 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
||||
fetcher.serverErrorCount = 2;
|
||||
fetcher.networkErrorCount = 3;
|
||||
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
fetcher.on("response", (response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
|
||||
fetcher.startPeriodicFetch();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(fetcher.serverErrorCount).toBe(0);
|
||||
expect(fetcher.networkErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should emit error event on network failure", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
@@ -440,3 +465,112 @@ describe("fetch() method", () => {
|
||||
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selfSignedCert dispatcher", () => {
|
||||
const { Agent } = require("undici");
|
||||
|
||||
it("should set rejectUnauthorized=false when selfSignedCert is true", () => {
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
selfSignedCert: true
|
||||
});
|
||||
|
||||
const options = fetcher.getRequestOptions();
|
||||
|
||||
expect(options.dispatcher).toBeInstanceOf(Agent);
|
||||
const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options");
|
||||
const dispatcherOptions = options.dispatcher[agentOptionsSymbol];
|
||||
expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("should not set a dispatcher when selfSignedCert is false", () => {
|
||||
fetcher = new HTTPFetcher(TEST_URL, {
|
||||
reloadInterval: 60000,
|
||||
selfSignedCert: false
|
||||
});
|
||||
|
||||
const options = fetcher.getRequestOptions();
|
||||
|
||||
expect(options.dispatcher).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Retry exhaustion fallback", () => {
|
||||
it("should fall back to reloadInterval after network retries exhausted", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
// Trigger maxRetries + 1 fetches to reach exhaustion
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fetcher.fetch();
|
||||
}
|
||||
|
||||
// First retries should use backoff (< reloadInterval)
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
// Third retry hits maxRetries, should fall back to reloadInterval
|
||||
expect(errors[2].retryAfter).toBe(300000);
|
||||
// Subsequent errors stay at reloadInterval
|
||||
expect(errors[3].retryAfter).toBe(300000);
|
||||
});
|
||||
|
||||
it("should fall back to reloadInterval after server error retries exhausted", async () => {
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await fetcher.fetch();
|
||||
}
|
||||
|
||||
// First retries should use backoff (< reloadInterval)
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
// Third retry hits maxRetries, should fall back to reloadInterval
|
||||
expect(errors[2].retryAfter).toBe(300000);
|
||||
// Subsequent errors stay at reloadInterval
|
||||
expect(errors[3].retryAfter).toBe(300000);
|
||||
});
|
||||
|
||||
it("should reset network error count on success", async () => {
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
http.get(TEST_URL, () => {
|
||||
requestCount++;
|
||||
if (requestCount <= 2) return HttpResponse.error();
|
||||
return HttpResponse.text("ok");
|
||||
})
|
||||
);
|
||||
|
||||
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 });
|
||||
|
||||
const errors = [];
|
||||
fetcher.on("error", (errorInfo) => errors.push(errorInfo));
|
||||
|
||||
// Two failures with backoff
|
||||
await fetcher.fetch();
|
||||
await fetcher.fetch();
|
||||
expect(errors).toHaveLength(2);
|
||||
expect(errors[0].retryAfter).toBe(15000);
|
||||
expect(errors[1].retryAfter).toBe(30000);
|
||||
|
||||
// Success resets counter
|
||||
await fetcher.fetch();
|
||||
expect(fetcher.networkErrorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// Tests use vi.spyOn on shared module objects (dns, undici).
|
||||
// vi.spyOn modifies the object property directly on the cached module instance, so it
|
||||
// is intercepted by server_functions.js regardless of the Module.prototype.require override
|
||||
// in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same
|
||||
// spy instance — mockClear() is called explicitly in beforeEach to reset call history.
|
||||
const dns = require("node:dns");
|
||||
const undici = require("undici");
|
||||
const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions");
|
||||
|
||||
describe("server_functions tests", () => {
|
||||
describe("The replaceSecretPlaceholder method", () => {
|
||||
describe("The replaceSecretPlaceholder method with cors=allowWhitelist", () => {
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowWhitelist" };
|
||||
});
|
||||
|
||||
it("Calls string without secret placeholder", () => {
|
||||
const teststring = "test string without secret placeholder";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
@@ -18,29 +29,48 @@ describe("server_functions tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("The replaceSecretPlaceholder method with cors=allowAll", () => {
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowAll" };
|
||||
});
|
||||
|
||||
it("Calls string without secret placeholder", () => {
|
||||
const teststring = "test string without secret placeholder";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
|
||||
it("Calls string with 2 secret placeholders", () => {
|
||||
const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**";
|
||||
const result = replaceSecretPlaceholder(teststring);
|
||||
expect(result).toBe(teststring);
|
||||
});
|
||||
});
|
||||
|
||||
describe("The cors method", () => {
|
||||
let fetchResponse;
|
||||
let fetchSpy;
|
||||
let fetchResponseHeadersGet;
|
||||
let fetchResponseArrayBuffer;
|
||||
let corsResponse;
|
||||
let request;
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
global.config = { cors: "allowAll" };
|
||||
fetchResponseHeadersGet = vi.fn(() => {});
|
||||
fetchResponseArrayBuffer = vi.fn(() => {});
|
||||
fetchResponse = {
|
||||
headers: {
|
||||
get: fetchResponseHeadersGet
|
||||
},
|
||||
|
||||
// Mock DNS to return a public IP (SSRF check must pass for these tests)
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
|
||||
// vi.spyOn may return the same spy instance across tests when restoreAllMocks
|
||||
// restores-but-reuses; mockClear() explicitly resets call history each time.
|
||||
fetchSpy = vi.spyOn(undici, "fetch");
|
||||
fetchSpy.mockClear();
|
||||
fetchSpy.mockImplementation(() => Promise.resolve({
|
||||
headers: { get: fetchResponseHeadersGet },
|
||||
arrayBuffer: fetchResponseArrayBuffer,
|
||||
ok: true
|
||||
};
|
||||
|
||||
fetch = vi.fn();
|
||||
fetch.mockImplementation(() => fetchResponse);
|
||||
|
||||
fetchMock = fetch;
|
||||
}));
|
||||
|
||||
corsResponse = {
|
||||
set: vi.fn(() => {}),
|
||||
@@ -53,7 +83,7 @@ describe("server_functions tests", () => {
|
||||
};
|
||||
|
||||
request = {
|
||||
url: "/cors?url=www.test.com"
|
||||
url: "/cors?url=http://www.test.com"
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,8 +93,8 @@ describe("server_functions tests", () => {
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(urlToCall);
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(urlToCall);
|
||||
});
|
||||
|
||||
it("Forwards Content-Type if json", async () => {
|
||||
@@ -126,9 +156,9 @@ describe("server_functions tests", () => {
|
||||
it("Fetches with user agent by default", async () => {
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("User-Agent");
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("User-Agent");
|
||||
});
|
||||
|
||||
it("Fetches with specified headers", async () => {
|
||||
@@ -138,10 +168,10 @@ describe("server_functions tests", () => {
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
|
||||
expect(fetchMock.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header1", "value1");
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header2", "value2");
|
||||
});
|
||||
|
||||
it("Sends specified headers", async () => {
|
||||
@@ -153,8 +183,8 @@ describe("server_functions tests", () => {
|
||||
|
||||
await cors(request, corsResponse);
|
||||
|
||||
expect(fetchMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchMock.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(fetchSpy.mock.calls).toHaveLength(1);
|
||||
expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers");
|
||||
expect(corsResponse.set.mock.calls).toHaveLength(3);
|
||||
expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type");
|
||||
expect(corsResponse.set.mock.calls[1][0]).toBe("header1");
|
||||
@@ -182,4 +212,93 @@ describe("server_functions tests", () => {
|
||||
global.config = previousConfig;
|
||||
});
|
||||
});
|
||||
|
||||
describe("The cors method blocks SSRF (DNS rebinding safe)", () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = {
|
||||
set: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn(function () { return this; }),
|
||||
json: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
it("Blocks localhost hostname without DNS", async () => {
|
||||
await cors({ url: "/cors?url=http://localhost/path" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" });
|
||||
});
|
||||
|
||||
it("Blocks non-http protocols", async () => {
|
||||
await cors({ url: "/cors?url=ftp://example.com/file" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks invalid URLs", async () => {
|
||||
await cors({ url: "/cors?url=not_a_valid_url" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks loopback addresses (127.0.0.1)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks RFC 1918 private addresses (192.168.x.x)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "192.168.1.1", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Blocks link-local / cloud metadata addresses (169.254.169.254)", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "169.254.169.254", family: 4 });
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Allows public unicast addresses", async () => {
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: vi.fn() },
|
||||
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
|
||||
});
|
||||
await cors({ url: "/cors?url=http://example.com/" }, response);
|
||||
expect(response.status).not.toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cors method with allowWhitelist", () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = {
|
||||
set: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn(function () { return this; }),
|
||||
json: vi.fn()
|
||||
};
|
||||
vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 });
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
headers: { get: vi.fn() },
|
||||
arrayBuffer: vi.fn(() => new ArrayBuffer(0))
|
||||
});
|
||||
});
|
||||
|
||||
it("Blocks domains not in whitelist", async () => {
|
||||
global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] };
|
||||
await cors({ url: "/cors?url=http://example.com/api" }, response);
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it("Allows domains in whitelist", async () => {
|
||||
global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] };
|
||||
await cors({ url: "/cors?url=http://example.com/api" }, response);
|
||||
expect(response.status).not.toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,10 @@ import { vi, describe, beforeEach, afterEach, it, expect } from "vitest";
|
||||
* Creates a fresh GitHelper instance with isolated mocks for each test run.
|
||||
* @param {{ current: import("vitest").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync.
|
||||
* @param {{ current: { error: import("vitest").Mock; info: import("vitest").Mock } | null }} loggerMockRef reference to logger stubs.
|
||||
* @param {{ current: import("vitest").MockInstance | null }} execShellSpyRef reference to the execShell spy.
|
||||
* @param {{ current: import("vitest").MockInstance | null }} execGitSpyRef reference to the execGit spy.
|
||||
* @returns {Promise<unknown>} resolved GitHelper instance.
|
||||
*/
|
||||
async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRef) {
|
||||
async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execGitSpyRef) {
|
||||
vi.resetModules();
|
||||
|
||||
fsStatSyncMockRef.current = vi.fn();
|
||||
@@ -20,10 +20,10 @@ async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRe
|
||||
vi.doMock("logger", () => loggerMockRef.current);
|
||||
|
||||
const defaults = await import("../../../js/defaults");
|
||||
const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper`);
|
||||
const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper.js`);
|
||||
const GitHelper = gitHelperModule.default || gitHelperModule;
|
||||
const instance = new GitHelper();
|
||||
execShellSpyRef.current = vi.spyOn(instance, "execShell");
|
||||
execGitSpyRef.current = vi.spyOn(instance, "execGit");
|
||||
instance.__loggerMock = loggerMockRef.current;
|
||||
return instance;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRe
|
||||
describe("Updatenotification", () => {
|
||||
const fsStatSyncMockRef = { current: null };
|
||||
const loggerMockRef = { current: null };
|
||||
const execShellSpyRef = { current: null };
|
||||
const execGitSpyRef = { current: null };
|
||||
let gitHelper;
|
||||
|
||||
let gitRemoteOut;
|
||||
@@ -43,10 +43,10 @@ describe("Updatenotification", () => {
|
||||
let gitFetchErr;
|
||||
let gitTagListOut;
|
||||
|
||||
const getExecutedCommands = () => execShellSpyRef.current.mock.calls.map(([command]) => command);
|
||||
const getExecutedCommands = () => execGitSpyRef.current.mock.calls.map((call) => call.slice(1).join(" "));
|
||||
|
||||
beforeEach(async () => {
|
||||
gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execShellSpyRef);
|
||||
gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execGitSpyRef);
|
||||
|
||||
fsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true });
|
||||
|
||||
@@ -59,40 +59,42 @@ describe("Updatenotification", () => {
|
||||
gitFetchErr = "";
|
||||
gitTagListOut = "";
|
||||
|
||||
execShellSpyRef.current.mockImplementation((command) => {
|
||||
if (command.includes("git remote -v")) {
|
||||
execGitSpyRef.current.mockImplementation((_folder, ...args) => {
|
||||
const command = args.join(" ");
|
||||
|
||||
if (command === "remote -v") {
|
||||
return Promise.resolve({ stdout: gitRemoteOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.includes("git rev-parse HEAD")) {
|
||||
if (command === "rev-parse HEAD") {
|
||||
return Promise.resolve({ stdout: gitRevParseOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.includes("git status -sb")) {
|
||||
if (command === "status -sb") {
|
||||
return Promise.resolve({ stdout: gitStatusOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.includes("git fetch -n --dry-run")) {
|
||||
if (command === "fetch -n --dry-run") {
|
||||
return Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr });
|
||||
}
|
||||
|
||||
if (command.includes("git rev-list --ancestry-path --count")) {
|
||||
if (command.startsWith("rev-list --ancestry-path --count ")) {
|
||||
return Promise.resolve({ stdout: gitRevListCountOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.includes("git rev-list --ancestry-path")) {
|
||||
if (command.startsWith("rev-list --ancestry-path ")) {
|
||||
return Promise.resolve({ stdout: gitRevListOut, stderr: "" });
|
||||
}
|
||||
|
||||
if (command.includes("git ls-remote -q --tags --refs")) {
|
||||
if (command === "ls-remote -q --tags --refs") {
|
||||
return Promise.resolve({ stdout: gitTagListOut, stderr: "" });
|
||||
}
|
||||
|
||||
return Promise.resolve({ stdout: "", stderr: "" });
|
||||
});
|
||||
|
||||
if (gitHelper.execShell !== execShellSpyRef.current) {
|
||||
throw new Error("execShell spy not applied");
|
||||
if (gitHelper.execGit !== execGitSpyRef.current) {
|
||||
throw new Error("execGit spy not applied");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,10 +121,10 @@ describe("Updatenotification", () => {
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 develop",
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 develop",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -134,20 +136,20 @@ describe("Updatenotification", () => {
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
expect(gitHelper.gitRepos).toHaveLength(1);
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
expect(execShellSpyRef.current.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(execGitSpyRef.current.mock.calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,12 +171,12 @@ describe("Updatenotification", () => {
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -185,20 +187,20 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
@@ -224,15 +226,15 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
@@ -241,20 +243,20 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
@@ -280,15 +282,15 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns status information early if isBehindInStatus", async () => {
|
||||
@@ -297,20 +299,20 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git rev-parse HEAD",
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"cd mock-path && git ls-remote -q --tags --refs",
|
||||
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"rev-parse HEAD",
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 60e0377..332e429 master",
|
||||
"ls-remote -q --tags --refs",
|
||||
"rev-list --ancestry-path 60e0377..332e429 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("excludes repo if status can't be retrieved", async () => {
|
||||
const errorMessage = "Failed to retrieve status";
|
||||
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
|
||||
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos).toHaveLength(0);
|
||||
@@ -334,12 +336,12 @@ describe("Updatenotification", () => {
|
||||
const repos = await gitHelper.getRepos();
|
||||
expect(repos[0]).toMatchSnapshot();
|
||||
expect(getExecutedCommands()).toMatchInlineSnapshot(`
|
||||
[
|
||||
"cd mock-path && git status -sb",
|
||||
"cd mock-path && git fetch -n --dry-run",
|
||||
"cd mock-path && git rev-list --ancestry-path --count 19f7faf..9d83101 master",
|
||||
]
|
||||
`);
|
||||
[
|
||||
"status -sb",
|
||||
"fetch -n --dry-run",
|
||||
"rev-list --ancestry-path --count 19f7faf..9d83101 master",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user