mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ad81e301 | ||
|
|
2912f4b080 | ||
|
|
97d1eca09d | ||
|
|
95b75177b8 | ||
|
|
880ec2d707 | ||
|
|
dbc7cccea3 | ||
|
|
d8b3579117 | ||
|
|
c7e53e9f38 | ||
|
|
6012521ccc | ||
|
|
0195a5b3d4 | ||
|
|
b12e761afb | ||
|
|
0c0d666ffa | ||
|
|
fc2ad0a48b | ||
|
|
dad03959d9 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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] });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user