Fix #2345 #2674 #574: rules card title filter, rules REST API, api docs;

Sandstorm username races.

- #2345: the Card Title Filter is now SAVED by every board trigger
  builder (a trigger doc missing the field could never satisfy the
  matcher's $in - those rules fired for nothing), SHOWN in the rule
  details, and ENFORCED for archive/restore triggers by resolving the
  title from the card when the activity lacks it; legacy field-less
  triggers keep matching; the filter popup prefills its current value.
- #2674: rules created via POST/PUT /api/boards/:boardId/rules never
  fired - missing matching fields are now normalized to '*' wildcards
  and trigger/action types validated; the addMember/removeMember rule
  actions no longer crash on unresolvable usernames or member-less
  cards and their writes are awaited. New docs/API/Rules.md (endpoints,
  matching fields, wildcard semantics, the exact two-rule
  add-on-move-to/remove-on-move-from workflow), linked from REST-API.md;
  api.py help now lists the rule commands with the same example.
- #574: Sandstorm unique-username generation probes case-insensitively
  (anchored, regex-escaped) and retries the next number when a
  concurrent insert wins the unique-index race, instead of aborting the
  login hook with E11000.

Tests: tests/rulesCardTitleFilter.test.cjs (12),
tests/rulesApiTriggerNormalize.test.cjs (19),
tests/sandstormUsername.test.cjs (11) - positive and negative cases.

Thanks to InfoSec812, sfahrenholz, mitar and xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-07-17 03:36:50 +03:00
1 parent 0f2179988b
commit 25d33cec36
14 files changed
+1269 -45

No files matched your search

+26
View File
@@ -195,6 +195,32 @@ and fixes the following bugs:
`tests/oauth2LoginStyle.test.cjs` (12 tests, positive + negative). Thanks to ArturRuta and
xet7.
- **Board Rules: the Card Title Filter did nothing on several triggers — and rules created
via the REST API never fired at all** ([#2345](https://github.com/wekan/wekan/issues/2345),
[#2674](https://github.com/wekan/wekan/issues/2674),
`client/components/rules/triggers/boardTriggers.js`, `.jade`, `server/rulesHelper.js`,
`server/models/rules.js`, new `models/lib/ruleCardTitleFilter.js`, `docs/API/Rules.md`,
`docs/API/REST-API.md`, `api.py`): the generic moved/archive trigger builders never saved
the filter (and a trigger doc MISSING the field can never satisfy the matcher's `$in`, so
those rules fired for nothing); a set filter never showed in the rule details; archive
activities carry no card title so their filters compared against undefined; and REST-created
rules skipped the wildcard defaulting entirely — the exact "remove user when moved away"
rule from #2674 silently never ran. Filters are now stored (empty → `*`), shown in the rule
description, matched with the title resolved from the card when the activity lacks it,
legacy field-less triggers keep matching, the API normalizes missing matching fields to
wildcards and validates types, and the rule actions no longer crash on unresolvable
usernames or member-less cards. The Rules REST API is now documented (`docs/API/Rules.md`)
and listed in api.py's help with the #2674 two-rule example. Tests:
`tests/rulesCardTitleFilter.test.cjs` (12) and `tests/rulesApiTriggerNormalize.test.cjs`
(19). Thanks to InfoSec812, sfahrenholz and xet7.
- **Sandstorm: username uniqueness probe was case-sensitive and raced concurrent logins**
([#574](https://github.com/wekan/wekan/issues/574), `sandstorm.js`, new
`models/lib/sandstormUsername.js`): deriving `max`, `max1`, … from the preferred handle
matched exact case only (an existing `Max` did not stop a new `max`) and the check-then-set
window let a concurrent insert claim the name first, aborting the hook with E11000. The
probe is now an anchored, escaped, case-insensitive regex and the claim retries the next
number on a duplicate-key loss. Tests: `tests/sandstormUsername.test.cjs` (11). Thanks to
mitar and xet7.
- **Inviting a second user whose email shares a local part failed with a bare "403"**
([#619](https://github.com/wekan/wekan/issues/619), `server/models/users.js`, new
`models/lib/inviteeUsername.js`): inviting `cats@foo.com` creates user "cats"; inviting
+22
View File
@@ -132,6 +132,16 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles, cardAging, cardAgingDays1/2/3)
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true, cardAging true, cardAgingDays1 5
Board Automation Rules API (IFTTT, Issue #2674):
python3 api.py listrules BOARDID # List automation rules of a board
python3 api.py getrule BOARDID RULEID # Get one rule with its trigger and action
python3 api.py addrule BOARDID TITLE TRIGGER_JSON ACTION_JSON # Add rule. Missing trigger matching fields default to the '*' wildcard
python3 api.py editrule BOARDID RULEID PATCH_JSON # Edit rule: {"title":..,"trigger":{..},"action":{..}}
python3 api.py removerule BOARDID RULEID # Remove rule (and its trigger + action)
# Example (Issue #2674) - add member when card moved TO list, remove member when moved AWAY FROM list:
# python3 api.py addrule BOARDID 'Add on move to Doing' '{"activityType":"moveCard","listName":"Doing"}' '{"actionType":"addMember","username":"someuser"}'
# python3 api.py addrule BOARDID 'Remove on move from Doing' '{"activityType":"moveCard","oldListName":"Doing"}' '{"actionType":"removeMember","username":"someuser"}'
Admin API:
python3 api.py newuser USERNAME EMAIL PASSWORD
@@ -1773,6 +1783,18 @@ if arguments >= 1:
# python3 api.py addrule BOARDID 'Archive after 90 days' \
# '{"activityType":"scheduledTrigger","scheduleKind":"aging","listName":"Completed","days":90,"atTime":"03:00"}' \
# '{"actionType":"archive"}'
#
# Example (Issue #2674 - add a user to the card when it is moved TO a list, and
# remove the user again when the card is moved AWAY FROM that list). Trigger
# matching fields that are omitted (userId, swimlaneName, cardTitle, and the
# unused one of listName/oldListName) are stored as the '*' wildcard by the
# server, so the rules fire for any user/swimlane/card title:
# python3 api.py addrule BOARDID 'Add member on move to Doing' \
# '{"activityType":"moveCard","listName":"Doing"}' \
# '{"actionType":"addMember","username":"someuser"}'
# python3 api.py addrule BOARDID 'Remove member on move from Doing' \
# '{"activityType":"moveCard","oldListName":"Doing"}' \
# '{"actionType":"removeMember","username":"someuser"}'
if arguments >= 2 and sys.argv[1] == 'listrules':
boardid = sys.argv[2]
@@ -106,6 +106,9 @@ template(name="boardCardTitlePopup")
form
label
| {{_ 'boardCardTitlePopup-title'}}
input.js-card-filter-name(type="text" value=title autofocus)
//- #2345: cardTitleFilter is passed as the popup's data context by the
//- js-open-card-title-popup handler so reopening the popup shows the
//- filter that is already set (it used to always render empty).
input.js-card-filter-name(type="text" value=cardTitleFilter autofocus)
input.js-card-filter-button.primary.wide(type="submit" value="{{_ 'set-filter'}}")
@@ -1,4 +1,31 @@
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import {
cardTitleFilterOrWildcard,
appendCardTitleFilterToDesc,
} from '/models/lib/ruleCardTitleFilter';
// #2345: fallback reference to the live boardTriggers template instance, so the
// card-title-filter popup can still store its value even if the Blaze
// data-context walk in Popup.getOpenerComponent() does not resolve (same
// module-level pattern as cardActions.js' sharedCardColorButtonValue).
let activeBoardTriggersTemplate = null;
// #2345: the trigger description is assembled from the wizard's DOM text, which
// never includes the card-title filter (it lives in a popup). Append it so the
// created rule's details actually show the filter.
function descWithCardTitleFilter(desc, cardTitle) {
let label = 'card title filter';
try {
const translated = TAPi18n.__('boardCardTitlePopup-title');
if (translated && translated !== 'boardCardTitlePopup-title') {
label = translated.toLowerCase();
}
} catch (e) {
// keep the English fallback label
}
return appendCardTitleFilterToDesc(desc, cardTitle, label);
}
Template.boardTriggers.onCreated(function () {
this.provaVar = new ReactiveVar('');
@@ -7,6 +34,13 @@ Template.boardTriggers.onCreated(function () {
this.setNameFilter = (name) => {
this.cardTitleFilters[this.currentPopupTriggerId] = name;
};
activeBoardTriggersTemplate = this;
});
Template.boardTriggers.onDestroyed(function () {
if (activeBoardTriggersTemplate === this) {
activeBoardTriggersTemplate = null;
}
});
Template.boardTriggers.events({
@@ -14,16 +48,24 @@ Template.boardTriggers.events({
const funct = Popup.open('boardCardTitle');
const divId = $(event.currentTarget.parentNode.parentNode).attr('id');
tpl.currentPopupTriggerId = divId;
funct.call(this, event);
// #2345: hand the already-stored filter to the popup as its data context so
// reopening it shows the current value instead of an empty input.
funct.call({ cardTitleFilter: tpl.cardTitleFilters[divId] || '' }, event);
},
'click .js-add-create-trigger'(event, tpl) {
const desc = Utils.getTriggerActionDesc(event, tpl);
const datas = Template.currentData();
const listName = tpl.find('#create-list-name').value;
const swimlaneName = tpl.find('#create-swimlane-name').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
const cardTitle = tpl.cardTitleFilters[divId];
// #2345: an unset/empty filter is stored as the '*' wildcard so the trigger
// document always carries a cardTitle field (the server matches with
// cardTitle: { $in: [...] }, which a document missing the field never satisfies).
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
// move to generic funciont
datas.triggerVar.set({
activityType: 'createCard',
@@ -36,13 +78,16 @@ Template.boardTriggers.events({
},
'click .js-add-moved-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const swimlaneName = tpl.find('#create-swimlane-name-2').value;
const actionSelected = tpl.find('#move-action').value;
const listName = tpl.find('#move-list-name').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
const cardTitle = tpl.cardTitleFilters[divId];
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
if (actionSelected === 'moved-to') {
datas.triggerVar.set({
activityType: 'moveCard',
@@ -68,12 +113,22 @@ Template.boardTriggers.events({
},
'click .js-add-gen-moved-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
// #2345: this trigger ignored the card-title-filter popup entirely AND
// stored no cardTitle field at all — and since the server matches triggers
// with cardTitle: { $in: [...] }, a moveCard trigger without the field
// never fired for any card. Store the filter (or the '*' wildcard).
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
datas.triggerVar.set({
activityType: 'moveCard',
boardId,
cardTitle,
swimlaneName: '*',
listName: '*',
oldListName: '*',
@@ -82,13 +137,20 @@ Template.boardTriggers.events({
},
'click .js-add-arch-trigger'(event, tpl) {
const datas = Template.currentData();
const desc = Utils.getTriggerActionDesc(event, tpl);
const actionSelected = tpl.find('#arch-action').value;
const boardId = Session.get('currentBoard');
const divId = $(event.currentTarget.parentNode).attr('id');
// #2345: the archive/unarchive trigger also dropped the card-title filter.
const cardTitle = cardTitleFilterOrWildcard(tpl.cardTitleFilters[divId]);
const desc = descWithCardTitleFilter(
Utils.getTriggerActionDesc(event, tpl),
cardTitle,
);
if (actionSelected === 'archived') {
datas.triggerVar.set({
activityType: 'archivedCard',
boardId,
cardTitle,
desc,
});
}
@@ -96,6 +158,7 @@ Template.boardTriggers.events({
datas.triggerVar.set({
activityType: 'restoredCard',
boardId,
cardTitle,
desc,
});
}
@@ -104,15 +167,27 @@ Template.boardTriggers.events({
Template.boardCardTitlePopup.events({
submit(event) {
// #2345: preventDefault FIRST — if anything below throws, a real form
// submit would reload the page and silently drop the filter.
event.preventDefault();
const title = $(event.target)
.find('.js-card-filter-name')
.val()
.trim();
const opener = Popup.getOpenerComponent();
if (opener?.setNameFilter) {
let opener = null;
try {
opener = Popup.getOpenerComponent();
} catch (e) {
opener = null;
}
if (!opener || typeof opener.setNameFilter !== 'function') {
// #2345: fall back to the live boardTriggers instance when the Blaze
// data-context walk cannot resolve the opener.
opener = activeBoardTriggersTemplate;
}
if (opener && typeof opener.setNameFilter === 'function') {
opener.setNameFilter(title);
}
event.preventDefault();
Popup.back();
},
});
+8
View File
@@ -46,6 +46,14 @@ When calling a production Wekan server, ensure it is running via HTTPS and has a
| `POST` | `/api/boards/:boardId/lists/:listId/cards` | [Add a card to a list, board, and swimlane.](Cards.md#add-card-to-list-board-swimlane) |
| `PUT` | `/api/boards/:boardId/lists/:fromListId/cards/:cardId` | [Update a card.](Cards.md#update-a-card) |
| `DELETE` | `/api/boards/:boardId/lists/:listId/cards/:cardId` | [Delete a card.](Cards.md#update-a-card) |
### Rules (Board Automation / IFTTT)
| HTTP Method | Url | Short Description |
| :--- | :--- | :--- |
| `GET` | `/api/boards/:boardId/rules` | [List all automation rules of a board.](Rules.md#list-rules) |
| `GET` | `/api/boards/:boardId/rules/:ruleId` | [Get one rule with its trigger and action.](Rules.md#get-one-rule) |
| `POST` | `/api/boards/:boardId/rules` | [Add an automation rule, e.g. add/remove a member when a card is moved to/from a list (#2674).](Rules.md#add-a-rule) |
| `PUT` | `/api/boards/:boardId/rules/:ruleId` | [Edit a rule's title, trigger and/or action.](Rules.md#edit-a-rule) |
| `DELETE` | `/api/boards/:boardId/rules/:ruleId` | [Remove a rule and its trigger and action.](Rules.md#delete-a-rule) |
### Board Domain Sharing
| HTTP Method | Url | Short Description |
| :--- | :--- | :--- |
+185
View File
@@ -0,0 +1,185 @@
# Rules (Board Automation / IFTTT) REST API
Board automation rules ("IFTTT rules") link a **trigger** (an event on the
board) to an **action** (what happens to the card/board). The REST API embeds
the full trigger and action inline, so a rule is self-contained.
This API makes the workflow of
[issue #2674](https://github.com/wekan/wekan/issues/2674) scriptable: add a
user to a card when it is moved **to** a list, and remove the user again when
the card is moved **away from** that list.
## Summary
| HTTP Method | Url | Short Description |
| :--- | :--- | :--- |
| `GET` | `/api/boards/:boardId/rules` | List all automation rules of a board |
| `GET` | `/api/boards/:boardId/rules/:ruleId` | Get one rule (with its trigger and action) |
| `POST` | `/api/boards/:boardId/rules` | Add an automation rule |
| `PUT` | `/api/boards/:boardId/rules/:ruleId` | Edit a rule's title, trigger and/or action |
| `DELETE` | `/api/boards/:boardId/rules/:ruleId` | Remove a rule (and its trigger and action) |
All endpoints require a `Bearer` token; write endpoints require board write
access (BoardAdmin).
## Trigger matching and the `*` wildcard
The rule engine matches an incoming board activity against **every** matching
field of the trigger's `activityType` (for example `moveCard` matches on
`boardId`, `listName`, `oldListName`, `userId`, `swimlaneName`, `cardTitle`).
A field set to `'*'` matches anything.
You only need to supply the fields you want to constrain: any matching field
missing from the trigger you POST/PUT is stored as the `'*'` wildcard
automatically. (Before this normalization existed, an API-created trigger
that omitted a field such as `userId` could never match any activity, so the
rule silently never fired — the bug reported in issue #2674.)
### Trigger `activityType` values
| `activityType` | fires when | matching fields |
| :--- | :--- | :--- |
| `createCard` | a card is created | `boardId`, `listName`, `userId`, `swimlaneName`, `cardTitle` |
| `moveCard` | a card is moved to/from a list or swimlane | `boardId`, `listName` (destination), `oldListName` (source), `userId`, `swimlaneName`, `cardTitle` |
| `archivedCard` / `restoredCard` | a card is archived / unarchived | `boardId`, `userId`, `cardTitle` |
| `joinMember` / `unjoinMember` | a member is added to / removed from a card | `boardId`, `username`, `userId` |
| `addChecklist` / `removeChecklist` / `completeChecklist` / `uncompleteChecklist` | checklist events | `boardId`, `checklistName`, `userId` |
| `addedChecklistItem` / `removedChecklistItem` / `checkedItem` / `uncheckedItem` | checklist item events | `boardId`, `checklistItemName`, `userId` |
| `addAttachment` / `deleteAttachment` | attachment events | `boardId`, `userId` |
| `addedLabel` / `removedLabel` | label events | `boardId`, `labelId`, `userId` |
| `scheduledTrigger` | time-based (due/aging/recurring); see `scheduleKind`, `days`, `atTime` | evaluated by the scheduler, not by activities |
For a card moved **to** a list, constrain `listName` (the destination list's
title). For a card moved **away from** a list, constrain `oldListName` (the
source list's title) and leave `listName` as `'*'`.
### Action `actionType` values
`moveCardToTop`, `moveCardToBottom`, `sendEmail`, `setDate`, `updateDate`,
`removeDate`, `setDateRelative`, `archive`, `unarchive`, `setColor`,
`addLabel`, `removeLabel`, `addMember`, `removeMember`, `checkAll`,
`uncheckAll`, `checkItem`, `uncheckItem`, `addChecklist`, `removeChecklist`,
`addChecklistWithItems`, `addSwimlane`, `createCard`, `linkCard`,
`markCardComplete`, `markCardIncomplete`, `sortList`, `moveAllCardsInList`.
The member actions take a `username`:
* `{"actionType": "addMember", "username": "someuser"}` — add the user to the card
* `{"actionType": "removeMember", "username": "someuser"}` — remove the user from the card
* `{"actionType": "removeMember", "username": "*"}` — remove **all** members from the card
## List rules
```shell
curl -H "Authorization: Bearer TOKEN" \
-X GET \
http://localhost:3000/api/boards/BOARDID/rules
```
## Get one rule
```shell
curl -H "Authorization: Bearer TOKEN" \
-X GET \
http://localhost:3000/api/boards/BOARDID/rules/RULEID
```
Result example:
```json
{
"_id": "8gDW2AtwbpNbqSyEw",
"title": "Remove member when moved away from Doing",
"trigger": {
"activityType": "moveCard",
"listName": "*",
"oldListName": "Doing",
"swimlaneName": "*",
"cardTitle": "*",
"userId": "*"
},
"action": {
"actionType": "removeMember",
"username": "someuser"
}
}
```
## Add a rule
```shell
curl -H "Authorization: Bearer TOKEN" \
-H "Content-type: application/json" \
-X POST \
http://localhost:3000/api/boards/BOARDID/rules \
-d '{
"title": "Add member when moved to Doing",
"trigger": { "activityType": "moveCard", "listName": "Doing" },
"action": { "actionType": "addMember", "username": "someuser" }
}'
```
### Full example: issue #2674 workflow
Add the user when a card is moved **to** the list "Doing", remove the user
again when the card is moved **away from** "Doing":
```shell
# Rule 1: card moved TO "Doing" -> add member
curl -H "Authorization: Bearer TOKEN" -H "Content-type: application/json" -X POST \
http://localhost:3000/api/boards/BOARDID/rules \
-d '{ "title": "Add member on move to Doing",
"trigger": { "activityType": "moveCard", "listName": "Doing" },
"action": { "actionType": "addMember", "username": "someuser" } }'
# Rule 2: card moved AWAY FROM "Doing" -> remove member
curl -H "Authorization: Bearer TOKEN" -H "Content-type: application/json" -X POST \
http://localhost:3000/api/boards/BOARDID/rules \
-d '{ "title": "Remove member on move from Doing",
"trigger": { "activityType": "moveCard", "oldListName": "Doing" },
"action": { "actionType": "removeMember", "username": "someuser" } }'
```
The omitted trigger fields (`listName` in rule 2, `oldListName` in rule 1,
plus `userId`, `swimlaneName`, `cardTitle`) are stored as `'*'` wildcards.
## Edit a rule
Any of `title`, `trigger` and `action` may be supplied; a supplied trigger or
action document replaces the stored fields. When the supplied trigger includes
an `activityType`, its missing matching fields are defaulted to `'*'` too.
```shell
curl -H "Authorization: Bearer TOKEN" \
-H "Content-type: application/json" \
-X PUT \
http://localhost:3000/api/boards/BOARDID/rules/RULEID \
-d '{ "action": { "actionType": "removeMember", "username": "*" } }'
```
## Delete a rule
Removes the rule together with its trigger and action documents.
```shell
curl -H "Authorization: Bearer TOKEN" \
-X DELETE \
http://localhost:3000/api/boards/BOARDID/rules/RULEID
```
## Python CLI (api.py)
The bundled `api.py` exposes the same capability:
```shell
python3 api.py listrules BOARDID
python3 api.py getrule BOARDID RULEID
python3 api.py addrule BOARDID 'TITLE' 'TRIGGER_JSON' 'ACTION_JSON'
python3 api.py editrule BOARDID RULEID 'PATCH_JSON'
python3 api.py removerule BOARDID RULEID
# Issue #2674: remove a user from the card when it is moved away from a list
python3 api.py addrule BOARDID 'Remove member on move from Doing' \
'{"activityType":"moveCard","oldListName":"Doing"}' \
'{"actionType":"removeMember","username":"someuser"}'
```
+80
View File
@@ -0,0 +1,80 @@
'use strict';
// #2345 "basic card title filter rules issue".
//
// The board-rule triggers ("when a card is created / moved / archived ...")
// offer a magnifier popup that lets the user restrict the trigger to cards
// whose title matches a filter. Three things were broken:
//
// 1. The generic "when a card is moved" and the archive/unarchive triggers
// never stored the filter at all — worse, they stored NO `cardTitle`
// field, and because the server matches triggers with
// `cardTitle: { $in: [...] }`, a trigger document that lacks the field
// can never match an activity that carries a card title, so those rules
// never fired for anything.
// 2. A stored filter never appeared in the trigger's human-readable
// description ("When a card is moved to list X" — no trace of "Part A"),
// which is exactly what the issue reporter complained about.
// 3. Old trigger documents created before the fix (without a `cardTitle`
// field) must keep behaving as "match every card".
//
// These helpers are pure (no Meteor imports) so both the client wizard and
// server/rulesHelper.js share one tested implementation.
// Normalize the raw popup input into the value stored on the trigger document.
// An empty / whitespace-only / missing filter means "match every card", which
// the rules engine expresses with the '*' wildcard (this matches the UI note:
// "leave a field empty to match every possible value").
function cardTitleFilterOrWildcard(raw) {
if (typeof raw !== 'string') return '*';
const trimmed = raw.trim();
return trimmed === '' ? '*' : trimmed;
}
// Build the list of trigger `cardTitle` values that match an activity whose
// card title is `activityCardTitle`, used as `cardTitle: { $in: [...] }` when
// looking up triggers. A trigger matches when its stored filter is:
// - the exact card title,
// - any single word of the card title (historic behaviour: the title is
// split on non-word characters),
// - the '*' wildcard (no filter set), or
// - null / a missing field (legacy triggers saved without a cardTitle —
// in MongoDB `$in: [null, ...]` also matches documents lacking the field,
// which keeps pre-fix rules firing as "every card").
function cardTitleMatchList(activityCardTitle) {
const matches = ['*', null];
if (activityCardTitle !== undefined && activityCardTitle !== null) {
const title = String(activityCardTitle);
return title
.split(/\W/)
.filter(word => word !== '')
.concat([title])
.concat(matches);
}
return matches;
}
// Pure predicate mirroring the $in semantics above: does a trigger stored with
// `filter` fire for a card titled `cardTitle`? (A missing/null filter — legacy
// trigger — matches everything, like the wildcard.)
function cardTitleFilterMatches(filter, cardTitle) {
if (filter === undefined || filter === null) return true;
return cardTitleMatchList(cardTitle).includes(filter);
}
// Append the filter to the trigger's human-readable description so the rule
// list / rule details show it ("when a card is moved to list x (card title
// filter: "Part A")"). A wildcard/empty filter leaves the description alone.
function appendCardTitleFilterToDesc(desc, cardTitle, label) {
const base = typeof desc === 'string' ? desc : '';
if (!cardTitle || cardTitle === '*') return base;
const tag = label && typeof label === 'string' ? label : 'card title filter';
return `${base} (${tag}: "${cardTitle}")`;
}
module.exports = {
cardTitleFilterOrWildcard,
cardTitleMatchList,
cardTitleFilterMatches,
appendCardTitleFilterToDesc,
};
+80
View File
@@ -0,0 +1,80 @@
'use strict';
// Pure helpers for deriving a unique WeKan username from a Sandstorm
// preferredHandle (used by the Users.after.insert hook in sandstorm.js), split
// out so they can be unit-tested in plain Node (tests/*.test.cjs) without a
// Meteor context.
//
// Fixes #574 "Sandstorm - generation of unique usernames":
// - The uniqueness probe must be CASE-INSENSITIVE. Meteor usernames are
// case-preserving but case-insensitively unique, while the old exact-match
// query was case-sensitive: an existing 'Max' did not stop a new 'max',
// producing two accounts that Meteor's account machinery considers the
// same username.
// - The handle must be regex-escaped before interpolation into the probe so
// a handle containing regex metacharacters is matched literally (Sandstorm
// promises lower-case ASCII letters/digits/underscores, but we must not
// rely on that for query correctness).
// - The probe -> update window is racy: a concurrent insert can claim the
// probed name between our find and our update. accounts-base keeps a
// unique index on `username`, so the losing update throws a MongoDB
// E11000 duplicate-key error; instead of letting that abort the insert
// hook we catch it and retry with the next appended number.
// 'max' + 0 -> 'max', 'max' + 1 -> 'max1', ... (historical numbering: the
// first fallback is 'max1', there is no 'max0').
function generateUniqueUsername(username, appendNumber) {
return username + String(appendNumber === 0 ? '' : appendNumber);
}
// Escape regex metacharacters so the text is matched literally.
function escapeForRegex(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Anchored, case-insensitive matcher for a username candidate, safe to put in
// a Mongo `username:` selector.
function usernameCaseInsensitiveRegex(username) {
return new RegExp(`^${escapeForRegex(username)}$`, 'i');
}
// True for MongoDB's duplicate-key error (unique index violation) in the
// shapes the Node driver / Meteor surface it: numeric code 11000/11001, or an
// E11000 marker in the message/errmsg.
function isDuplicateKeyError(error) {
if (!error) return false;
if (error.code === 11000 || error.code === 11001) return true;
const text = `${error.message || ''} ${error.errmsg || ''}`;
return text.includes('E11000') || text.includes('E11001');
}
// Walk appendNumber upward until `isTakenAsync(candidate)` reports the name
// free, then hand the candidate to `claimAsync(candidate)` (the DB update).
// If claiming throws a duplicate-key error — another session raced us to the
// same name — move on to the next number instead of crashing. Any other error
// propagates unchanged. Resolves to the username that was actually claimed.
async function claimUniqueUsername(base, isTakenAsync, claimAsync) {
for (let appendNumber = 0; ; appendNumber += 1) {
const candidate = generateUniqueUsername(base, appendNumber);
if (await isTakenAsync(candidate)) {
continue;
}
try {
await claimAsync(candidate);
return candidate;
} catch (error) {
if (!isDuplicateKeyError(error)) {
throw error;
}
// Lost the race for this candidate; try the next number.
}
}
}
module.exports = {
generateUniqueUsername,
escapeForRegex,
usernameCaseInsensitiveRegex,
isDuplicateKeyError,
claimUniqueUsername,
};
+25 -22
View File
@@ -9,6 +9,10 @@ import { Accounts } from 'meteor/accounts-base';
// server boot. Import them explicitly.
import Users from '/models/users';
import Activities from '/models/activities';
import {
claimUniqueUsername,
usernameCaseInsensitiveRegex,
} from '/models/lib/sandstormUsername';
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
// in the package definition.
@@ -326,29 +330,28 @@ if (isSandstorm && Meteor.isServer) {
// Sandstorm doesn't enforce this property -- see #352. Our strategy to
// generate unique usernames from the Sandstorm `preferredHandle` is to
// append a number that we increment until we generate a username that no
// one already uses (eg, 'max', 'max1', 'max2').
function generateUniqueUsername(username, appendNumber) {
return username + String(appendNumber === 0 ? '' : appendNumber);
}
// one already uses (eg, 'max', 'max1', 'max2'). Meteor usernames are
// case-preserving but case-insensitively unique, so the probe uses an
// anchored case-insensitive regex (escaped, see #574), and the update is
// retried with the next number when a concurrent insert wins the race to
// the same name (unique-index duplicate-key error).
const username = doc.services.sandstorm.preferredHandle;
let appendNumber = 0;
while (
await Meteor.users.findOneAsync({
_id: { $ne: doc._id },
username: generateUniqueUsername(username, appendNumber),
})
) {
appendNumber += 1;
}
await Users.updateAsync(doc._id, {
$set: {
username: generateUniqueUsername(username, appendNumber),
'profile.fullname': doc.services.sandstorm.name,
'profile.avatarUrl': doc.services.sandstorm.picture,
},
});
await claimUniqueUsername(
username,
async candidate =>
!!(await Meteor.users.findOneAsync({
_id: { $ne: doc._id },
username: usernameCaseInsensitiveRegex(candidate),
})),
candidate =>
Users.updateAsync(doc._id, {
$set: {
username: candidate,
'profile.fullname': doc.services.sandstorm.name,
'profile.avatarUrl': doc.services.sandstorm.picture,
},
}),
);
await updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
});
+45 -3
View File
@@ -3,6 +3,7 @@ import { WebApp } from 'meteor/webapp';
import { Authentication } from '/server/authentication';
import { sendJsonResult } from '/server/apiMiddleware';
import { ReactiveCache } from '/imports/reactiveCache';
import { TriggersDef } from '/server/triggersDef';
import Rules from '/models/rules';
import Triggers from '/models/triggers';
import Actions from '/models/actions';
@@ -31,6 +32,31 @@ function strip(doc) {
return out;
}
// #2674 ("Rule: remove USER from card when move"): the rule matcher
// (server/rulesHelper.js buildMatchingFieldsMap) queries EVERY matching field
// of the trigger's activityType with {$in: [<activity value>, '*']}. A Mongo
// $in never matches a document that LACKS the field, so a trigger created over
// REST without e.g. userId / listName / swimlaneName / cardTitle could never
// match any activity and its rule silently never fired — exactly the "user is
// added when the card moves to the list, but never removed when it moves away"
// failure of #2674. The Rules UI wizard (sanitizeObject) and the rules JSON
// import (normalizeTrigger) both default missing matching fields to the '*'
// wildcard; do the same for API-created/edited triggers. Trigger kinds not
// driven by activities (scheduledTrigger, button — not in TriggersDef) are
// left untouched.
function normalizeTriggerDoc(trigger) {
const out = { ...trigger };
const def = TriggersDef[out.activityType];
if (def) {
def.matchingFields.forEach(field => {
if (out[field] === undefined || out[field] === null || out[field] === '') {
out[field] = '*';
}
});
}
return out;
}
async function serializeRule(rule) {
const trigger = await ReactiveCache.getTrigger(rule.triggerId);
const action = await ReactiveCache.getAction(rule.actionId);
@@ -98,6 +124,11 @@ if (Meteor.isServer) {
*
* @description The trigger and action are embedded inline. See the Rules
* documentation for the available trigger activityTypes and action actionTypes.
* Trigger matching fields that are omitted (e.g. userId, listName,
* swimlaneName, cardTitle) default to the '*' wildcard, so e.g. issue #2674's
* "remove a member when a card is moved away from a list" only needs
* trigger {activityType: 'moveCard', oldListName: '...'} and
* action {actionType: 'removeMember', username: '...'}.
*
* @param {string} boardId the board ID
* @param {string} title the rule title
@@ -113,7 +144,16 @@ if (Meteor.isServer) {
if (!trigger || !action) {
throw new Meteor.Error('bad-request', 'trigger and action are required');
}
const triggerId = await Triggers.insertAsync({ ...strip(trigger), boardId: paramBoardId });
if (!trigger.activityType) {
throw new Meteor.Error('bad-request', 'trigger.activityType is required');
}
if (!action.actionType) {
throw new Meteor.Error('bad-request', 'action.actionType is required');
}
const triggerId = await Triggers.insertAsync({
...normalizeTriggerDoc(strip(trigger)),
boardId: paramBoardId,
});
const actionId = await Actions.insertAsync({ ...strip(action), boardId: paramBoardId });
const ruleId = await Rules.insertAsync({
title: title || 'API rule',
@@ -133,7 +173,9 @@ if (Meteor.isServer) {
* @summary Edit an automation rule
*
* @description Any of title, trigger and action may be supplied; the trigger
* and action documents are replaced ($set) when present.
* and action documents are replaced ($set) when present. A supplied trigger
* that includes an activityType gets its missing matching fields defaulted
* to the '*' wildcard, like on creation.
*
* @param {string} boardId the board ID
* @param {string} ruleId the rule ID
@@ -157,7 +199,7 @@ if (Meteor.isServer) {
}
if (req.body.trigger) {
await Triggers.updateAsync(rule.triggerId, {
$set: { ...strip(req.body.trigger), boardId: paramBoardId },
$set: { ...normalizeTriggerDoc(strip(req.body.trigger)), boardId: paramBoardId },
});
}
if (req.body.action) {
+45 -9
View File
@@ -9,6 +9,7 @@ import Checklists from '/models/checklists';
import Swimlanes from '/models/swimlanes';
import { relativeDateOffset } from '/models/lib/relativeDateOffset';
import { resolveRuleSwimlaneId, resolveRuleListId } from '/models/lib/ruleActionResolve';
import { cardTitleMatchList } from '/models/lib/ruleCardTitleFilter';
// #5536: robustly resolve a destination board's default swimlane, tolerating a
// board that lacks a swimlane literally titled 'Default' (renamed/translated) or
@@ -131,10 +132,27 @@ export const RulesHelper = {
value = oldSwimlane.title;
}
}
let matchesList = [value, '*'];
if ((field === 'cardTitle') && (value !== undefined)) {
matchesList = value.split(/\W/).concat(matchesList);
if (field === 'cardTitle') {
// #2345: archivedCard/restoredCard activities carry no cardTitle, so a
// "when a card is archived" trigger with a title filter could never be
// enforced. Resolve the title from the activity's card instead.
if (value === undefined && activity.cardId) {
const card = await ReactiveCache.getCard(activity.cardId);
if (card) {
value = card.title;
}
}
// #2345: cardTitleMatchList() matches the exact title, its single
// words, the '*' wildcard, and null — the latter also matches trigger
// documents saved WITHOUT a cardTitle field (the old wizard's generic
// "when a card is moved" / archive triggers), which otherwise never
// satisfy a $in query and made those rules fire for nothing.
matchingMap[field] = {
$in: cardTitleMatchList(value),
};
continue;
}
const matchesList = [value, '*'];
matchingMap[field] = {
$in: matchesList,
};
@@ -385,19 +403,37 @@ export const RulesHelper = {
if (action.actionType === 'removeLabel') {
card.removeLabel(action.labelId);
}
// #2674: resolve the username defensively for the member actions. A rule
// whose username no longer resolves (user renamed/deleted, or a typo in an
// API-created rule) crashed here on `undefined._id`; the activity hook
// swallows the error, so the rule silently "did nothing" — the classic
// "member is added on move-to but never removed on move-from" report. Warn
// instead of crashing, and await the writes so failures are not lost.
if (action.actionType === 'addMember') {
const memberId = (await ReactiveCache.getUser({ username: action.username }))._id;
card.assignMember(memberId);
const member = await ReactiveCache.getUser({ username: action.username });
if (member) {
await card.assignMember(member._id);
} else {
console.warn(
`WeKan rule action addMember: user "${action.username}" not found; skipping.`,
);
}
}
if (action.actionType === 'removeMember') {
if (action.username === '*') {
const members = card.members;
const members = card.members || [];
for (let i = 0; i < members.length; i++) {
card.unassignMember(members[i]);
await card.unassignMember(members[i]);
}
} else {
const memberId = (await ReactiveCache.getUser({ username: action.username }))._id;
card.unassignMember(memberId);
const member = await ReactiveCache.getUser({ username: action.username });
if (member) {
await card.unassignMember(member._id);
} else {
console.warn(
`WeKan rule action removeMember: user "${action.username}" not found; skipping.`,
);
}
}
}
// #5283: the checklist (and checklist item) may not exist on the card — guard
+340
View File
@@ -0,0 +1,340 @@
'use strict';
// Plain-Node regression guard (no Meteor) for issue #2674
// ("Rule: remove USER from card when move").
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/rulesApiTriggerNormalize.test.cjs
//
// The reported symptom: a rule "add user to card when it is moved TO list X"
// fires, but the companion rule "remove user from card when it is moved AWAY
// FROM list X" never does. Two silent causes remained in the REST API path
// (the rule-action errors are swallowed by the activity hook, so nothing is
// logged):
//
// 1. The rule matcher (server/rulesHelper.js buildMatchingFieldsMap) queries
// EVERY matching field of the trigger's activityType with
// {$in: [<activity value>, '*']}. A Mongo $in never matches a document
// that LACKS the field, so a trigger created over
// POST /api/boards/:boardId/rules without e.g. userId / listName /
// swimlaneName could never match any activity — the rule silently never
// fired. The UI wizard (sanitizeObject) and the rules JSON import
// (normalizeTrigger) both default missing fields to '*'; the API now does
// the same via normalizeTriggerDoc().
//
// 2. The addMember/removeMember actions crashed on `undefined._id` whenever
// the stored username no longer resolved (user renamed/deleted, or a typo
// in an API-created rule) — and the removeMember '*' branch crashed on a
// card without a members array. They now warn and skip instead, and the
// card writes are awaited.
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');
const rulesApiSrc = read('server/models/rules.js');
const rulesHelperSrc = read('server/rulesHelper.js');
const apiPySrc = read('api.py');
const rulesDocSrc = read('docs/API/Rules.md');
const restApiDocSrc = read('docs/API/REST-API.md');
// --- extract the REAL implementation pieces ----------------------------------
// TriggersDef (server/triggersDef.js is an ES module; strip `export`).
const triggersDefSrc = read('server/triggersDef.js').replace(/^export /m, '');
// eslint-disable-next-line no-new-func
const TriggersDef = new Function(`${triggersDefSrc}\nreturn TriggersDef;`)();
// normalizeTriggerDoc from the rules REST API.
const normSrc = rulesApiSrc.match(/function normalizeTriggerDoc\(trigger\) \{[\s\S]*?\n\}/);
assert.ok(normSrc, 'normalizeTriggerDoc found in server/models/rules.js');
// eslint-disable-next-line no-new-func
const normalizeTriggerDoc = new Function(
'TriggersDef',
`${normSrc[0]}\nreturn normalizeTriggerDoc;`,
)(TriggersDef);
// buildMatchingFieldsMap from the rule engine (an object-literal method).
const bmfSrc = rulesHelperSrc.match(
/async buildMatchingFieldsMap\(activity, matchingFields\) \{[\s\S]*?\n \},/,
);
assert.ok(bmfSrc, 'buildMatchingFieldsMap found in server/rulesHelper.js');
const { cardTitleMatchList } = require(path.join(
repoRoot,
'models/lib/ruleCardTitleFilter.js',
));
function makeMatcherEngine(reactiveCacheStub) {
// eslint-disable-next-line no-new-func
return new Function(
'ReactiveCache',
'cardTitleMatchList',
`return { ${bmfSrc[0].replace(/,\s*$/, '')} };`,
)(reactiveCacheStub, cardTitleMatchList);
}
// Minimal Mongo find() semantics for {field: {$in: [...]}} selectors: a $in
// only matches a document MISSING the field when the $in list contains null
// (Meteor stores/queries `undefined` as null).
function mongoDocMatches(doc, selector) {
return Object.entries(selector).every(([field, cond]) => {
if (cond && typeof cond === 'object' && Array.isArray(cond.$in)) {
const inList = cond.$in.map(v => (v === undefined ? null : v));
const present =
Object.prototype.hasOwnProperty.call(doc, field) && doc[field] !== undefined;
if (!present) return inList.includes(null);
return inList.includes(doc[field] === null ? null : doc[field]);
}
return doc[field] === cond;
});
}
let passed = 0;
const tests = [];
function test(name, fn) {
tests.push([name, fn]);
}
// --- the #2674 board -----------------------------------------------------------
const LISTS = {
L0: { _id: 'L0', title: 'List 0' },
L1: { _id: 'L1', title: 'List 1' },
};
const reactiveCacheStub = {
async getList(id) {
return LISTS[id];
},
async getSwimlane() {
return undefined;
},
async getCard() {
return { _id: 'C1', title: 'Test card' };
},
};
// Activity recorded when the card is moved AWAY FROM "List 1" (into "List 0").
const movedFromList1Activity = {
activityType: 'moveCard',
boardId: 'B1',
cardId: 'C1',
cardTitle: 'Test card',
listName: 'List 0',
listId: 'L0',
oldListId: 'L1',
swimlaneName: 'Default',
userId: 'U1',
};
// Activity recorded when the card is moved TO "List 1" (from "List 0").
const movedToList1Activity = {
...movedFromList1Activity,
listName: 'List 1',
listId: 'L1',
oldListId: 'L0',
};
async function matcherFor(activity) {
const engine = makeMatcherEngine(reactiveCacheStub);
return engine.buildMatchingFieldsMap(
activity,
TriggersDef[activity.activityType].matchingFields,
);
}
// --- normalizeTriggerDoc unit behavior ----------------------------------------
test('missing moveCard matching fields default to the * wildcard', () => {
const out = normalizeTriggerDoc({ activityType: 'moveCard', oldListName: 'List 1' });
assert.strictEqual(out.oldListName, 'List 1', 'explicit value kept');
assert.strictEqual(out.listName, '*');
assert.strictEqual(out.userId, '*');
assert.strictEqual(out.swimlaneName, '*');
assert.strictEqual(out.cardTitle, '*');
assert.strictEqual(out.boardId, '*'); // strip() removed it; route re-adds the real one
});
test('empty-string and null values become the wildcard too', () => {
const out = normalizeTriggerDoc({
activityType: 'moveCard',
listName: '',
userId: null,
oldListName: 'List 1',
});
assert.strictEqual(out.listName, '*');
assert.strictEqual(out.userId, '*');
assert.strictEqual(out.oldListName, 'List 1');
});
test('explicit values are never overwritten', () => {
const out = normalizeTriggerDoc({
activityType: 'createCard',
listName: 'Doing',
userId: 'someUser',
cardTitle: 'Part A',
});
assert.strictEqual(out.listName, 'Doing');
assert.strictEqual(out.userId, 'someUser');
assert.strictEqual(out.cardTitle, 'Part A');
assert.strictEqual(out.swimlaneName, '*', 'only the missing field is defaulted');
});
test('negative: non-activity triggers (scheduledTrigger/button) are untouched', () => {
const sched = {
activityType: 'scheduledTrigger',
scheduleKind: 'aging',
listName: 'Completed',
days: 90,
};
assert.deepStrictEqual(normalizeTriggerDoc(sched), sched);
const button = { activityType: 'button', buttonKind: 'card' };
assert.deepStrictEqual(normalizeTriggerDoc(button), button);
});
// --- the #2674 end-to-end matching scenario ------------------------------------
test('#2674: UI-style "moved AWAY FROM List 1 -> removeMember" trigger matches', async () => {
const selector = await matcherFor(movedFromList1Activity);
const uiTrigger = {
activityType: 'moveCard',
boardId: 'B1',
listName: '*',
oldListName: 'List 1',
swimlaneName: '*',
cardTitle: '*',
userId: '*',
};
assert.strictEqual(mongoDocMatches(uiTrigger, selector), true);
});
test('#2674 regression: the same trigger created over REST WITHOUT normalization never matched', async () => {
const selector = await matcherFor(movedFromList1Activity);
// What POST /api/boards/:boardId/rules used to store: only the fields the
// caller supplied. userId/listName/swimlaneName are missing entirely.
const apiTriggerBeforeFix = {
activityType: 'moveCard',
boardId: 'B1',
oldListName: 'List 1',
};
assert.strictEqual(
mongoDocMatches(apiTriggerBeforeFix, selector),
false,
'a trigger missing matching fields can never satisfy the $in query',
);
});
test('#2674 fix: after normalizeTriggerDoc the API trigger matches the move-away activity', async () => {
const selector = await matcherFor(movedFromList1Activity);
const apiTrigger = {
...normalizeTriggerDoc({ activityType: 'moveCard', oldListName: 'List 1' }),
boardId: 'B1', // the route sets the real boardId after normalization
};
assert.strictEqual(mongoDocMatches(apiTrigger, selector), true);
});
test('negative: the remove-rule does NOT fire when the card moves INTO List 1', async () => {
const selector = await matcherFor(movedToList1Activity);
const removeTrigger = {
...normalizeTriggerDoc({ activityType: 'moveCard', oldListName: 'List 1' }),
boardId: 'B1',
};
assert.strictEqual(mongoDocMatches(removeTrigger, selector), false);
});
test('companion add-rule ("moved TO List 1") matches only the move-in activity', async () => {
const addTrigger = {
...normalizeTriggerDoc({ activityType: 'moveCard', listName: 'List 1' }),
boardId: 'B1',
};
assert.strictEqual(
mongoDocMatches(addTrigger, await matcherFor(movedToList1Activity)),
true,
);
assert.strictEqual(
mongoDocMatches(addTrigger, await matcherFor(movedFromList1Activity)),
false,
);
});
// --- the REST routes actually use the normalization ---------------------------
test('POST /api/boards/:boardId/rules normalizes the trigger and validates types', () => {
assert.ok(/Triggers\.insertAsync\(\{\s*\n\s*\.\.\.normalizeTriggerDoc\(strip\(trigger\)\)/.test(rulesApiSrc));
assert.ok(rulesApiSrc.includes("'trigger.activityType is required'"));
assert.ok(rulesApiSrc.includes("'action.actionType is required'"));
});
test('PUT /api/boards/:boardId/rules/:ruleId normalizes a replaced trigger', () => {
assert.ok(/\$set: \{ \.\.\.normalizeTriggerDoc\(strip\(req\.body\.trigger\)\), boardId: paramBoardId \}/.test(rulesApiSrc));
});
test('negative: no raw un-normalized trigger insert remains in the rules API', () => {
assert.ok(!/Triggers\.insertAsync\(\{ \.\.\.strip\(trigger\)/.test(rulesApiSrc));
});
// --- the removeMember/addMember actions no longer crash silently ---------------
test('removeMember tolerates a card without a members array', () => {
assert.ok(rulesHelperSrc.includes('const members = card.members || [];'));
});
test('member actions resolve the user defensively and await the card writes', () => {
assert.ok(/if \(action\.actionType === 'addMember'\) \{\s*\n\s*const member = await ReactiveCache\.getUser\(\{ username: action\.username \}\);\s*\n\s*if \(member\) \{\s*\n\s*await card\.assignMember\(member\._id\);/.test(rulesHelperSrc));
assert.ok(/await card\.unassignMember\(member\._id\);/.test(rulesHelperSrc));
assert.ok(rulesHelperSrc.includes('not found; skipping.'));
});
test('negative: the crashing `(await getUser(...))._id` pattern is gone', () => {
assert.ok(!/\(await ReactiveCache\.getUser\(\{ username: action\.username \}\)\)\._id/.test(rulesHelperSrc));
});
// --- the capability is documented (api.py + API docs) --------------------------
test('api.py help text lists the Rules API commands', () => {
const syntaxBlock = apiPySrc.slice(0, apiPySrc.indexOf('if arguments == 0'));
assert.ok(syntaxBlock.includes('Board Automation Rules API (IFTTT, Issue #2674):'));
for (const cmd of ['listrules', 'getrule', 'addrule', 'editrule', 'removerule']) {
assert.ok(syntaxBlock.includes(`api.py ${cmd} BOARDID`), `help lists ${cmd}`);
}
assert.ok(syntaxBlock.includes('"actionType":"removeMember"'), 'help shows the #2674 example');
});
test('api.py implements the rule commands it documents', () => {
for (const cmd of ['listrules', 'getrule', 'addrule', 'editrule', 'removerule']) {
assert.ok(apiPySrc.includes(`sys.argv[1] == '${cmd}'`), `api.py implements ${cmd}`);
}
});
test('docs/API/Rules.md documents all five endpoints and the #2674 workflow', () => {
assert.ok(rulesDocSrc.includes('`GET` | `/api/boards/:boardId/rules`'));
assert.ok(rulesDocSrc.includes('`GET` | `/api/boards/:boardId/rules/:ruleId`'));
assert.ok(rulesDocSrc.includes('`POST` | `/api/boards/:boardId/rules`'));
assert.ok(rulesDocSrc.includes('`PUT` | `/api/boards/:boardId/rules/:ruleId`'));
assert.ok(rulesDocSrc.includes('`DELETE` | `/api/boards/:boardId/rules/:ruleId`'));
assert.ok(rulesDocSrc.includes('"actionType": "removeMember"'));
assert.ok(rulesDocSrc.includes('oldListName'));
assert.ok(/issues\/2674/.test(rulesDocSrc));
});
test('docs/API/REST-API.md summary links to the Rules API page', () => {
assert.ok(restApiDocSrc.includes('### Rules (Board Automation / IFTTT)'));
assert.ok(restApiDocSrc.includes('Rules.md#add-a-rule'));
});
// --- runner --------------------------------------------------------------------
(async () => {
for (const [name, fn] of tests) {
try {
await fn();
passed += 1;
console.log(' ok -', name);
} catch (e) {
console.error(' FAIL -', name);
console.error(e && e.stack ? e.stack : e);
process.exitCode = 1;
}
}
console.log(`\n${passed}/${tests.length} tests passed`);
if (passed !== tests.length) process.exit(1);
})();
+143
View File
@@ -0,0 +1,143 @@
'use strict';
// Plain-Node unit test (no Meteor) for the board-rule card-title filter.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/rulesCardTitleFilter.test.cjs
//
// Regression guard for #2345 ("basic card title filter rules issue"):
// - Setting a "Card Title Filter" on a rule trigger did not show up in the
// created rule's description, and for the generic "when a card is moved"
// and archive/unarchive triggers the filter was never stored at all.
// - Those triggers stored NO cardTitle field, and the server matches
// triggers with `cardTitle: { $in: [...] }` — a document lacking the field
// never satisfies that query, so such rules never fired.
// - A stored filter must be enforced: the rule fires only for cards whose
// title matches; an empty filter (stored as '*') keeps matching every card.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
cardTitleFilterOrWildcard,
cardTitleMatchList,
cardTitleFilterMatches,
appendCardTitleFilterToDesc,
} = require('../models/lib/ruleCardTitleFilter');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- filter stored -----------------------------------------------------------
test('a typed filter is stored as-is (trimmed)', () => {
assert.strictEqual(cardTitleFilterOrWildcard('Part A'), 'Part A');
assert.strictEqual(cardTitleFilterOrWildcard(' Part A '), 'Part A');
});
test('an empty/unset filter is stored as the "*" wildcard (per the UI note)', () => {
assert.strictEqual(cardTitleFilterOrWildcard(''), '*');
assert.strictEqual(cardTitleFilterOrWildcard(' '), '*');
assert.strictEqual(cardTitleFilterOrWildcard(undefined), '*');
assert.strictEqual(cardTitleFilterOrWildcard(null), '*');
});
test('every board trigger doc built by the wizard now carries a cardTitle field', () => {
// #2345 root cause 1: the generic moved / archive triggers stored no
// cardTitle, so `cardTitle: { $in: [...] }` could never match them.
const src = fs.readFileSync(
path.join(__dirname, '..', 'client', 'components', 'rules', 'triggers', 'boardTriggers.js'),
'utf8',
);
const triggerDocs = src.match(/triggerVar\.set\(\{[\s\S]*?\}\)/g) || [];
assert.ok(triggerDocs.length >= 6, `expected >= 6 triggerVar.set blocks, got ${triggerDocs.length}`);
for (const doc of triggerDocs) {
assert.ok(
/\bcardTitle\b/.test(doc),
`a trigger doc is missing cardTitle:\n${doc}`,
);
}
// The stored value goes through the wildcard normalizer.
assert.ok(src.includes('cardTitleFilterOrWildcard'), 'wizard must normalize the filter');
});
test('the trigger description shows the filter (rule details feedback)', () => {
// #2345 root cause 2: "it does not show Part A" in the rule details.
const desc = appendCardTitleFilterToDesc(
'when a card is moved to list x',
'Part A',
'card title filter',
);
assert.ok(desc.includes('Part A'), `desc must mention the filter: ${desc}`);
assert.ok(desc.startsWith('when a card is moved to list x'), 'original desc kept');
});
test('NEGATIVE: a wildcard/empty filter leaves the description untouched', () => {
assert.strictEqual(
appendCardTitleFilterToDesc('when a card is moved', '*'),
'when a card is moved',
);
assert.strictEqual(appendCardTitleFilterToDesc('d', ''), 'd');
assert.strictEqual(appendCardTitleFilterToDesc('d', undefined), 'd');
});
// --- filter enforced (matching) ----------------------------------------------
test('a trigger with filter "Part A" fires for a card titled "Part A"', () => {
assert.strictEqual(cardTitleFilterMatches('Part A', 'Part A'), true);
assert.ok(cardTitleMatchList('Part A').includes('Part A'));
});
test('a single-word filter matches any card whose title contains that word', () => {
// Historic matching semantics: the activity title is split on non-word chars.
assert.strictEqual(cardTitleFilterMatches('Part555', 'Part555'), true);
assert.strictEqual(cardTitleFilterMatches('Part', 'Part A555'), true);
});
test('NEGATIVE: a non-matching title does not fire the rule', () => {
assert.strictEqual(cardTitleFilterMatches('Part A', 'Part B555'), false);
assert.strictEqual(cardTitleFilterMatches('Part A', 'Something else'), false);
assert.strictEqual(cardTitleFilterMatches('Part555', 'Card'), false);
assert.ok(!cardTitleMatchList('Other card').includes('Part A'));
});
test('empty filter (stored as "*") matches every card', () => {
assert.strictEqual(cardTitleFilterMatches('*', 'Part A'), true);
assert.strictEqual(cardTitleFilterMatches('*', 'anything at all'), true);
assert.ok(cardTitleMatchList('whatever').includes('*'));
});
test('legacy triggers saved without a cardTitle field keep matching every card', () => {
// The $in list must contain null: in MongoDB `$in: [null, ...]` also matches
// documents lacking the field, so pre-fix "when a card is moved" rules
// (which stored no cardTitle) fire again instead of never firing.
assert.ok(cardTitleMatchList('Part A').includes(null));
assert.ok(cardTitleMatchList(undefined).includes(null));
assert.strictEqual(cardTitleFilterMatches(null, 'Part A'), true);
assert.strictEqual(cardTitleFilterMatches(undefined, 'Part A'), true);
});
test('an activity without a resolvable card title only fires wildcard/legacy triggers', () => {
const list = cardTitleMatchList(undefined);
assert.deepStrictEqual(list, ['*', null]);
assert.strictEqual(cardTitleFilterMatches('Part A', undefined), false);
});
test('server matching uses the shared helper for the cardTitle field', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'server', 'rulesHelper.js'),
'utf8',
);
assert.ok(
src.includes("from '/models/lib/ruleCardTitleFilter'"),
'rulesHelper must import the shared filter helper',
);
assert.ok(
src.includes('cardTitleMatchList(value)'),
'rulesHelper must build the cardTitle $in list with cardTitleMatchList',
);
});
console.log(`\n${passed} tests passed`);
+181
View File
@@ -0,0 +1,181 @@
'use strict';
// Plain-Node unit test (no Meteor) for the Sandstorm unique-username helpers.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/sandstormUsername.test.cjs
//
// Covers #574 "Sandstorm - generation of unique usernames": the uniqueness
// probe must be case-INsensitive (Meteor usernames are case-preserving but
// case-insensitively unique), the handle must be regex-escaped before being
// interpolated into the probe, and the probe->update race must be resolved by
// catching the E11000 duplicate-key error and retrying with the next number.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
generateUniqueUsername,
escapeForRegex,
usernameCaseInsensitiveRegex,
isDuplicateKeyError,
claimUniqueUsername,
} = require('../models/lib/sandstormUsername');
const tests = [];
let passed = 0;
function test(name, fn) {
tests.push([name, fn]);
}
// --- generateUniqueUsername (historical numbering) ---------------------------
test('appendNumber 0 keeps the bare handle, 1+ append the number', () => {
assert.strictEqual(generateUniqueUsername('max', 0), 'max');
assert.strictEqual(generateUniqueUsername('max', 1), 'max1');
assert.strictEqual(generateUniqueUsername('max', 12), 'max12');
});
// --- case-insensitive, anchored, escaped probe --------------------------------
test('probe regex matches the same name in any case', () => {
const re = usernameCaseInsensitiveRegex('max');
assert.ok(re.test('max'));
assert.ok(re.test('Max'));
assert.ok(re.test('MAX'));
});
test('probe regex is anchored: no prefix/suffix/substring matches', () => {
const re = usernameCaseInsensitiveRegex('max');
assert.ok(!re.test('max1'), 'must not match a longer name');
assert.ok(!re.test('emax'), 'must not match a suffix hit');
assert.ok(!re.test('a max b'), 'must not match a substring hit');
});
test('regex metacharacters in a handle are matched literally', () => {
assert.strictEqual(escapeForRegex('a.b+c'), 'a\\.b\\+c');
const re = usernameCaseInsensitiveRegex('a.b');
assert.ok(re.test('a.b'));
assert.ok(!re.test('axb'), 'the dot must not act as a wildcard');
// A hostile handle must not blow up regex construction.
assert.ok(usernameCaseInsensitiveRegex('x($[a]*\\').test('x($[a]*\\'));
});
// --- duplicate-key error detection --------------------------------------------
test('recognizes duplicate-key errors by code and by message', () => {
assert.ok(isDuplicateKeyError({ code: 11000 }));
assert.ok(isDuplicateKeyError({ code: 11001 }));
assert.ok(
isDuplicateKeyError(
new Error('E11000 duplicate key error collection: wekan.users index: username_1'),
),
);
assert.ok(isDuplicateKeyError({ errmsg: 'E11000 duplicate key error' }));
});
test('does not misclassify other errors as duplicate-key', () => {
assert.ok(!isDuplicateKeyError(null));
assert.ok(!isDuplicateKeyError(undefined));
assert.ok(!isDuplicateKeyError(new Error('connection reset')));
assert.ok(!isDuplicateKeyError({ code: 121, message: 'validation failed' }));
});
// --- claimUniqueUsername: probing ----------------------------------------------
function takenSet(names) {
const lower = names.map(n => n.toLowerCase());
return async candidate => lower.includes(candidate.toLowerCase());
}
test('claims the bare handle when nothing conflicts', async () => {
const claimed = [];
const result = await claimUniqueUsername('max', takenSet([]), async c => {
claimed.push(c);
});
assert.strictEqual(result, 'max');
assert.deepStrictEqual(claimed, ['max']);
});
test('skips names that are taken, including case-insensitive hits (#574)', async () => {
// 'Max' and 'MAX1' exist with different case: the old case-sensitive query
// would have handed out 'max' again; the fix must walk on to 'max2'.
const result = await claimUniqueUsername(
'max',
takenSet(['Max', 'MAX1']),
async () => {},
);
assert.strictEqual(result, 'max2');
});
// --- claimUniqueUsername: the race ----------------------------------------------
test('retries with the next number when the update loses the unique-index race', async () => {
// The probe says 'max' is free, but a concurrent insert claims it first:
// the update throws E11000 and the helper must claim 'max1' instead.
const attempts = [];
let raced = false;
const result = await claimUniqueUsername(
'max',
async () => false,
async candidate => {
attempts.push(candidate);
if (!raced) {
raced = true;
const err = new Error('E11000 duplicate key error');
err.code = 11000;
throw err;
}
},
);
assert.strictEqual(result, 'max1');
assert.deepStrictEqual(attempts, ['max', 'max1']);
});
test('a non-duplicate error from the update propagates unchanged', async () => {
const boom = new Error('mongo is down');
await assert.rejects(
claimUniqueUsername(
'max',
async () => false,
async () => {
throw boom;
},
),
err => err === boom,
);
});
// --- wiring: sandstorm.js actually uses the helpers ------------------------------
test('sandstorm.js probes case-insensitively and claims via the racy-safe helper', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'sandstorm.js'), 'utf8');
assert.ok(
/from ['"]\/models\/lib\/sandstormUsername['"]/.test(src),
'sandstorm.js must import from /models/lib/sandstormUsername',
);
assert.ok(
src.includes('usernameCaseInsensitiveRegex(candidate)'),
'the uniqueness probe must use the anchored case-insensitive regex',
);
assert.ok(
src.includes('claimUniqueUsername('),
'the username must be claimed through the duplicate-key-retrying helper',
);
assert.ok(
!/username:\s*generateUniqueUsername\(/.test(src),
'the old case-sensitive exact-match probe must be gone',
);
});
// --- runner ---------------------------------------------------------------------
(async () => {
for (const [name, fn] of tests) {
await fn();
passed += 1;
console.log(' ok -', name);
}
console.log(`\nsandstormUsername: ${passed} tests passed`);
})().catch(err => {
console.error(err);
process.exit(1);
});