mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 18:45:11 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cc91cd67 | ||
|
|
33c2ad8fb0 | ||
|
|
c936e7708d | ||
|
|
5c1f6088f4 | ||
|
|
ceb20a81fc | ||
|
|
2128e6e02f | ||
|
|
49ee37a3a8 | ||
|
|
19350f2611 | ||
|
|
2306a36814 | ||
|
|
a4d2313a68 | ||
|
|
570cc34552 | ||
|
|
7ae45651dc | ||
|
|
db52764592 | ||
|
|
2b99ffe288 | ||
|
|
b4b130858f | ||
|
|
0275363043 | ||
|
|
42d8341d47 | ||
|
|
a9aaa22592 | ||
|
|
4d5fe63fc4 |
No files matched your search
@@ -124,6 +124,80 @@ its reason.
|
||||
|
||||
</details>
|
||||
|
||||
# v13.00 2026-11-07 WeKan ® release
|
||||
|
||||
**In short:** **CreateBleed** is fixed: "Only admins can create boards" now
|
||||
holds on every path that creates a board - the browser console, the REST API,
|
||||
imports, copies, clones and cards - not only on the Add Board form. The **REST
|
||||
API documentation** spells the name **WeKan**, so the API pages published on
|
||||
wekan.fi pass its brand-spelling check again.
|
||||
|
||||
This release fixes the following CRITICAL SECURITY ISSUE of [CreateBleed](https://wekan.fi/hall-of-fame/createbleed/):
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/5c1f6088f4">Only admins create boards on every path, not only through Add Board</a>. Thanks to tsurutanmen and xet7.</summary>
|
||||
|
||||
[CreateBleed](https://wekan.fi/hall-of-fame/createbleed/),
|
||||
[GHSA-qcfm-2g4w-wqcc](https://github.com/wekan/wekan/security/advisories/GHSA-qcfm-2g4w-wqcc).
|
||||
Admin Panel / Settings / Visibility / "Only admins can create boards"
|
||||
([#4475](https://github.com/wekan/wekan/issues/4475)) was checked only inside
|
||||
`createBoardWithInitialSwimlanes`, the Add Board form. A user who is not an
|
||||
admin still created working boards with a console `Boards.insert` - the
|
||||
board collection's allow rule approves any signed-in user - with
|
||||
`POST /api/boards` and with both board import routes, as reported, and also
|
||||
with `importBoard`, `cloneBoard`, `copyBoard`, `POST /api/boards/:boardId/copy`
|
||||
and `createBoardFromCard`. The boards belonged to their creator; no other
|
||||
user's data was read or changed.
|
||||
|
||||
One decision (`models/lib/boardCreationPolicy.js`) and one guard
|
||||
(`server/lib/boardCreationGuard.js`) are now asked by every one of those paths
|
||||
before they write, and by a new `Boards.deny` insert rule. A board template
|
||||
stays every member's own, and boards WeKan makes by itself - a user's
|
||||
Templates board, a board's subtasks board - are not user creations. A refused
|
||||
REST call answers 403. A refusal is recorded in Admin Panel / Problems as
|
||||
CreateBleed (`authz.board-creation`, low, CWE-863, never disables the
|
||||
account), and Duplicate and "Create board from card" are hidden like Add
|
||||
Board, so only a client bypassing the interface reaches it.
|
||||
`tests/createBleed.test.cjs` drives the decision, the guard and the real deny
|
||||
rule against the reporter's console insert, and fails if any path that creates
|
||||
a board for a user does not ask the guard, or if anything else reads the
|
||||
setting; `tests/playwright/specs/createbleed-board-creation-admin-only.e2e.js`
|
||||
replays the report against a running server.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following documentation bugs:
|
||||
|
||||
**REST API documentation** - the name is WeKan in the API reference, from the
|
||||
generator to the page wekan.fi publishes.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/4d5fe63fc4">The REST API documentation spells the name WeKan</a>. Thanks to xet7.</summary>
|
||||
|
||||
The header of `openapi/generate_openapi.py` and the JSDoc of six models said
|
||||
"Wekan REST API", "extend Wekan", "a production Wekan server" and "in the
|
||||
Wekan board", so every `public/api/wekan.yml` and the API pages built from it
|
||||
said Wekan too, and the v12.95, v12.96 and v12.97 pages on wekan.fi failed its
|
||||
`tests/brand-spelling.test.cjs`. The generator, the model comments and
|
||||
`wekan.yml` now say WeKan. `tests/openapiBrandSpelling.test.cjs` checks the
|
||||
generated file, the generator header and every model JSDoc block, and fails on
|
||||
the files before this change.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0275363043">The built API page says WeKan, so the v12.99 page on wekan.fi does too</a>. Thanks to xet7.</summary>
|
||||
|
||||
The v12.99 version bump rebuilt `public/api/wekan.html` with the generator from
|
||||
before the fix, and `releases/release-website.sh` copies that file to wekan.fi
|
||||
as `api/v12.99/index.html`. Its visible text and the JSON Redoc rebuilds the
|
||||
page from now say WeKan, and `tests/openapiBrandSpelling.test.cjs` checks the
|
||||
built page as well.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.99 2026-11-06 WeKan ® release
|
||||
|
||||
**In short:** The **mac-arm64** release bundle builds again: its job asked for
|
||||
|
||||
+10
-12
@@ -5,6 +5,16 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v13.00 2026-11-07 WeKan ® release
|
||||
|
||||
**CreateBleed** is fixed: "Only admins can create boards" now
|
||||
holds on every path that creates a board - the browser console, the REST API,
|
||||
imports, copies, clones and cards - not only on the Add Board form. The **REST
|
||||
API documentation** spells the name **WeKan**, so the API pages published on
|
||||
wekan.fi pass its brand-spelling check again.
|
||||
|
||||
[All changes in v13.00](CHANGELOG-big.md#v1300-2026-11-07-wekan--release)
|
||||
|
||||
# v12.99 2026-11-06 WeKan ® release
|
||||
|
||||
The **mac-arm64** release bundle builds again: its job asked for
|
||||
@@ -43,15 +53,3 @@ boot because the **nodemailer** 10 raised inside Meteor's email package no
|
||||
longer had the files that package was built against.
|
||||
|
||||
[All changes in v12.96](CHANGELOG-big.md#v1296-2026-11-05-wekan--release)
|
||||
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
**Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, 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 in v12.95](CHANGELOG-big.md#v1295-2026-11-05-wekan--release)
|
||||
+1
-1
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.99
|
||||
ARG VERSION=13.00
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.99.0"
|
||||
appVersion: "v13.00.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -641,8 +641,14 @@ function docker_exec(){
|
||||
# Run a Playwright browser project (chromium / firefox / webkit) inside the
|
||||
# official Playwright container. First arg is the browser; any extra args are
|
||||
# passed through to `playwright test`. WeKan must already be running on the host
|
||||
# (default http://127.0.0.1:3000, Meteor's bundled Mongo on 3001); the container
|
||||
# (default http://localhost:3000, Meteor's bundled Mongo on 3001); the container
|
||||
# shares the host network so it can reach both.
|
||||
#
|
||||
# The browser must use the test server's ROOT_URL origin, http://localhost:3000.
|
||||
# Meteor-Files sends every upload to ROOT_URL + /cdn/storage/.../__upload, and
|
||||
# answers no CORS preflight for another origin, so a page opened on
|
||||
# http://127.0.0.1:3000 could not upload anything: the drawing specs' Save never
|
||||
# finished. Health checks with curl may still use 127.0.0.1; a browser may not.
|
||||
function run_playwright_docker(){
|
||||
local browser="$1"; shift
|
||||
local reporoot="$WEKAN_DIR"
|
||||
@@ -658,7 +664,7 @@ function run_playwright_docker(){
|
||||
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
|
||||
[ -z "$pwver" ] && pwver="1.60.0"
|
||||
local image="mcr.microsoft.com/playwright:v${pwver}-noble"
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://localhost:3000}"
|
||||
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
|
||||
local docker_host_proxy_port=""
|
||||
local docker_network="host"
|
||||
@@ -807,7 +813,7 @@ function run_node_e2e_docker(){
|
||||
-e HOME=/repo/.tools/tmp \
|
||||
-e TMPDIR=/repo/.tools/tmp \
|
||||
-e NODE_OPTIONS="${NODE_OPTIONS:-}" \
|
||||
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://127.0.0.1:3000}" \
|
||||
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://localhost:3000}" \
|
||||
-e WEKAN_MONGO_URL="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}" \
|
||||
-v "$reporoot":/repo -w /repo \
|
||||
"$image" \
|
||||
|
||||
@@ -112,9 +112,10 @@ template(name="allBoardsMultiSelectionSidebar")
|
||||
a.sidebar-btn.js-archive-selected-boards
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archive-board'}}
|
||||
a.sidebar-btn.js-duplicate-selected-boards
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
if boardCreationAllowed
|
||||
a.sidebar-btn.js-duplicate-selected-boards
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
a.sidebar-btn.js-export-selected-boards
|
||||
i.fa.fa-download
|
||||
span {{_ 'export-selected-boards'}}
|
||||
|
||||
@@ -1270,10 +1270,11 @@ template(name="cardDetailsActionsPopup")
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
unless canModifyCard
|
||||
li
|
||||
a.js-create-board-from-card
|
||||
i.fa.fa-external-link
|
||||
| {{_ 'linkCardToNewBoard'}}
|
||||
if boardCreationAllowed
|
||||
li
|
||||
a.js-create-board-from-card
|
||||
i.fa.fa-external-link
|
||||
| {{_ 'linkCardToNewBoard'}}
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
|
||||
//- #1173: the card export popup IS the shared export body - the same checkbox
|
||||
|
||||
@@ -725,6 +725,11 @@ a:not(.disabled).is-active i.fa {
|
||||
.viewer code {
|
||||
padding: 10px 12px 7px;
|
||||
background: #dedede;
|
||||
/* Its own text colour with its own light background: a dark theme's light
|
||||
text (#bbb in the dark theme's opened card) was inherited onto #dedede,
|
||||
1.42:1 (46-axe-theme-contrast). #222 on #dedede is 11.8:1, and
|
||||
codeHighlight.css's token colours are chosen for this background too. */
|
||||
color: #222;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viewer a {
|
||||
|
||||
@@ -123,8 +123,16 @@ Popup.template.events({
|
||||
const drag = tpl._dateMove;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const boxLeft = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + drag.dx + evt.clientX - drag.x));
|
||||
const boxTop = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + drag.dy + evt.clientY - drag.y));
|
||||
// The popup's size NOW, not when the drag began: a long menu gets its
|
||||
// 960px column layout a frame after it is drawn (popupMenuLayout.js), so a
|
||||
// drag that starts in that frame would keep clamping for the narrow size
|
||||
// and push the grown popup most of the way out of the window - and undo
|
||||
// the ResizeObserver that had just put it back inside.
|
||||
const size = drag.element.getBoundingClientRect();
|
||||
const width = size.width || drag.width;
|
||||
const height = size.height || drag.height;
|
||||
const boxLeft = Math.max(12, Math.min(window.innerWidth - width - 12, drag.left + drag.dx + evt.clientX - drag.x));
|
||||
const boxTop = Math.max(12, Math.min(window.innerHeight - height - 12, drag.top + drag.dy + evt.clientY - drag.y));
|
||||
const left = boxLeft - drag.dx;
|
||||
const top = boxTop - drag.dy;
|
||||
// Dragging uses physical pointer coordinates in either text direction.
|
||||
@@ -230,13 +238,16 @@ Popup.template.onRendered(function () {
|
||||
this._focusTrap = event => trapTabKey(event, this._popupElement);
|
||||
this._popupElement?.addEventListener('keydown', this._focusTrap);
|
||||
if (this._popupElement) {
|
||||
const update = () => {
|
||||
this._menuLayoutFrame = null;
|
||||
updatePopupMenuColumns(this._popupElement);
|
||||
};
|
||||
this._menuLayoutObserver = new MutationObserver(() => {
|
||||
if (this._menuLayoutFrame == null) this._menuLayoutFrame = requestAnimationFrame(update);
|
||||
});
|
||||
const update = () => updatePopupMenuColumns(this._popupElement);
|
||||
// At once, not on the next animation frame: a MutationObserver callback
|
||||
// already runs before the browser paints, and a frame later the popup had
|
||||
// been drawn - and could be clicked or dragged - at its narrow size, then
|
||||
// jumped to the 960px column layout under the pointer. A click aimed at
|
||||
// Board View's "Lists" landed on "Calendar"'s place, and a drag clamped
|
||||
// for the narrow size (06-views-layout, card-title-edit-and-move-6839).
|
||||
// update() toggles a class only when it changes, so the attribute it
|
||||
// observes settles after one more call.
|
||||
this._menuLayoutObserver = new MutationObserver(update);
|
||||
this._menuLayoutObserver.observe(this._popupElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
|
||||
update();
|
||||
// A movable popup that grows - Drawing replacing the card's menu in this
|
||||
@@ -278,6 +289,5 @@ Popup.template.onRendered(function () {
|
||||
Popup.template.onDestroyed(function () {
|
||||
this._menuLayoutObserver?.disconnect();
|
||||
this._boundsObserver?.disconnect();
|
||||
if (this._menuLayoutFrame != null) cancelAnimationFrame(this._menuLayoutFrame);
|
||||
this._popupElement?.removeEventListener('keydown', this._focusTrap);
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
|
||||
// #4475: Admin Panel -> Settings -> Visibility -> "Only admins can create
|
||||
// boards" (tableVisibilityMode-boardCreationAdminOnly). Shared by every
|
||||
// client-side "Add board" entry point so the button hides consistently;
|
||||
// the real enforcement is server-side, in createBoardWithInitialSwimlanes
|
||||
// (server/models/boards.js) - this only avoids showing a control that
|
||||
// would be rejected.
|
||||
// client-side entry point that makes a new board - Add Board, and with it
|
||||
// Import and Template, Duplicate in the All Boards sidebar, and "Create
|
||||
// board from card" - so they hide consistently. The real enforcement is on
|
||||
// the server, on every path (CreateBleed, server/lib/boardCreationGuard.js);
|
||||
// this only avoids offering a control the server would refuse.
|
||||
export function boardCreationAllowed() {
|
||||
const restricted = TableVisibilityModeSettings.findOne(
|
||||
'tableVisibilityMode-boardCreationAdminOnly',
|
||||
@@ -16,3 +18,6 @@ export function boardCreationAllowed() {
|
||||
}
|
||||
return !!ReactiveCache.getCurrentUser()?.isAdmin;
|
||||
}
|
||||
|
||||
// For templates: {{#if boardCreationAllowed}}.
|
||||
Template.registerHelper('boardCreationAllowed', () => boardCreationAllowed());
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-12.99-win64.zip](https://github.com/wekan/wekan/releases/download/v12.99/wekan-12.99-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-13.00-win64.zip](https://github.com/wekan/wekan/releases/download/v13.00/wekan-13.00-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
|
||||
|
||||
@@ -22,7 +22,7 @@ Right click and download files 1-4:
|
||||
|
||||
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
|
||||
7. Unzip `wekan-12.99-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-13.00-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
+21
-6
@@ -2300,6 +2300,22 @@ Cards.helpers({
|
||||
}
|
||||
},
|
||||
|
||||
// Adds a finished session to the total, where the total is kept: the linked
|
||||
// board, the linked card, or this card. An $inc, not read-and-$set: the
|
||||
// client's copy of a linked board or card can still be on its way (it is
|
||||
// published after the card that links to it), and getSpentTime() then reads
|
||||
// null - a Stop at that moment replaced a board's 4 logged hours with the 1
|
||||
// hour of the session. The server adds to what it has, whatever the client
|
||||
// has seen.
|
||||
addSpentTime(hours) {
|
||||
const amount = Number(hours);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||
if (this.isLinkedBoard()) {
|
||||
return Boards.updateAsync({ _id: this.linkedId }, { $inc: { spentTime: amount } });
|
||||
}
|
||||
return Cards.updateAsync({ _id: this.getRealId() }, { $inc: { spentTime: amount } });
|
||||
},
|
||||
|
||||
// Flowtime (#3919) session helpers. These mirror the shape of
|
||||
// setSpentTime()/setIsOvertime() above, but do not follow linked
|
||||
// cards/boards - a Flowtime session belongs to one person working on one
|
||||
@@ -2341,8 +2357,7 @@ Cards.helpers({
|
||||
if (!this.flowStartAt) return null;
|
||||
const elapsedHours =
|
||||
(Date.now() - new Date(this.flowStartAt).getTime()) / (1000 * 60 * 60);
|
||||
const newSpentTime = (this.getSpentTime() || 0) + elapsedHours;
|
||||
await this.setSpentTime(newSpentTime);
|
||||
await this.addSpentTime(elapsedHours);
|
||||
return Cards.updateAsync(
|
||||
{ _id: this._id },
|
||||
{ $set: { flowStartAt: null, flowInterruptions: 0, flowUserId: null } },
|
||||
@@ -2403,13 +2418,13 @@ Cards.helpers({
|
||||
},
|
||||
|
||||
// Called when a work interval's countdown reaches zero: adds the interval
|
||||
// duration (in hours) into the existing spentTime field via setSpentTime()
|
||||
// - the same method the manual time-entry popup uses - increments the
|
||||
// duration (in hours) into the existing spentTime field - the one the
|
||||
// manual time-entry popup edits - with addSpentTime(), increments the
|
||||
// completed-interval count, and switches to a break interval.
|
||||
async completePomodoroWorkInterval() {
|
||||
if (!this.pomodoroStartAt || this.pomodoroPhase !== 'work') return null;
|
||||
const workHours = this.getPomodoroWorkMinutes() / 60;
|
||||
await this.setSpentTime((this.getSpentTime() || 0) + workHours);
|
||||
await this.addSpentTime(workHours);
|
||||
const newCount = this.getPomodoroCount() + 1;
|
||||
return Cards.updateAsync(
|
||||
{ _id: this._id },
|
||||
@@ -2443,7 +2458,7 @@ Cards.helpers({
|
||||
const elapsedHours =
|
||||
(Date.now() - new Date(this.pomodoroStartAt).getTime()) /
|
||||
(1000 * 60 * 60);
|
||||
await this.setSpentTime((this.getSpentTime() || 0) + elapsedHours);
|
||||
await this.addSpentTime(elapsedHours);
|
||||
}
|
||||
return Cards.updateAsync(
|
||||
{ _id: this._id },
|
||||
|
||||
@@ -61,6 +61,14 @@ function sanitizeImported(value, source, invocation) {
|
||||
// have their board replaced; for anyone else the import still runs and simply
|
||||
// leaves that board alone. No Problems record: a member who is not an admin
|
||||
// reaches this by importing from a board page, which is ordinary use.
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc): this file is shared with the client, and
|
||||
// the guard reads server-only settings, so it is required on the server only.
|
||||
async function assertMayCreateBoardOnServer(userId, source) {
|
||||
if (!Meteor.isServer) return;
|
||||
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
|
||||
await assertMayCreateBoard(userId, { source, type: 'board' });
|
||||
}
|
||||
|
||||
async function replaceableBoardId(userId, boardId) {
|
||||
if (!boardId || !userId) return undefined;
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
@@ -98,6 +106,9 @@ Meteor.methods({
|
||||
}
|
||||
// Admin Panel / Features / Security: master switch to disable all import.
|
||||
await assertImportEnabled();
|
||||
// CreateBleed: every import makes a new board - the UI's, and POST
|
||||
// /api/boards/import and /api/boards/import/:source, which call this.
|
||||
await assertMayCreateBoardOnServer(this.userId, 'method:importBoard');
|
||||
try { validateImportSourceShape(importSource, board); }
|
||||
catch (error) { throw new Meteor.Error('invalid-import-format', error.message); }
|
||||
let creator;
|
||||
@@ -675,6 +686,8 @@ Meteor.methods({
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
// CreateBleed: a clone is a new board.
|
||||
await assertMayCreateBoardOnServer(this.userId, 'method:cloneBoard');
|
||||
// The board's export without its file data: the attachments' rows only.
|
||||
// Their files are streamed from the source board's own storage into the
|
||||
// copy as the importer reaches them, one at a time, instead of every file
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: who may create a board while
|
||||
// Admin Panel -> Settings -> Visibility -> "Only admins can create boards"
|
||||
// (tableVisibilityMode-boardCreationAdminOnly) is on.
|
||||
//
|
||||
// The decision, as arithmetic: no server, no database. Every path that makes a
|
||||
// new WORKING board asks it through server/lib/boardCreationGuard.js -
|
||||
// createBoardWithInitialSwimlanes, a client Boards.insert, POST /api/boards,
|
||||
// importBoard (the UI's and both REST import routes), cloneBoard, copyBoard
|
||||
// and createBoardFromCard. Only the first of them used to read the setting.
|
||||
//
|
||||
// A board TEMPLATE (type 'template-board') is not a working board: a member
|
||||
// keeps their Templates board, and making a working board from a template is
|
||||
// copyBoard with type 'board', which this refuses. Boards WeKan makes itself -
|
||||
// a user's Templates container, a board's default subtasks board - are not
|
||||
// "created by the user" and never ask.
|
||||
|
||||
const TEMPLATE_TYPES = new Set(['template-board']);
|
||||
|
||||
// The type a new board will have; none means a working board, as the schema
|
||||
// defaults it.
|
||||
function boardTypeOf(type) {
|
||||
return typeof type === 'string' && type ? type : 'board';
|
||||
}
|
||||
|
||||
// May this user create a board of this type?
|
||||
// adminOnly: the setting's booleanValue
|
||||
// isAdmin: the user's isAdmin (only `true` counts)
|
||||
// type: the new board's type
|
||||
function boardCreationAllowed({ adminOnly, isAdmin, type } = {}) {
|
||||
if (adminOnly !== true) return true;
|
||||
if (isAdmin === true) return true;
|
||||
return TEMPLATE_TYPES.has(boardTypeOf(type));
|
||||
}
|
||||
|
||||
module.exports = { boardCreationAllowed, boardTypeOf, TEMPLATE_TYPES };
|
||||
@@ -102,6 +102,13 @@ const CATALOG = {
|
||||
// GroupLinkBleed, member variant: a client board update adding a member
|
||||
// instead of going through inviteUserToBoard.
|
||||
'authz.member-add': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc): a non-admin creating a working board
|
||||
// while "Only admins can create boards" is on, on a path other than the
|
||||
// Add Board form - a client Boards.insert, POST /api/boards, an import, a
|
||||
// clone, a copy or a card turned into a board. blocksAccount: false - a
|
||||
// browser tab opened before the admin switched it on still shows the
|
||||
// buttons, and that member is no attacker.
|
||||
'authz.board-creation': { category: 'authz', bleed: 'CreateBleed', severity: 'low', cwe: 'CWE-863', blocksAccount: false },
|
||||
'injection.prototype': { category: 'injection', bleed: 'PrototypeBleed', severity: 'high', cwe: 'CWE-1321' },
|
||||
'authz.notification-tray': { category: 'authz', bleed: 'TrayBleed', severity: 'high', cwe: 'CWE-639' },
|
||||
'authn.cas-state': { category: 'authn', bleed: 'CasTokenBleed', severity: 'high', cwe: 'CWE-352' },
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export function normalizeListColor(color) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A list (column) in the Wekan board.
|
||||
* A list (column) in the WeKan board.
|
||||
*/
|
||||
Lists.attachSchema(
|
||||
new SimpleSchema({
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const Org = new Mongo.Collection('org');
|
||||
|
||||
/**
|
||||
* A Organization in Wekan. A Enterprise in Trello.
|
||||
* A Organization in WeKan. A Enterprise in Trello.
|
||||
*/
|
||||
Org.attachSchema(
|
||||
new SimpleSchema({
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const Team = new Mongo.Collection('team');
|
||||
|
||||
/**
|
||||
* A Team in Wekan. Organization in Trello.
|
||||
* A Team in WeKan. Organization in Trello.
|
||||
*/
|
||||
Team.attachSchema(
|
||||
new SimpleSchema({
|
||||
|
||||
@@ -412,7 +412,7 @@ export class TrelloCreator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Wekan cards corresponding to the supplied Trello cards,
|
||||
* Create the WeKan cards corresponding to the supplied Trello cards,
|
||||
* as well as all linked data: activities, comments, and attachments
|
||||
* @param trelloCards
|
||||
* @param boardId
|
||||
|
||||
@@ -6,7 +6,7 @@ const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const SessionData = new Mongo.Collection('sessiondata');
|
||||
|
||||
/**
|
||||
* A UserSessionData in Wekan. Organization in Trello.
|
||||
* A UserSessionData in WeKan. Organization in Trello.
|
||||
*/
|
||||
SessionData.attachSchema(
|
||||
new SimpleSchema({
|
||||
|
||||
@@ -397,7 +397,7 @@ export class WekanCreator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Wekan cards corresponding to the supplied Wekan cards,
|
||||
* Create the WeKan cards corresponding to the supplied WeKan cards,
|
||||
* as well as all linked data: activities, comments, and attachments
|
||||
* @param wekanCards
|
||||
* @param boardId
|
||||
@@ -660,7 +660,7 @@ export class WekanCreator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Wekan custom fields corresponding to the supplied Wekan
|
||||
* Create the WeKan custom fields corresponding to the supplied WeKan
|
||||
* custom fields.
|
||||
* @param wekanCustomFields
|
||||
* @param boardId
|
||||
|
||||
@@ -1140,7 +1140,7 @@ def extract_route(call):
|
||||
def iter_route_calls(node):
|
||||
'''Recursively yield CallExpression nodes that may register a route, in
|
||||
source order. Routes appear at the top level (Meteor 3 style) or wrapped in
|
||||
one of the server-only guards Wekan uses — `if (Meteor.isServer) { ... }`,
|
||||
one of the server-only guards WeKan uses — `if (Meteor.isServer) { ... }`,
|
||||
`runOnServer(function () { ... })`, `Meteor.startup(...)`, try blocks — so we
|
||||
descend through those constructs and into callback function bodies.'''
|
||||
if node is None:
|
||||
@@ -1295,17 +1295,17 @@ def parse_schemas(schemas_dirs):
|
||||
def generate_openapi(schemas, entry_points, version):
|
||||
print('''swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
title: WeKan REST API
|
||||
version: {0}
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
The REST API allows you to control and extend WeKan with ease.
|
||||
|
||||
If you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.
|
||||
|
||||
> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.
|
||||
|
||||
# Production Security Concerns
|
||||
When calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
|
||||
When calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
|
||||
|
||||
* Only call via HTTPS
|
||||
* Implement a timed authorization token expiration strategy
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.99.0",
|
||||
"version": "v13.00.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v12.99.0",
|
||||
"version": "v13.00.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wekan",
|
||||
"version": "v12.99.0",
|
||||
"version": "v13.00.0",
|
||||
"description": "Open-Source kanban",
|
||||
"private": true,
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<title>WeKan REST API v12.99</title>
|
||||
<title>WeKan REST API v13.00</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
|
||||
|
||||
<body>
|
||||
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="WeKan REST API" class="sc-eStdmu iyAVQU">WeKan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
|
||||
<script type="module">
|
||||
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
|
||||
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.99","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
|
||||
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"WeKan REST API","version":"v13.00","description":"The REST API allows you to control and extend WeKan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
|
||||
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
|
||||
|
||||
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
swagger: '2.0'
|
||||
info:
|
||||
title: Wekan REST API
|
||||
version: v12.99
|
||||
title: WeKan REST API
|
||||
version: v13.00
|
||||
description: |
|
||||
The REST API allows you to control and extend Wekan with ease.
|
||||
The REST API allows you to control and extend WeKan with ease.
|
||||
|
||||
If you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.
|
||||
|
||||
> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.
|
||||
|
||||
# Production Security Concerns
|
||||
When calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
|
||||
When calling a production WeKan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:
|
||||
|
||||
* Only call via HTTPS
|
||||
* Implement a timed authorization token expiration strategy
|
||||
@@ -7660,7 +7660,7 @@ definitions:
|
||||
- userId
|
||||
Lists:
|
||||
type: object
|
||||
description: A list (column) in the Wekan board.
|
||||
description: A list (column) in the WeKan board.
|
||||
properties:
|
||||
scrum:
|
||||
type: object
|
||||
@@ -7867,7 +7867,7 @@ definitions:
|
||||
- soft
|
||||
Org:
|
||||
type: object
|
||||
description: A Organization in Wekan. A Enterprise in Trello.
|
||||
description: A Organization in WeKan. A Enterprise in Trello.
|
||||
properties:
|
||||
orgDisplayName:
|
||||
description: |
|
||||
@@ -8474,7 +8474,7 @@ definitions:
|
||||
- type
|
||||
Team:
|
||||
type: object
|
||||
description: A Team in Wekan. Organization in Trello.
|
||||
description: A Team in WeKan. Organization in Trello.
|
||||
properties:
|
||||
teamDisplayName:
|
||||
description: |
|
||||
|
||||
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
|
||||
appTitle = (defaultText = "Wekan"),
|
||||
# The name of the app as it is displayed to the user.
|
||||
|
||||
appVersion = 1299,
|
||||
appVersion = 1300,
|
||||
# Increment this for every release.
|
||||
|
||||
appMarketingVersion = (defaultText = "12.99~2026-11-06"),
|
||||
appMarketingVersion = (defaultText = "13.00~2026-11-06"),
|
||||
# Human-readable presentation of the app version.
|
||||
|
||||
minUpgradableAppVersion = 0,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
const { boardCreationAllowed, boardTypeOf } = require('/models/lib/boardCreationPolicy');
|
||||
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: "Only admins can create boards"
|
||||
// was enforced only in createBoardWithInitialSwimlanes. A non-admin still
|
||||
// created boards by a client Boards.insert, POST /api/boards, both board
|
||||
// import routes and importBoard, cloneBoard, copyBoard and
|
||||
// createBoardFromCard. Every one of them now asks here, before it writes;
|
||||
// the decision itself is models/lib/boardCreationPolicy.js.
|
||||
|
||||
const SETTING_ID = 'tableVisibilityMode-boardCreationAdminOnly';
|
||||
|
||||
async function decide(userId, type) {
|
||||
const setting = await TableVisibilityModeSettings.findOneAsync(SETTING_ID);
|
||||
const adminOnly = setting?.booleanValue === true;
|
||||
if (!adminOnly) return true;
|
||||
const user = userId ? await ReactiveCache.getUser(userId) : null;
|
||||
return boardCreationAllowed({ adminOnly, isAdmin: user?.isAdmin, type });
|
||||
}
|
||||
|
||||
function record(userId, source, type) {
|
||||
// Only refused attempts reach here: the Add Board buttons, Import, Clone,
|
||||
// Duplicate and "Create board from card" are hidden for a non-admin while
|
||||
// the setting is on (client/lib/boardCreationAllowed.js).
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.board-creation', action: 'blocked', source, userId,
|
||||
detail: `Board creation refused: only admins may create boards (type ${boardTypeOf(type)}).`,
|
||||
});
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
}
|
||||
|
||||
// For a method or a REST route: throws not-authorized when refused.
|
||||
export async function assertMayCreateBoard(userId, { source, type } = {}) {
|
||||
if (await decide(userId, type)) return;
|
||||
record(userId, source, type);
|
||||
const error = new Meteor.Error('not-authorized', 'Board creation is restricted to admins.');
|
||||
// A REST route answers it as 403 (server/lib/apiResponseHelpers.js
|
||||
// httpStatusForError), not as the 500 an unknown Meteor.Error becomes.
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For an allow/deny rule, which answers rather than throws: true when allowed.
|
||||
export async function mayCreateBoard(userId, { source, type } = {}) {
|
||||
if (await decide(userId, type)) return true;
|
||||
record(userId, source, type);
|
||||
return false;
|
||||
}
|
||||
+9
-15
@@ -29,6 +29,7 @@ import Lists from '/models/lists';
|
||||
import Rules from '/models/rules';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { assertMayCreateBoard } from '/server/lib/boardCreationGuard';
|
||||
import Triggers from '/models/triggers';
|
||||
import Users from '/models/users';
|
||||
import { ensureIndex } from '/server/lib/mongoStartup';
|
||||
@@ -179,21 +180,10 @@ Meteor.methods({
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
|
||||
// #4475: an admin can restrict board creation to admins only. The
|
||||
// setting lives beside its sibling tableVisibilityMode-allowPrivateOnly
|
||||
// in Admin Panel -> Settings -> Visibility, so it is read the same way.
|
||||
const boardCreationAdminOnly = await TableVisibilityModeSettings.findOneAsync(
|
||||
'tableVisibilityMode-boardCreationAdminOnly',
|
||||
);
|
||||
if (boardCreationAdminOnly && boardCreationAdminOnly.booleanValue) {
|
||||
const creator = await ReactiveCache.getUser(this.userId);
|
||||
if (!creator || creator.isAdmin !== true) {
|
||||
throw new Meteor.Error(
|
||||
'not-authorized',
|
||||
'Board creation is restricted to admins.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// #4475: an admin can restrict board creation to admins only. One check,
|
||||
// shared with every other path that creates a board (CreateBleed,
|
||||
// server/lib/boardCreationGuard.js).
|
||||
await assertMayCreateBoard(this.userId, { source: 'createBoardWithInitialSwimlanes', type });
|
||||
|
||||
// Apply the instance visibility policy on this server-side insert too.
|
||||
// Collection allow/deny does not run for insertAsync inside a method.
|
||||
@@ -1063,6 +1053,8 @@ WebApp.handlers.get('/api/boards/:boardId', async function(req, res) {
|
||||
WebApp.handlers.post('/api/boards', async function(req, res) {
|
||||
try {
|
||||
Authentication.checkLoggedIn(req.userId);
|
||||
// CreateBleed: "Only admins can create boards" holds for the API too.
|
||||
await assertMayCreateBoard(req.userId, { source: 'rest:POST /api/boards', type: 'board' });
|
||||
const allowPrivateOnly =
|
||||
(await TableVisibilityModeSettings.findOneAsync('tableVisibilityMode-allowPrivateOnly'))?.booleanValue;
|
||||
const permission = allowPrivateOnly ? 'private' : (req.body.permission || 'private');
|
||||
@@ -1539,6 +1531,8 @@ WebApp.handlers.post('/api/boards/:boardId/copy', async function(req, res) {
|
||||
// could still copy the board, and an unknown id answered 500.
|
||||
await Authentication.checkBoardAdmin(req.userId, id);
|
||||
const board = await ReactiveCache.getBoard(id);
|
||||
// CreateBleed: a copy is a new board of the source's type.
|
||||
await assertMayCreateBoard(req.userId, { source: 'rest:POST /api/boards/:boardId/copy', type: board && board.type });
|
||||
// Only a string title is taken from the request, and it is applied to a
|
||||
// separate object so the cached source board is not changed.
|
||||
const title = typeof req.body?.title === 'string' && req.body.title ? req.body.title : await Boards.uniqueTitle(board.title);
|
||||
|
||||
@@ -162,6 +162,9 @@ Meteor.methods({
|
||||
if (!sourceBoard || !allowIsBoardMemberWithWriteAccess(this.userId, sourceBoard)) {
|
||||
throw new Meteor.Error('not-authorized');
|
||||
}
|
||||
// CreateBleed: the card becomes a new working board.
|
||||
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
|
||||
await assertMayCreateBoard(this.userId, { source: 'method:createBoardFromCard', type: 'board' });
|
||||
|
||||
const boardTitle = (title && title.trim()) || card.title || '';
|
||||
if (!boardTitle) throw new Meteor.Error('invalid-title');
|
||||
|
||||
@@ -3,6 +3,7 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { findWhere, where } from '/imports/lib/collectionHelpers';
|
||||
import { allowIsBoardAdminOrSiteAdmin, canUpdateBoardSort, canUpdateBoardSameWidthValue } from '/server/lib/utils';
|
||||
import { canWriteSubtaskDeposit, recordSubtaskDepositDenial } from '/server/lib/subtaskDepositAccess';
|
||||
import { mayCreateBoard } from '/server/lib/boardCreationGuard';
|
||||
const { isOpenPermission } = require('/models/lib/boardPermission');
|
||||
|
||||
Boards.deny({
|
||||
@@ -21,6 +22,12 @@ Boards.deny({
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
return true;
|
||||
}
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc): "Only admins can create boards"
|
||||
// held only in createBoardWithInitialSwimlanes, and a client insert
|
||||
// reached Boards.allow.insert, which approves any signed-in user. The one
|
||||
// client insert WeKan makes is a board template in the Templates board,
|
||||
// which the setting leaves to every member.
|
||||
if (!(await mayCreateBoard(userId, { source: 'ddp:boards.insert', type: doc.type }))) return true;
|
||||
if (!doc.subtasksDefaultBoardId) return false;
|
||||
if (await canWriteSubtaskDeposit(userId, doc.subtasksDefaultBoardId)) return false;
|
||||
recordSubtaskDepositDenial('ddp:board-insert');
|
||||
|
||||
@@ -1440,6 +1440,10 @@ Meteor.methods({
|
||||
catch (error) { throw new Meteor.Error('invalid-copy-options', error.message); }
|
||||
}
|
||||
const copy = boardWithCopyProperties(board, values);
|
||||
// CreateBleed: a copy is a new board - unless it is a board template, and
|
||||
// a template made a working board again is a copy of type 'board'.
|
||||
const { assertMayCreateBoard } = require('/server/lib/boardCreationGuard');
|
||||
await assertMayCreateBoard(this.userId, { source: 'method:copyBoard', type: values.type || board.type });
|
||||
|
||||
if (selection) return copy.copy(!selection.cards, selection);
|
||||
return copy.copy(!!values.withoutCards);
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@
|
||||
# base: core24 (not core26) · no build-base: devel · grade: stable
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
name: wekan
|
||||
version: '12.99'
|
||||
version: '13.00'
|
||||
base: core24
|
||||
confinement: strict
|
||||
grade: stable
|
||||
@@ -703,18 +703,18 @@ parts:
|
||||
armhf) WEKAN_ARCH="armhf" ;;
|
||||
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
|
||||
esac
|
||||
WEKAN_ZIP="wekan-12.99-${WEKAN_ARCH}.zip"
|
||||
WEKAN_ZIP="wekan-13.00-${WEKAN_ARCH}.zip"
|
||||
echo "Cleaning environment first"
|
||||
rm -rf .build
|
||||
mkdir .build
|
||||
cd .build
|
||||
#wget https://github.com/wekan/wekan/releases/download/v12.99/wekan-12.99-amd64.zip
|
||||
#wget https://github.com/wekan/wekan/releases/download/v13.00/wekan-13.00-amd64.zip
|
||||
# Retry: a release asset can 404 for a short while right after it is
|
||||
# uploaded (CDN/propagation lag), and a plain wget treated that as
|
||||
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
|
||||
# missing bundle still fails after the tries are spent.
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
|
||||
"https://github.com/wekan/wekan/releases/download/v12.99/${WEKAN_ZIP}"
|
||||
"https://github.com/wekan/wekan/releases/download/v13.00/${WEKAN_ZIP}"
|
||||
unzip "${WEKAN_ZIP}"
|
||||
python3 ../releases/check-telemetry.py --bundle bundle
|
||||
rm "${WEKAN_ZIP}"
|
||||
|
||||
@@ -97,7 +97,7 @@ const stored = new Set();
|
||||
const walk = dir => {
|
||||
for (const item of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${item.name}`;
|
||||
if (item.isDirectory()) { if (!['node_modules', '_build', 'tests'].includes(item.name)) walk(rel); continue; }
|
||||
if (item.isDirectory()) { if (!(['node_modules', '_build', 'tests'].includes(item.name) || item.name.startsWith('_build'))) walk(rel); continue; }
|
||||
if (!/\.js$/.test(item.name)) continue;
|
||||
for (const m of read(rel).matchAll(/activityType: '([a-zA-Z]+)'/g)) stored.add(m[1]);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ test('negative: every file that serves attachment or document data applies the r
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) { if (!['node_modules', '_build', '.build'].includes(e.name)) walk(rel); }
|
||||
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
|
||||
else if (/\.js$/.test(e.name)) files.push(rel);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ const SKIP = new Set(['_build', 'node_modules', '.npm', 'tests']);
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(e.name)) continue;
|
||||
if (SKIP.has(e.name) || e.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full, out);
|
||||
else if (e.name.endsWith('.js')) out.push(full);
|
||||
|
||||
@@ -126,6 +126,9 @@ const requireStub = name => {
|
||||
if (name === '/models/lib/cardCopyOverrides') return { cardWithCopyOverrides: card => card };
|
||||
if (name === '/server/lib/cardCopyDestination') return { requireCardCopyDestination: async () => {} };
|
||||
if (name === '/models/lib/scrumCopy') return { copiedScrumMetadata: () => ({}) };
|
||||
// CreateBleed's guard (tests/createBleed.test.cjs): creating is allowed here,
|
||||
// so these tests see the copy methods' own checks.
|
||||
if (name === '/server/lib/boardCreationGuard') return { assertMayCreateBoard: async () => {} };
|
||||
throw new Error(`unexpected require ${name}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const VARS = authConfigEnvVars();
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), {withFileTypes: true})) {
|
||||
if (['_build', '.build', 'node_modules', '.tools', '.meteor', '.npm', 'tests'].includes(entry.name)) continue;
|
||||
if ((['_build', '.build', 'node_modules', '.tools', '.meteor', '.npm', 'tests'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(rel);
|
||||
|
||||
@@ -56,12 +56,19 @@ test('the server method rejects a non-admin when the setting is on', () => {
|
||||
const at = server.indexOf("async createBoardWithInitialSwimlanes(payload) {");
|
||||
assert.notStrictEqual(at, -1, 'the method exists');
|
||||
const body = server.slice(at, server.indexOf('\n },', at));
|
||||
assert.ok(/TableVisibilityModeSettings\.findOneAsync\(\s*\n?\s*'tableVisibilityMode-boardCreationAdminOnly'/.test(body),
|
||||
'the guard reads the global setting');
|
||||
assert.ok(/creator\.isAdmin !== true/.test(body),
|
||||
'a non-admin creator is rejected while the setting is on');
|
||||
assert.ok(body.indexOf('boardCreationAdminOnly') < body.indexOf('Boards.insertAsync'),
|
||||
// Changed on purpose (CreateBleed, GHSA-qcfm-2g4w-wqcc): the check was
|
||||
// here alone, so every other path that creates a board skipped it. It is
|
||||
// now one guard, server/lib/boardCreationGuard.js, called from this method
|
||||
// and from all the others (tests/createBleed.test.cjs).
|
||||
assert.ok(/await assertMayCreateBoard\(this\.userId, \{ source: 'createBoardWithInitialSwimlanes', type \}\)/.test(body),
|
||||
'the method asks the shared guard');
|
||||
assert.ok(body.indexOf('assertMayCreateBoard') < body.indexOf('Boards.insertAsync'),
|
||||
'the guard runs before the board is inserted');
|
||||
const guard = read('server/lib/boardCreationGuard.js');
|
||||
assert.ok(/'tableVisibilityMode-boardCreationAdminOnly'/.test(guard) && /TableVisibilityModeSettings\.findOneAsync\(SETTING_ID\)/.test(guard),
|
||||
'the guard reads the global setting');
|
||||
assert.ok(/isAdmin === true/.test(read('models/lib/boardCreationPolicy.js')),
|
||||
'a non-admin creator is rejected while the setting is on');
|
||||
});
|
||||
|
||||
test('client hides the "Add board" entry points the same way, but only as a UI convenience', () => {
|
||||
|
||||
@@ -11,13 +11,15 @@ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
// #3249: "private boards only" refuses 'instance' boards as well as public
|
||||
// ones; the server code asks isOpenPermission() for both.
|
||||
const { isOpenPermission } = require('../models/lib/boardPermission.js');
|
||||
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', prefix + '\nreturn permission;');
|
||||
// assertMayCreateBoard is CreateBleed's guard (tests/createBleed.test.cjs);
|
||||
// here creating is allowed, so the private-only policy is seen alone.
|
||||
const run = new AsyncFunction('payload', 'check', 'Match', 'Meteor', 'TableVisibilityModeSettings', 'ReactiveCache', 'require', 'isOpenPermission', 'assertMayCreateBoard', prefix + '\nreturn permission;');
|
||||
(async () => {
|
||||
for (const privateOnly of [false, true]) {
|
||||
for (const requested of ['private', 'public', 'instance']) {
|
||||
const logs = [];
|
||||
const settings = { findOneAsync: async key => ({ booleanValue: key === 'tableVisibilityMode-allowPrivateOnly' && privateOnly }) };
|
||||
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }), isOpenPermission);
|
||||
const result = await run.call({ userId: 'creator' }, { title: 'T', slug: 't', permission: requested }, () => {}, { ObjectIncluding: x => x, Maybe: x => x }, {}, settings, {}, () => ({ record: x => logs.push(x) }), isOpenPermission, async () => {});
|
||||
assert.equal(result, privateOnly ? 'private' : requested);
|
||||
assert.equal(logs.length, privateOnly && requested !== 'private' ? 1 : 0, 'only blocked public or instance requests are logged');
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ test('negative: every server path that returns card data applies the board role,
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
|
||||
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
|
||||
else if (e.name.endsWith('.js')) files.push(rel);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +203,7 @@ test('activities, Chat view, the All Boards feed and position history read by th
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
|
||||
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
|
||||
else if (e.name.endsWith('.js') && /\$or: \[\{ assignees: [\w.]+ \}, \{ members: [\w.]+ \}\]/.test(read(rel))) offenders.push(rel);
|
||||
}
|
||||
};
|
||||
@@ -300,7 +300,7 @@ test('custom roles: nothing sets the role flags without the custom role followin
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) { if (!['node_modules', '_build', '.build', 'tests'].includes(e.name)) walk(rel); }
|
||||
if (e.isDirectory()) { if (!(['node_modules', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
|
||||
else if (e.name.endsWith('.js') && /`members\.\$\{\w+\}\.is(NormalAssignedOnly|CommentAssignedOnly|ReadAssignedOnly|NoComments|CommentOnly|Worker|ReadOnly)`/.test(read(rel))) writers.push(rel);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,7 +115,7 @@ const WRITES_BOARD = /Boards\.(?:direct\.)?(?:update|updateAsync|upsert|upsertAs
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
|
||||
|
||||
@@ -116,3 +116,29 @@ assert.equal(element.style.left, '400px');
|
||||
events['keydown .js-date-popup-resize']({ ...event, key: 'ArrowRight' });
|
||||
assert.equal(element.style.width, '420px', 'right arrow shrinks the left-hand RTL grip');
|
||||
console.log('RTL resize reverses horizontal input and keeps the right edge anchored');
|
||||
|
||||
// A popup that GROWS while it is dragged stays inside the window. A long menu
|
||||
// (the member menu) gets its 960px column layout a frame after it is drawn
|
||||
// (client/lib/popupMenuLayout.js); a drag begun in that frame clamped with the
|
||||
// 380px it started at, so the grown popup ended at left 850 and mostly outside
|
||||
// a 1280px window (card-title-edit-and-move-6839.e2e.js, memberMenuPopup).
|
||||
context.window.getComputedStyle = () => ({ direction: 'ltr' });
|
||||
element.querySelector = () => null;
|
||||
element.style.width = '380px';
|
||||
element.style.height = '400px';
|
||||
events['pointerdown .header'](moveEvent, tpl);
|
||||
assert.ok(tpl._dateMove, 'the drag begins at the narrow size');
|
||||
element.style.width = '960px'; // the column layout arrives mid-drag
|
||||
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX + 1000 }, tpl);
|
||||
assert.equal(element.style.left, `${1280 - 960 - 12}px`,
|
||||
'the clamp uses the size the popup has now, so all of it stays in the window');
|
||||
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX - 10000 }, tpl);
|
||||
assert.equal(element.style.left, '12px', 'and the left edge too');
|
||||
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
|
||||
// Negative: a popup that does not change size moves exactly as dragged.
|
||||
element.style.width = '400px';
|
||||
events['pointerdown .header'](moveEvent, tpl);
|
||||
events['pointermove .header']({ ...moveEvent, clientX: moveEvent.clientX - 40 }, tpl);
|
||||
assert.equal(element.style.left, `${440 - 40}px`, 'an unchanged popup follows the pointer');
|
||||
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
|
||||
console.log('A popup that grows during a drag is clamped by its current size');
|
||||
@@ -355,7 +355,7 @@ function trippedCanaryIds() {
|
||||
const found = new Map();
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.npm', '.build'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.npm', '.build'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel);
|
||||
else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
||||
|
||||
@@ -253,7 +253,7 @@ await test('every retireCardDropPreview call passes the node to hide', async ()
|
||||
const files = [];
|
||||
(function walk(dir) {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.git', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.git', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const f = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(f);
|
||||
else if (/\.js$/.test(e.name)) files.push(f);
|
||||
|
||||
@@ -190,7 +190,7 @@ test('the server saves it in the site-admin-only Features method (negative: no o
|
||||
const writers = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '.meteor', '_build', '.build', '.tools'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '.meteor', '_build', '.build', '.tools'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel);
|
||||
// A projection - `cardFieldStates: 1` in a fields/publication list - only
|
||||
|
||||
@@ -118,7 +118,7 @@ test('a member, and anybody on a public board, still get the card', async () =>
|
||||
const CARD_GUARD = /isVisibleBy\(|readableCard\(|editableCard\(/;
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
|
||||
|
||||
@@ -35,7 +35,7 @@ test('negative: every delete path goes through the collection, so the hook sees
|
||||
// removes the card's items itself in the same breath.
|
||||
const walk = dir => fs.readdirSync(path.join(__dirname, '..', dir), { withFileTypes: true }).flatMap(e => {
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return ['node_modules', '_build', '.build', '.tools', 'tests'].includes(e.name) ? [] : walk(rel);
|
||||
if (e.isDirectory()) return (['node_modules', '_build', '.build', '.tools', 'tests'].includes(e.name) || e.name.startsWith('_build')) ? [] : walk(rel);
|
||||
return /\.js$/.test(e.name) ? [rel] : [];
|
||||
});
|
||||
for (const file of [...walk('client'), ...walk('server'), ...walk('models')]) {
|
||||
|
||||
@@ -130,7 +130,7 @@ const BINDS_BOARD = /boardId(?::\s*paramBoardId|\s*[!=]==\s*paramBoardId)/;
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
|
||||
|
||||
@@ -48,7 +48,7 @@ test('NEGATIVE: every place that copies subtasks filters them, nowhere copies th
|
||||
const files = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '.meteor', '.tools', '_build', '.build', 'tests', '.git'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '.meteor', '.tools', '_build', '.build', 'tests', '.git'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.js')) files.push(full);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
// CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: "Only admins can create boards"
|
||||
// (Admin Panel -> Settings -> Visibility,
|
||||
// tableVisibilityMode-boardCreationAdminOnly) was checked only inside
|
||||
// createBoardWithInitialSwimlanes. A non-admin still created boards by a
|
||||
// client Boards.insert (Boards.allow.insert approves any signed-in user),
|
||||
// POST /api/boards and both board import routes - the report - and, found
|
||||
// with it, importBoard, cloneBoard, copyBoard, POST /api/boards/:boardId/copy
|
||||
// and createBoardFromCard.
|
||||
//
|
||||
// * the decision (models/lib/boardCreationPolicy.js), as arithmetic, with the
|
||||
// reporter's case exactly;
|
||||
// * the guard (server/lib/boardCreationGuard.js), run for real in a vm:
|
||||
// refused, recorded in Admin Panel -> Problems, a 403 for REST;
|
||||
// * the REAL deny rule of server/permissions/boards.js against the
|
||||
// reporter's console insert;
|
||||
// * negative: every path in the tree that makes a working board for a user
|
||||
// asks the guard, and nothing else reads the setting.
|
||||
// Run: node tests/createBleed.test.cjs
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
const { boardCreationAllowed } = require('../models/lib/boardCreationPolicy');
|
||||
const { categoryFor } = require('../models/lib/securityCategories');
|
||||
|
||||
test('the decision: with the setting on, only an admin creates a working board', () => {
|
||||
// The report: an admin turns the setting on; user u is not an admin.
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'board' }), false);
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: undefined }), false, 'no type is a working board');
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: 'true', type: 'board' }), false, 'only isAdmin === true counts');
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'template-container' }), false);
|
||||
// Allowed: an admin, the setting off, and a member's own board template.
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: true, type: 'board' }), true);
|
||||
assert.equal(boardCreationAllowed({ adminOnly: false, isAdmin: false, type: 'board' }), true);
|
||||
assert.equal(boardCreationAllowed({ adminOnly: undefined, isAdmin: false }), true, 'no setting document is off');
|
||||
assert.equal(boardCreationAllowed({ adminOnly: true, isAdmin: false, type: 'template-board' }), true);
|
||||
});
|
||||
|
||||
// server/lib/boardCreationGuard.js, its imports stubbed.
|
||||
function loadGuard({ adminOnly, user }) {
|
||||
const recorded = [];
|
||||
const exports = {};
|
||||
const src = read('server/lib/boardCreationGuard.js')
|
||||
.replace(/^import \{ Meteor \} from .*$/m, '')
|
||||
.replace(/^import \{ ReactiveCache \} from .*$/m, '')
|
||||
.replace(/^import TableVisibilityModeSettings from .*$/m, '')
|
||||
.replace(/^export async function /gm, 'exports.$fn = async function ')
|
||||
.replace(/exports\.\$fn = async function (\w+)/g, 'exports.$1 = async function $1');
|
||||
assert.ok(!/^import /m.test(src), 'every import of the guard is stubbed');
|
||||
class MeteorError extends Error { constructor(error, reason) { super(reason); this.error = error; this.reason = reason; } }
|
||||
vm.runInNewContext(src, {
|
||||
exports, console,
|
||||
Meteor: { Error: MeteorError },
|
||||
ReactiveCache: { getUser: async id => (user && user._id === id ? user : null) },
|
||||
TableVisibilityModeSettings: {
|
||||
findOneAsync: async id => (id === 'tableVisibilityMode-boardCreationAdminOnly' && adminOnly !== undefined ? { booleanValue: adminOnly } : null),
|
||||
},
|
||||
require: spec => {
|
||||
if (spec === '/server/lib/securityLog') return { record: evt => recorded.push(evt) };
|
||||
if (spec === '/models/lib/boardCreationPolicy') return require('../models/lib/boardCreationPolicy');
|
||||
throw new Error(`unexpected require ${spec}`);
|
||||
},
|
||||
});
|
||||
return { ...exports, recorded };
|
||||
}
|
||||
|
||||
test('the guard refuses a non-admin, records it, and answers REST with 403', async () => {
|
||||
const g = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
|
||||
await assert.rejects(g.assertMayCreateBoard('u', { source: 'rest:POST /api/boards', type: 'board' }), error => {
|
||||
assert.equal(error.error, 'not-authorized');
|
||||
assert.equal(error.statusCode, 403, 'a REST route answers 403, not 500');
|
||||
return true;
|
||||
});
|
||||
assert.equal(await g.mayCreateBoard('u', { source: 'ddp:boards.insert' }), false);
|
||||
assert.equal(g.recorded.length, 2);
|
||||
for (const evt of g.recorded) {
|
||||
assert.equal(evt.key, 'authz.board-creation');
|
||||
assert.equal(evt.action, 'blocked');
|
||||
assert.equal(evt.userId, 'u');
|
||||
}
|
||||
assert.deepEqual(g.recorded.map(e => e.source), ['rest:POST /api/boards', 'ddp:boards.insert']);
|
||||
});
|
||||
|
||||
test('negative: the guard lets an admin, a template and an instance without the setting through, unrecorded', async () => {
|
||||
const admin = loadGuard({ adminOnly: true, user: { _id: 'a', isAdmin: true } });
|
||||
await admin.assertMayCreateBoard('a', { source: 's', type: 'board' });
|
||||
assert.equal(await admin.mayCreateBoard('a', { source: 's' }), true);
|
||||
const member = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
|
||||
assert.equal(await member.mayCreateBoard('u', { source: 's', type: 'template-board' }), true);
|
||||
const off = loadGuard({ adminOnly: false, user: { _id: 'u', isAdmin: false } });
|
||||
await off.assertMayCreateBoard('u', { source: 's', type: 'board' });
|
||||
const unset = loadGuard({ adminOnly: undefined, user: { _id: 'u', isAdmin: false } });
|
||||
assert.equal(await unset.mayCreateBoard('u', { source: 's' }), true);
|
||||
assert.equal(admin.recorded.length + member.recorded.length + off.recorded.length + unset.recorded.length, 0,
|
||||
'only a refusal is an attempt; ordinary creation is not logged');
|
||||
});
|
||||
|
||||
test('the reporter\'s console insert is refused by the real deny rule; a template insert is not', async () => {
|
||||
const denies = [];
|
||||
const src = read('server/permissions/boards.js')
|
||||
.replace(/^import Boards from .*$/m, '')
|
||||
.replace(/^import TableVisibilityModeSettings from .*$/m, 'const TableVisibilityModeSettings = { findOneAsync: async () => null };')
|
||||
.replace(/^import \{ findWhere, where \} from .*$/m, 'const where = () => []; const findWhere = () => undefined;')
|
||||
.replace(/^import \{[^}]*\} from '\/server\/lib\/utils';$/m,
|
||||
'const allowIsBoardAdminOrSiteAdmin = () => true; const canUpdateBoardSort = () => false; const canUpdateBoardSameWidthValue = () => false;')
|
||||
.replace(/^import \{[^}]*\} from '\/server\/lib\/subtaskDepositAccess';$/m,
|
||||
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};')
|
||||
.replace(/^import \{ mayCreateBoard \} from '\/server\/lib\/boardCreationGuard';$/m, '');
|
||||
assert.ok(!/^import /m.test(src), 'every import of server/permissions/boards.js is stubbed');
|
||||
const guard = loadGuard({ adminOnly: true, user: { _id: 'u', isAdmin: false } });
|
||||
vm.runInNewContext(src, {
|
||||
Boards: { deny: rules => denies.push(rules), allow: () => {} }, console,
|
||||
mayCreateBoard: guard.mayCreateBoard,
|
||||
require: spec => require(path.join(ROOT, spec)),
|
||||
});
|
||||
const refused = async doc => {
|
||||
for (const rules of denies) if (rules.insert && await rules.insert('u', doc)) return true;
|
||||
return false;
|
||||
};
|
||||
// Boards.insert({ title: 'x', permission: 'private' }) from the report.
|
||||
assert.equal(await refused({ title: 'x', permission: 'private' }), true);
|
||||
assert.equal(await refused({ title: 'x', permission: 'private', type: 'board' }), true);
|
||||
assert.equal(guard.recorded[0].source, 'ddp:boards.insert');
|
||||
// The one insert the client makes: a board template in the Templates board.
|
||||
assert.equal(await refused({ title: 't', permission: 'private', type: 'template-board' }), false);
|
||||
});
|
||||
|
||||
// Each path that makes a new working board for a user, and what it must say.
|
||||
const PATHS = [
|
||||
['server/models/boards.js', /async createBoardWithInitialSwimlanes\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'createBoardWithInitialSwimlanes', type \}\)/],
|
||||
['server/models/boards.js', /WebApp\.handlers\.post\('\/api\/boards', /, /assertMayCreateBoard\(req\.userId, \{ source: 'rest:POST \/api\/boards'/],
|
||||
['server/models/boards.js', /WebApp\.handlers\.post\('\/api\/boards\/:boardId\/copy', /, /assertMayCreateBoard\(req\.userId, \{ source: 'rest:POST \/api\/boards\/:boardId\/copy'/],
|
||||
['server/permissions/boards.js', / async insert\(userId, doc\) \{/, /mayCreateBoard\(userId, \{ source: 'ddp:boards\.insert', type: doc\.type \}\)/],
|
||||
['models/import.js', /async importBoard\(/, /assertMayCreateBoardOnServer\(this\.userId, 'method:importBoard'\)/],
|
||||
['models/import.js', /async cloneBoard\(/, /assertMayCreateBoardOnServer\(this\.userId, 'method:cloneBoard'\)/],
|
||||
['server/publications/boards.js', /async copyBoard\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'method:copyBoard'/],
|
||||
['server/models/cards.js', /async createBoardFromCard\(/, /assertMayCreateBoard\(this\.userId, \{ source: 'method:createBoardFromCard'/],
|
||||
];
|
||||
|
||||
// The body of the function or handler that starts at `start`: up to the next
|
||||
// top-level method, handler or Boards.deny/allow block.
|
||||
function bodyAt(text, start) {
|
||||
const at = text.search(start);
|
||||
assert.notEqual(at, -1, `${start} not found`);
|
||||
const rest = text.slice(at + 1);
|
||||
const next = rest.search(/\n(?: async \w+\(|WebApp\.handlers\.|Boards\.(?:deny|allow)\(|\}\);\n)/);
|
||||
return text.slice(at, at + 1 + (next === -1 ? rest.length : next));
|
||||
}
|
||||
|
||||
test('every path that makes a working board asks the guard before it writes', () => {
|
||||
for (const [file, start, call] of PATHS) {
|
||||
const body = bodyAt(read(file), start);
|
||||
assert.match(body, call, `${file}: ${start}`);
|
||||
}
|
||||
// The REST import routes reach importBoard, which asks.
|
||||
const boardsJs = read('server/models/boards.js');
|
||||
for (const route of ["'/api/boards/import'", "'/api/boards/import/:source'"]) {
|
||||
const body = bodyAt(boardsJs, new RegExp(`WebApp\\.handlers\\.post\\(${route.replace(/[/:]/g, m => `\\${m}`)}, `));
|
||||
assert.match(body, /Meteor\.callAsync\('importBoard'/, `${route} imports through importBoard`);
|
||||
}
|
||||
// models/import.js's helper really calls the guard on the server.
|
||||
assert.match(read('models/import.js'), /async function assertMayCreateBoardOnServer\(userId, source\) \{\s*if \(!Meteor\.isServer\) return;\s*const \{ assertMayCreateBoard \} = require\('\/server\/lib\/boardCreationGuard'\);\s*await assertMayCreateBoard\(userId, \{ source, type: 'board' \}\);/);
|
||||
});
|
||||
|
||||
// Files that make boards WeKan creates by itself, or the shared copy and
|
||||
// import machinery the guarded paths above call. A NEW Boards.insert
|
||||
// elsewhere must either ask the guard or be added here with its reason.
|
||||
const SYSTEM_CREATORS = {
|
||||
'server/models/users.js': 'the Templates container a user gets at sign-up (ensureTemplatesBoardForUserId)',
|
||||
'models/boards.js': 'Board.copy(), called by the guarded copyBoard, REST copy and the template-only saveBoardAsTemplate; and getDefaultSubtasksBoardAsync, a board\'s own subtasks board',
|
||||
'models/csvCreator.js': 'an import creator, reached through the guarded importBoard',
|
||||
'models/kanboardCreator.js': 'an import creator, reached through the guarded importBoard',
|
||||
'models/jiraCreator.js': 'an import creator, reached through the guarded importBoard',
|
||||
};
|
||||
|
||||
function sources(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '.tools', '.build', '.meteor', '.git', 'tests', 'packages'].includes(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) sources(rel, out);
|
||||
else if (/\.js$/.test(entry.name)) out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('negative: no other server code creates a board without the guard', () => {
|
||||
const files = [...sources('server'), ...sources('models')];
|
||||
const unguarded = [];
|
||||
for (const file of files) {
|
||||
const text = read(file);
|
||||
if (!/Boards\.(?:direct\.)?insert(?:Async)?\(/.test(text)) continue;
|
||||
if (/assertMayCreateBoard|mayCreateBoard/.test(text)) continue;
|
||||
if (SYSTEM_CREATORS[file]) continue;
|
||||
unguarded.push(file);
|
||||
}
|
||||
assert.deepEqual(unguarded, [],
|
||||
'these files insert boards with no CreateBleed guard and no reason in SYSTEM_CREATORS');
|
||||
});
|
||||
|
||||
test('negative: the setting is read in one place on the server, the guard', () => {
|
||||
const readers = [...sources('server'), ...sources('models')]
|
||||
// In code, not in a comment that names it.
|
||||
.filter(file => /tableVisibilityMode-boardCreationAdminOnly/.test(read(file).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')))
|
||||
.filter(file => file !== 'server/models/collectionBootstrap.js'); // creates the setting's document
|
||||
assert.deepEqual(readers, ['server/lib/boardCreationGuard.js']);
|
||||
});
|
||||
|
||||
test('the refusal is in Admin Panel -> Problems under CreateBleed, and blocks no account', () => {
|
||||
const cat = categoryFor('authz.board-creation');
|
||||
assert.equal(cat.bleed, 'CreateBleed');
|
||||
assert.equal(cat.cwe, 'CWE-863');
|
||||
assert.equal(cat.blocksAccount, false, 'a tab opened before the switch is no attacker');
|
||||
});
|
||||
|
||||
test('the UI offers no new-board control to a non-admin while it is on', () => {
|
||||
const helper = read('client/lib/boardCreationAllowed.js');
|
||||
assert.match(helper, /Template\.registerHelper\('boardCreationAllowed', \(\) => boardCreationAllowed\(\)\)/);
|
||||
assert.match(read('client/components/boards/allBoardsSidebar.jade'),
|
||||
/if boardCreationAllowed\n\s+a\.sidebar-btn\.js-duplicate-selected-boards/);
|
||||
assert.match(read('client/components/cards/cardDetails.jade'),
|
||||
/if boardCreationAllowed\n\s+li\n\s+a\.js-create-board-from-card/);
|
||||
});
|
||||
@@ -103,7 +103,7 @@ test('CustomFieldBleed: the selector logic itself refuses a field of another boa
|
||||
|
||||
test('CustomFieldBleed negative: no REST handler anywhere writes a custom field by _id alone', () => {
|
||||
const walk = dir => fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap(e => {
|
||||
if (['tests', 'node_modules', '_build', '.build'].includes(e.name)) return [];
|
||||
if ((['tests', 'node_modules', '_build', '.build'].includes(e.name) || e.name.startsWith('_build'))) return [];
|
||||
const rel = `${dir}/${e.name}`;
|
||||
return e.isDirectory() ? walk(rel) : (/\.(c|m)?js$/.test(rel) ? [rel] : []);
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ test('negative: no second {token}-substitution implementation was added near the
|
||||
const suspiciousFiles = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '.git', '_build', '.build', '.tools', '.meteor'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '.git', '_build', '.build', '.tools', '.meteor'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!/\.(js|jsx)$/.test(entry.name)) continue;
|
||||
|
||||
@@ -57,7 +57,7 @@ test('nothing else in the tree still calls archiver as a v7 factory (negative)',
|
||||
const offenders = [];
|
||||
(function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (skipDirs.has(entry.name)) continue;
|
||||
if (skipDirs.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!/\.(js|cjs|mjs)$/.test(entry.name)) continue;
|
||||
|
||||
@@ -100,7 +100,7 @@ test('both permission files apply the shared guard and record the attempt', () =
|
||||
// hole again.
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel, out);
|
||||
else if (/\.js$/.test(entry.name)) out.push(rel);
|
||||
|
||||
@@ -76,7 +76,7 @@ test('negative: a site admin runs the instance-wide ones, the board admin fixBoa
|
||||
// --- negative, whole tree ----------------------------------------------------
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
|
||||
|
||||
@@ -122,7 +122,7 @@ test('negative: no server or model file joins a raw name into a path', () => {
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(rel); continue; }
|
||||
if (!rel.endsWith('.js')) continue;
|
||||
|
||||
@@ -64,9 +64,17 @@ assert.doesNotMatch(addBody[1], /spentTime/,
|
||||
// the session fields.
|
||||
const stopBody = cardsModel.match(/stopFlowSession\(\)\s*\{([\s\S]*?)\n \},/);
|
||||
assert.ok(stopBody, 'stopFlowSession has a body');
|
||||
assert.match(stopBody[1], /this\.setSpentTime\(newSpentTime\)/,
|
||||
'stopping a session must add its duration through setSpentTime(), the same method the manual popup uses');
|
||||
assert.match(stopBody[1], /\(this\.getSpentTime\(\) \|\| 0\) \+ elapsedHours/,
|
||||
// Changed on purpose: the duration is ADDED on the server (addSpentTime, an
|
||||
// $inc on the same spentTime field the manual popup edits) instead of
|
||||
// read-and-$set through setSpentTime(), because the client's copy of a linked
|
||||
// board or card can still be on its way and a Stop then replaced the total
|
||||
// with the session alone (tests/linkedTimerSessions.test.cjs).
|
||||
assert.match(stopBody[1], /this\.addSpentTime\(elapsedHours\)/,
|
||||
'stopping a session must add its duration to the same spentTime field, with addSpentTime()');
|
||||
assert.doesNotMatch(stopBody[1], /getSpentTime\(\)/, 'and must not compute the total from the client copy');
|
||||
const addSpentTimeBody = cardsModel.match(/ addSpentTime\(hours\) \{([\s\S]*?)\n \},/);
|
||||
assert.ok(addSpentTimeBody, 'models/cards.js has addSpentTime(hours)');
|
||||
assert.match(addSpentTimeBody[1], /\$inc: \{ spentTime: amount \}/,
|
||||
'the session duration is ADDED to the existing spentTime total, not a separate one');
|
||||
assert.match(stopBody[1], /flowStartAt:\s*null/);
|
||||
assert.match(stopBody[1], /flowInterruptions:\s*0/);
|
||||
|
||||
@@ -78,7 +78,7 @@ test('no other app-wide handler sets a Content-Security-Policy a 304 could carry
|
||||
const skip = new Set(['node_modules', '.git', '.build', '_build', '.tools', 'tests', 'public']);
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (skip.has(entry.name)) continue;
|
||||
if (skip.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel);
|
||||
else if (/\.(c|m)?js$/.test(entry.name)) {
|
||||
|
||||
@@ -46,7 +46,11 @@ function loadRules() {
|
||||
'const allowIsBoardAdminOrSiteAdmin = () => true; const canUpdateBoardSort = () => false;'
|
||||
+ 'const canUpdateBoardSameWidthValue = () => false;')
|
||||
.replace(/^import \{[^}]*\} from '\/server\/lib\/subtaskDepositAccess';$/m,
|
||||
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};');
|
||||
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};')
|
||||
// CreateBleed's rule (tests/createBleed.test.cjs): creating a board is
|
||||
// allowed here, so these tests see the group and member rules alone.
|
||||
.replace(/^import \{ mayCreateBoard \} from '\/server\/lib\/boardCreationGuard';$/m,
|
||||
'const mayCreateBoard = async () => true;');
|
||||
assert.ok(!/^import /m.test(src), 'every import of server/permissions/boards.js is stubbed');
|
||||
vm.runInNewContext(src, { Boards, require: resolve, console });
|
||||
return { denies, recorded };
|
||||
|
||||
@@ -97,7 +97,7 @@ test('negative: every ChangeHistory insert in the tree keeps empty strings and d
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '.npm', '_build', '.build', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '.npm', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { walk(rel); continue; }
|
||||
if (!/\.(c?m?js)$/.test(e.name)) continue;
|
||||
|
||||
@@ -115,7 +115,7 @@ const change = n => ({ _id: `r${n}`, boardId: 'b1', entityType: 'card', entityId
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) { if (!['node_modules', '_build', 'tests'].includes(entry.name)) walk(rel); continue; }
|
||||
if (entry.isDirectory()) { if (!(['node_modules', '_build', 'tests'].includes(entry.name) || entry.name.startsWith('_build'))) walk(rel); continue; }
|
||||
if (!/\.(c|m)?js$/.test(entry.name)) continue;
|
||||
const src = read(rel);
|
||||
if (/previousHash:\s*previous/.test(src) && !/historyChainTip\(|appendLegacyHistoryRow|appendHistoryChain/.test(src)) offenders.push(rel);
|
||||
|
||||
@@ -27,7 +27,7 @@ const SKIP = new Set(['node_modules', '_build', '.build', '.tools', '.meteor']);
|
||||
|
||||
function jsFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
if (SKIP.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) jsFiles(full, out);
|
||||
else if (entry.name.endsWith('.js')) out.push(full);
|
||||
|
||||
@@ -24,7 +24,7 @@ function operationNames() {
|
||||
const names = new Set();
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['_build', 'node_modules', '.npm', 'tests'].includes(e.name)) continue;
|
||||
if ((['_build', 'node_modules', '.npm', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, e.name);
|
||||
// Meteor's own packages, with their own test methods, are not WeKan's.
|
||||
if (isMeteorPackageFork(rel)) continue;
|
||||
|
||||
@@ -224,7 +224,7 @@ function sourceFiles(dirs) {
|
||||
fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true }).forEach(entry => {
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!['node_modules', '_build', '.build', '.tools'].includes(entry.name)) walk(rel);
|
||||
if (!(['node_modules', '_build', '.build', '.tools'].includes(entry.name) || entry.name.startsWith('_build'))) walk(rel);
|
||||
} else if (/\.js$/.test(entry.name)) out.push(rel);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ test('importNewUsers() uses the sentinel, and no code searches the string "*"',
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '.build', '_build', '.tools', '.meteor', '.git'].includes(e.name)) continue;
|
||||
if ((['node_modules', '.build', '_build', '.tools', '.meteor', '.git'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (/\.(js|mjs|cjs)$/.test(e.name) && /(searchUsers|getUserByUsername|getUserFilter)\(\s*['"`]\*['"`]\s*\)/.test(fs.readFileSync(p, 'utf8'))) {
|
||||
|
||||
@@ -151,7 +151,7 @@ test('sync.js schedules through it, and nowhere hands a setting to parser.text d
|
||||
const files = [];
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (['node_modules', '.build', '_build', '.tools', '.meteor', '.git'].includes(e.name)) continue;
|
||||
if ((['node_modules', '.build', '_build', '.tools', '.meteor', '.git'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p); else if (/\.(js|mjs|cjs)$/.test(e.name)) files.push(p);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ assert.match(config, /listId: list\._id, sourceKey: credential\.sourceKey, gener
|
||||
|
||||
// Negative, tree-wide: nothing else writes the field on an existing list.
|
||||
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
||||
if (['node_modules', 'tests', '_build', '.build'].includes(entry.name)) return [];
|
||||
if ((['node_modules', 'tests', '_build', '.build'].includes(entry.name) || entry.name.startsWith('_build'))) return [];
|
||||
const full = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(full) : full.endsWith('.js') ? [full] : [];
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ test('the private board\'s own member still sees its linked boards\' activities'
|
||||
// --- negative, whole tree ----------------------------------------------------
|
||||
function walk(dir, out = []) {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${e.name}`;
|
||||
if (e.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(e.name)) out.push(rel);
|
||||
|
||||
@@ -11,9 +11,14 @@ function fixture() {
|
||||
const methods = vm.runInNewContext(`({${source.slice(start, end)}})`, {
|
||||
Date, Cards: { updateAsync: async (selector, modifier) => { writes.push({ selector, modifier }); return 1; } },
|
||||
});
|
||||
// getSpentTime() is null: the linked board or card has not reached the
|
||||
// client yet, the race the e2e spec hit. A session must still credit its
|
||||
// own length - it is added on the server (addSpentTime, an $inc), never
|
||||
// computed from what the client happens to have.
|
||||
const card = { ...methods, _id: 'wrapper', linkedId: 'source', spentTime: 999,
|
||||
getRealId: () => 'source', getSpentTime: () => 4,
|
||||
setSpentTime: async value => { credits.push(value); } };
|
||||
getRealId: () => 'source', getSpentTime: () => null,
|
||||
setSpentTime: async () => { throw new Error('a session must not $set a total read on the client'); },
|
||||
addSpentTime: async hours => { credits.push(hours); } };
|
||||
return { card, writes, credits };
|
||||
}
|
||||
test('every timer session write targets the displayed wrapper, not its source', async () => {
|
||||
@@ -28,20 +33,41 @@ test('every timer session write targets the displayed wrapper, not its source',
|
||||
assert.equal(writes.length, 7);
|
||||
for (const { selector } of writes) assert.equal(selector._id, 'wrapper');
|
||||
});
|
||||
test('completed work credits the displayed source total, never the stale wrapper total', async () => {
|
||||
test('a session credits its own length, even before the client has the source total', async () => {
|
||||
const { card, credits } = fixture();
|
||||
card.pomodoroStartAt = new Date(); card.pomodoroPhase = 'work'; card.pomodoroWorkMinutes = 30;
|
||||
await card.completePomodoroWorkInterval(); assert.equal(credits[0], 4.5);
|
||||
await card.completePomodoroWorkInterval(); assert.equal(credits[0], 0.5);
|
||||
card.flowStartAt = new Date(Date.now() - 3600000);
|
||||
await card.stopFlowSession(); assert.ok(credits[1] >= 5 && credits[1] < 5.01);
|
||||
await card.stopFlowSession(); assert.ok(credits[1] >= 1 && credits[1] < 1.01);
|
||||
card.pomodoroStartAt = new Date(Date.now() - 1800000);
|
||||
await card.stopPomodoro(); assert.ok(credits[2] >= 4.5 && credits[2] < 4.51);
|
||||
await card.stopPomodoro(); assert.ok(credits[2] >= 0.5 && credits[2] < 0.51);
|
||||
});
|
||||
test('addSpentTime adds on the server copy of the linked board, the linked card or the card', async () => {
|
||||
const body = source.match(/ addSpentTime\(hours\) \{([\s\S]*?)\n \},/);
|
||||
assert.ok(body, 'models/cards.js has addSpentTime(hours)');
|
||||
const calls = [];
|
||||
const add = vm.runInNewContext(`(function(hours) {${body[1]}})`, {
|
||||
Number,
|
||||
Boards: { updateAsync: (selector, modifier) => calls.push(['board', selector._id, modifier]) },
|
||||
Cards: { updateAsync: (selector, modifier) => calls.push(['card', selector._id, modifier]) },
|
||||
});
|
||||
add.call({ isLinkedBoard: () => true, linkedId: 'board' }, 1);
|
||||
add.call({ isLinkedBoard: () => false, getRealId: () => 'source' }, 0.5);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['board', 'board', { $inc: { spentTime: 1 } }], ['card', 'source', { $inc: { spentTime: 0.5 } }]]);
|
||||
// Negative: nothing, a negative or a non-number adds nothing.
|
||||
for (const bad of [0, -1, NaN, 'x', undefined]) add.call({ isLinkedBoard: () => false, getRealId: () => 'source' }, bad);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
test('negative: no timer helper computes a new total from the client copy', () => {
|
||||
const timers = source.slice(start, end);
|
||||
assert.doesNotMatch(timers, /getSpentTime\(\)/,
|
||||
'a Flowtime or Pomodoro credit read getSpentTime() and $set the sum - with the source not yet on the client that replaced the total');
|
||||
});
|
||||
test('failed credit leaves the active timer available instead of clearing it', async () => {
|
||||
for (const method of ['stopFlowSession', 'completePomodoroWorkInterval', 'stopPomodoro']) {
|
||||
const { card, writes } = fixture();
|
||||
card.flowStartAt = card.pomodoroStartAt = new Date(); card.pomodoroPhase = 'work';
|
||||
card.setSpentTime = async () => { throw new Error('credit denied'); };
|
||||
card.addSpentTime = async () => { throw new Error('credit denied'); };
|
||||
await assert.rejects(card[method](), /credit denied/); assert.equal(writes.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ test('negative: no ensureIndex call anywhere passes something that is not an ind
|
||||
const offenders = [];
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '.npm', '_build', '.build', 'tests'].includes(e.name)) continue;
|
||||
if ((['node_modules', '.npm', '_build', '.build', 'tests'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { walk(rel); continue; }
|
||||
if (!/\.(c?m?js)$/.test(e.name)) continue;
|
||||
|
||||
@@ -113,7 +113,7 @@ test('negative: no server-side WIP-limit write without a board-admin check', ()
|
||||
const files = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
||||
if (skip.has(entry.name)) continue;
|
||||
if (skip.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel);
|
||||
else if (/\.(c|m)?js$/.test(entry.name)) files.push(rel);
|
||||
|
||||
@@ -102,7 +102,7 @@ test('negative: no key, secret, token, nonce, salt, password or code is made fro
|
||||
const hits = [];
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '.npm', '_build', '.build', 'tests', 'fontawesome-free'].includes(e.name)) continue;
|
||||
if ((['node_modules', '.npm', '_build', '.build', 'tests', 'fontawesome-free'].includes(e.name) || e.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(rel);
|
||||
else if (/\.(c?m?js|ts)$/.test(e.name) && !/_tests?\.js$|-tests?\.js$|\.tests\.js$/.test(e.name)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ const SKIP = new Set(['node_modules', '.tools', '_build', '.build', '.git', '.me
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
if (SKIP.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(sh|bat|cmd|py|cjs|mjs|js)$/.test(entry.name) && !/\.test\.cjs$/.test(entry.name)) out.push(full);
|
||||
|
||||
@@ -130,7 +130,7 @@ const CLASSIFICATION_ONLY = new Map([
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', 'tests', '.meteor'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(rel, out);
|
||||
else if (/\.(c|m)?js$/.test(entry.name)) out.push(rel);
|
||||
|
||||
@@ -147,7 +147,7 @@ test('negative: an admin of the board moves files, a member validates them', asy
|
||||
test('negative: no method moves a file to a caller-named storage unchecked', () => {
|
||||
const skip = new Set(['node_modules', '_build', '.build', '.tools', '.git', 'tests', '.meteor', 'client']);
|
||||
const walk = dir => fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
|
||||
if (skip.has(entry.name)) return [];
|
||||
if (skip.has(entry.name) || entry.name.startsWith('_build')) return [];
|
||||
const rel = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(rel) : /\.(c|m)?js$/.test(entry.name) ? [rel] : [];
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const assert = require('assert');
|
||||
const fs=require('fs'),path=require('path');const {parse}=require('@babel/parser');
|
||||
const ROOT=path.join(__dirname,'..');const files=[];
|
||||
function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){if(['_build','node_modules','.npm','tests'].includes(e.name))continue;const f=path.join(d,e.name);if(e.isDirectory())walk(f);else if(f.endsWith('.js')||f.endsWith('.cjs'))files.push(f);}}
|
||||
function walk(d){for(const e of fs.readdirSync(d,{withFileTypes:true})){if((['_build','node_modules','.npm','tests'].includes(e.name) || e.name.startsWith('_build')))continue;const f=path.join(d,e.name);if(e.isDirectory())walk(f);else if(f.endsWith('.js')||f.endsWith('.cjs'))files.push(f);}}
|
||||
['client','server','models','imports','config'].forEach(t=>walk(path.join(ROOT,t)));
|
||||
const cache=new Map();
|
||||
function resolve(from,spec){let b;if(spec.startsWith('/'))b=path.join(ROOT,spec);else if(spec.startsWith('.'))b=path.resolve(path.dirname(from),spec);else return null;for(const c of [b,b+'.js',b+'.cjs',path.join(b,'index.js')])if(fs.existsSync(c)&&fs.statSync(c).isFile())return c;return null;}
|
||||
|
||||
@@ -35,7 +35,7 @@ test('the Draggable table draws a native checkbox without text-field padding', (
|
||||
test('negative: no stylesheet brings back a native checkbox with text-field padding', () => {
|
||||
const skip = new Set(['node_modules', '_build', '.build', '.tools', '.git']);
|
||||
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
||||
if (skip.has(e.name)) return [];
|
||||
if (skip.has(e.name) || e.name.startsWith('_build')) return [];
|
||||
const p = path.join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : e.name.endsWith('.css') ? [p] : [];
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(e.name)) continue;
|
||||
if (SKIP.has(e.name) || e.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full, out);
|
||||
else if (e.name.endsWith('.js')) out.push(full);
|
||||
|
||||
@@ -30,7 +30,7 @@ function files() {
|
||||
const out = [];
|
||||
const walk = dir => {
|
||||
for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
||||
if (['node_modules', '_build', '.build', '.git', '.tools', 'old-CHANGELOG', 'test-results'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.git', '.tools', 'old-CHANGELOG', 'test-results'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = dir ? path.join(dir, entry.name) : entry.name;
|
||||
if (entry.isDirectory()) { walk(rel); continue; }
|
||||
if (/\.(sh|bat|ps1|cmd|py|mjs|cjs|js|md)$/.test(entry.name) && !['CHANGELOG.md', 'CHANGELOG-big.md'].includes(rel)) out.push(rel);
|
||||
|
||||
@@ -38,7 +38,7 @@ const skip = new Set(['.git', '.tools', 'node_modules', '_build', '.build', '.me
|
||||
const scripts = [];
|
||||
(function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (skip.has(entry.name)) continue;
|
||||
if (skip.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else if (/\.(sh|bat|cmd|ps1|ya?ml)$|^Dockerfile|^Makefile$|^snapcraft/.test(entry.name)) scripts.push(file);
|
||||
|
||||
@@ -74,7 +74,7 @@ test('negative: nothing closes the opened card on Minimongo\'s word alone', () =
|
||||
assert.strictEqual((details.match(/closeGoneCard\(/g) || []).length, 2, 'its definition and that one call');
|
||||
const walk = dir => fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(e => {
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) return ['node_modules', '_build', '.build', '.tools'].includes(e.name) ? [] : walk(rel);
|
||||
if (e.isDirectory()) return (['node_modules', '_build', '.build', '.tools'].includes(e.name) || e.name.startsWith('_build')) ? [] : walk(rel);
|
||||
return /\.js$/.test(e.name) ? [rel] : [];
|
||||
});
|
||||
for (const file of walk('client')) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
// The name is WeKan in the REST API documentation.
|
||||
// Run: node tests/openapiBrandSpelling.test.cjs
|
||||
//
|
||||
// The API pages on wekan.fi (api/v<version>/index.html) are Redoc pages built
|
||||
// from public/api/wekan.yml, which openapi/generate_openapi.py writes from its
|
||||
// own header text and the models' JSDoc. Those said "Wekan REST API",
|
||||
// "extend Wekan", "a production Wekan server" and "in the Wekan board", so
|
||||
// every release published API pages that wekan.fi's
|
||||
// tests/brand-spelling.test.cjs rejects (v12.95, v12.96 and v12.97 did). This
|
||||
// checks every source of that text, and the generated file.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
|
||||
|
||||
// The same shape wekan.fi's check uses: the name, not part of an address, a
|
||||
// path, a word or a flag.
|
||||
const WRONG = /(?<![\w./@:#%&?=+-])(?:Wekan|WEKAN)(?!\w|\.\w|[/@:])/g;
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
function offending(text) {
|
||||
return text.split('\n')
|
||||
.map((line, i) => ({ line, n: i + 1 }))
|
||||
.filter(({ line }) => { WRONG.lastIndex = 0; return WRONG.test(line); });
|
||||
}
|
||||
|
||||
test('the pattern finds the wrong spellings and leaves addresses alone', () => {
|
||||
for (const bad of ['title: Wekan REST API', 'extend WEKAN with ease']) {
|
||||
assert.strictEqual(offending(bad).length, 1, bad);
|
||||
}
|
||||
for (const ok of ['title: WeKan REST API', 'https://github.com/wekan/wekan',
|
||||
'public/api/wekan.yml', 'snap install wekan', 'Wekan/models']) {
|
||||
assert.strictEqual(offending(ok).length, 0, ok);
|
||||
}
|
||||
});
|
||||
|
||||
test('the generated public/api/wekan.yml spells the name WeKan', () => {
|
||||
const hits = offending(read('public/api/wekan.yml'));
|
||||
assert.deepStrictEqual(hits.map(h => `wekan.yml:${h.n}: ${h.line.trim()}`), []);
|
||||
assert.match(read('public/api/wekan.yml'), /^ {2}title: WeKan REST API$/m);
|
||||
});
|
||||
|
||||
test('the built public/api/wekan.html spells the name WeKan', () => {
|
||||
// releases/release-website.sh copies this file to wekan.fi as
|
||||
// api/v<version>/index.html, so what it says is what the site shows. The
|
||||
// v12.99 version bump rebuilt it before the generator was fixed. Redoc's
|
||||
// data-text attribute and the search index are not shown text; the visible
|
||||
// text and the JSON Redoc rebuilds the page from are.
|
||||
const html = read('public/api/wekan.html')
|
||||
.replace(/data-text="[^"]*"/g, '')
|
||||
.replace(/"searchIndex":\{[\s\S]*$/, '');
|
||||
const hits = [];
|
||||
WRONG.lastIndex = 0;
|
||||
for (let m; (m = WRONG.exec(html));) {
|
||||
hits.push(html.slice(Math.max(0, m.index - 30), m.index + 30));
|
||||
}
|
||||
assert.deepStrictEqual(hits, []);
|
||||
});
|
||||
|
||||
test('the generator header spells the name WeKan', () => {
|
||||
const generator = read('openapi/generate_openapi.py');
|
||||
const start = generator.indexOf("print('''swagger: '2.0'");
|
||||
assert.notStrictEqual(start, -1, 'generator header not found');
|
||||
const header = generator.slice(start, generator.indexOf("'''", start + 10));
|
||||
assert.deepStrictEqual(offending(header).map(h => h.line.trim()), []);
|
||||
assert.match(header, /title: WeKan REST API/);
|
||||
});
|
||||
|
||||
test('no model JSDoc the generator reads spells the name Wekan', () => {
|
||||
const found = [];
|
||||
for (const dir of ['models', 'server/models']) {
|
||||
const abs = path.join(repoRoot, dir);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
for (const file of fs.readdirSync(abs).filter(f => f.endsWith('.js'))) {
|
||||
const text = fs.readFileSync(path.join(abs, file), 'utf8');
|
||||
// Only JSDoc blocks: /** ... */ is what becomes an API description.
|
||||
for (const block of text.match(/\/\*\*[\s\S]*?\*\//g) || []) {
|
||||
for (const h of offending(block)) found.push(`${dir}/${file}: ${h.line.trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(found, []);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
// A lock for spec files that change the SAME site-wide setting.
|
||||
//
|
||||
// Playwright runs the tests of one file in order (fullyParallel: false) but
|
||||
// two files at once with two workers. A site-wide setting is one document for
|
||||
// every worker, so two files that write it undo each other:
|
||||
// password-length-report.e2e.js and password-min-length-signup-login.e2e.js
|
||||
// both set, and after every test restore, `loginOptions` (the password
|
||||
// policy). In the full Chromium run they ran side by side and one test of
|
||||
// each failed - the other file had switched enforcement back on, or off.
|
||||
//
|
||||
// holdSharedSetting(test, 'password-policy') at the top of each such file
|
||||
// makes it hold the lock for all its tests: the second file waits until the
|
||||
// first is finished. The lock is a directory - mkdir is atomic - under
|
||||
// TMPDIR, which build.sh sets to .tools/tmp for every worker of a run, and
|
||||
// holds the owning process id, so a lock left by a worker that died is taken
|
||||
// over instead of blocking the run.
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const WAIT_MS = 15 * 60 * 1000;
|
||||
|
||||
function lockDir(name) {
|
||||
if (!/^[a-z0-9-]+$/.test(name)) throw new Error(`shared setting lock name must be kebab-case: ${name}`);
|
||||
return path.join(process.env.TMPDIR || os.tmpdir(), `wekan-e2e-setting-lock-${name}`);
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
if (!pid) return true; // being written: treat as held for now
|
||||
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
||||
}
|
||||
|
||||
// How many holders this process has per name: one worker can run the same
|
||||
// file again (--repeat-each, a retry) before the earlier run's afterAll, and
|
||||
// the lock must stay taken until the LAST holder releases it.
|
||||
const held = new Map();
|
||||
|
||||
async function acquire(name, opts = {}) {
|
||||
const count = held.get(name) || 0;
|
||||
if (count > 0) { held.set(name, count + 1); return releaser(name); }
|
||||
await take(name, opts);
|
||||
held.set(name, 1);
|
||||
return releaser(name);
|
||||
}
|
||||
|
||||
function releaser(name) {
|
||||
let done = false;
|
||||
return () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const count = (held.get(name) || 1) - 1;
|
||||
if (count > 0) { held.set(name, count); return; }
|
||||
held.delete(name);
|
||||
const dir = lockDir(name);
|
||||
try {
|
||||
if (Number(fs.readFileSync(path.join(dir, 'pid'), 'utf8')) === process.pid) fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch (e) { /* already gone */ }
|
||||
};
|
||||
}
|
||||
|
||||
async function take(name, { waitMs = WAIT_MS } = {}) {
|
||||
const dir = lockDir(name);
|
||||
const owner = path.join(dir, 'pid');
|
||||
const started = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(owner, String(process.pid));
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
let pid = 0;
|
||||
try { pid = Number(fs.readFileSync(owner, 'utf8')) || 0; } catch (_) { /* not written yet */ }
|
||||
if (pid === process.pid) return; // left by this process
|
||||
if (!alive(pid)) { fs.rmSync(dir, { recursive: true, force: true }); continue; }
|
||||
if (Date.now() - started > waitMs) throw new Error(`shared setting "${name}" still held by process ${pid}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function holdSharedSetting(test, name) {
|
||||
let release = null;
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
testInfo.setTimeout(WAIT_MS + 60 * 1000);
|
||||
release = await acquire(name);
|
||||
});
|
||||
test.afterAll(() => {
|
||||
if (release) release();
|
||||
release = null;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { holdSharedSetting, acquire };
|
||||
@@ -20,9 +20,15 @@ async function openSettings(page, section) {
|
||||
async function openCardSectionMenu(page) {
|
||||
const pane = page.locator('.pop-over[data-popup="boardCardSettingsPopup"] .js-card-settings-pane[data-side="card"]');
|
||||
const section = pane.locator('.js-card-settings-section[data-section="card"]');
|
||||
await section.locator('.js-open-card-settings-section-menu').click();
|
||||
const menu = section.locator('.js-card-settings-panel[data-kind="section"]');
|
||||
await expect(menu).toBeVisible();
|
||||
// Under the load of a full two-worker run a click right after the popup is
|
||||
// drawn can come before its events are bound, and the panel never opened
|
||||
// (Firefox, the non-admin test). The button toggles the panel, so click
|
||||
// again only while it is still closed.
|
||||
await expect(async () => {
|
||||
if (!(await menu.isVisible())) await section.locator('.js-open-card-settings-section-menu').click();
|
||||
await expect(menu).toBeVisible({ timeout: 2000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
return menu;
|
||||
}
|
||||
// Swimlane and List show their table in the popup; Card in Card's hamburger.
|
||||
|
||||
@@ -57,6 +57,19 @@ async function emptyHeaderPoint(details) {
|
||||
const box = locator => locator.boundingBox();
|
||||
// Where it is, not how tall: the window settles its height as it renders.
|
||||
const at = async locator => { const b = await locator.boundingBox(); return { x: Math.round(b.x), y: Math.round(b.y) }; };
|
||||
// A long menu gets its column layout a frame after it is drawn, and grows to
|
||||
// 960px; measure the popup only once its box has stopped changing, so the test
|
||||
// drags the popup the user sees rather than the one drawn a frame earlier.
|
||||
async function settled(locator) {
|
||||
let previous = '';
|
||||
await expect.poll(async () => {
|
||||
const b = await locator.boundingBox();
|
||||
const now = b ? [b.x, b.y, b.width, b.height].map(Math.round).join(',') : '';
|
||||
const same = now !== '' && now === previous;
|
||||
previous = now;
|
||||
return same;
|
||||
}, { intervals: [100, 100, 200, 300], timeout: 5000 }).toBe(true);
|
||||
}
|
||||
|
||||
test('handles off: any part of the title edits, the empty header row moves the window', async ({ page, user, board }) => {
|
||||
const cp = await openCard(page, user, board, false);
|
||||
@@ -114,6 +127,7 @@ for (const handles of [false, true]) {
|
||||
await open();
|
||||
const pop = page.locator('.js-pop-over').last();
|
||||
await expect(pop).toBeVisible();
|
||||
await settled(pop);
|
||||
const titleBar = pop.locator('.header-title');
|
||||
const handle = pop.locator('.js-popup-drag-handle');
|
||||
await expect(handle).toHaveCount(handles ? 1 : 0);
|
||||
|
||||
@@ -32,7 +32,7 @@ const centre = async locator => {
|
||||
return { x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) };
|
||||
};
|
||||
|
||||
async function openCardWindow(page, board, user, { handles }) {
|
||||
async function openCardWindow(page, board, user, { handles, touch = true }) {
|
||||
db.updateOne('users', { _id: user.id }, { $set: { 'profile.showDesktopDragHandles': handles } });
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsCardNumber: true } });
|
||||
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
|
||||
@@ -42,6 +42,10 @@ async function openCardWindow(page, board, user, { handles }) {
|
||||
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
|
||||
if (handles) await expect(page.locator('.js-card-drag-handle')).toBeVisible();
|
||||
else await expect(page.locator('.js-card-drag-handle')).toHaveCount(0);
|
||||
// Touch comes from the DevTools protocol, which only Chromium has; the mouse
|
||||
// test asks for none, so it runs in Firefox and WebKit too (it failed there
|
||||
// with "CDP session is only available in Chromium" before it had moved).
|
||||
if (!touch) return null;
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
return cdp;
|
||||
@@ -141,7 +145,7 @@ test('with drag handles on, the handle moves the window under a finger and the h
|
||||
});
|
||||
|
||||
test('a mouse still moves the window by the header row', async ({ boardPage: page, board, user }) => {
|
||||
await openCardWindow(page, board, user, { handles: false });
|
||||
await openCardWindow(page, board, user, { handles: false, touch: false });
|
||||
const before = await cardBox(page);
|
||||
const header = await page.locator('.card-details .card-details-header').boundingBox();
|
||||
const title = await page.locator('.card-details .card-details-title').boundingBox();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CreateBleed (GHSA-qcfm-2g4w-wqcc), #4475: "Only admins can create boards".
|
||||
*
|
||||
* The setting was checked only in createBoardWithInitialSwimlanes. This is the
|
||||
* report, as the reporter wrote it, against a running WeKan: with the setting
|
||||
* on, a non-admin's console Boards.insert, POST /api/boards, POST
|
||||
* /api/boards/import, importBoard, copyBoard and createBoardFromCard create no
|
||||
* board, each refusal is in Admin Panel -> Problems, the Duplicate button is
|
||||
* not offered, and an admin and a board template still work.
|
||||
*
|
||||
* The setting is one document for every worker, and while it is on another
|
||||
* spec cannot create a board as a non-admin through the UI: it is on only for
|
||||
* the checks themselves, and back to what it was in `finally`.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, waitForMeteor } = require('../helpers/auth');
|
||||
|
||||
const BASE_URL = process.env.WEKAN_BASE_URL || 'http://localhost:3000';
|
||||
const SETTING = 'tableVisibilityMode-boardCreationAdminOnly';
|
||||
const auth = token => ({ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' });
|
||||
|
||||
function setAdminOnly(on) {
|
||||
const prior = db.findOne('tableVisibilityModeSettings', { _id: SETTING });
|
||||
if (prior) db.updateOne('tableVisibilityModeSettings', { _id: SETTING }, { $set: { booleanValue: on } });
|
||||
else db.insertOne('tableVisibilityModeSettings', { _id: SETTING, booleanValue: on, sort: 0 });
|
||||
return () => {
|
||||
if (prior) db.updateOne('tableVisibilityModeSettings', { _id: SETTING }, { $set: { booleanValue: prior.booleanValue } });
|
||||
else db.deleteOne('tableVisibilityModeSettings', { _id: SETTING });
|
||||
};
|
||||
}
|
||||
|
||||
const boardsOf = userId => db.find('boards', { 'members.userId': userId, type: 'board' }).map(b => b._id);
|
||||
// One row per kind of refusal (models/lib/eventLogSummary.js); `count` grows.
|
||||
const createBleedCount = () => db.find('eventlog', { stream: 'security', bleed: 'CreateBleed' })
|
||||
.reduce((n, row) => n + (row.count || 1), 0);
|
||||
|
||||
test.describe('CreateBleed: only admins create boards, on every path', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('a non-admin creates no board by insert, REST, import, copy or a card; each refusal is recorded', async ({ page, request, user, board }) => {
|
||||
await page.goto(`${BASE_URL}/sign-in`);
|
||||
await waitForMeteor(page);
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
const before = boardsOf(user.id);
|
||||
const cardId = db.find('cards', { boardId: board.boardId })[0]._id;
|
||||
const loggedBefore = createBleedCount();
|
||||
const restore = setAdminOnly(true);
|
||||
try {
|
||||
// The server learns the setting from its observer: wait until the Add
|
||||
// Board form is refused, the one path that was already guarded.
|
||||
await expect.poll(() => page.evaluate(async () => {
|
||||
try { await Meteor.callAsync('createBoardWithInitialSwimlanes', { title: 'probe', slug: 'probe' }); return 'created'; }
|
||||
catch (error) { return error.error; }
|
||||
}), { timeout: 20_000 }).toBe('not-authorized');
|
||||
|
||||
const errors = await page.evaluate(async ({ boardId, cardId }) => {
|
||||
const Boards = Meteor.connection._stores.boards._getCollection();
|
||||
const tryIt = async fn => { try { await fn(); return 'created'; } catch (error) { return error.error || String(error); } };
|
||||
return {
|
||||
// The report's console line.
|
||||
insert: await tryIt(() => new Promise((resolve, reject) => {
|
||||
Meteor.connection.apply('/boards/insert', [{ _id: Random.id(), title: 'x', permission: 'private' }],
|
||||
{ returnStubValue: false }, (error, result) => (error ? reject(error) : resolve(result)));
|
||||
})),
|
||||
importBoard: await tryIt(() => Meteor.callAsync('importBoard', { title: 'i' }, {}, 'wekan', null)),
|
||||
copyBoard: await tryIt(() => Meteor.callAsync('copyBoard', boardId, { title: 'c', type: 'board' })),
|
||||
createBoardFromCard: await tryIt(() => Meteor.callAsync('createBoardFromCard', cardId, 'from card')),
|
||||
};
|
||||
}, { boardId: board.boardId, cardId });
|
||||
expect(errors.insert).not.toBe('created');
|
||||
expect(errors.importBoard).toBe('not-authorized');
|
||||
expect(errors.copyBoard).toBe('not-authorized');
|
||||
expect(errors.createBoardFromCard).toBe('not-authorized');
|
||||
|
||||
// POST /api/boards and the import route answer 403, not 500.
|
||||
const created = await request.post(`${BASE_URL}/api/boards`, { headers: auth(user.token), data: { title: 'rest' } });
|
||||
expect(created.status()).toBe(403);
|
||||
const imported = await request.post(`${BASE_URL}/api/boards/import`, { headers: auth(user.token), data: { board: { title: 'rest import' } } });
|
||||
expect(imported.status(), 'refused before the export is even parsed').toBe(403);
|
||||
|
||||
expect(boardsOf(user.id).sort(), 'no new working board').toEqual(before.sort());
|
||||
// Each refusal is one row in Admin Panel -> Problems, CreateBleed.
|
||||
// insert, importBoard, copyBoard, createBoardFromCard, the two REST
|
||||
// calls - and the probe above, at least once.
|
||||
await expect.poll(() => createBleedCount() - loggedBefore, { timeout: 10_000 }).toBeGreaterThanOrEqual(7);
|
||||
expect(db.find('eventlog', { stream: 'security', bleed: 'CreateBleed', source: 'ddp:boards.insert' })[0]?.userId)
|
||||
.toBe(user.id);
|
||||
|
||||
// The UI does not offer Duplicate to this member.
|
||||
await page.goto(`${BASE_URL}/`);
|
||||
await waitForMeteor(page);
|
||||
await expect(page.locator('.js-duplicate-selected-boards')).toHaveCount(0);
|
||||
} finally {
|
||||
restore();
|
||||
const extra = boardsOf(user.id).filter(id => !before.includes(id));
|
||||
if (extra.length) db.cleanup({ boardIds: extra });
|
||||
}
|
||||
});
|
||||
|
||||
test('negative: an admin, a board template and an instance without the setting still create boards', async ({ page, request, user, adminUser }) => {
|
||||
const created = [];
|
||||
const restore = setAdminOnly(true);
|
||||
try {
|
||||
// An admin, over REST, while it is on.
|
||||
const res = await request.post(`${BASE_URL}/api/boards`, { headers: auth(adminUser.token), data: { title: 'admin board' } });
|
||||
expect(res.status()).toBe(200);
|
||||
created.push((await res.json())._id);
|
||||
|
||||
// A member's own board template, the one client insert WeKan makes.
|
||||
await page.goto(`${BASE_URL}/sign-in`);
|
||||
await waitForMeteor(page);
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
const templateId = await page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const _id = Random.id();
|
||||
Meteor.connection.apply('/boards/insert', [{ _id, title: 'my template', slug: 'my-template', permission: 'private', type: 'template-board' }],
|
||||
{ returnStubValue: false }, error => (error ? reject(error) : resolve(_id)));
|
||||
}));
|
||||
created.push(templateId);
|
||||
expect(db.findOne('boards', { _id: templateId })?.type).toBe('template-board');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
try {
|
||||
// The setting off: a member creates a board again, once the server's
|
||||
// settings observer has seen it (up to ten seconds on a polled MongoDB).
|
||||
await expect.poll(async () => {
|
||||
const res = await request.post(`${BASE_URL}/api/boards`, { headers: auth(user.token), data: { title: 'member board' } });
|
||||
if (res.status() === 200) created.push((await res.json())._id);
|
||||
return res.status();
|
||||
}, { timeout: 20_000 }).toBe(200);
|
||||
} finally {
|
||||
db.cleanup({ boardIds: created.filter(Boolean) });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ const { test, expect } = require('@playwright/test');
|
||||
const hotkeysSource = fs.readFileSync(
|
||||
path.join(__dirname, '../../../node_modules/hotkeys-js/dist/hotkeys-js.umd.cjs'), 'utf8');
|
||||
|
||||
test('a shortcut runs once for the d key on a Latin, a Farsi and a Cyrillic layout', async ({ page }) => {
|
||||
test('a shortcut runs once for the d key on a Latin, a Farsi and a Cyrillic layout', async ({ page, browserName }) => {
|
||||
// The key events come from the DevTools protocol, which only Chromium has
|
||||
// (Firefox failed with "CDP session is only available in Chromium").
|
||||
test.skip(browserName !== 'chromium', 'the key events come from the DevTools protocol');
|
||||
await page.setContent('<body></body>');
|
||||
await page.evaluate(source => {
|
||||
const module = { exports: {} };
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, loginWithCredentials, logout, waitForMeteor } = require('../helpers/auth');
|
||||
const { holdSharedSetting } = require('../helpers/sharedSettingLock');
|
||||
|
||||
// The password policy is one setting for every worker: hold it for this whole
|
||||
// file, so the other password spec cannot change it in between.
|
||||
holdSharedSetting(test, 'password-policy');
|
||||
|
||||
|
||||
const BASE_URL = process.env.WEKAN_BASE_URL || 'http://localhost:3000';
|
||||
@@ -29,8 +34,12 @@ test.beforeEach(() => {
|
||||
resetLockout();
|
||||
});
|
||||
test.afterEach(() => {
|
||||
// Back to the DEFAULT policy, not to what beforeEach saw: a snapshot taken
|
||||
// after an earlier test was interrupted already carries its enforced
|
||||
// minimum, and restoring it kept that minimum on for every later test.
|
||||
db.updateOne('settings', { _id: setting._id }, {
|
||||
$set: { loginOptions: setting.loginOptions || {}, disableRegistration: !!setting.disableRegistration },
|
||||
$set: { disableRegistration: !!setting.disableRegistration },
|
||||
$unset: { 'loginOptions.passwordMinLength': '', 'loginOptions.passwordMinLengthEnforce': '' },
|
||||
});
|
||||
resetLockout();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, loginWithCredentials, waitForMeteor } = require('../helpers/auth');
|
||||
const { holdSharedSetting } = require('../helpers/sharedSettingLock');
|
||||
|
||||
// The password policy is one setting for every worker: hold it for this whole
|
||||
// file, so the other password spec cannot change it in between.
|
||||
holdSharedSetting(test, 'password-policy');
|
||||
|
||||
const BASE_URL = process.env.WEKAN_BASE_URL || 'http://localhost:3000';
|
||||
const userIdIn = page => page.evaluate(() => (typeof Meteor !== 'undefined' ? Meteor.userId() : null));
|
||||
@@ -36,8 +41,12 @@ test.beforeEach(() => {
|
||||
resetLockout();
|
||||
});
|
||||
test.afterEach(() => {
|
||||
// Back to the DEFAULT policy, not to what beforeEach saw: a snapshot taken
|
||||
// after an earlier test was interrupted already carries its enforced
|
||||
// minimum, and restoring it kept that minimum on for every later test.
|
||||
db.updateOne('settings', { _id: setting._id }, {
|
||||
$set: { disableRegistration: !!setting.disableRegistration, loginOptions: setting.loginOptions || {} },
|
||||
$set: { disableRegistration: !!setting.disableRegistration },
|
||||
$unset: { 'loginOptions.passwordMinLength': '', 'loginOptions.passwordMinLengthEnforce': '' },
|
||||
});
|
||||
resetLockout();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: the browser tests open WeKan at the test server's ROOT_URL origin.
|
||||
// Run: node tests/playwrightBaseUrlMatchesRootUrl.test.cjs
|
||||
//
|
||||
// Meteor-Files sends every upload to ROOT_URL + /cdn/storage/<collection>/__upload
|
||||
// (packages/ostrio:files client: `_rootUrl` is __meteor_runtime_config__.ROOT_URL)
|
||||
// and answers no CORS preflight for any other origin. build.sh started the test
|
||||
// server with ROOT_URL=http://localhost:3000 but sent the Docker Playwright
|
||||
// browsers to http://127.0.0.1:3000, so every upload from a page was blocked:
|
||||
//
|
||||
// Access to fetch at 'http://localhost:3000/cdn/storage/attachments/__upload'
|
||||
// from origin 'http://127.0.0.1:3000' has been blocked by CORS policy
|
||||
//
|
||||
// and the four drawing specs whose Save uploads an attachment timed out. Run
|
||||
// with WEKAN_BASE_URL=http://localhost:3000 the same eight drawing tests pass.
|
||||
// A health check with curl may still use 127.0.0.1 - only a browser is bound
|
||||
// by the origin - so this checks what browsers are given, not every address.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const buildSh = fs.readFileSync(path.join(repoRoot, 'build.sh'), 'utf8');
|
||||
const config = fs.readFileSync(path.join(repoRoot, 'tests/playwright/playwright.config.js'), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const origin = url => new URL(url).origin;
|
||||
|
||||
// Every ROOT_URL the test servers are started with.
|
||||
const rootUrls = [...buildSh.matchAll(/\bROOT_URL="?(http:\/\/(?:localhost|127\.0\.0\.1):3000)"?/g)].map(m => m[1]);
|
||||
// Every default a browser run is given.
|
||||
const baseDefaults = [...buildSh.matchAll(/WEKAN_BASE_URL:-(http:\/\/[^}"\s]+)\}/g)].map(m => m[1]);
|
||||
|
||||
test('the scan finds the test server ROOT_URLs and the browser defaults', () => {
|
||||
assert.ok(rootUrls.length >= 3, `ROOT_URLs found: ${rootUrls.length}`);
|
||||
assert.ok(baseDefaults.length >= 2, `WEKAN_BASE_URL defaults found: ${baseDefaults.length}`);
|
||||
});
|
||||
|
||||
test('the local test servers all use one ROOT_URL origin', () => {
|
||||
assert.deepStrictEqual([...new Set(rootUrls.map(origin))], ['http://localhost:3000']);
|
||||
});
|
||||
|
||||
test('every browser default in build.sh is that ROOT_URL origin', () => {
|
||||
const wrong = baseDefaults.filter(u => origin(u) !== 'http://localhost:3000');
|
||||
assert.deepStrictEqual(wrong, [],
|
||||
'a browser opened on another origin than ROOT_URL cannot upload (Meteor-Files CORS)');
|
||||
});
|
||||
|
||||
test('the Playwright config default is that origin too', () => {
|
||||
const m = config.match(/const BASE_URL = process\.env\.WEKAN_BASE_URL \|\| '([^']+)'/);
|
||||
assert.ok(m, 'playwright.config.js BASE_URL default not found');
|
||||
assert.strictEqual(origin(m[1]), 'http://localhost:3000');
|
||||
});
|
||||
|
||||
test('negative: the same check rejects the 127.0.0.1 default that broke uploads', () => {
|
||||
const broken = buildSh.replace('WEKAN_BASE_URL:-http://localhost:3000}', 'WEKAN_BASE_URL:-http://127.0.0.1:3000}');
|
||||
const defaults = [...broken.matchAll(/WEKAN_BASE_URL:-(http:\/\/[^}"\s]+)\}/g)].map(m => m[1]);
|
||||
assert.ok(defaults.some(u => origin(u) !== 'http://localhost:3000'));
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: a Playwright test that opens a DevTools (CDP) session skips outside
|
||||
// Chromium.
|
||||
// Run: node tests/playwrightCdpChromiumOnly.test.cjs
|
||||
//
|
||||
// browserContext.newCDPSession exists only in Chromium. In the full Firefox
|
||||
// run two tests failed on it before they had tested anything - "CDP session is
|
||||
// only available in Chromium": keyboard-nonlatin-once, which had no skip, and
|
||||
// card-window-touch-drag's mouse test, which called the helper that sets up
|
||||
// touch emulation although it needs none. Each test that opens a session -
|
||||
// itself, or through a helper of its file that does - says
|
||||
// `test.skip(browserName !== 'chromium', ...)`, or its file skips the whole
|
||||
// describe; a helper call that asks for no touch (`touch: false`) opens none.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const specsDir = path.join(__dirname, 'playwright', 'specs');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const SKIP = /test\.skip\(\s*(?:\(\{\s*browserName\s*\}\)\s*=>\s*)?browserName\s*!==\s*'chromium'/;
|
||||
|
||||
// The test blocks of a file that open a CDP session without a Chromium skip.
|
||||
function unguarded(text) {
|
||||
// Helpers of this file that open a session.
|
||||
const helpers = [...text.matchAll(/(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{/g)]
|
||||
.filter(m => {
|
||||
const body = text.slice(m.index, text.indexOf('\n}', m.index) + 2);
|
||||
return /newCDPSession/.test(body);
|
||||
})
|
||||
.map(m => m[1]);
|
||||
const fileSkip = /test\.describe[\s\S]*?test\.skip\(\s*\(\{\s*browserName\s*\}\)\s*=>\s*browserName\s*!==\s*'chromium'/.test(text);
|
||||
const starts = [...text.matchAll(/\n\s*test\(\s*[`'"]/g)].map(m => m.index);
|
||||
const found = [];
|
||||
starts.forEach((start, i) => {
|
||||
const block = text.slice(start, starts[i + 1] === undefined ? text.length : starts[i + 1]);
|
||||
const opens = /newCDPSession/.test(block) || helpers.some(name => {
|
||||
const calls = [...block.matchAll(new RegExp(`\\b${name}\\(([^;]*)\\)`, 'g'))];
|
||||
return calls.some(c => !/touch:\s*false/.test(c[1]));
|
||||
});
|
||||
if (opens && !SKIP.test(block) && !fileSkip) {
|
||||
found.push(block.match(/test\(\s*[`'"]([^`'"]+)/)[1]);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
const specs = fs.readdirSync(specsDir).filter(f => f.endsWith('.e2e.js'))
|
||||
.map(f => ({ f, text: fs.readFileSync(path.join(specsDir, f), 'utf8') }))
|
||||
.filter(({ text }) => /newCDPSession/.test(text));
|
||||
|
||||
test('the scan finds the specs that open a CDP session', () => {
|
||||
const names = specs.map(s => s.f);
|
||||
for (const f of ['keyboard-nonlatin-once.e2e.js', 'card-window-touch-drag.e2e.js', 'due-date-keyboard-shortcut.e2e.js']) {
|
||||
assert.ok(names.includes(f), `${f} in ${names.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every test that opens a CDP session skips outside Chromium', () => {
|
||||
const missing = specs.flatMap(({ f, text }) => unguarded(text).map(name => `${f}: ${name}`));
|
||||
assert.deepStrictEqual(missing, [],
|
||||
'these tests open a CDP session in every browser, and fail in Firefox and WebKit before testing anything');
|
||||
});
|
||||
|
||||
test('negative: the check finds both shapes that failed in Firefox', () => {
|
||||
const direct = `
|
||||
test('keys', async ({ page }) => {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
});`;
|
||||
assert.deepStrictEqual(unguarded(direct), ['keys']);
|
||||
const viaHelper = `
|
||||
async function openWindow(page, { touch = true }) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
}
|
||||
test('mouse', async ({ page }) => {
|
||||
await openWindow(page, { handles: false });
|
||||
});
|
||||
test('mouse without touch', async ({ page }) => {
|
||||
await openWindow(page, { handles: false, touch: false });
|
||||
});
|
||||
test('finger', async ({ page, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'touch');
|
||||
await openWindow(page, {});
|
||||
});`;
|
||||
assert.deepStrictEqual(unguarded(viaHelper), ['mouse']);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: Playwright specs that change the site-wide password policy hold one
|
||||
// lock, and put the policy back to its default rather than to a snapshot.
|
||||
// Run: node tests/playwrightSharedSettingLock.test.cjs
|
||||
//
|
||||
// In the full Chromium run (two workers) password-length-report.e2e.js and
|
||||
// password-min-length-signup-login.e2e.js ran side by side. Both write
|
||||
// settings.loginOptions.passwordMinLength / passwordMinLengthEnforce, one
|
||||
// document for every worker, so each switched enforcement on or off under
|
||||
// the other, and one test of each failed. Both also "restored" the
|
||||
// loginOptions they had seen before the test - and once a test had been
|
||||
// interrupted, that snapshot already held an enforced minimum, which every
|
||||
// later test then restored, so the minimum stayed enforced for the rest of
|
||||
// the run. tests/playwright/helpers/sharedSettingLock.js makes the second
|
||||
// file wait for the first, and the specs now $unset the two keys afterwards.
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const specsDir = path.join(__dirname, 'playwright', 'specs');
|
||||
const helper = path.join(__dirname, 'playwright', 'helpers', 'sharedSettingLock.js');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
const specs = fs.readdirSync(specsDir).filter(f => f.endsWith('.e2e.js'))
|
||||
.map(f => ({ f, text: fs.readFileSync(path.join(specsDir, f), 'utf8') }));
|
||||
// Writing the policy names its keys; a spec that only opens the Password
|
||||
// length page (admin-pane-titles-and-help) reads it and needs no lock.
|
||||
const writesPolicy = ({ text }) => /loginOptions\.passwordMinLength/.test(text);
|
||||
const policySpecs = specs.filter(writesPolicy);
|
||||
|
||||
test('the scan finds the password policy specs', () => {
|
||||
const names = policySpecs.map(s => s.f);
|
||||
assert.ok(names.includes('password-length-report.e2e.js'), names.join(', '));
|
||||
assert.ok(names.includes('password-min-length-signup-login.e2e.js'), names.join(', '));
|
||||
});
|
||||
|
||||
test('every spec that changes the password policy holds the password-policy lock', () => {
|
||||
const missing = policySpecs.filter(({ text }) => !/holdSharedSetting\(test, 'password-policy'\)/.test(text)).map(s => s.f);
|
||||
assert.deepStrictEqual(missing, [],
|
||||
'these specs change the site-wide password policy without the lock, so they can run beside another that does');
|
||||
});
|
||||
|
||||
test('negative: no such spec restores loginOptions from a snapshot', () => {
|
||||
const restoring = policySpecs.filter(({ text }) => /loginOptions:\s*setting\.loginOptions/.test(text)).map(s => s.f);
|
||||
assert.deepStrictEqual(restoring, [],
|
||||
'restoring a snapshot keeps an enforced minimum that an interrupted test left behind');
|
||||
for (const { f, text } of policySpecs) {
|
||||
assert.match(text, /\$unset: \{ 'loginOptions\.passwordMinLength': '', 'loginOptions\.passwordMinLengthEnforce': '' \}/, f);
|
||||
}
|
||||
});
|
||||
|
||||
async function lockBehaviour() {
|
||||
// Driven for real, in a throwaway TMPDIR.
|
||||
const os = require('os');
|
||||
const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'setting-lock-test-'));
|
||||
const old = process.env.TMPDIR;
|
||||
process.env.TMPDIR = tmp;
|
||||
try {
|
||||
delete require.cache[require.resolve(helper)];
|
||||
const { acquire } = require(helper);
|
||||
const dir = path.join(tmp, 'wekan-e2e-setting-lock-probe');
|
||||
const a = await acquire('probe');
|
||||
const b = await acquire('probe');
|
||||
a();
|
||||
assert.ok(fs.existsSync(dir), 'still held while another holder in this process has it');
|
||||
b();
|
||||
assert.ok(!fs.existsSync(dir), 'released by the last holder');
|
||||
// A lock whose owner is gone is taken over rather than waited for.
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'pid'), '2147483646');
|
||||
const c = await acquire('probe');
|
||||
assert.strictEqual(fs.readFileSync(path.join(dir, 'pid'), 'utf8'), String(process.pid));
|
||||
c();
|
||||
// Negative: a live other holder is waited for, until the time limit.
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'pid'), String(process.ppid));
|
||||
await assert.rejects(acquire('probe', { waitMs: 300 }), /still held by process/);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
await assert.rejects(acquire('Not Kebab'), /kebab-case/);
|
||||
} finally {
|
||||
process.env.TMPDIR = old;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
passed += 1;
|
||||
console.log(' ok - the lock waits for another holder, is shared inside one process, and survives a dead holder');
|
||||
}
|
||||
|
||||
lockBehaviour().then(() => console.log(`\n${passed} passed`), error => { console.error(error); process.exit(1); });
|
||||
@@ -41,14 +41,17 @@ assert.match(cardsSrc, /stopPomodoro\(\) \{/);
|
||||
assert.match(cardsSrc, /isPomodoroActive\(\) \{/);
|
||||
assert.match(cardsSrc, /getPomodoroBreakMinutes\(\) \{/);
|
||||
|
||||
// A completed work interval reuses setSpentTime() - the SAME helper the
|
||||
// manual time-entry popup and Flowtime already use - rather than a
|
||||
// duplicate accumulator.
|
||||
// A completed work interval adds to the SAME spentTime field the manual
|
||||
// time-entry popup edits, with addSpentTime() - the helper Flowtime uses too -
|
||||
// rather than a duplicate accumulator. Changed on purpose from read-and-$set
|
||||
// through setSpentTime(): an $inc on the server does not replace the total
|
||||
// when the client's copy of a linked board or card has not arrived yet
|
||||
// (tests/linkedTimerSessions.test.cjs).
|
||||
const workIntervalBody = cardsSrc.match(
|
||||
/completePomodoroWorkInterval\(\) \{([\s\S]*?)\n \},/,
|
||||
)[1];
|
||||
assert.match(workIntervalBody, /this\.setSpentTime\(/,
|
||||
'a completed work interval must add its duration via the existing setSpentTime() helper');
|
||||
assert.match(workIntervalBody, /this\.addSpentTime\(workHours\)/,
|
||||
'a completed work interval must add its duration via addSpentTime()');
|
||||
assert.match(workIntervalBody, /pomodoroPhase: 'break'/,
|
||||
'a completed work interval must switch the card to the break phase');
|
||||
assert.match(workIntervalBody, /pomodoroCount: newCount/,
|
||||
@@ -59,7 +62,7 @@ assert.match(workIntervalBody, /pomodoroCount: newCount/,
|
||||
const breakIntervalBody = cardsSrc.match(
|
||||
/completePomodoroBreakInterval\(\) \{([\s\S]*?)\n \},/,
|
||||
)[1];
|
||||
assert.doesNotMatch(breakIntervalBody, /setSpentTime/,
|
||||
assert.doesNotMatch(breakIntervalBody, /(set|add)SpentTime/,
|
||||
'completing a break interval must add no time');
|
||||
assert.match(breakIntervalBody, /pomodoroPhase: null/,
|
||||
'completing a break interval must go back to ready-to-start, not auto-start the next work interval');
|
||||
@@ -70,7 +73,7 @@ assert.doesNotMatch(breakIntervalBody, /pomodoroPhase: 'work'/,
|
||||
// and clears every Pomodoro field back to its empty/null default.
|
||||
const stopBody = cardsSrc.match(/stopPomodoro\(\) \{([\s\S]*?)\n \},/)[1];
|
||||
assert.match(stopBody, /if \(this\.pomodoroPhase === 'work'\) \{/);
|
||||
assert.match(stopBody, /this\.setSpentTime\(/);
|
||||
assert.match(stopBody, /this\.addSpentTime\(elapsedHours\)/);
|
||||
assert.match(stopBody, /pomodoroStartAt: null,/);
|
||||
assert.match(stopBody, /pomodoroPhase: null,/);
|
||||
assert.match(stopBody, /pomodoroCount: 0,/);
|
||||
|
||||
@@ -34,4 +34,36 @@ const fs = require('node:fs');
|
||||
active = null;
|
||||
assert.equal(updatePopupMenuColumns(popup), false, 'empty/unrendered popup is safe');
|
||||
console.log('Popup menus: groups, widgets, stack navigation, short menus and empty states pass');
|
||||
|
||||
// The layout is applied before the popup is painted, not a frame later. On
|
||||
// the next animation frame the popup had already been drawn - and clicked
|
||||
// or dragged - at its narrow size, then jumped to 960px under the pointer:
|
||||
// Board View's "Lists" click landed on "Calendar" (06-views-layout,
|
||||
// Firefox) and a drag clamped for the narrow size
|
||||
// (card-title-edit-and-move-6839).
|
||||
const popupJs = fs.readFileSync('client/components/main/popup.js', 'utf8');
|
||||
const observer = popupJs.match(/this\._menuLayoutObserver = new MutationObserver\(([^;]*)\);/);
|
||||
assert.ok(observer, 'popup.js observes the popup for its menu layout');
|
||||
assert.doesNotMatch(observer[1], /requestAnimationFrame|setTimeout|Meteor\.defer/,
|
||||
'the menu layout must be applied in the observer callback itself, before the next paint');
|
||||
assert.doesNotMatch(popupJs, /requestAnimationFrame\(update\)/, 'negative: no deferred menu layout anywhere in popup.js');
|
||||
// And driven: a mutation applies the layout synchronously.
|
||||
let callback = null;
|
||||
let applied = 0;
|
||||
const onRendered = popupJs.match(/Popup\.template\.onRendered\(function \(\) \{([\s\S]*?)\n\}\);/)[1];
|
||||
const vm = require('node:vm');
|
||||
const element = { addEventListener() {}, dataset: {}, classList: { contains: () => false } };
|
||||
vm.runInNewContext(`(function () {${onRendered}}).call(self)`, {
|
||||
self: { find: () => element, autorun() {}, popupMaximized: { set() {} } },
|
||||
MutationObserver: class { constructor(fn) { callback = fn; } observe() {} },
|
||||
updatePopupMenuColumns: () => { applied += 1; },
|
||||
requestAnimationFrame: () => { throw new Error('deferred to an animation frame'); },
|
||||
ResizeObserver: undefined, Template: { currentData: () => ({}) }, Tracker: { afterFlush() {} },
|
||||
MAXIMIZABLE_POPUPS: new Set(), MOVABLE_POPUPS: new Set(), trapTabKey() {}, keptInside() {},
|
||||
rememberedMaximized: () => false, setPopupMaximized() {}, $: () => ({ addClass() {}, one() {} }), CSSEvents: {},
|
||||
});
|
||||
const before = applied;
|
||||
callback([]);
|
||||
assert.equal(applied, before + 1, 'a mutation applies the layout at once');
|
||||
console.log('Popup menu layout is applied before the popup is painted');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -140,7 +140,7 @@ test('negative: no positionHistory.* method skips the guard, here or elsewhere',
|
||||
}
|
||||
const skip = new Set(['node_modules', '_build', '.build', '.tools', '.git', 'tests', '.meteor']);
|
||||
const walk = dir => fs.readdirSync(path.join(root, dir), { withFileTypes: true }).flatMap(entry => {
|
||||
if (skip.has(entry.name)) return [];
|
||||
if (skip.has(entry.name) || entry.name.startsWith('_build')) return [];
|
||||
const rel = path.join(dir, entry.name);
|
||||
return entry.isDirectory() ? walk(rel) : /\.(c|m)?js$/.test(entry.name) ? [rel] : [];
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ const SKIP = new Set(['node_modules', '_build', '.build', '.tools', '.meteor', '
|
||||
|
||||
function sourceFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
if (SKIP.has(entry.name) || entry.name.startsWith('_build')) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) sourceFiles(full, out);
|
||||
else if (/\.(js|mjs|cjs)$/.test(entry.name)) out.push(full);
|
||||
|
||||
@@ -96,7 +96,7 @@ test('one intEnv and one recorder, no copies (negative)', () => {
|
||||
const walk = dir => {
|
||||
for (const e of fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true })) {
|
||||
const rel = path.join(dir, e.name);
|
||||
if (e.isDirectory()) { if (!['_build', 'node_modules'].includes(e.name)) walk(rel); }
|
||||
if (e.isDirectory()) { if (!(['_build', 'node_modules'].includes(e.name) || e.name.startsWith('_build'))) walk(rel); }
|
||||
else if (rel.endsWith('.js')) {
|
||||
const src = read(rel);
|
||||
if (/function intEnv\(/.test(src) && rel !== path.join('models', 'lib', 'intEnv.js')) copies.push(`${rel}: intEnv`);
|
||||
|
||||
@@ -132,7 +132,7 @@ const BOARD_CHECKS = [
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(path.join(ROOT, dir), {withFileTypes: true})) {
|
||||
if (['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(entry.name)) continue;
|
||||
if ((['node_modules', '_build', '.build', '.tools', '.meteor', 'tests'].includes(entry.name) || entry.name.startsWith('_build'))) continue;
|
||||
const rel = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory()) walk(rel, out);
|
||||
else if (/\.(js|cjs|mjs)$/.test(entry.name)) out.push(rel);
|
||||
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user