mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
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:
1 parent
0f2179988b
commit
25d33cec36
14 files changed
+1269
-45
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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 |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
@@ -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"}'
|
||||
```
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user