Compare commits

..
14 Commits
Author SHA1 Message Date
Lauri Ojansivu 58ad81e301 continuousBackup integration suite: stop each engine before its directory is removed.
Three tests registered the hook that removes their temp directory before the hook
that stops their engine. node:test runs after-hooks in the order they were
registered, not last-in first-out, so the directory was gone when stop() sealed
into it, and the ENOENT (files/index.ndjson) surfaced after the test as
"generated asynchronous activity after the test ended" - which failed the whole
suite although all 8 tests passed. One hook now stops the engine first and then
removes the directory. Five runs in a row, and the suite runner, pass.

Thanks to xet7 !
2026-11-04 19:20:58 +02:00
Lauri Ojansivu 2912f4b080 Close #6870: attachments are already off site-wide in Admin Panel / Attachments / Transfer limits.
How: Admin Panel / Attachments / Transfer limits, set "Attachments upload" and
"API upload" to Blocked. That is the site-wide "Disable attachments" the issue
asks for, as two settings instead of one checkbox.

Comment for the issue:

  A site-wide switch for this already exists, so a new "Disable attachments"
  setting is not needed. Open Admin Panel / Attachments / Transfer limits and set
  "Attachments upload" and "API upload" to Blocked. While they are on, the server
  refuses an upload on every path - the upload pipeline, the api.attachment.upload
  and api.attachment.copy methods, and the /api/attachment upload,
  upload-background and copy routes - on every board, including boards created
  later, and no board's allowsAttachments flag has to be touched. Download, move
  and delete are not affected, so files uploaded earlier stay reachable. What was
  missing is that a card still showed the "+" button and accepted a drop for an
  upload that would then be refused; since d8b3579117 the card asks the server
  once a minute and hides both. There is no second setting, so nothing can
  disagree with what the server enforces. Two settings instead of one checkbox
  is the one difference from the request.

Thanks to Raymond-Voogt and xet7 !

Fixes #6870,
2026-11-04 19:17:53 +02:00
Lauri Ojansivu 97d1eca09d CHANGELOG: In short for the upcoming release. 2026-11-04 19:12:14 +02:00
Lauri Ojansivu 95b75177b8 CHANGELOG: continuous backup runs on one server at a time, with a lease. 2026-11-04 19:12:06 +02:00
Lauri Ojansivu 880ec2d707 Continuous backup runs on one server at a time, with a lease.
With several WeKan servers on one database, every server applied the continuous
backup setting, so each of them tailed the oplog and wrote its own archives into
the same target directory (#6861, item 3).

The leader holds a lease: one continuousBackupLease document with its id and an
expiry. Taking it is one atomic operation (an insert while there is none, else
an update that matches only an unheld, own or expired lease), the leader renews
it every 15 seconds and it lapses after 45, so another server takes over within
a minute when the leader stops. A server that loses the lease stops its stream.
Startup, saving the settings and a change saved on another server all converge
through that loop, and a restore or a fetch from the cloud takes the lease and
waits one renewal so it is the only writer. One server is always its own leader.

Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader; renewal, expiry, loss of the old leader and release behave.
tests/continuousBackupLease.test.cjs pins the same and the wiring in
server/continuousBackup.js. docs/Backup/Continuous-Backup.md no longer says
single-server only.

Thanks to Raymond-Voogt and xet7 !

Refs #6861, not closed: the remaining items of the issue were fixed in 4e28465964.
2026-11-04 19:11:32 +02:00
Lauri Ojansivu dbc7cccea3 CHANGELOG: a card no longer offers the attachment plus when uploads are blocked for the whole site. 2026-11-04 19:07:11 +02:00
Lauri Ojansivu d8b3579117 A card no longer offers the attachment "+" when uploads are blocked for the whole site.
#6870 asks for a site-wide switch that turns attachments off, because the
per-board allowsAttachments flag has to be set on every board. That switch is
there already: Admin Panel / Attachments / Transfer limits, "Attachments upload"
and "API upload" set to Blocked, and the server refuses on every path - the
upload pipeline, api.attachment.upload and .copy, and the /api/attachment
upload, upload-background and copy routes. What was missing is that the card
still showed the "+" button and accepted a drop for an upload that would be
refused.

attachmentUploadPolicy tells a signed-in client the one boolean (nothing about
the limits), asked at most once a minute, and the "+" and the drop/paste upload
follow it. No second setting is added, so there is nothing that could disagree
with what the server enforces. tests/attachmentUploadPolicy.test.cjs pins the
client, the method, and the server enforcement it relies on, and that no
"disableAttachments" setting exists.

Thanks to Raymond-Voogt and xet7 !

Refs #6870, not closed: the reporter asked for a single Features checkbox, and the
two Blocked limits are two settings.
2026-11-04 19:07:09 +02:00
Lauri Ojansivu c7e53e9f38 CHANGELOG: an integration update for a missing or wrong-board integration returns 404. 2026-11-04 19:04:14 +02:00
Lauri Ojansivu 6012521ccc An integration update for a missing or wrong-board integration returns 404.
PUT /api/boards/:boardId/integrations/:intId answered 200 with the requested id
when no integration matched that id and board (PR #6871). The two activities
routes (POST and DELETE .../activities) had the same fault: they updated
nothing and answered 200 with `data: null`. All three now look the integration
up on that board after board-admin authorization and answer the JSON 404 the GET
route already uses (#6862), before validating or writing anything.

tests/restIntegrationUpdate.test.cjs and the Playwright spec are the PR's.
tests/restIntegrationWriteRoutes404.test.cjs adds the activities routes and
pins, for the whole file, that every handler that updates an integration looks
it up first and only after authorization.

Thanks to AhmedLukman and xet7 !
2026-11-04 19:04:12 +02:00
Lauri Ojansivu 0195a5b3d4 Merge remote-tracking branch 'origin/main' 2026-11-04 15:02:36 +02:00
Lauri Ojansivu b12e761afb CHANGELOG: missing-board exports return 404 and an over-long export token 400. 2026-11-04 14:50:49 +02:00
Lauri Ojansivu 0c0d666ffa Missing-board exports return 404, and an over-long export token returns 400.
The board Excel export answered a missing board with HTTP 200 and the text
"Board not found" (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 too, while
export.js already used 404 and 400. They now choose their status the same way.

tests/boardExcelNotFound.test.cjs and the Playwright spec are the PR's.
tests/exportErrorStatuses.test.cjs scans every models/export*.js route for a
text answer without a status, so the fault cannot return anywhere there.

Thanks to AhmedLukman and xet7 !
2026-11-04 14:50:33 +02:00
Lauri Ojansivu fc2ad0a48b CHANGELOG: nodemailer, body-parser, qs, uuid and postcss-selector-parser raised in the bundle. 2026-11-04 14:45:57 +02:00
Lauri Ojansivu dad03959d9 Raise nodemailer, body-parser, qs, uuid and postcss-selector-parser in the bundle.
A Grype scan of the image found advisories in packages that Meteor's own
packages carry in programs/server/npm/node_modules/meteor/<package>, which
package.json cannot reach.

nodemailer 8.0.11 -> 10.3.1: meteor/email only calls createTransport, the
'stream' plugin hook, nodemailer/lib/well-known and nodemailer/lib/mail-composer,
and all four work in 10.3.1 (checked with the bundle's own package specifiers,
through its exports map). body-parser 1.20.6/1.20.8 -> 1.20.9 and 2.3.0 ->
2.4.0: a minimum may now be "name@MAJOR", so each major is raised inside
itself. qs -> 6.16.1, postcss-selector-parser 6.1.4 -> 6.1.5.

uuid 8.3.2 and 9.0.1 -> 11.1.1: 11 moved its entry point, which is why it was
refused before. The bumper now writes the recorded dist/index.js back as an
ESM re-export of dist/esm/index.js, which require() loads and which leaves
uuid's own ESM and CJS entry points as they are.

glob 14 and minimatch 11 are already in package.json. Run against a copy of
the published image's bundle, the bumper raises all of these and every raised
copy loads.

Thanks to the Grype scan reporter and xet7 !
2026-11-04 14:45:04 +02:00
25 changed files with 1266 additions and 80 deletions

No files matched your search

+148
View File
@@ -124,6 +124,154 @@ its reason.
</details>
# Upcoming WeKan ® release
**In short:** **Continuous backup** now runs on one server at a time, with a
lease, when WeKan has
several servers. Missing-board **exports** and **integration updates** answer
404 instead of 200, a
card stops offering the attachment "+" when uploads are blocked, and the Docker
image's **nodemailer**,
**body-parser**, **qs**, **uuid** and **postcss-selector-parser** are raised
past the advisories a Grype
scan found.
This release fixes the following bugs:
**Exports** - what the export routes answer when something is wrong.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0c0d666ffa">Missing-board exports return 404, and an over-long export token returns 400</a>. Thanks to AhmedLukman and xet7.</summary>
The board Excel export answered a missing board with HTTP 200 and only the text
`Board not found` (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 as well,
while `export.js` already used 404 and 400. All of them now choose their status
the same way.
`tests/exportErrorStatuses.test.cjs` scans every `models/export*.js` route for a
text answer without a status, so the fault cannot return anywhere there; the
PR's route and browser tests stay.
</details>
**Continuous backup** - which server streams it.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/880ec2d707">Only one server streams it at a time, holding a lease</a>. Thanks to Raymond-Voogt and xet7.</summary>
With several WeKan servers on one database, every server applied the continuous
backup setting, so
each of them tailed the oplog and wrote its own archives into the same target
directory
(\#6861). The leader now holds a lease, one `continuousBackupLease` document
with its id and an
expiry. Taking it is one atomic operation, the leader renews it every 15 seconds
and it lapses after
45, so another server takes over within a minute when the leader stops, and a
server that loses the
lease stops its stream. Startup, saving the settings and a change saved on
another server converge
through that loop; a restore or a fetch from the cloud takes the lease and waits
one renewal so it
is the only writer. One server is always its own leader.
Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader, and
renewal, expiry, the old leader losing the lease and release behave.
`tests/continuousBackupLease.test.cjs`
pins the same and the wiring.
</details>
**Attachments** - what a card offers when the administrator blocked uploads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d8b3579117">A card no longer offers the attachment "+" when uploads are blocked for the whole site</a>. Thanks to Raymond-Voogt and xet7.</summary>
Admin Panel / Attachments / Transfer limits already has "Attachments upload" and
"API upload" set to
Blocked, and the server refuses on every path: the upload pipeline,
`api.attachment.upload` and
`.copy`, and the `/api/attachment` upload, upload-background and copy routes.
The card still showed
the "+" button and accepted a drop or paste for an upload that would then be
refused. It now asks
the server one boolean, at most once a minute, and hides both. No second setting
was added
(\#6870 proposed a `disableAttachments`), so nothing can disagree with what the
server enforces.
`tests/attachmentUploadPolicy.test.cjs` pins the client, the method, the server
enforcement it relies
on, and that no `disableAttachments` setting exists.
</details>
**REST API** - what the integration routes answer for an integration that is not
there.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6012521ccc">An integration update for a missing or wrong-board integration returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
`PUT /api/boards/:boardId/integrations/:intId` answered 200 with the requested
id when no
integration matched that id and board (PR #6871). The two activities routes
(`POST` and `DELETE`
`.../activities`) updated nothing and answered 200 with `data: null`. All three
now look the
integration up on that board after board-admin authorization and answer the JSON
404 the `GET`
route already uses, before validating or writing anything.
`tests/restIntegrationWriteRoutes404.test.cjs` pins, for the whole file, that
every handler that
updates an integration looks it up first and only after authorization; the PR's
route and browser
tests stay.
</details>
and updates the following dependencies:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/dad03959d9">nodemailer 10.3.1, body-parser 1.20.9 and 2.4.0, qs 6.16.1, uuid 11.1.1 and postcss-selector-parser 6.1.5 in the bundle</a>. Thanks to the Grype scan reporter and xet7.</summary>
A Grype scan of the image found High and Medium advisories in packages that
Meteor's own packages carry inside `programs/server/npm/node_modules/meteor/`,
where `package.json` and its `overrides` cannot reach.
`releases/bump-bundle-npm-deps.mjs` raises them after `meteor build`.
- **nodemailer 8.0.11 to 10.3.1** (GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j,
GHSA-p6gq-j5cr-w38f and the rest fixed in 10.1.4 or later). It was listed as
not fixable because the fix is a new major. `meteor/email` only calls
`createTransport`, the `stream` plugin hook, `nodemailer/lib/well-known` and
`nodemailer/lib/mail-composer`, and all four were run against 10.3.1 through
the bundle's own package specifiers.
- **body-parser 1.20.6 and 1.20.8 to 1.20.9, and 2.3.0 to 2.4.0**
(GHSA-27r3-jxmh-p828). A minimum may now be written `body-parser@2`, which
applies to copies of that major only, so each major is raised inside itself.
- **qs to 6.16.1** (GHSA-6pq8-jhm2-2vcw) and **postcss-selector-parser 6.1.4 to
6.1.5** (GHSA-rj75-hqrm-r3gf).
- **uuid 8.3.2 and 9.0.1 to 11.1.1** (GHSA-w5hq-g745-h8pq). uuid 11 moved its
entry point from `dist/index.js` to `dist/cjs/index.js`, which killed the
server on boot the one time it was tried. The bumper now writes the old
`dist/index.js` back as `export * from './esm/index.js'`: it is an ES module
because uuid is `"type": "module"`, and Node 26 `require()`s it.
glob 14 and minimatch 11 were already in `package.json`; the scanned image
predates them. The runtime image installs no compiler (`gcc-14-base` is Debian's
one-file package), so the multi-stage build the report asks for is already how
it is built. Run against a copy of the published image's bundle, the bumper
raised all of the above and every raised copy loaded.
`tests/bundleNpmSecurityBumps.test.cjs` pins the minimums, the per-major
rule and the shim, and runs the bumper with `--dry-run` on a bundle of
body-parser, uuid and nodemailer copies at, below and above their minimums.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.94 2026-11-04 WeKan ® release
**In short:** On an iPad the **opened card covers the header bar** instead of
+14
View File
@@ -5,6 +5,20 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# Upcoming WeKan ® release
**Continuous backup** now runs on one server at a time, with a
lease, when WeKan has
several servers. Missing-board **exports** and **integration updates** answer
404 instead of 200, a
card stops offering the attachment "+" when uploads are blocked, and the Docker
image's **nodemailer**,
**body-parser**, **qs**, **uuid** and **postcss-selector-parser** are raised
past the advisories a Grype
scan found.
[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)
# v12.94 2026-11-04 WeKan ® release
On an iPad the **opened card covers the header bar** instead of
+1 -1
View File
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
.attachment-gallery
//- #6796: a small "+" like every other section's, not a full-width box.
if canModifyCard
if canAddAttachment
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
i.fa.fa-plus
+8
View File
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
import { formatDateTime } from '/imports/lib/dateUtils';
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
});
Template.attachmentGallery.helpers({
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
canAddAttachment() {
return Utils.canModifyCard() && !attachmentUploadsBlocked();
},
// #6796: the small file button instead of the preview tile
// (models/lib/attachmentTile.cjs).
attachmentTileCompact(file) {
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
return [];
}
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
if (attachmentUploadsBlocked()) return [];
// Check if board allows attachments
const board = card.board();
if (!board || !board.allowsAttachments) {
+23
View File
@@ -0,0 +1,23 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
// #6870: whether the administrator blocked attachment uploads for the whole site
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
// way and a wrong guess here only costs a button.
const blocked = new ReactiveVar(false);
let askedAt = 0;
export function refreshUploadPolicy() {
if (Date.now() - askedAt < 60 * 1000) return;
askedAt = Date.now();
Meteor.callAsync('attachmentUploadPolicy')
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
.catch(() => { askedAt = 0; });
}
// reactive
export function attachmentUploadsBlocked() {
refreshUploadPolicy();
return blocked.get();
}
+22 -10
View File
@@ -20,17 +20,29 @@ only what changes: documents, files and log lines. It writes them seconds after
they happen, to a directory that can be another disk, an NFS share or an
[rclone mount](Rclone/Rclone.md).
## One server only, for now
## Several servers: one of them streams
Continuous backup is for installations with **one WeKan server**. When it is
switched on in the Admin Panel, every WeKan server that shares the database
applies the setting, so with several servers (for example the replicas of a
Kubernetes Deployment) each of them tails the oplog and writes its own archives
into the same target directory: the same backup stream, many times, into one
place. It has no leader: one server should run the stream and another take it
over when that one stops (a lease), and that is not built yet
([#6861](https://github.com/wekan/wekan/issues/6861)). With several servers, use
the database's own backup instead.
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
Deployment), **one of them runs the stream** and the others stand by
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
document, `continuousBackupLease`, holding the leader's id and an expiry.
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
database operation, so two servers can never both lead.
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
and continues from the position the stream saved in the target.
- A server that finds it has lost the lease stops its stream, so two never write one target.
- Saving the settings, switching the feature off and on, and a change saved on another server all
converge through the same loop: the leader follows the change on its next tick.
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
holds it and wait one renewal, so the former leader has stopped before anything is written. The
stream resumes through the loop afterwards.
An installation with one server is always its own leader, so nothing changes for it. The target
directory is still one place: with several servers it must be on storage they all reach (a shared
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
generation instead of continuing.
## Licences: only copyfree code by default
+2
View File
@@ -76,6 +76,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -96,6 +97,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+4
View File
@@ -100,6 +100,7 @@ runOnServer(function() {
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -129,6 +130,7 @@ runOnServer(function() {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
@@ -199,6 +201,7 @@ runOnServer(function() {
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
@@ -218,6 +221,7 @@ runOnServer(function() {
const loginToken = req.query && req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
+25
View File
@@ -0,0 +1,25 @@
'use strict';
// Whether this installation lets people upload attachments (#6870).
//
// The switch already exists: Admin Panel / Attachments / Transfer limits has "Attachments upload"
// and "API upload" set to Blocked (limitSettings.attachmentsUploadBlocked / apiUploadBlocked).
// The server refuses an upload on every path when it is on - the upload pipeline
// (models/attachments.server.js) and the API methods and routes (server/attachmentApi.js,
// server/routes/attachmentApi.js). What was missing is that the card still offered the "+" button
// and a drop zone for an upload that would be refused. This is the one decision the server
// method that tells the client and the tests share, so there is no second setting that could
// disagree with the one the server enforces.
//
// Only the boolean leaves the server: the rest of the limits (sizes, storage paths) are for
// administrators.
function uploadPolicy(limitSettings) {
const limits = limitSettings && typeof limitSettings === 'object' ? limitSettings : {};
return {
// anything but a real `true` keeps uploads as they were
uploadsBlocked: limits.attachmentsUploadBlocked === true,
};
}
module.exports = { uploadPolicy };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Which server streams the continuous backup (#6861, item 3).
//
// The continuous backup tails the oplog and writes archives into one target directory. With several
// WeKan servers on one database - the replicas of a Kubernetes Deployment - every one of them applied
// the setting, so the same stream was written many times into one place. It needs a leader: one
// server runs the stream, and another takes over when it stops.
//
// The leader holds a LEASE: one document, `continuousBackupLease/continuous-backup`, with the
// holder's id and an expiry. Every decision is ONE database operation, so there is no gap between
// "is it free" and "it is mine" for a second server to slip through (the pattern of
// models/lib/dailyClaim.js and dueScanClaim.js):
// - acquireLease: an insert while there is no lease at all (a duplicate key says someone has it),
// else an update whose filter matches only a lease that is unheld, already ours, or expired;
// - the holder renews it every RENEW_MS, and it lapses TTL_MS after the last renewal, so a leader
// that crashed, was rolled out or lost its database for a minute is replaced by the next server
// to look - without waiting for anybody to notice.
// No upsert on purpose: an upsert whose filter does not match has to insert, and what a database
// does then (a duplicate key, or a second document) is the very thing the lease must not depend on.
//
// `collection` is a MongoDB driver collection (insertOne, updateOne).
const LEASE_ID = 'continuous-backup';
const RENEW_MS = 15 * 1000;
const TTL_MS = 45 * 1000;
const isDuplicateKey = error => !!error && (error.code === 11000 || /E11000|duplicate key/i.test(String(error.message || '')));
// Resolves true when `holder` leads from now until the lease expires.
async function acquireLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return true;
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne(
{ _id: LEASE_ID, $or: [{ holder }, { holder: null }, { expiresAt: { $lte: now } }] },
{ $set: { holder, expiresAt } },
);
return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0;
}
// Gives the lease up when this server is the holder (a clean stop, or the feature switched off), so
// the next server does not have to wait for it to expire.
async function releaseLease(collection, holder) {
await collection.updateOne({ _id: LEASE_ID, holder }, { $set: { holder: null, expiresAt: new Date(0) } });
}
// Takes the lease whoever holds it. For a restore, which must be the only writer: the former leader
// finds at its next renewal that it lost the lease and stops, so the caller then waits RENEW_MS
// before it writes anything (see waitMs).
async function takeoverLease(collection, holder, now = new Date(), ttlMs = TTL_MS) {
const expiresAt = new Date(now.getTime() + ttlMs);
try {
await collection.insertOne({ _id: LEASE_ID, holder, expiresAt });
return { waitMs: 0 };
} catch (error) {
if (!isDuplicateKey(error)) throw error;
}
const result = await collection.updateOne({ _id: LEASE_ID }, { $set: { holder, expiresAt } });
// taking a lease this server already held needs no wait: nobody else was streaming
return { waitMs: RENEW_MS, matched: (result.matchedCount || 0) > 0 };
}
module.exports = { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease };
+19 -4
View File
@@ -155,14 +155,20 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-bundle-bump-'));
const bumped = [];
const skipped = [];
for (const [name, minimum] of Object.entries(manifest.minimums)) {
const outdated = copiesOf(name).filter(c => compare(c.version, minimum) < 0);
for (const [key, minimum] of Object.entries(manifest.minimums)) {
// "body-parser@2" limits the minimum to copies of that major; "@babel/runtime"
// is a scoped name, not a major, so only an @ after the first character counts.
const at = key.lastIndexOf('@');
const name = at > 0 ? key.slice(0, at) : key;
const major = at > 0 ? key.slice(at + 1) : null;
const outdated = copiesOf(name).filter(c =>
(major === null || c.version.split('.')[0] === major) && compare(c.version, minimum) < 0);
if (!outdated.length) {
skipped.push(name);
skipped.push(key);
continue;
}
const prefix = path.join(tmpRoot, name.replace('/', '+'));
const prefix = path.join(tmpRoot, key.replace(/[\/@]/g, '+') + '-' + minimum);
if (!dryRun) {
fs.mkdirSync(prefix, { recursive: true });
// A prefix with no package.json is what we want: npm installs the one
@@ -180,6 +186,15 @@ for (const [name, minimum] of Object.entries(manifest.minimums)) {
fs.rmSync(copy.dir, { recursive: true, force: true });
fs.cpSync(path.join(prefix, 'node_modules', name), copy.dir, { recursive: true });
// The entry file the bundle recorded for the OLD version, written back so it
// resolves again (uuid 11 moved dist/index.js to dist/cjs/index.js).
for (const [rel, body] of Object.entries((manifest.shims || {})[name] || {})) {
const file = path.join(copy.dir, rel);
if (fs.existsSync(file)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
console.log(` + ${rel} (shim for the entry point ${name} ${copy.version} was built against)`);
}
// Whatever the new version needs that this node_modules does not have. An
// existing sibling is left as it is - it is some other package's choice.
+30 -32
View File
@@ -7,10 +7,20 @@
"be raised is the bundle, after `meteor build` has produced it.",
"",
"Every entry is a version that FIXES a published advisory in the version the",
"bundle would otherwise ship, and stays INSIDE THE SAME MAJOR. That is not",
"caution for its own sake: a Meteor bundle records the file a package resolves",
"to when it is built, so a major that moves its entry point cannot be swapped",
"in afterwards - see uuid under notFixable, which is what taught us that.",
"bundle would otherwise ship. A minimum stays inside the same major WHEN a fix",
"exists there. When the fix is only in a newer major it is still taken, but only",
"after the new major was run the way the Meteor package uses it: nodemailer 10",
"against the calls meteor/email makes, uuid 11 through a shim (see \"shims\").",
"",
"A key may be \"name@MAJOR\" (body-parser@2): the minimum then applies only to",
"copies of that major, so body-parser 1.x and 2.x are each raised inside their",
"own major and neither is dragged across it.",
"",
"A bundle records the file a package resolves to when it is built, so a major",
"that moved its entry point cannot just be swapped in: uuid 11 moved it from",
"dist/index.js to dist/cjs/index.js and the server died on boot (\"Cannot find",
"module .../uuid/dist/index.js\"). \"shims\" writes the old entry file back,",
"re-exporting the new one.",
"",
"Applied by releases/bump-bundle-npm-deps.mjs, pinned by",
"tests/bundleNpmSecurityBumps.test.cjs. A package NOT listed here is a package",
@@ -21,7 +31,8 @@
"minimums": {
"@babel/runtime": "7.29.7",
"baseline-browser-mapping": "2.11.0",
"body-parser": "1.20.6",
"body-parser": "1.20.9",
"body-parser@2": "2.4.0",
"browserslist": "4.28.7",
"colord": "2.9.4",
"compression": "1.8.2",
@@ -30,43 +41,30 @@
"lodash": "4.18.1",
"lodash.template": "4.18.1",
"nanoid": "3.3.18",
"nodemailer": "8.0.11",
"nodemailer": "10.3.1",
"on-headers": "1.1.0",
"openpgp": "5.11.3",
"picomatch": "4.0.5",
"postcss": "8.5.26",
"qs": "6.16.0",
"postcss-selector-parser": "6.1.5",
"qs": "6.16.1",
"source-map-js": "1.2.2",
"svgo": "2.8.4",
"tmp": "0.2.7",
"underscore": "1.13.8"
"underscore": "1.13.8",
"uuid": "11.1.1"
},
"notFixable": {
"nodemailer": [
"8.0.11 is the newest 8.x. CVE-2026-82659 (fixed in 9.0.1), GHSA-2x7j-588g-ccc2,",
"GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp (9.1.0), GHSA-8m3c-c648-2xjj (9.1.1),",
"GHSA-6vj9-mwq6-2f5v and GHSA-8vvx-rff5-p5rq (10.0.2) and GHSA-v53p-9fqp-m79j",
"(10.0.6) are fixed only in majors the Meteor email package is not written",
"against. They move when meteor/email moves."
],
"postcss-selector-parser": [
"CVE-2026-104844 in the 6.1.4 meteor/minifier-css brings (through cssnano) is",
"fixed only in 7.1.6, a new major its dependents are not written against.",
"minifier-css runs at build time; it moves when Meteor's minifier-css moves."
],
"uuid": [
"CVE-2026-41907 affects the 8.3.2 in meteor/ddp-server and",
"meteor/inter-process-messaging and the 9.0.1 ldapts brings to wekan-ldap,",
"and the lowest fixed release is 11.1.1 - a major that MOVED its entry",
"point from dist/index.js to dist/cjs/index.js. The bundle resolves the old",
"path, so putting 11.1.1 in its place stops the server before it starts:",
"",
" error on boot.js Error: Cannot find module",
" \"/node_modules/meteor/inter-process-messaging/node_modules/uuid/dist/index.js\"",
"",
"It was tried, it failed exactly there, and it is left at the version the",
"packages were built against. It moves when those packages move: Meteor's",
"for ddp-server and inter-process-messaging, ldapts' for wekan-ldap."
"6.1.5 fixes GHSA-rj75-hqrm-r3gf and is taken. CVE-2026-104844 in the copy",
"meteor/minifier-css brings (through cssnano) is fixed only in 7.1.6, a new major",
"its dependents are not written against. minifier-css runs at build time; it",
"moves when Meteor's minifier-css moves."
]
},
"shims": {
"uuid": {
"dist/index.js": "export * from './esm/index.js';\n"
}
}
}
+73 -8
View File
@@ -6,6 +6,7 @@
import { Meteor } from 'meteor/meteor';
import { check, Match } from 'meteor/check';
import { Mongo, MongoInternals } from 'meteor/mongo';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
import { ZipArchive } from 'archiver';
import path from 'path';
@@ -31,10 +32,18 @@ function cloudRemote(provider) {
return adapter ? adapterRemote(adapter.storage, adapter.bucketName) : null;
}
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
const { RENEW_MS, acquireLease, releaseLease, takeoverLease } = require('/models/lib/backupLease');
export const ContinuousBackupSettings = new Mongo.Collection('continuousBackupSettings');
ContinuousBackupSettings.deny({ insert: () => true, update: () => true, remove: () => true });
// #6861: with several servers on one database only one streams (models/lib/backupLease.js).
export const ContinuousBackupLease = new Mongo.Collection('continuousBackupLease');
ContinuousBackupLease.deny({ insert: () => true, update: () => true, remove: () => true });
const leaseCollection = () => ContinuousBackupLease.rawCollection();
// This server's name in the lease.
const serverId = Random.id();
const writablePath = () => path.resolve(process.env.WRITABLE_PATH || process.cwd());
const filesRoot = () => filesRootFrom(writablePath());
// The FerretDB SQLite directory, from the environment the startup scripts
@@ -78,6 +87,55 @@ async function requireSiteAdmin(userId) {
}
let restoring = false;
// The leader loop. Every server with the feature switched on runs it: the one holding the lease
// streams, the others stand by and one of them takes over within about a minute when the leader
// stops renewing (a pod restart, a rollout, a crash). A single server is always its own leader.
let leading = false;
let appliedStamp = null;
let leaseTimer = null;
const stampOf = settings => String(settings.updatedAt ? new Date(settings.updatedAt).getTime() : 0);
async function evaluateLease() {
if (restoring) return;
try {
const saved = await ContinuousBackupSettings.findOneAsync('settings');
const settings = await savedSettings();
if (!settings.enabled) {
if (backupManager().status().running) await backupManager().stop();
if (leading) await releaseLease(leaseCollection(), serverId);
leading = false; appliedStamp = null;
return;
}
leading = await acquireLease(leaseCollection(), serverId);
if (!leading) {
// someone else streams: stop ours, if it was running, so two never write one target
if (backupManager().status().running) await backupManager().stop();
appliedStamp = null;
return;
}
const stamp = stampOf(saved || {});
// start the stream, or follow a settings change another server saved
if (!backupManager().status().running || stamp !== appliedStamp) {
await backupManager().apply(settings);
appliedStamp = stamp;
}
} catch (error) { reportProblem('lease', error); }
}
function startLeaseLoop() {
if (leaseTimer) return;
leaseTimer = Meteor.setInterval(() => { evaluateLease(); }, RENEW_MS);
}
// A restore, and a fetch from the cloud, must be the only writer into the target: take the lease
// from whoever has it and wait one renewal, so the former leader has found out and stopped.
async function becomeOnlyWriter() {
const wasLeader = leading;
const { waitMs } = await takeoverLease(leaseCollection(), serverId);
leading = true;
if (!wasLeader) await new Promise(resolve => Meteor.setTimeout(resolve, waitMs));
}
Meteor.methods({
async 'continuousBackup.getSettings'() {
await requireSiteAdmin(this.userId);
@@ -104,12 +162,14 @@ Meteor.methods({
}
}
await ContinuousBackupSettings.upsertAsync('settings', { $set: { ...settings, updatedAt: new Date(), updatedBy: this.userId } });
await backupManager().apply(settings);
return backupManager().status();
// Not apply(): this server may not be the leader. It converges through the lease like every
// other server; the leader follows the change on its next tick.
await evaluateLease();
return { ...backupManager().status(), leading };
},
async 'continuousBackup.status'() {
await requireSiteAdmin(this.userId);
return { ...backupManager().status(), restoring };
return { ...backupManager().status(), restoring, leading };
},
// A lost or empty target filled again from the cloud copy, so its restore
// points can be restored. Files already here are kept.
@@ -121,13 +181,15 @@ Meteor.methods({
const remote = cloudRemote(settings.upload);
if (!remote) throw new Meteor.Error('continuous-backup-no-upload', `The ${settings.upload} storage is not configured`);
restoring = true;
await backupManager().stop();
try {
await becomeOnlyWriter();
await backupManager().stop();
fs.mkdirSync(settings.target, { recursive: true, mode: 0o700 });
return await fetchFromCloud({ target: settings.target, remote, prefix: settings.uploadPrefix });
} finally {
restoring = false;
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
if (settings.enabled) await evaluateLease();
}
},
async 'continuousBackup.restorePoints'() {
@@ -164,6 +226,7 @@ Meteor.methods({
// The stream pauses while the restore reads its target, and resumes
// from its saved position after it: the restore's writes are changes
// like any other, and are recorded then.
await becomeOnlyWriter();
await backupManager().stop();
try {
if (request.what === 'database') {
@@ -175,7 +238,9 @@ Meteor.methods({
.map(source => [`${source.area}\0${source.key}`, { path: source.path, file: !!source.file }]));
return await restoreFiles({ target, name: request.generation, until: request.until, roots, mode: request.mode, cipher });
} finally {
if (settings.enabled) await backupManager().apply(settings).catch(error => reportProblem('restart', error));
appliedStamp = null;
restoring = false;
if (settings.enabled) await evaluateLease();
}
} catch (error) {
if (error.code === 'continuous-backup-restore-refused' || error.code === 'continuous-backup-key') {
@@ -189,7 +254,7 @@ Meteor.methods({
Meteor.startup(async () => {
try {
const settings = await savedSettings();
if (settings.enabled) await backupManager().apply(settings);
await evaluateLease();
startLeaseLoop();
} catch (error) { reportProblem('startup', error); }
});
+21
View File
@@ -0,0 +1,21 @@
import { Meteor } from 'meteor/meteor';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
const { uploadPolicy } = require('/models/lib/attachmentUploadPolicy');
// #6870: tells a signed-in client whether uploads are blocked site-wide, so a card does not offer a
// "+" button and a drop zone for an upload the server will refuse. The refusal itself is enforced
// on the server whatever the client does (models/attachments.server.js, server/attachmentApi.js);
// this only decides what is shown. Only the boolean is returned.
Meteor.methods({
async attachmentUploadPolicy() {
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
let limitSettings;
try {
limitSettings = (await AttachmentStorageSettings.findOneAsync({}))?.limitSettings;
} catch (error) {
// an unreadable setting leaves uploads as they were, as the server's own check does
limitSettings = undefined;
}
return uploadPolicy(limitSettings);
},
});
+20
View File
@@ -110,11 +110,29 @@ WebApp.handlers.post('/api/boards/:boardId/integrations', async function(req, re
}
});
// An integration that is not on THIS board (a wrong id, or the id of another board's
// integration) answers 404, as GET does (#6862). The write routes used to answer 200 -
// PUT with the requested id, the activities routes with `data: null` - for an update
// that changed nothing, so a client could not tell it from a success. Checked after
// board-admin authorization, so it tells nobody without that right anything.
async function integrationOr404(res, boardId, intId) {
const integration = await ReactiveCache.getIntegration(
{ _id: intId, boardId },
{ fields: { _id: 1 } },
);
if (!integration) {
sendJsonResult(res, { code: 404, data: { error: 'Integration not found' } });
return null;
}
return integration;
}
WebApp.handlers.put('/api/boards/:boardId/integrations/:intId', async function(req, res) {
try {
const paramBoardId = req.params.boardId;
const paramIntId = req.params.intId;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
// #3695: how this webhook delivers - { webhook: { text, fields, grouping,
// schedule, ... } }, or null to inherit the board / Admin Panel default
@@ -201,6 +219,7 @@ WebApp.handlers.delete(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
@@ -228,6 +247,7 @@ WebApp.handlers.post(
const paramIntId = req.params.intId;
const newActivities = req.body.activities;
await Authentication.checkBoardAdmin(req.userId, paramBoardId);
if (!(await integrationOr404(res, paramBoardId, paramIntId))) return;
await Integrations.direct.updateAsync(
{ _id: paramIntId, boardId: paramBoardId },
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// #6870: attachments off for the whole installation. The switch exists already - Admin Panel /
// Attachments / Transfer limits, "Attachments upload" and "API upload" set to Blocked - and the
// server refuses on every path. What the card lacked was to STOP OFFERING the upload. This pins
// the client side, and the server enforcement it relies on, so the two cannot drift apart.
//
// Run: node tests/attachmentUploadPolicy.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { uploadPolicy } = require('../models/lib/attachmentUploadPolicy');
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
test('only a real true blocks uploads', () => {
assert.deepEqual(uploadPolicy({ attachmentsUploadBlocked: true }), { uploadsBlocked: true });
for (const v of [undefined, null, {}, [], 'x', { attachmentsUploadBlocked: false },
{ attachmentsUploadBlocked: 'true' }, { attachmentsUploadBlocked: 1 }, { apiUploadBlocked: true }]) {
assert.deepEqual(uploadPolicy(v), { uploadsBlocked: false }, JSON.stringify(v));
}
});
test('negative: the answer carries only the boolean, nothing about limits or storage', () => {
const answer = uploadPolicy({ attachmentsUploadBlocked: true, attachmentsUploadMaxBytes: 5, apiUploadMaxBytes: 7, secret: 'x' });
assert.deepEqual(Object.keys(answer), ['uploadsBlocked']);
});
test('the method answers signed-in users only, and falls back to "not blocked" on a read error', () => {
const src = read('server/methods/attachmentUploadPolicy.js');
assert.match(src, /async attachmentUploadPolicy\(\) \{\s*if \(!this\.userId\) throw new Meteor\.Error\('error-notAuthorized'\);/);
assert.match(src, /catch \(error\) \{[^}]*limitSettings = undefined;/);
});
test('the card offers the "+" only when uploads are not blocked', () => {
const jade = read('client/components/cards/attachments.jade');
assert.match(jade, /if canAddAttachment\s*\n\s*a\.card-label\.add-label\.add-attachment\.js-add-attachment/);
assert.doesNotMatch(jade, /if canModifyCard\s*\n\s*a\.card-label\.add-label\.add-attachment/, 'the old condition is gone');
const js = read('client/components/cards/attachments.js');
assert.match(js, /canAddAttachment\(\) \{\s*return Utils\.canModifyCard\(\) && !attachmentUploadsBlocked\(\);/);
// and a drop or paste does nothing either
assert.match(js, /if \(attachmentUploadsBlocked\(\)\) return \[\];\s*\n\s*\/\/ Check if board allows attachments/);
});
test('the client asks once a minute at most and shows uploads until the server answers', () => {
const src = read('client/lib/attachmentUploadPolicy.js');
assert.match(src, /new ReactiveVar\(false\)/);
assert.match(src, /Date\.now\(\) - askedAt < 60 \* 1000/);
assert.match(src, /catch\(\(\) => \{ askedAt = 0; \}\)/);
});
test('the server refuses on every upload path whatever the client shows', () => {
const pipeline = read('models/attachments.server.js');
assert.match(pipeline, /uploadsBlocked = settings\?\.limitSettings\?\.attachmentsUploadBlocked === true/);
const methods = read('server/attachmentApi.js');
const routes = read('server/routes/attachmentApi.js');
// api.attachment.upload and api.attachment.copy, the HTTP upload, upload-background and copy
assert.equal((methods.match(/if \(apiUploadBlocked\)/g) || []).length >= 2, true, 'upload and copy methods');
assert.equal((routes.match(/if \(apiUploadBlocked\)/g) || []).length >= 3, true, 'upload, background and copy routes');
});
test('negative: there is no second "disable attachments" setting to disagree with the server', () => {
for (const f of ['models/settings.js', 'server/publications/settings.js', 'client/components/settings/settingBody.jade']) {
assert.doesNotMatch(read(f), /disableAttachments/, f);
}
});
+107
View File
@@ -0,0 +1,107 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const exportFields = require('../models/lib/exportFields');
function fixture({ board, disabled = false, lookupError } = {}) {
let handler;
const calls = [];
class Exporter {
constructor() { calls.push('exporter'); }
async build(res) { calls.push('build'); res.end('workbook'); }
}
const context = {
runOnServer: fn => fn(),
ReactiveCache: { async getBoard(id) {
calls.push(['board', id]);
if (lookupError) throw lookupError;
return board;
} },
Meteor: { settings: { public: {} } },
require(name) {
switch (name) {
case './server/ExporterExcel': return { ExporterExcel: Exporter };
case './server/ExporterExcelBoard': return { ExporterExcelBoard: Exporter };
case '/models/lib/exportFields': return exportFields;
case 'meteor/webapp': return { WebApp: { handlers: { get(url, fn) {
assert.equal(url, '/api/boards/:boardId/exportExcel');
handler = fn;
} } } };
case '/server/apiMiddleware': return { safeRoute: fn => fn };
case '/server/authentication': return { Authentication: { async checkUserId() {
calls.push('authenticate');
throw Object.assign(new Error('Unauthorized'), { statusCode: 403 });
} } };
case '/models/lib/importExportSecurity': return { async refuseDisabledExport(res) {
if (!disabled) return false;
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Export disabled');
return true;
} };
default: throw new Error(`Unexpected module: ${name}`);
}
},
};
const source = fs.readFileSync(path.join(__dirname, '../models/exportExcel.js'), 'utf8');
vm.runInNewContext(source.replace(/^import .*;\r?\n/gm, ''), context);
assert.equal(typeof handler, 'function');
return {
calls,
async get(query = {}) {
const res = {
statusCode: 200,
headers: {},
ends: 0,
writeHead(status, headers) { this.statusCode = status; this.headers = headers; },
end(body) { this.body = body; this.ends += 1; },
};
await handler({ params: { boardId: 'board' }, query }, res);
assert.equal(res.ends, 1, 'respond exactly once');
return res;
},
};
}
for (const query of [{}, { authToken: 'token' }]) {
test(`a missing board returns plain-text 404 ${query.authToken ? 'with a token' : 'anonymously'}`, async () => {
const f = fixture();
const res = await f.get(query);
assert.equal(res.statusCode, 404);
assert.equal(res.headers['Content-Type'], 'text/plain; charset=utf-8');
assert.equal(res.body, 'Board not found');
assert.deepEqual(f.calls, [['board', 'board']]);
});
}
for (const query of [{}, { fields: 'description' }]) {
test(`an existing public board still builds ${query.fields ? 'the table' : 'the card layout'}`, async () => {
const f = fixture({ board: { isPublic: () => true } });
const res = await f.get(query);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'workbook');
assert.deepEqual(f.calls, [['board', 'board'], 'exporter', 'build']);
});
}
test('an existing private board still requires authentication', async () => {
const f = fixture({ board: { isPublic: () => false } });
const res = await f.get();
assert.equal(res.statusCode, 403);
assert.equal(res.body, 'Unauthorized');
assert.deepEqual(f.calls, [['board', 'board'], 'authenticate']);
});
test('disabled exports are refused before looking up a board', async () => {
const f = fixture({ disabled: true });
assert.equal((await f.get()).statusCode, 403);
assert.deepEqual(f.calls, []);
});
test('a database failure is not misreported as a missing board', async () => {
const error = new Error('database unavailable');
await assert.rejects(() => fixture({ lookupError: error }).get(), error);
});
+53 -19
View File
@@ -41,8 +41,9 @@ console.log('bundleNpmSecurityBumps:');
test('the manifest lists the packages the scan found, at fixed versions', () => {
const expected = {
'nodemailer': '8.0', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16', 'body-parser': '1.20',
'nodemailer': '10.3', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16.1', 'body-parser': '1.20.9', 'body-parser@2': '2.4',
'uuid': '11.1.1', 'postcss-selector-parser': '6.1.5',
'cookie': '0.7', 'on-headers': '1.1', 'tmp': '0.2', 'diff': '3.5',
'@babel/runtime': '7.', 'underscore': '1.13.8',
// Artifact Hub's Trivy report of the v12.38 image: same-major fixes.
@@ -57,25 +58,58 @@ test('the manifest lists the packages the scan found, at fixed versions', () =>
}
});
test('uuid is NOT bumped, and says why (negative)', () => {
// The one that was tried and reverted. If somebody adds it back, the bundle
// stops booting - so the reason lives where they would look.
assert.ok(!('uuid' in manifest.minimums), 'uuid must not be in minimums');
const reason = (manifest.notFixable.uuid || []).join(' ');
assert.ok(/Cannot find module/.test(reason) && /dist\/cjs\/index\.js/.test(reason),
'and notFixable carries the boot error it caused');
test('uuid is bumped to 11.1.1 and the entry point the bundle recorded is written back', () => {
// uuid 11 moved its entry from dist/index.js to dist/cjs/index.js, and a bundle
// that recorded dist/index.js died on boot ("Cannot find module
// .../uuid/dist/index.js"). The fix is a shim at the old path, not a refusal.
assert.strictEqual(manifest.minimums.uuid, '11.1.1');
const shim = (manifest.shims || {}).uuid || {};
assert.strictEqual(shim['dist/index.js'], "export * from './esm/index.js';\n",
'dist/index.js re-exports the ESM build (uuid is "type": "module"; a CJS shim would be parsed as ESM)');
assert.ok(/manifest\.shims/.test(bumper) && /writeFileSync\(file, body\)/.test(bumper),
'and the bumper writes it after every replacement');
// negative: nothing is described as unfixable that was fixed
for (const name of ['uuid', 'nodemailer']) {
assert.ok(!(name in manifest.notFixable), `${name} must not stay in notFixable`);
}
});
test('a fix that needs a new major is recorded in notFixable, never in minimums (negative)', () => {
for (const name of ['nodemailer', 'postcss-selector-parser', 'uuid']) {
const reason = (manifest.notFixable[name] || []).join(' ');
assert.ok(/major/.test(reason), `${name} says the fix is a new major`);
}
// nodemailer stays at the newest 8.x; the 9.x/10.x fixes are named, not taken.
assert.ok(manifest.minimums.nodemailer.startsWith('8.'));
assert.ok(!('postcss-selector-parser' in manifest.minimums));
// lodash.template has had a fixed release (4.18.x) since 2026-04; it is no
// longer described as unfixable.
test('body-parser 1.x and 2.x are each raised inside their own major', () => {
assert.strictEqual(manifest.minimums['body-parser'], '1.20.9');
assert.strictEqual(manifest.minimums['body-parser@2'], '2.4.0');
const fs2 = require('fs'), os = require('os'), cp = require('child_process');
const bundle = fs2.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'bump-test-'));
const put = (dir, version) => {
const d = path.join(bundle, 'programs/server/npm/node_modules', dir);
fs2.mkdirSync(d, { recursive: true });
fs2.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version }));
};
put('meteor/a/node_modules/body-parser', '1.20.6');
put('meteor/b/node_modules/body-parser', '2.3.0');
put('meteor/c/node_modules/body-parser', '2.4.0');
put('meteor/d/node_modules/body-parser', '1.20.9');
put('meteor/e/node_modules/uuid', '8.3.2');
put('meteor/f/node_modules/uuid', '14.0.2');
put('meteor/g/node_modules/nodemailer', '8.0.11');
const out = cp.execFileSync(process.execPath,
[path.join(repoRoot, 'releases/bump-bundle-npm-deps.mjs'), bundle, '--dry-run'], { encoding: 'utf8' });
fs2.rmSync(bundle, { recursive: true, force: true });
const lines = out.split('\n').filter(l => /->/.test(l));
const has = re => lines.some(l => re.test(l));
assert.ok(has(/body-parser 1\.20\.6 -> 1\.20\.9/), '1.x below 1.20.9 is raised to 1.20.9');
assert.ok(has(/body-parser 2\.3\.0 -> 2\.4\.0/), '2.3.0 is raised to 2.4.0');
// negative: neither crosses into the other major, and fixed copies are left alone
assert.ok(!has(/body-parser 1\.\d+\.\d+ -> 2\./) && !has(/body-parser 2\.\d+\.\d+ -> 1\./));
assert.ok(!has(/body-parser 2\.4\.0 ->/) && !has(/body-parser 1\.20\.9 ->/));
assert.ok(has(/uuid 8\.3\.2 -> 11\.1\.1/) && !has(/uuid 14\./), 'uuid 14 is above the minimum');
assert.ok(has(/nodemailer 8\.0\.11 -> 10\.3\.1/));
});
test('a fix that needs a new major its dependents cannot take is recorded in notFixable (negative)', () => {
const reason = (manifest.notFixable['postcss-selector-parser'] || []).join(' ');
assert.ok(/major/.test(reason) && /CVE-2026-104844/.test(reason));
assert.ok(manifest.minimums['postcss-selector-parser'] === '6.1.5',
'but the same-major fix that exists is taken');
assert.ok(!('lodash.template' in manifest.notFixable));
for (const [name, version] of Object.entries(manifest.minimums)) {
assert.ok(/^\d+\.\d+\.\d+$/.test(version), `${name} is pinned to an exact version`);
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// #6861, item 3: with several WeKan servers on one database exactly ONE streams the continuous
// backup, and another takes over when it stops. Run: node tests/continuousBackupLease.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const { LEASE_ID, RENEW_MS, TTL_MS, acquireLease, releaseLease, takeoverLease } = require('../models/lib/backupLease');
// A collection with the atomic semantics the lease relies on: every call is ONE step, and an insert
// of an existing _id is a duplicate key. (Each method runs to completion before the next starts, as
// a database's single-document operations do.)
function fakeCollection() {
const docs = new Map();
const matches = (doc, filter, now) => Object.entries(filter).every(([key, want]) => {
if (key === '$or') return want.some(branch => matches(doc, branch, now));
const have = doc[key];
if (want && typeof want === 'object' && !(want instanceof Date)) {
if ('$lte' in want) return have instanceof Date && have <= want.$lte;
return false;
}
if (want === null) return have === null || have === undefined;
return have === want;
});
return {
docs,
async insertOne(doc) {
await Promise.resolve();
if (docs.has(doc._id)) throw Object.assign(new Error('E11000 duplicate key error'), { code: 11000 });
docs.set(doc._id, { ...doc });
return { insertedId: doc._id };
},
async updateOne(filter, update) {
await Promise.resolve();
const doc = docs.get(filter._id);
if (!doc || !matches(doc, filter)) return { matchedCount: 0, modifiedCount: 0 };
Object.assign(doc, update.$set);
return { matchedCount: 1, modifiedCount: 1 };
},
};
}
const at = ms => new Date(1700000000000 + ms);
test('24 servers acquiring at once: exactly one leads', async () => {
const c = fakeCollection();
const results = await Promise.all(Array.from({ length: 24 }, (_, i) => acquireLease(c, `server-${i}`, at(0))));
assert.equal(results.filter(Boolean).length, 1);
assert.equal(c.docs.size, 1, 'one lease document');
});
test('the leader renews, and the others stay out while the lease is alive', async () => {
const c = fakeCollection();
assert.equal(await acquireLease(c, 'a', at(0)), true);
assert.equal(await acquireLease(c, 'b', at(RENEW_MS)), false, 'b cannot take a live lease');
assert.equal(await acquireLease(c, 'a', at(RENEW_MS)), true, 'a renews');
assert.equal(c.docs.get(LEASE_ID).expiresAt.getTime(), at(RENEW_MS).getTime() + TTL_MS, 'and the expiry moves');
assert.equal(await acquireLease(c, 'b', at(2 * RENEW_MS)), false, 'still a\'s after the renewal');
});
test('failover: a leader that stops renewing is replaced once its lease has expired', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
assert.equal(await acquireLease(c, 'b', at(TTL_MS - 1)), false, 'one millisecond before it lapses');
assert.equal(await acquireLease(c, 'b', at(TTL_MS)), true, 'when it lapses b leads');
assert.equal(await acquireLease(c, 'a', at(TTL_MS + 1)), false, 'and the old leader finds it lost it');
});
test('release lets the next server lead at once; only the holder can release', async () => {
const c = fakeCollection();
await acquireLease(c, 'a', at(0));
await releaseLease(c, 'b');
assert.equal(await acquireLease(c, 'c', at(1)), false, 'b was not the holder, so nothing was released');
await releaseLease(c, 'a');
assert.equal(await acquireLease(c, 'c', at(1)), true, 'a released');
});
test('takeover: a restore becomes the only writer, and the former leader loses the lease', async () => {
const c = fakeCollection();
await acquireLease(c, 'leader', at(0));
const { waitMs } = await takeoverLease(c, 'restorer', at(5));
assert.equal(waitMs, RENEW_MS, 'it waits one renewal so the former leader has stopped');
assert.equal(await acquireLease(c, 'leader', at(RENEW_MS)), false, 'the former leader cannot renew');
assert.equal(await acquireLease(c, 'restorer', at(RENEW_MS)), true);
assert.equal((await takeoverLease(fakeCollection(), 'first', at(0))).waitMs, 0, 'with nobody to wait for there is no wait');
});
test('other database errors are not swallowed (negative)', async () => {
const broken = { async insertOne() { throw new Error('connection lost'); }, async updateOne() { return {}; } };
await assert.rejects(() => acquireLease(broken, 'a', at(0)), /connection lost/);
await assert.rejects(() => takeoverLease(broken, 'a', at(0)), /connection lost/);
});
test('negative: the lease never upserts (an unmatched upsert must insert, and what then happens is the database\'s)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'models/lib/backupLease.js'), 'utf8');
assert.doesNotMatch(src.replace(/\/\/.*$/gm, ''), /upsert/);
});
test('continuousBackup.js runs the stream through the lease, on every path', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server/continuousBackup.js'), 'utf8');
// the only direct apply() is inside the leader loop, after acquireLease said this server leads
const applies = src.split('\n').filter(l => /backupManager\(\)\.apply\(/.test(l));
assert.equal(applies.length, 1, 'one apply(), in evaluateLease');
assert.match(src, /leading = await acquireLease\(leaseCollection\(\), serverId\);\s*\n\s*if \(!leading\) \{[^}]*backupManager\(\)\.stop\(\)/);
assert.match(src, /async function evaluateLease\(\) \{\s*\n\s*if \(restoring\) return;/);
// startup and saveSettings converge through the loop; restore and fetchFromCloud take the lease first
assert.match(src, /Meteor\.startup\(async \(\) => \{\s*try \{\s*await evaluateLease\(\);\s*startLeaseLoop\(\);/);
assert.match(src, /await evaluateLease\(\);\s*\n\s*return \{ \.\.\.backupManager\(\)\.status\(\), leading \};/);
assert.equal((src.match(/await becomeOnlyWriter\(\);/g) || []).length, 2, 'restore and fetchFromCloud');
assert.match(src, /const wasLeader = leading;[\s\S]*if \(!wasLeader\) await new Promise/);
// a settings change another server saved is followed by the leader
assert.match(src, /stamp !== appliedStamp/);
});
+62
View File
@@ -0,0 +1,62 @@
'use strict';
// A missing board, or a token that is too long, must not answer an export request
// with HTTP 200. PR #6869 fixed the Excel board route; the same two answers were in
// the PDF routes, so this pins the SHAPE of the fault over every export route and
// not only the one that was reported.
//
// Run: node tests/exportErrorStatuses.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { test } = require('node:test');
const modelsDir = path.join(__dirname, '..', 'models');
const files = fs.readdirSync(modelsDir).filter(f => /^export.*\.js$/.test(f));
const read = f => fs.readFileSync(path.join(modelsDir, f), 'utf8').split('\n');
// every `res.end('literal error text')` and the last non-blank line before it
function endsWithoutStatus(lines) {
const found = [];
lines.forEach((line, i) => {
if (!/res\.end\((['"`]|TAPi18n)/.test(line)) return;
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j -= 1;
if (!/writeHead|statusCode|\.status\(/.test(lines[j])) found.push(`${i + 1}: ${line.trim()}`);
});
return found;
}
test('no export route ends a text answer without choosing its status', () => {
assert.ok(files.length >= 5, 'the export route files are found');
for (const f of files) {
assert.deepEqual(endsWithoutStatus(read(f)), [], `${f} answers with the default 200`);
}
});
test('negative: the scan does catch the fault shape', () => {
const bad = ["if (!board) {", " res.end('Board not found');", " return;", "}"];
assert.equal(endsWithoutStatus(bad).length, 1);
const good = ["if (!board) {", " res.writeHead(404, {});", " res.end('Board not found');", "}"];
assert.equal(endsWithoutStatus(good).length, 0);
});
test('"Board not found" is a 404 and a too-long token is a 400, in every export route', () => {
for (const f of files) {
const lines = read(f);
lines.forEach((line, i) => {
const m = /res\.end\('(Board not found|Invalid token)'\)/.exec(line);
if (!m) return;
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j -= 1;
const status = /writeHead\((\d+)/.exec(lines[j]);
assert.ok(status, `${f}:${i + 1} sets a status`);
if (m[1] === 'Board not found') assert.equal(status[1], '404', `${f}:${i + 1}`);
else assert.ok(['400', '401'].includes(status[1]), `${f}:${i + 1} is ${status[1]}`);
});
}
// the PDF routes are the ones that had it
const pdf = fs.readFileSync(path.join(modelsDir, 'exportPDF.js'), 'utf8');
assert.equal((pdf.match(/writeHead\(404[^)]*\);\s*res\.end\('Board not found'\)/g) || []).length, 2);
});
+23 -6
View File
@@ -139,7 +139,15 @@ for (const platform of ['darwin', 'linux']) {
// A watcher that never reports anything: FSEvents in its first moments.
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: platform });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
// "generated asynchronous activity after the test ended".
let stopEngine;
t.after(async () => {
await stopEngine?.();
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
});
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
@@ -150,7 +158,7 @@ for (const platform of ['darwin', 'linux']) {
const scan = engine.scan.bind(engine);
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
t.after(() => engine.stop());
stopEngine = () => engine.stop();
assert.deepEqual(calls.slice(0, 2), ['watch', 'scan'], 'the watcher is up before the first scan');
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
for (let i = 0; i < 40 && engine.status().puts < 2; i += 1) await sleep(100);
@@ -175,7 +183,15 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
const calls = [];
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: 'darwin' });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
// node:test runs after-hooks in the order they were registered, not last-in first-out. The engine
// must be stopped BEFORE its directory is removed: a hook that removed the directory first let
// stop() seal into a path that no longer existed, and the ENOENT surfaced after the test as
// "generated asynchronous activity after the test ended".
let stopEngine;
t.after(async () => {
await stopEngine?.();
fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true });
});
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
@@ -188,7 +204,7 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
let stopped = false;
t.after(() => (stopped ? undefined : engine.stop()));
stopEngine = () => (stopped ? undefined : engine.stop());
if (stopAt === 'at once') { await engine.stop(); stopped = true; }
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
await sleep(2600); // past the two-second catch-up
@@ -200,7 +216,8 @@ for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once
test('file streams: attachments, avatars and logs, with watching, restored to a moment', { timeout: 60000 }, async t => {
const root = tmpRoot();
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
let stopEngine;
t.after(async () => { await stopEngine?.(); fs.rmSync(root, { recursive: true, force: true }); }); // engine first: hooks run in registration order
const live = path.join(root, 'live');
const sources = [
{ area: 'attachments', key: 'attachments', path: path.join(live, 'attachments') },
@@ -223,7 +240,7 @@ test('file streams: attachments, avatars and logs, with watching, restored to a
onError: error => { throw error; }, onState: async s => { state = s; } }).start();
// Its seal and scan timers keep the process alive: stop it however this test
// ends, or a failed assertion below becomes a 300 s suite timeout.
t.after(() => engine.stop());
stopEngine = () => engine.stop();
assert.equal(engine.status().files, 4, 'the symbolic link is skipped');
// Watching notices a change without a rescan (the scan runs hourly here).
// File-system events can lag by seconds on a loaded machine: allow 20 s.
@@ -0,0 +1,58 @@
'use strict';
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const BoardPage = require('../pages/BoardPage');
const ExcelJS = require('../../../node_modules/@wekanteam/exceljs');
async function expectWorkbook(response) {
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('spreadsheetml.sheet');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(await response.body());
const values = [];
workbook.eachSheet(sheet => sheet.eachRow(row => values.push(JSON.stringify(row.values))));
expect(values.join('\n')).toContain('Alpha Card');
}
test('a missing board Excel export returns 404 with or without a token', async ({ request, user }) => {
const url = `/api/boards/${db.uid('missing')}/exportExcel`;
for (const suffix of ['', `?authToken=${encodeURIComponent(user.token)}`]) {
const response = await request.get(url + suffix);
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
expect(response.headers()['content-disposition']).toBeUndefined();
expect(await response.text()).toBe('Board not found');
}
});
test('the board menu exports Excel and a deleted board link returns 404', async ({ boardPage: page, board }) => {
await new BoardPage(page).openSidebar();
await page.locator('.board-sidebar .js-open-board-menu').click();
await page.locator('.js-pop-over .js-export-board').click();
const anchor = page.locator('.js-pop-over a[href*="/exportExcel?"]');
await expect(anchor).toBeVisible();
const href = await anchor.getAttribute('href');
await expectWorkbook(await page.request.get(href));
db.deleteOne('boards', { _id: board.boardId });
const response = await page.goto(href);
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toBe('text/plain; charset=utf-8');
expect(response.headers()['content-disposition']).toBeUndefined();
expect(await response.text()).toBe('Board not found');
await expect(page.locator('body')).toHaveText('Board not found');
});
test('public board Excel exports still produce both workbook layouts', async ({ request, board }) => {
db.updateOne('boards', { _id: board.boardId }, { $set: { permission: 'public' } });
for (const suffix of ['', '?fields=description']) {
await expectWorkbook(await request.get(`/api/boards/${board.boardId}/exportExcel${suffix}`));
}
});
test('private board Excel exports still refuse an unrelated member', async ({ request, board, user2 }) => {
const response = await request.get(`/api/boards/${board.boardId}/exportExcel?authToken=${encodeURIComponent(user2.token)}`);
expect(response.status()).toBe(403);
expect(await response.text()).not.toContain('Alpha Card');
});
@@ -0,0 +1,101 @@
'use strict';
const { test: base, expect } = require('../fixtures');
const db = require('../helpers/db');
const headers = token => ({ Authorization: `Bearer ${token}` });
const integrationPath = (boardId, id) => `/api/boards/${boardId}/integrations/${id}`;
const test = base.extend({
integration: async ({ request, user, board }, use) => {
const response = await request.post(`/api/boards/${board.boardId}/integrations`, {
headers: headers(user.token), data: { url: 'https://example.com/webhook' },
});
expect(response.status()).toBe(200);
const { _id } = await response.json();
try {
await use({ id: _id, path: integrationPath(board.boardId, _id) });
} finally {
db.deleteOne('integrations', { _id });
}
},
});
test('integration PUT rejects missing and wrong-board IDs and preserves real updates', async ({ request, user, board, integration }) => {
const other = db.seedBoard({ ownerId: user.id });
try {
const before = db.findOne('integrations', { _id: integration.id });
for (const path of [
integrationPath(board.boardId, 'missing-integration'),
integrationPath(other.boardId, integration.id),
]) {
for (const data of [{}, { title: 'Should not change', enabled: false, activities: [] }]) {
const response = await request.put(path, { headers: headers(user.token), data });
expect(response.status()).toBe(404);
expect(response.headers()['content-type']).toContain('application/json');
expect(await response.json()).toEqual({ error: 'Integration not found' });
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
}
}
expect(db.findOne('integrations', { _id: 'missing-integration' })).toBeNull();
for (const data of [{ title: 'Updated webhook', enabled: false, activities: [] }, { title: 'Updated webhook', enabled: false }, {}]) {
const response = await request.put(integration.path, { headers: headers(user.token), data });
expect(response.status()).toBe(200);
expect(await response.json()).toEqual({ _id: integration.id });
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Updated webhook', enabled: false, activities: [] });
}
const response = await request.get(integration.path, { headers: headers(user.token) });
expect(response.status()).toBe(200);
expect(await response.json()).toMatchObject({ _id: integration.id, title: 'Updated webhook', enabled: false, activities: [] });
} finally {
db.cleanup({ boardIds: [other.boardId] });
}
});
test('integration PUT preserves authentication and board-admin requirements', async ({ request, board, integration }) => {
const before = db.findOne('integrations', { _id: integration.id });
for (const id of [integration.id, 'missing-integration']) {
const path = integrationPath(board.boardId, id);
expect((await request.put(path, { data: { title: 'Unauthorized' } })).status()).toBe(401);
// A refused management request may block its actor. Use a fresh ordinary
// member for each ID so each request exercises the board-admin guard.
const member = db.seedUser();
try {
db.updateOne('boards', { _id: board.boardId }, { $push: { members: {
userId: member.id, isActive: true, isAdmin: false, isNoComments: false, isCommentOnly: false,
} } });
expect((await request.put(path, { headers: headers(member.token), data: { title: 'Forbidden' } })).status()).toBe(403);
} finally {
db.cleanup({ userIds: [member.id] });
}
}
expect(db.findOne('integrations', { _id: integration.id })).toEqual(before);
});
test('successful REST updates appear in webhook settings after a wrong-board update is rejected', async ({ request, user, board, integration, boardPage }) => {
const other = db.seedBoard({ ownerId: user.id });
try {
const response = await request.put(integration.path, {
headers: headers(user.token), data: { title: 'Retained webhook', enabled: false, activities: [] },
});
expect(response.status()).toBe(200);
const rejected = await request.put(integrationPath(other.boardId, integration.id), {
headers: headers(user.token), data: { title: 'Wrong-board overwrite', enabled: true },
});
expect(rejected.status()).toBe(404);
const menu = boardPage.locator('.board-sidebar .js-open-board-menu');
if (!(await menu.isVisible())) await boardPage.locator('.js-toggle-page-sidebar').first().click();
await menu.click();
await boardPage.locator('.js-outgoing-webhooks').click();
const form = boardPage.locator('form.integration-form').filter({
has: boardPage.locator(`input[name="id"][value="${integration.id}"]`),
});
await expect(form.locator('.js-outgoing-webhooks-title')).toHaveValue('Retained webhook');
await expect(form.locator('.js-toggle-webhook-enabled .materialCheckBox')).toHaveClass(/is-checked/);
expect(db.findOne('integrations', { _id: integration.id })).toMatchObject({ title: 'Retained webhook', enabled: false });
} finally {
db.cleanup({ boardIds: [other.boardId] });
}
});
+124
View File
@@ -0,0 +1,124 @@
'use strict';
// Exercise the registered integration PUT handler, including board scope and
// authorization, without starting Meteor. Live database coverage is in Playwright.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const { normalizeDeliverySettings } = require('../models/lib/notificationDelivery');
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
const route = '/api/boards/:boardId/integrations/:intId';
const plain = value => JSON.parse(JSON.stringify(value));
function fixture({ deny = false, lookupError } = {}) {
const calls = [];
const documents = [
{ _id: 'integration', boardId: 'board', title: 'Webhook', enabled: true, activities: ['all'] },
{ _id: 'foreign-integration', boardId: 'other-board', title: 'Other webhook', enabled: true },
];
const matches = (doc, selector) => Object.entries(selector).every(([key, value]) => doc[key] === value);
let handler;
const context = {
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(verb => [verb, (url, fn) => {
if (verb === 'put' && url === route) handler = fn;
}])) },
Authentication: { async checkBoardAdmin(userId, boardId) {
calls.push(['admin', userId, boardId]);
if (deny) throw Object.assign(new Error('Forbidden'), { statusCode: 403 });
} },
ReactiveCache: { async getIntegration(selector) {
calls.push(['lookup', plain(selector)]);
if (lookupError) throw lookupError;
return documents.find(doc => matches(doc, selector));
} },
Integrations: { direct: { async updateAsync(selector, modifier) {
calls.push(['update', plain(selector), plain(modifier)]);
const doc = documents.find(item => matches(item, selector));
if (!doc) return 0;
Object.assign(doc, modifier.$set);
for (const key of Object.keys(modifier.$unset || {})) delete doc[key];
return 1;
} } },
normalizeDeliverySettings,
publicErrorData,
sendJsonResult(res, result) { res.responses.push(plain(result)); },
};
const source = fs.readFileSync(path.join(__dirname, '../server/models/integrations.js'), 'utf8');
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
assert.equal(typeof handler, 'function');
return {
calls, documents,
async put(body = {}, params = {}) {
const res = { responses: [] };
await handler({ userId: 'admin', params: { boardId: 'board', intId: 'integration', ...params }, body }, res);
assert.equal(res.responses.length, 1, 'respond exactly once');
return res.responses[0];
},
};
}
test('a valid integration updates falsy values and keeps the existing response', async () => {
const f = fixture();
assert.deepEqual(await f.put({ title: 'Updated webhook', enabled: false, activities: [] }), {
code: 200, data: { _id: 'integration' },
});
assert.deepEqual(f.documents[0], {
_id: 'integration', boardId: 'board', title: 'Updated webhook', enabled: false, activities: [],
});
});
test('empty and unchanged updates of an existing integration remain successful', async () => {
const f = fixture();
for (const body of [{}, { title: 'Webhook', enabled: true }]) {
assert.deepEqual(await f.put(body), { code: 200, data: { _id: 'integration' } });
}
});
for (const [name, params] of [
['missing integration', { intId: 'missing' }],
['integration belonging to another board', { intId: 'foreign-integration' }],
['existing integration under another board', { boardId: 'other-board' }],
]) {
test(`${name} returns 404 before any field is written or validated`, async () => {
for (const body of [{}, { title: 'Changed', enabled: false, activities: [] }, { notificationDelivery: 'invalid' }]) {
const f = fixture();
const before = plain(f.documents);
assert.deepEqual(await f.put(body, params), { code: 404, data: { error: 'Integration not found' } });
assert.deepEqual(f.calls, [
['admin', 'admin', params.boardId || 'board'],
['lookup', { _id: params.intId || 'integration', boardId: params.boardId || 'board' }],
]);
assert.deepEqual(f.documents, before);
}
});
}
test('board authorization precedes lookups for both existing and missing IDs', async () => {
for (const intId of ['integration', 'missing']) {
const f = fixture({ deny: true });
const response = await f.put({ title: 'Changed' }, { intId });
assert.equal(response.code, 403);
assert.deepEqual(f.calls, [['admin', 'admin', 'board']]);
assert.equal(f.documents[0].title, 'Webhook');
}
});
test('database errors keep their server-error response instead of becoming 404', async () => {
const f = fixture({ lookupError: new Error('database unavailable') });
const response = await f.put({ title: 'Changed' });
assert.equal(response.code, 500);
assert.equal(f.documents[0].title, 'Webhook');
assert.ok(!f.calls.some(([name]) => name === 'update'));
});
test('invalid delivery settings still reject an existing integration before writes', async () => {
const f = fixture();
const response = await f.put({ title: 'Changed', notificationDelivery: 'invalid' });
assert.equal(response.code, 400);
assert.equal(response.data.error, 'invalid-notification-delivery');
assert.equal(f.documents[0].title, 'Webhook');
assert.ok(!f.calls.some(([name]) => name === 'update'));
});
@@ -0,0 +1,77 @@
'use strict';
// Every integration route that WRITES must answer 404 for an integration that is not on the
// board in the URL (#6862 did GET; PR #6871 did PUT). The two activities routes had the same
// fault: they updated nothing and answered 200 with `data: null`. This pins the shape over the
// whole file, so a new write route cannot bring it back.
//
// Run: node tests/restIntegrationWriteRoutes404.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const { publicErrorData } = require('../server/lib/apiResponseHelpers');
const file = path.join(__dirname, '..', 'server/models/integrations.js');
const source = fs.readFileSync(file, 'utf8');
test('every handler that updates an integration looks it up on that board first', () => {
const updating = source.split(/\nWebApp\.handlers\./).slice(1)
.filter(block => /Integrations\.direct\.updateAsync/.test(block));
assert.ok(updating.length >= 3, 'PUT and both activities routes are found');
for (const block of updating) {
const update = block.indexOf('Integrations.direct.updateAsync');
const guard = block.indexOf('integrationOr404(');
assert.ok(guard > -1 && guard < update, `${block.split('\n')[0]} checks before it writes`);
// and only after board-admin authorization, so the answer tells nobody anything
assert.ok(block.indexOf('checkBoardAdmin') < guard, `${block.split('\n')[0]} authorizes first`);
}
});
test('negative: the check is not the old "write, then answer 200 with whatever is there"', () => {
assert.match(source, /async function integrationOr404\(/);
assert.match(source, /code: 404, data: \{ error: 'Integration not found' \}/);
});
function run(handlerUrl, verb, integrations) {
const calls = [];
let handler;
const context = {
WebApp: { handlers: Object.fromEntries(['get', 'post', 'put', 'delete'].map(v => [v, (url, fn) => {
if (v === verb && url === handlerUrl) handler = fn;
}])) },
Authentication: { async checkBoardAdmin() {} },
ReactiveCache: { async getIntegration(selector) {
return integrations.find(i => i._id === selector._id && i.boardId === selector.boardId);
} },
Integrations: { direct: { async updateAsync(...args) { calls.push(args); return 1; } } },
normalizeDeliverySettings: x => x,
publicErrorData,
sendJsonResult(res, result) { res.responses.push(JSON.parse(JSON.stringify(result))); },
};
vm.runInNewContext(source.slice(source.indexOf('WebApp.handlers.get(')), context);
return {
calls,
async call(params, body) {
const res = { responses: [] };
await handler({ userId: 'u', params, body }, res);
return res.responses[0];
},
};
}
for (const verb of ['post', 'delete']) {
test(`${verb.toUpperCase()} .../activities answers 404 and writes nothing for a missing or foreign integration`, async () => {
const url = '/api/boards/:boardId/integrations/:intId/activities';
const r = run(url, verb, [{ _id: 'i1', boardId: 'b1' }, { _id: 'i2', boardId: 'b2' }]);
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'nope' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
assert.deepEqual(await r.call({ boardId: 'b1', intId: 'i2' }, { activities: ['a'] }), { code: 404, data: { error: 'Integration not found' } });
assert.equal(r.calls.length, 0, 'no write happened');
// positive: its own integration is still updated
const ok = await r.call({ boardId: 'b1', intId: 'i1' }, { activities: ['a'] });
assert.equal(ok.code, 200);
assert.equal(r.calls.length, 1);
});
}