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:
Karsten Hassel
2026-04-30 22:49:25 +02:00
committed by GitHub
parent d05ea751d9
commit fb41d24ef5
61 changed files with 4551 additions and 3132 deletions

View File

@@ -0,0 +1,116 @@
import Module from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Creates a fresh weather node helper instance with isolated mocks.
* @returns {Promise<object>} The mocked weather node helper.
*/
async function loadWeatherNodeHelper () {
vi.resetModules();
const loggerMock = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn()
};
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === "node_helper") {
return {
create: vi.fn((definition) => definition)
};
}
if (id === "logger") {
return loggerMock;
}
return originalRequire.apply(this, arguments);
};
let helper;
try {
const helperModule = await import("../../../../../defaultmodules/weather/node_helper");
helper = helperModule.default || helperModule;
} finally {
Module.prototype.require = originalRequire;
}
helper.providers = {};
helper.lastData = {};
helper.sendSocketNotification = vi.fn();
return helper;
}
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
});
describe("weather node_helper reconnect handling", () => {
it("re-sends cached weather data when a client reconnects", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const cachedPayload = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.providers[instanceId] = { locationName: "Munich, BY" };
helper.lastData[instanceId] = cachedPayload;
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(1, "WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(2, "WEATHER_DATA", cachedPayload);
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(2);
});
it("does not send WEATHER_DATA on reconnect when no cached payload exists", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
helper.providers[instanceId] = { locationName: "Munich, BY" };
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenCalledWith("WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(1);
});
it("cleans up provider and cached data when stopping an instance", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const stop = vi.fn();
helper.providers[instanceId] = { stop };
helper.lastData[instanceId] = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.stopWeatherProvider(instanceId);
expect(stop).toHaveBeenCalledTimes(1);
expect(helper.providers[instanceId]).toBeUndefined();
expect(helper.lastData[instanceId]).toBeUndefined();
});
});

View File

@@ -144,12 +144,15 @@ describe("OpenMeteoProvider", () => {
provider.start();
const result = await dataPromise;
const currentHourUnix = Math.floor(currentData.current_weather.time / 3600) * 3600;
const currentHourIndex = currentData.hourly.time.findIndex((time) => time === currentHourUnix);
expect(result).toBeDefined();
expect(result.temperature).toBe(8.5);
expect(result.windSpeed).toBeCloseTo(4.7, 1);
expect(result.windFromDirection).toBe(9);
expect(result.humidity).toBe(83);
expect(result.humidity).toBe(currentData.hourly.relativehumidity_2m[currentHourIndex]);
expect(result.humidity).not.toBe(currentData.hourly.relativehumidity_2m[0]);
});
it("should include sunrise and sunset from daily data", async () => {

View File

@@ -8,6 +8,8 @@ import { setupServer } from "msw/node";
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest";
import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" };
import currentData from "../../../../../mocks/weather_owm_current.json" with { type: "json" };
import forecastData from "../../../../../mocks/weather_owm_forecast.json" with { type: "json" };
let server;
@@ -232,4 +234,321 @@ describe("OpenWeatherMapProvider", () => {
expect(provider.locationName).toBe("America/New_York");
});
});
describe("API v2.5 - Current Weather (/weather endpoint)", () => {
it("should parse current weather from /weather endpoint", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/weather",
type: "current"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/weather", () => {
return HttpResponse.json(currentData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(result.temperature).toBe(-0.27);
expect(result.feelsLikeTemp).toBe(-3.9);
expect(result.humidity).toBe(54);
expect(result.windSpeed).toBe(3.09);
expect(result.windFromDirection).toBe(220);
expect(result.weatherType).toBe("cloudy-windy");
expect(result.sunrise).toBeInstanceOf(Date);
expect(result.sunset).toBeInstanceOf(Date);
});
it("should set location name from city name and country", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/weather",
type: "current"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/weather", () => {
return HttpResponse.json(currentData);
})
);
await provider.initialize();
provider.start();
await dataPromise;
expect(provider.locationName).toBe("Munich, DE");
});
});
describe("API v2.5 - Forecast (/forecast endpoint)", () => {
it("should parse /forecast endpoint into daily grouped forecast", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
});
it("should correctly aggregate min/max temperatures per day", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1: temp_min values: -1.5, -1.5, -1.0, 0.5, 1.5, 1.0, 0.5, -0.5 → min=-1.5
expect(result[0].minTemperature).toBe(-1.5);
// Day 1: temp_max values: -0.5, -0.9, 0.0, 1.5, 2.5, 2.0, 1.2, 0.1 → max=2.5
expect(result[0].maxTemperature).toBe(2.5);
// Day 2: temp_min values: 0.0, 0.5, 1.5, 3.0, 4.5, 4.0, 2.5, 1.0 → min=0.0
expect(result[1].minTemperature).toBe(0.0);
// Day 2: temp_max values: 1.0, 1.5, 2.5, 4.0, 5.5, 5.0, 3.5, 2.0 → max=5.5
expect(result[1].maxTemperature).toBe(5.5);
});
it("should pick daytime weather type (8-17h)", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1 daytime entries have icon "10d" → "rain"
expect(result[0].weatherType).toBe("rain");
// Day 2 daytime entries have icon "09d" → "showers"
expect(result[1].weatherType).toBe("showers");
});
it("should accumulate precipitation per day", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Day 1: two rain entries of 0.6 each = 1.2
expect(result[0].rain).toBeCloseTo(1.2);
expect(result[0].precipitationAmount).toBeCloseTo(1.2);
// Day 2: one snow entry of 0.5
expect(result[1].snow).toBeCloseTo(0.5);
expect(result[1].precipitationAmount).toBeCloseTo(0.5);
});
it("should set location name from city in forecast response", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "forecast"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
await dataPromise;
expect(provider.locationName).toBe("Munich, DE");
});
});
describe("API v2.5 - Hourly (/forecast endpoint with type hourly)", () => {
it("should return individual 3h entries instead of aggregating", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(forecastData.list.length);
});
it("should map temperature and wind from each 3h slot", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
expect(result[0].temperature).toBe(forecastData.list[0].main.temp);
expect(result[0].windSpeed).toBe(forecastData.list[0].wind.speed);
expect(result[0].precipitationProbability).toBe(forecastData.list[0].pop * 100);
});
it("should include precipitation when present in a slot", async () => {
const provider = new OpenWeatherMapProvider({
lat: 48.14,
lon: 11.58,
apiKey: "test-key",
apiVersion: "2.5",
weatherEndpoint: "/forecast",
type: "hourly"
});
const dataPromise = new Promise((resolve) => {
provider.setCallbacks(resolve, vi.fn());
});
server.use(
http.get("https://api.openweathermap.org/data/2.5/forecast", () => {
return HttpResponse.json(forecastData);
})
);
await provider.initialize();
provider.start();
const result = await dataPromise;
// Entry at index 3 has rain: { "3h": 0.6 }
expect(result[3].rain).toBe(0.6);
expect(result[3].precipitationAmount).toBe(0.6);
// Entry at index 11 has snow: { "3h": 0.5 }
expect(result[11].snow).toBe(0.5);
expect(result[11].precipitationAmount).toBe(0.5);
});
});
});

View File

@@ -3,6 +3,8 @@
*
* Tests data parsing for current, forecast, and hourly weather types.
* SMHI provides data only for Sweden, uses metric system.
*
* Fixture: weather_smhi.json uses SNOW1gv1 format (replaced PMP3gv2 2026-03-31)
*/
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
@@ -123,11 +125,10 @@ describe("SMHIProvider", () => {
provider.setCallbacks(resolve, vi.fn());
});
// Use data with rain (pcat=3 at index 2)
// Use entry at index 2 which has ptype=5 (snow) but pmedian=0.0
const rainData = JSON.parse(JSON.stringify(smhiData));
// Make the rain entry the closest to "now"
rainData.timeSeries = [rainData.timeSeries[2]];
rainData.timeSeries[0].validTime = new Date().toISOString();
rainData.timeSeries[0].time = new Date().toISOString();
server.use(
http.get("https://opendata-download-metfcst.smhi.se/*", () => {
@@ -140,7 +141,8 @@ describe("SMHIProvider", () => {
const result = await dataPromise;
expect(result.rain).toBe(0.0); // pmedian value with pcat=3 (rain)
// pmedian is 0.0 at this entry, so all precipitation amounts are 0
expect(result.rain).toBe(0);
expect(result.precipitationAmount).toBe(0.0);
expect(result.snow).toBe(0);
});

View File

@@ -240,6 +240,7 @@ describe("WeatherAPIProvider", () => {
expect(result[0].minTemperature).toBe(-8);
expect(result[0].maxTemperature).toBe(-1);
expect(result[0].weatherType).toBe("day-sprinkle");
expect(result[0].uvIndex).toBe(1);
expect(result[0].sunrise).toBeInstanceOf(Date);
expect(result[0].sunset).toBeInstanceOf(Date);
});
@@ -275,6 +276,7 @@ describe("WeatherAPIProvider", () => {
expect(result[0].humidity).toBe(85);
expect(result[0].windFromDirection).toBe(210);
expect(result[0].weatherType).toBe("night-sprinkle");
expect(result[0].uvIndex).toBe(0);
expect(result[0].precipitationProbability).toBe(50);
});
});

View File

@@ -85,6 +85,9 @@ describe("WeatherFlowProvider", () => {
expect(result).toBeDefined();
expect(result.temperature).toBe(16);
expect(result.humidity).toBe(28);
expect(result.precipitationAmount).toBe(0);
expect(result.precipitationUnits).toBe("mm");
expect(result.precipitationProbability).toBe(0);
expect(result.weatherType).not.toBeNull();
});

View File

@@ -15,7 +15,6 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach }
import yrData from "../../../../../mocks/weather_yr.json" with { type: "json" };
const YR_FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/**";
const YR_SUNRISE_URL = "https://api.met.no/weatherapi/sunrise/**";
// Fixed time: 30 minutes after the first timeseries entry (2026-02-06T21:00:00Z)
// This ensures timeseries[0] is always chosen as the closest past entry.