Import: always create virtual users, map later from the sidebar; bound imports

Board import no longer asks for member mapping up front. Every imported member is
brought in as a virtual (placeholder) user carrying its avatar, username and full
name (the existing createPlaceholderUsers path), added to the board inactive with
no permissions. The client import flow is now a single step that imports
immediately with an empty mapping -- no map-members screen, nothing to get stuck
on.

Mapping a virtual member to an existing user is a deliberate, later action done by
a board admin from the board sidebar member-avatar popup ("Map to existing user"),
and CANNOT be used to gain privileges: the new board-scoped method
mapImportedBoardMember only maps a virtual member onto an existing ACTIVE, REAL
member of the SAME board and never changes that member's role (so no new
membership is created and nothing is escalated); authorization and this guarantee
live in the pure planBoardMemberMapping helper. The mapping reassigns the virtual
member's cards/comments/activities ON THAT BOARD to the target and removes the
placeholder from the board.

Automatic hang mitigation so an import can never spin forever: a client watchdog
surfaces an import-timeout and clears the spinner if importBoard does not answer in
WEKAN_IMPORT_TIMEOUT_MS (default 2 min), and the server bounds creator.create with
the same deadline via a new generic withDeadline() wrapper.

Covered by tests/boardMemberMapPlan.test.cjs (no-escalation + auth matrix),
tests/withDeadline.test.cjs (deadline wrapper) and tests/importVirtualMembers.test.cjs
(wiring).

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-07-21 19:43:48 +03:00
1 parent 2659780f4a
commit c0a1a27847
12 files changed
+524 -12

No files matched your search

+22 -10
View File
@@ -173,7 +173,11 @@ function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
Template.import.onCreated(function () {
this.error = new ReactiveVar('');
this.steps = ['importTextarea', 'importMapMembers'];
// Import never asks for member mapping up front: every imported member is brought
// in as a virtual (placeholder) user carrying its avatar / username / full name,
// and a board admin maps a virtual member to an existing board user later from the
// sidebar member-avatar popup. So there is a single step; import runs immediately.
this.steps = ['importTextarea'];
this._currentStepIndex = new ReactiveVar(0);
this.importedData = new ReactiveVar();
this.membersToMap = new ReactiveVar([]);
@@ -311,15 +315,10 @@ Template.import.onCreated(function () {
};
this.finishImport = async () => {
const membersMapping = this.membersToMap.get();
// No import-time member mapping: every imported member becomes a virtual
// (placeholder) user. Mapping a virtual member to an existing board user is done
// later from the board sidebar (privilege-bounded), so pass an empty mapping.
const mappingById = {};
if (membersMapping) {
membersMapping.forEach(member => {
if (member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
}
const importedData = this.importedData.get();
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
@@ -353,8 +352,18 @@ Template.import.onCreated(function () {
return;
}
// wekan / csv: unchanged DDP import.
// wekan / csv: DDP import, guarded by a client-side watchdog so a stalled or
// hung import can never leave the spinner running forever — if the method has not
// answered in WEKAN_IMPORT_TIMEOUT_MS (default 2 min) we surface a timeout error
// and clear the spinner. The server bounds the import itself too (see importBoard).
this.membersToMap.set([]);
let settled = false;
const timeoutMs = parseInt(window.WEKAN_IMPORT_TIMEOUT_MS, 10) || 120000;
const watchdog = Meteor.setTimeout(() => {
if (settled) return;
settled = true;
this.setError('import-timeout');
}, timeoutMs);
Meteor.call(
'importBoard',
importedData,
@@ -362,6 +371,9 @@ Template.import.onCreated(function () {
this.importSource,
Session.get('fromBoard'),
(err, res) => {
if (settled) return; // already timed out
settled = true;
Meteor.clearTimeout(watchdog);
if (err) {
this.setError(err.error);
} else {
+18
View File
@@ -859,6 +859,11 @@ template(name="memberPopup")
a.js-change-role
| {{_ 'change-permissions'}}
span.quiet (#{memberType})
if isImportedMember
li
a.js-map-imported-member
i.fa.fa-user-plus
| {{_ 'map-to-existing-user'}}
unless currentUser.isWorker
li
if $eq currentUser._id userId
@@ -866,6 +871,19 @@ template(name="memberPopup")
else if currentUser.isBoardAdmin
a.js-remove-member {{_ 'remove-from-board'}}
template(name="mapImportedMemberPopup")
p.quiet {{_ 'map-to-existing-user-desc'}}
ul.pop-over-list
each u in mapTargets
li
a.js-pick-map-target(data-id="{{u._id}}")
+userAvatar(userId=u._id)
span.full-name= u.profile.fullname
| (@#{u.username})
unless hasMapTargets
li
p.quiet {{_ 'map-to-existing-user-none'}}
template(name="removeMemberPopup")
p {{_ 'remove-member-pop' name=user.profile.fullname username=user.username boardTitle=board.title}}
button.js-confirm.negate.full(type="submit") {{_ 'remove-member'}}
+42
View File
@@ -460,6 +460,12 @@ Template.memberPopup.helpers({
isInvited() {
return ReactiveCache.getUser(this.userId).isInvitedTo(Session.get('currentBoard'));
},
// A virtual (placeholder) member created by a board import — can be mapped to an
// existing real board user from this popup.
isImportedMember() {
const user = ReactiveCache.getUser(this.userId);
return !!user && user.authenticationMethod === 'imported';
},
});
@@ -588,6 +594,7 @@ Template.memberPopup.events({
Popup.back();
},
'click .js-change-role': Popup.open('changePermissions'),
'click .js-map-imported-member': Popup.open('mapImportedMember'),
'click .js-remove-member': Popup.afterConfirm('removeMember', async function() {
// This works from removing member from board, card members and assignees.
const boardId = Session.get('currentBoard');
@@ -611,6 +618,41 @@ Template.memberPopup.events({
});
// Existing REAL (non-imported), active members of the current board that a virtual
// (placeholder) member can be mapped onto. Excludes imported placeholders and the
// placeholder itself, so a mapping never creates a new member or maps to another virtual
// user — which, with the board-admin gate and unchanged target role, makes it impossible
// to gain privileges via mapping.
function importedMapTargets(placeholderId) {
const board = Utils.getCurrentBoard();
if (!board) return [];
return (board.activeMembers() || [])
.map(m => ReactiveCache.getUser(m.userId))
.filter(u => u && u._id !== placeholderId && u.authenticationMethod !== 'imported');
}
Template.mapImportedMemberPopup.helpers({
mapTargets() {
return importedMapTargets(this.userId);
},
hasMapTargets() {
return importedMapTargets(this.userId).length > 0;
},
});
Template.mapImportedMemberPopup.events({
'click .js-pick-map-target'(event) {
const targetId = event.currentTarget.dataset.id;
const placeholderId = this.userId;
const boardId = Session.get('currentBoard');
if (targetId && placeholderId && boardId) {
Meteor.call('mapImportedBoardMember', boardId, placeholderId, targetId, () => {
Popup.back();
});
}
},
});
Template.removeMemberPopup.helpers({
user() {
return ReactiveCache.getUser(this.userId);
+4
View File
@@ -349,6 +349,9 @@
"change-color": "Change Color",
"change-password": "Change Password",
"change-permissions": "Change permissions",
"map-to-existing-user": "Map to existing user",
"map-to-existing-user-desc": "Pick an existing board member this imported (virtual) member really is. Their cards, comments and activity move to that member. It never changes anyone's role, so it cannot grant extra permissions.",
"map-to-existing-user-none": "No existing board members to map to yet. Add the real user to this board first, then map.",
"change-settings": "Change Settings",
"theme-default": "Default (no override)",
"theme": "Theme",
@@ -648,6 +651,7 @@
"import-trello-zip-no-boards": "No Trello board .json files were found in the .zip.",
"import-trello-zip-progress": "Importing boards from .zip, please wait…",
"import-trello-failed": "Importing from Trello failed.",
"import-timeout": "Import took too long and was stopped. Please try again; if it keeps happening, the board may be too large or the database may be busy.",
"import-trello-zip-failed": "Importing the .zip failed.",
"import-trello-zip-read-failed": "Could not read the uploaded .zip.",
"import-trello-zip-too-large": "The .zip is too large to import.",
+19 -1
View File
@@ -9,6 +9,15 @@ import { EXTERNAL_PARSERS } from './lib/externalParsers';
import { Exporter } from './exporter';
import { getMembersToMap } from './wekanmapper';
import { assertImportEnabled } from './lib/importExportSecurity';
import { withDeadline } from './lib/withDeadline';
// Hard deadline for a single board import, so a stalled/hung import can never leave the
// client's spinner running forever — the method returns a timeout error instead.
// Tunable via WEKAN_IMPORT_TIMEOUT_MS (0/invalid disables the deadline).
function importDeadlineMs() {
const ms = parseInt(process.env.WEKAN_IMPORT_TIMEOUT_MS, 10);
return Number.isFinite(ms) ? ms : 120000;
}
// Parse an uploaded .xlsx (base64) into the row-array shape the CsvCreator
// consumes (board[0] is the header row). Excel import reuses the CSV creator.
@@ -91,7 +100,16 @@ Meteor.methods({
// 2. check parameters are ok from a business point of view (exist &
// authorized) nothing to check, everyone can import boards in their account
// 3. create all elements
// 3. create all elements, bounded by a hard deadline on the server so a hung
// import (e.g. a database operation that never returns) surfaces a timeout error
// to the client instead of spinning forever. The client also runs its own watchdog.
if (Meteor.isServer) {
return await withDeadline(
creator.create(importedBoard, currentBoard),
importDeadlineMs(),
() => new Meteor.Error('import-timeout', 'Import took too long and was aborted'),
);
}
return await creator.create(importedBoard, currentBoard);
},
});
+65
View File
@@ -0,0 +1,65 @@
// Pure decision helper for mapping an imported VIRTUAL (placeholder) board member to an
// existing REAL user on the same board, from the board sidebar member-avatar popup.
//
// Security model — mapping can NEVER be used to gain privileges:
// - Only a board admin (or a site admin) may map.
// - The target must be an existing, ACTIVE, real (non-imported) member of THIS board.
// Mapping never adds a new member and never changes the target's role, so the target
// keeps exactly the role they already had — nothing is escalated. To give a real
// user a board role you use the normal add-member / change-permissions flow first
// (which is itself privilege-checked), then map the virtual member onto them.
// - The source must be an imported placeholder member of this board.
//
// Returns { ok:true, newMembers, placeholderId, targetId } (newMembers = members with the
// placeholder entry removed) or { ok:false, error } with a stable error key. Pure and
// dependency-free so it is unit-tested without a database.
function planBoardMemberMapping({
members,
placeholderId,
targetId,
callerId,
placeholderIsImported,
targetIsImported,
callerIsSiteAdmin = false,
} = {}) {
const list = Array.isArray(members) ? members : [];
const find = id => list.find(m => m && m.userId === id);
if (!placeholderId || !targetId || placeholderId === targetId) {
return { ok: false, error: 'bad-map' };
}
// Authorization: board admin of THIS board, or a site admin.
const caller = find(callerId);
const callerIsBoardAdmin = !!(caller && caller.isActive !== false && caller.isAdmin);
if (!callerIsSiteAdmin && !callerIsBoardAdmin) {
return { ok: false, error: 'not-authorized' };
}
// Source must be an imported placeholder that is a member of this board.
if (!placeholderIsImported) {
return { ok: false, error: 'not-a-placeholder' };
}
if (!find(placeholderId)) {
return { ok: false, error: 'placeholder-not-member' };
}
// Target must be a REAL (non-imported), ACTIVE member of this board — so no new
// membership is created and no role is granted by the mapping itself.
if (targetIsImported) {
return { ok: false, error: 'target-not-real' };
}
const targetMember = find(targetId);
if (!targetMember || targetMember.isActive === false) {
return { ok: false, error: 'target-not-board-member' };
}
// Resulting membership: drop the placeholder entry, leave the target's entry (and role)
// exactly as it was. No entry is added, no flag is raised.
const newMembers = list.filter(m => m && m.userId !== placeholderId);
return { ok: true, newMembers, placeholderId, targetId };
}
module.exports = { planBoardMemberMapping };
+52
View File
@@ -0,0 +1,52 @@
// Bound a promise so a hung or stalled operation can never block forever. Resolves
// with the promise's value if it settles within `ms`; otherwise rejects with a timeout
// error (from `onTimeout()` if given, else a generic Error). The underlying promise is
// not cancelled — JavaScript cannot cancel a promise — but the caller is freed, so a
// method/handler returns an error to the client instead of spinning indefinitely.
//
// Pure and dependency-free so it can be unit-tested and reused anywhere a long
// operation needs a hard deadline (imports, migrations, external fetches, …).
//
// Usage:
// await withDeadline(doWork(), 120000, () => new Meteor.Error('op-timeout'));
function withDeadline(promise, ms, onTimeout) {
// A non-positive / invalid deadline means "no deadline": just await the promise.
if (!(ms > 0)) {
return Promise.resolve(promise);
}
return new Promise((resolve, reject) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
const err =
typeof onTimeout === 'function' ? onTimeout() : new Error('operation-timeout');
reject(err);
}, ms);
// Do not keep the process alive just for this timer (Node only; harmless elsewhere).
if (timer && typeof timer.unref === 'function') {
timer.unref();
}
Promise.resolve(promise).then(
value => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(value);
},
error => {
if (done) return;
done = true;
clearTimeout(timer);
reject(error);
},
);
});
}
module.exports = { withDeadline };
+1 -1
View File
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/adminReportsPagination.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/migrateNonFinite.test.cjs && node tests/addListComposerOptions.test.cjs && node tests/sidebarWidth.test.cjs && node tests/sidebarResize.test.cjs && node tests/securityMeifukun.test.cjs && node tests/securityLog.test.cjs && node tests/exporterBackpressure.test.cjs && node tests/ldapEmailFieldmap.test.cjs && node tests/listWidthDefaults.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/boardMemberMapPlan.test.cjs && node tests/withDeadline.test.cjs && node tests/importVirtualMembers.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/adminReportsPagination.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/importedUserReconciliationPlan.test.cjs && node tests/ldapPhoto.test.cjs && node tests/importedBoardMemberPlan.test.cjs && node tests/migrateNonFinite.test.cjs && node tests/addListComposerOptions.test.cjs && node tests/sidebarWidth.test.cjs && node tests/sidebarResize.test.cjs && node tests/securityMeifukun.test.cjs && node tests/securityLog.test.cjs && node tests/exporterBackpressure.test.cjs && node tests/ldapEmailFieldmap.test.cjs && node tests/listWidthDefaults.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+76
View File
@@ -7,6 +7,7 @@ import Cards from '/models/cards';
import Activities from '/models/activities';
import CardComments from '/models/cardComments';
import { planReconciliation } from '/models/lib/importedUserReconciliationPlan';
import { planBoardMemberMapping } from '/models/lib/boardMemberMapPlan';
// ============================================================================
// Imported-user reconciliation
@@ -68,6 +69,69 @@ export async function mergeImportedUserInto(placeholderId, targetId) {
return { ok: true, mergedInto: targetId };
}
// Board-scoped mapping of one imported VIRTUAL (placeholder) member to an existing REAL
// user on the SAME board, done by a board admin from the sidebar member-avatar popup.
// Unlike mergeImportedUserInto (global, site-admin-only), this only ever touches the one
// board and can never grant privileges: the target must already be an active real member
// of the board and keeps their own role (see models/lib/boardMemberMapPlan.js). The
// placeholder's work ON THIS BOARD (card membership/assignment, activities, comments) is
// reassigned to the target, and the placeholder is removed from this board's members.
// Server-only.
export async function mapImportedBoardMemberInto(boardId, placeholderId, targetId, callerId) {
if (!Meteor.isServer) return { ok: false };
const board = await ReactiveCache.getBoard(boardId);
if (!board) throw new Meteor.Error('no-board', 'board does not exist');
const caller = await ReactiveCache.getUser(callerId);
const placeholder = await ReactiveCache.getUser(placeholderId);
const target = await ReactiveCache.getUser(targetId);
const plan = planBoardMemberMapping({
members: board.members || [],
placeholderId,
targetId,
callerId,
placeholderIsImported: !!placeholder && placeholder.authenticationMethod === 'imported',
targetIsImported: !target || target.authenticationMethod === 'imported',
callerIsSiteAdmin: !!(caller && caller.isAdmin),
});
if (!plan.ok) {
throw new Meteor.Error(plan.error || 'not-authorized');
}
// Reassign the placeholder's references, scoped to THIS board only.
for (const field of ['members', 'assignees']) {
const cards = await Cards.find({ boardId, [field]: placeholderId }).fetchAsync();
for (const c of cards) {
const arr = Array.from(
new Set((c[field] || []).map(id => (id === placeholderId ? targetId : id))),
);
await Cards.direct.updateAsync(c._id, { $set: { [field]: arr } });
}
}
await Activities.direct.updateAsync(
{ boardId, userId: placeholderId }, { $set: { userId: targetId } }, { multi: true },
);
await CardComments.direct.updateAsync(
{ boardId, userId: placeholderId }, { $set: { userId: targetId } }, { multi: true },
);
// Drop the placeholder from this board's members (target keeps their own role).
await Boards.direct.updateAsync(boardId, { $set: { members: plan.newMembers } });
// If the placeholder is no longer a member of any board, remove the now-orphaned
// virtual user so it does not linger; otherwise leave it for its other boards.
const stillMember = await Boards.find(
{ 'members.userId': placeholderId }, { fields: { _id: 1 } },
).fetchAsync();
if (stillMember.length === 0 && placeholder && placeholder.authenticationMethod === 'imported') {
await Users.direct.removeAsync(placeholderId);
}
return { ok: true, mappedInto: targetId };
}
// Deactivate a placeholder and all its board memberships (used when no valid account
// matches — e.g. the person is not in LDAP).
async function deactivatePlaceholder(placeholderId) {
@@ -121,5 +185,17 @@ if (Meteor.isServer) {
if (!user || !user.isAdmin) throw new Meteor.Error('not-authorized');
return await mergeImportedUserInto(placeholderId, targetId);
},
// Board-scoped: a board admin maps one imported VIRTUAL member of a board to an
// existing REAL, active member of the SAME board (from the sidebar avatar popup).
// Authorization and the no-escalation guarantee are enforced in
// planBoardMemberMapping (called inside mapImportedBoardMemberInto): the target keeps
// their own role, and only board admins (or site admins) may map.
async mapImportedBoardMember(boardId, placeholderId, targetId) {
check(boardId, String);
check(placeholderId, String);
check(targetId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
return await mapImportedBoardMemberInto(boardId, placeholderId, targetId, this.userId);
},
});
}
+97
View File
@@ -0,0 +1,97 @@
'use strict';
// Unit tests for the board-scoped virtual->real member mapping planner
// (models/lib/boardMemberMapPlan.js). This is the security gate for mapping an imported
// VIRTUAL (placeholder) board member to an existing REAL board user from the sidebar:
// it must be usable only by a board (or site) admin, only onto an existing ACTIVE real
// member of the board, and it must NEVER change any role — so it can never escalate.
//
// Run: node tests/boardMemberMapPlan.test.cjs
const assert = require('assert');
const { planBoardMemberMapping } = require('../models/lib/boardMemberMapPlan.js');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// A board: admin (real), normal member "bob" (real), a virtual placeholder "ph".
const members = [
{ userId: 'admin', isAdmin: true, isActive: true },
{ userId: 'bob', isAdmin: false, isActive: true },
{ userId: 'ph', isAdmin: false, isActive: false },
];
const base = {
members,
placeholderId: 'ph',
targetId: 'bob',
callerId: 'admin',
placeholderIsImported: true,
targetIsImported: false,
callerIsSiteAdmin: false,
};
console.log('boardMemberMapPlan:');
test('board admin maps a virtual member onto an existing active real member', () => {
const r = planBoardMemberMapping(base);
assert.strictEqual(r.ok, true);
// The placeholder entry is removed; every other entry (incl. the target and its role)
// is left EXACTLY as it was — no escalation.
assert.deepStrictEqual(r.newMembers, [
{ userId: 'admin', isAdmin: true, isActive: true },
{ userId: 'bob', isAdmin: false, isActive: true },
]);
const bob = r.newMembers.find(m => m.userId === 'bob');
assert.strictEqual(bob.isAdmin, false, 'target keeps its own (non-admin) role');
});
test('a non-admin board member cannot map', () => {
const r = planBoardMemberMapping({ ...base, callerId: 'bob' });
assert.deepStrictEqual(r, { ok: false, error: 'not-authorized' });
});
test('a site admin who is not a board member may map', () => {
const r = planBoardMemberMapping({ ...base, callerId: 'someSiteAdmin', callerIsSiteAdmin: true });
assert.strictEqual(r.ok, true);
});
test('source must be an imported placeholder', () => {
const r = planBoardMemberMapping({ ...base, placeholderIsImported: false });
assert.deepStrictEqual(r, { ok: false, error: 'not-a-placeholder' });
});
test('target must be a REAL (non-imported) user', () => {
const r = planBoardMemberMapping({ ...base, targetIsImported: true });
assert.deepStrictEqual(r, { ok: false, error: 'target-not-real' });
});
test('target must be an ACTIVE member of this board', () => {
const inactiveTarget = members.map(m => (m.userId === 'bob' ? { ...m, isActive: false } : m));
const r = planBoardMemberMapping({ ...base, members: inactiveTarget });
assert.deepStrictEqual(r, { ok: false, error: 'target-not-board-member' });
const r2 = planBoardMemberMapping({ ...base, targetId: 'stranger' });
assert.deepStrictEqual(r2, { ok: false, error: 'target-not-board-member' });
});
test('placeholder must be a member of this board', () => {
const r = planBoardMemberMapping({ ...base, placeholderId: 'ghost' });
assert.deepStrictEqual(r, { ok: false, error: 'placeholder-not-member' });
});
test('placeholder and target must differ', () => {
const r = planBoardMemberMapping({ ...base, targetId: 'ph' });
assert.deepStrictEqual(r, { ok: false, error: 'bad-map' });
});
test('an inactive board-admin entry does not authorize', () => {
const inactiveAdmin = [
{ userId: 'admin', isAdmin: true, isActive: false },
{ userId: 'bob', isAdmin: false, isActive: true },
{ userId: 'ph', isAdmin: false, isActive: false },
];
const r = planBoardMemberMapping({ ...base, members: inactiveAdmin });
assert.deepStrictEqual(r, { ok: false, error: 'not-authorized' });
});
console.log(`\n${passed} tests passed`);
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Wiring guards for the "import always creates virtual users, map later" feature:
// - Import never asks for member mapping up front (single step, empty mapping), so
// every imported member is brought in as a virtual (placeholder) user.
// - Import is bounded by a hang-mitigation watchdog (client + server) so it can never
// spin forever.
// - A board admin maps a virtual member to an existing real board user LATER from the
// sidebar member-avatar popup, via a board-scoped, no-escalation server method.
//
// Run: node tests/importVirtualMembers.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
console.log('importVirtualMembers:');
test('import runs immediately with no member-mapping step', () => {
const js = read('client/components/import/import.js');
assert.ok(/this\.steps = \['importTextarea'\]/.test(js), 'only the textarea step remains (no importMapMembers step)');
// finishImport must not build a mapping from membersToMap — every member is virtual.
const fi = js.slice(js.indexOf('this.finishImport'));
assert.ok(/const mappingById = \{\};/.test(fi), 'finishImport passes an empty membersMapping');
assert.ok(!/membersMapping\.forEach/.test(fi), 'no per-member wekanId mapping is applied at import');
});
test('import is bounded by a client-side watchdog (no infinite spinner)', () => {
const js = read('client/components/import/import.js');
assert.ok(/Meteor\.setTimeout\(/.test(js) && /import-timeout/.test(js), 'a watchdog surfaces import-timeout');
assert.ok(/Meteor\.clearTimeout\(watchdog\)/.test(js), 'the watchdog is cleared on completion');
});
test('import is bounded on the server by a hard deadline', () => {
const srv = read('models/import.js');
assert.ok(/withDeadline\(/.test(srv), 'importBoard wraps creator.create with a deadline');
assert.ok(/WEKAN_IMPORT_TIMEOUT_MS/.test(srv), 'deadline is configurable');
assert.ok(/'import-timeout'/.test(srv), 'server raises import-timeout');
});
test('the deadline + map-plan helpers are CommonJS (importable + testable)', () => {
assert.ok(/module\.exports = \{ withDeadline \}/.test(read('models/lib/withDeadline.js')));
assert.ok(/module\.exports = \{ planBoardMemberMapping \}/.test(read('models/lib/boardMemberMapPlan.js')));
});
test('the board-scoped map method is board-scoped and no-escalation', () => {
const srv = read('server/importedUserReconciliation.js');
assert.ok(/mapImportedBoardMember\b/.test(srv), 'exposes mapImportedBoardMember');
assert.ok(/planBoardMemberMapping\(/.test(srv), 'authorizes/decides via the pure planner');
// Scoped to one board: card queries and the membership update are keyed by boardId.
assert.ok(/Cards\.find\(\{ boardId, /.test(srv), 'card reassignment is scoped to the board');
assert.ok(/Boards\.direct\.updateAsync\(boardId,/.test(srv), 'only this board\'s members are changed');
// Activities/comments reassignment is also board-scoped.
assert.ok(/\{ boardId, userId: placeholderId \}/.test(srv), 'activities/comments reassignment is board-scoped');
});
test('the sidebar offers mapping only for virtual members, to existing real board users', () => {
const js = read('client/components/sidebar/sidebar.js');
const jade = read('client/components/sidebar/sidebar.jade');
assert.ok(/isImportedMember\(\)/.test(js) && /authenticationMethod === 'imported'/.test(js),
'a virtual-member helper detects imported placeholders');
assert.ok(/if isImportedMember/.test(jade), 'the map action shows only for virtual members');
assert.ok(/'click \.js-map-imported-member': Popup\.open\('mapImportedMember'\)/.test(js), 'opens the picker');
assert.ok(/Meteor\.call\('mapImportedBoardMember'/.test(js), 'picking a target calls the board-scoped method');
// The picker excludes imported placeholders (can only map onto REAL users) and the self.
assert.ok(/authenticationMethod !== 'imported'/.test(js) && /activeMembers\(\)/.test(js),
'targets are existing active real board members only');
});
test('i18n keys for the map action exist', () => {
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
for (const k of ['map-to-existing-user', 'map-to-existing-user-desc', 'map-to-existing-user-none', 'import-timeout']) {
assert.ok(typeof en[k] === 'string' && en[k].length > 0, `missing i18n key: ${k}`);
}
});
console.log(`\n${passed} tests passed`);
+47
View File
@@ -0,0 +1,47 @@
'use strict';
// Unit tests for the generic hang-mitigation deadline wrapper
// (models/lib/withDeadline.js): a long/hung operation must never block forever — the
// wrapper rejects with a timeout error while letting fast operations pass through.
//
// Run: node tests/withDeadline.test.cjs
const assert = require('assert');
const { withDeadline } = require('../models/lib/withDeadline.js');
(async () => {
let passed = 0;
const ok = (name) => { passed += 1; console.log(' ok -', name); };
// Resolves with the value when the promise settles before the deadline.
assert.strictEqual(await withDeadline(Promise.resolve(42), 1000), 42);
ok('passes through a value that settles in time');
// Rejects with the caller's timeout error when the promise never settles.
let err;
try { await withDeadline(new Promise(() => {}), 20, () => new Error('op-timeout')); }
catch (e) { err = e; }
assert.ok(err && err.message === 'op-timeout', 'uses onTimeout() error');
ok('rejects with the custom timeout error when it hangs');
// A default timeout error is used when no onTimeout is given.
err = null;
try { await withDeadline(new Promise(() => {}), 20); }
catch (e) { err = e; }
assert.ok(err && /timeout/i.test(err.message), 'has a default timeout error');
ok('has a default timeout error');
// The promise's own rejection propagates (not masked by the deadline).
err = null;
try { await withDeadline(Promise.reject(new Error('boom')), 1000); }
catch (e) { err = e; }
assert.strictEqual(err.message, 'boom');
ok('propagates the underlying rejection');
// ms <= 0 means "no deadline": just awaits the promise.
assert.strictEqual(await withDeadline(Promise.resolve('x'), 0), 'x');
assert.strictEqual(await withDeadline(Promise.resolve('y'), -5), 'y');
ok('treats a non-positive deadline as no deadline');
console.log(`\nwithDeadline: ${passed} tests passed`);
})().catch(e => { console.error('FAIL', e); process.exit(1); });