Compare commits

...
87 Commits
Author SHA1 Message Date
github-actions[bot] 6b98272272 Bump versions for v12.78 2026-11-02 07:14:42 +00:00
Lauri Ojansivu ee22fb1a3b Prepare v12.78 release 2026-11-02 09:13:43 +02:00
Lauri Ojansivu 154c9a6e52 Updated Changelog. 2026-11-02 09:11:57 +02:00
Lauri Ojansivu 121f37be91 Tests: drawing refit, Due Cards board scope, dependency import counts, and SAML's one Issuer.
Changes since October whose commits lacked a unit, negative or browser
test:
- 2929b61c85: a new drawing refits when its window changes, and is left
  alone once drawn on, edited, or zoomed or resized by hand.
- c64b3a3b1d: Due Cards, in "me" and "all users", shows an assigned-only
  member only their assigned cards; the owner sees them all.
- 8a6aeba7d1: the All Boards dependency import says how many lines were
  added, already there and unmatched.
- 5c50f38e25: a signed SAML Response with two Issuers is refused, which
  node-saml 5.1.0 accepted; the same Response with one Issuer logs in.
The browser specs pass in Chromium.

Thanks to xet7 !
2026-11-02 00:54:59 +02:00
Lauri Ojansivu 79c40407d1 Tests: Admin Panel pane titles, audit log alignment, GCS help, Data health "Never", card reports.
October Admin Panel changes whose commits had no test of their own:
- 85af731c1b and 2017f2871f: the Password length and Audit log panes
  name themselves in the title bar, the audit log has its own URL, and
  its cells align to the start - right in Hebrew - with no left/right
  rule left in its CSS.
- e6f7d226ca: no help line of a storage field is shown twice.
- 77956c98ed: Data health's "Never" is its own key, also when an
  administrator translates the List Sync one differently.
- 69f3358154: the card reports stay instance-wide and admin-only.
tests/playwright/specs/admin-pane-titles-and-help.e2e.js passes in
Chromium.

Thanks to xet7 !
2026-11-02 00:31:24 +02:00
Lauri Ojansivu 148d18d456 Tests: OpenSSL 4 decrypt refusal, the Scrum rollover lease, and the backup catch-up scan.
October changes whose commits had no node test of their own:
- ffe49dd2fd: decryptFile() refuses both the OpenSSL 4 and the OpenSSL 3
  wording of a failed authentication as "changed or the key is wrong",
  on any OpenSSL; an unrelated error (ENOSPC) is passed through.
- 0218cd823f: the rollover and batch-job leases are claimed before every
  write, another server's live lease is refused, and each lease field is
  written in one place in the whole tree.
- 8cbe2605fe: on macOS, a stopped engine and one with watching off run no
  catch-up scan.

Thanks to xet7 !
2026-11-02 00:30:57 +02:00
Lauri Ojansivu 15877dd1bc Source server: a local package fork that a prebuilt package implies is loaded first.
ecmascript implies the local dynamic-import fork, so compiling ddp-server,
listed before it in .meteor/versions, stopped the source-loaded server
with "isopack dynamic-import not yet loaded". The loader now loads the
local package asked for and retries. The source server still stops later,
on "Cannot find module 'meteor/accounts-base'"; the bundle server is what
the browser tests ran against.

Thanks to xet7 !
2026-11-02 00:24:55 +02:00
Lauri Ojansivu 3ccb465c3f Tests: the #6755 browser test focuses the minicard link itself.
Chromium does not leave the focus on the minicard after its menu popup
is closed with Escape - the menu's opener is a link without href, which
cannot take the focus back - so the test asserted a state the browser
had not reached. It now focuses the minicard's link, the state of the
report, and checks that d still opens the Due date editor from there.
Passes in Chromium and WebKit.

Thanks to mimZD and xet7 !
2026-11-02 00:24:55 +02:00
Lauri Ojansivu 71d9776b61 CHANGELOG: the Rules page has a Back to the board.
Thanks to mimZD and xet7 !
2026-11-02 00:07:50 +02:00
Lauri Ojansivu 34d9817110 Rules page: Back to the board.
The Rules page lost its Back with its own sidebar; only the trigger, action
and rule details pages kept one, to the rules list, so the way back to the
board was the Board View menu. The rules list and its Workflow, Bots,
Blocks and History views now have a Back link to the board at the top. The
three sub-pages keep their own Back and show no second one.

Thanks to mimZD and xet7 !

Fixes #6852.
2026-11-02 00:07:42 +02:00
Lauri Ojansivu aa939d7bec Tests: CrossBoardExportBleed and UnassignedAttachBleed are in the security regression inventory.
Both are published on the Hall of Fame, so the inventory counts 143 and
names each one's unit and browser regression tests. CrossBoardExportBleed
was published without being added, which failed the whole node-suite run.

Thanks to xet7 !
2026-11-02 00:05:44 +02:00
Lauri Ojansivu 194e5b0a51 CHANGELOG: UnassignedAttachBleed, and shortcuts after a click on a minicard or a closed popup.
Thanks to Javier García Herrera, mimZD and xet7 !
2026-11-02 00:05:10 +02:00
Lauri Ojansivu 532a96da11 Security: UnassignedAttachBleed - attachment API writes obey assigned-only card scope.
An assigned-only member whose card assignment was removed kept the card and
attachment ids and, through POST /api/attachment/upload and
DELETE /api/attachment/delete/:id, still added a file to that card and
soft-deleted its existing one: both asked only for board write access. The
DDP methods api.attachment.upload/delete, and copy and move onto a target
card over REST and DDP, had the same shape.

mayWriteCardAttachment (server/lib/assignedOnlyAttachments.js) applies the
same card rule the ordinary card Delete already used to all eight handlers.
A refusal is recorded in Admin Panel / Problems as UnassignedAttachBleed
(authz.assigned-attachment-write, medium: never disables the account).
tests/unassignedAttachBleed.test.cjs reproduces the reported sequence and
fails if any attachment API write lacks the card check;
tests/playwright/specs/unassigned-attach-bleed.e2e.js replays it over REST.

GHSA-w26g-rc43-q6cm.

Thanks to Javier García Herrera and xet7 !
2026-11-02 00:04:03 +02:00
Lauri Ojansivu ba0de93f79 Keyboard shortcuts keep working after a click on a minicard or a closed popup.
The shortcut filter refused every key while a link or button had the focus.
The minicard is itself a link, so a click on its date badge or its menu left
that link focused, and closing a popup returns focus to the button that
opened it: hovering a card and pressing d then did nothing until a reload.

A focused control now keeps only the keys it uses itself - Enter for any of
them, Space for a button, summary or checkbox, End/Home/arrows for a tab -
so Space still activates a focused button (audit A01) and every other key
is a shortcut again.

Thanks to mimZD and xet7 !

Fixes #6755.
2026-11-02 00:03:54 +02:00
Lauri Ojansivu 9a15491985 CHANGELOG: sign-in errors kept through a redraw, and the Firefox full-run test fixes.
Thanks to xet7 !
2026-11-01 20:35:06 +02:00
Lauri Ojansivu c8ed3e0fd0 Tests: the bulk export popup is opened until it stays, in all five Haitian Creole tests.
Each test navigates to All Boards and at once opens Export all boards.
All Boards is still drawing just after the route changes, and in
EVERYTHING's full Firefox run the popup opened then was closed again: no
header title to read (the first test; it passed in the run before).

All five tests open the popup again until its title is there. The spec in
Firefox, three times beside the running suite: 15 of 15.

Thanks to xet7 !
2026-11-01 18:56:50 +02:00
Lauri Ojansivu 4a9c3f21d2 Tests: the drawing surface's width is waited for, not read once after its viewBox.
The test waited until the drawing's viewBox had grown past the old 800,
then read the surface's box once. The element's layout follows the
viewBox a frame later, and in EVERYTHING's full Firefox run it was still
800 px against more than 1088 expected (it passed in the run before).

It polls the surface's width as well. The spec in Firefox, three times
beside the running suite: 9 of 9.

Thanks to xet7 !
2026-11-01 18:23:13 +02:00
Lauri Ojansivu 3a2fef4dc7 Sign-in: login errors are rendered from template state, and the SAML error test clicks a finished form.
saml-popup-error's first test failed in EVERYTHING's full Firefox run and
2 of 3 times alone: #login-error-message stayed empty after the SAML
button was clicked with a stub that fails at once.

The test clicked #at-saml (force) as soon as Meteor connected, while the
sign-in form was still drawing: the click landed on a button being
replaced, or - in another attempt - below the window ("Element is
outside of the viewport"), and no handler ran. It now waits for the form,
scrolls the button into view and clicks until the error shows. With one
worker both tests pass 6 of 6 in Firefox against a rebuilt bundle.

Found on the way: #login-error-message sits inside `if isLoading`, and
showLoginError wrote into the element - when that block is drawn again
the element is new and empty, so an error shown just before was lost. The
template now renders the message from a ReactiveVar (loginErrorText) as
escaped text, so a redraw keeps it and it can still never be HTML.
oauthProviders.test.cjs pins the same region, the translated codes and
text-only output in that form.

The other sign-in specs (44 files) pass in Firefox with it, apart from
runs where two copies of a settings-changing spec ran at once.

Thanks to xet7 !
2026-11-01 17:49:21 +02:00
Lauri Ojansivu f241681e80 Tests: background notification recovery checks its own mail, not the first one the sink caught.
The SMTP sink captures every mail the shared test server sends while it
listens. The test waited until user2 had exactly one mail, then read
sink.messages[0] - in EVERYTHING's full Firefox run another worker's
comment notification ("Please look", to another user) had arrived first,
so "Frozen recovery body 0" was looked for in the wrong mail.

It reads the mail addressed to user2. The whole spec in Firefox, twice:
24 passed, 2 conditionally skipped.

Thanks to xet7 !
2026-11-01 17:32:09 +02:00
Lauri Ojansivu c71c875649 Tests: the custom field settings popup is opened until its Create button shows.
The String Template help test opened a card and at once its custom field
settings popup. The opened card was still drawing, and in Firefox under
the whole suite the popup sometimes closed again before Create could be
clicked: ve-CC and rup failed in EVERYTHING's full Firefox run, 42
others passed.

The test opens the popup again until its Create button is there. ve-CC,
rup and en, twice each in Firefox beside the running suite: 6 of 6.

Thanks to xet7 !
2026-11-01 17:06:02 +02:00
Lauri Ojansivu 0da86171e9 CHANGELOG: TODO Later - the whole EVERYTHING run has passed in the browsers, conformance and FerretDB.
Thanks to xet7 !
2026-11-01 14:15:06 +02:00
Lauri Ojansivu b80e21fd48 CHANGELOG: Android bundles start again.
Thanks to xet7 !
2026-11-01 14:11:20 +02:00
Lauri Ojansivu 24a7270b7f Android bundles start again: npm 12 runs argon2's and bcrypt's install scripts.
All four Android bundle jobs failed: WeKan crash-looped on

  No native build was found for platform=android arch=x64 runtime=node
  abi=147 ... loaded from: .../meteor/accounts-password/node_modules/argon2

until the 240 s smoke test gave up. Meteor's npm rebuild had printed
"install scripts blocked because they are not covered by allowScripts",
though prepare-bundle-npm.mjs writes argon2 and bcrypt into every
rebuild directory's allowScripts.

npm 12 applies allowScripts to a project's DECLARED dependencies only.
The package.json that prepare-bundle-npm writes there declared none, so
argon2 and bcrypt were "extraneous" and their scripts stayed blocked -
reproduced with npm 12.2.1 on this release's bundle, and with the two
declared the same rebuild runs both install scripts. Linux, macOS and
Windows never noticed: prebuilds load there, and the Windows jobs compile
the two themselves. Android has no prebuild, and check-addon-arch passed
the Linux ones it cannot load.

prepare-bundle-npm.mjs now declares each reviewed installer a rebuild
directory has installed, at its installed version, and leaves a declared
one as it is. The Android build runs it itself first - the Linux bundle
it rebuilds may predate this - and loads argon2 and bcrypt with Android's
Node.js right after the install, so a missing addon stops the build with
its name instead of a smoke-test timeout.

prepareBundleNpm.test.cjs and androidBundles.test.cjs pin both; they fail
on the code before. Not run on Android here: the build needs Termux on
an Android-CPU runner.

Thanks to xet7 !
2026-11-01 14:11:10 +02:00
Lauri Ojansivu 73ea323da1 CHANGELOG: CrossBoardExportBleed, GHSA-8885-xrq4-j47h.
Thanks to ccsimplyspolit and xet7 !
2026-11-01 14:01:07 +02:00
Lauri Ojansivu be6727607c CrossBoardExportBleed: a checklist scope stays on its board, and every export obeys Disable all export and Anonymize export users.
GHSA-8885-xrq4-j47h, reported by ccsimplyspolit, both findings confirmed
in the current code:

1. GET /api/boards/<board>/export?checklistId=<id> (and exportZip)
   selected the scoped checklist and its items by id alone. A checklist
   of another board - a private one too - was streamed with every item
   and its creator, whose profile the anonymization map did not cover.
   Both selectors are now bound to the exported board's cards, and a
   checklist of another board is recorded in Admin Panel / Problems
   (authz.export-checklist-scope).

2. The streaming CSV/TSV export asked neither Disable all export nor
   Anonymize export users. Neither did, it turned out, Excel (board,
   card, all boards), PDF (board and card), the chart exports, the
   calendar feed or the external formats: only the WeKan JSON, ZIP and
   Trello exports did, though the setting promises that "the server
   rejects any export request" and names CSV and Excel.
   - refuseDisabledExport() answers 403 before anything is written, and
     all 13 HTTP export routes call it first; assertExportEnabled() still
     guards the builders. Both record the attempt (authz.export-disabled,
     never blocking the account: a page opened before the switch still
     shows the menus).
   - anonymizeExportIdentities() anonymizes what one exporter writes -
     users, @mentions, requested-by / assigned-by, comments - with the map
     build() uses, board members included. The CSV/TSV stream, Excel,
     PDF, chart exports (forExport: the chart views keep names), the
     calendar feed and every external format call it.

tests/crossBoardExportBleed.test.cjs drives the gate and the anonymizer
and scans the tree: no scoped checklist selected by id alone, no export
route without the gate, no exporter naming people without anonymizing.
All 7 fail on the code before. The new spec
security-cross-board-export.e2e.js replays the reporter's requests and
checks every export route while export is disabled and the CSV and
Kanboard exports while anonymizing; it passes with export-access and
scrum-export-anonymization against a rebuilt bundle.

Thanks to ccsimplyspolit and xet7 !
2026-11-01 14:00:19 +02:00
Lauri Ojansivu 9c5fa760c3 CHANGELOG: glob 14.0.3, checked: no WeKan code loads it, no telemetry, tarballs match the lockfile.
Thanks to dependabot and xet7 !
2026-11-01 13:41:40 +02:00
Lauri Ojansivu 27040c6f2f Merge pull request #6851 from wekan/dependabot/npm_and_yarn/npm_and_yarn-0e36e05768
Bump glob from 13.0.6 to 14.0.3 in the npm_and_yarn group across 1 directory
2026-11-01 13:36:11 +02:00
Lauri Ojansivu c15e1ab4b2 Tests: the Card Settings splitter is dragged once the popup holds still.
The splitter test measured the splitter straight after opening Card
Settings, while the popup was still placing itself. In WebKit the press
landed where the splitter had been and dragged nothing: the pane stayed
526 px against more than 606 expected (the first attempt in EVERYTHING's
WebKit stage; the retry passed).

It now measures once two reads of the splitter and the panes 150 ms apart
agree. WebKit 5 of 5, Chromium 3 of 3.

Thanks to xet7 !
2026-11-01 11:58:06 +02:00
Lauri Ojansivu b676cdc169 CHANGELOG: the WebKit date-range refusal and the language and push fixes of the browser tests.
Thanks to xet7 !
2026-11-01 11:25:20 +02:00
Lauri Ojansivu 4ad13db5ef Filters: the date-range forms are checked by WeKan, not refused by WebKit after Clear.
After Clear emptied a refused range, WebKit kept a half-edited date
segment in the From field and fired `invalid` on it at the next submit -
though its validity read valid - so the form never submitted and the new
range never applied, with no message (card-date-range.e2e.js in WebKit,
also on retry: 0 submit events, `invalid` on .js-card-date-from). The old
Clear handler emptied the fields the same way, so this was there before.

Both range forms are novalidate now. The submit handler already decides
whether a range is valid (rangeRefused), marks the end date invalid with
the reason and says it in the form, so nothing is lost.

filterDateRangeRefusal.test.cjs pins novalidate on both forms. Against a
rebuilt bundle card-date-range and card-movement-filter pass twice each
in WebKit, Firefox and Chromium.

Thanks to xet7 !
2026-11-01 11:24:59 +02:00
Lauri Ojansivu efe0e9287c Tests: a language switch is waited for, and WebKit does not freeze on asking for a push subscription.
Two causes behind most of EVERYTHING's WebKit failures so far, both in the
test pages rather than in WeKan:

- 94 specs switch language with setLanguage and then click at once. The
  client loads that language afterwards and draws everything again, which
  closes a popup opened before that: the board view popup of the flow and
  report help specs (Latin monte-carlo in WebKit; Corsican and Scottish
  Gaelic in earlier Firefox runs) had no entry left to click. The
  fixture's context now wraps Meteor.callAsync in each page so that a
  setLanguage call resolves once <html lang> - which client/lib/i18n.js
  sets in the same flush that redraws - names the language. It waits at
  most 10 s and then resolves anyway, so it can only make a spec patient,
  never fail one. A page of a context a spec makes itself uses
  untilLanguageShown().
- Playwright's WebKit freezes the page's main thread on
  PushManager.getSubscription(): after it not even a timer runs. Member
  Notification Settings asks it on opening, to show whether this device
  gets push (client/lib/webPush.js), so every WebKit spec that opened it
  hung in its next click - notification-activity-mute and the member
  scope of notification-delivery-settings, in every language. In WebKit
  the page now answers as a device with no subscription does. Chromium,
  Firefox and Safari answer the real call; no spec subscribes.

WebKit: those notification specs 10 of 10, and the Latin, Corsican and
Product Backlog specs 13 of 13; Chromium and Firefox 11 of 11 each with
the same subset. playwrightPageInit.test.cjs runs both page scripts on a
fake page: setLanguage waits for the language and keeps its result,
other methods are not held, a page whose Meteor arrives later is
wrapped, the wait is capped; WebKit's getSubscription answers null and
Chromium's and Firefox's are left alone; the fixture installs both.

Thanks to xet7 !
2026-11-01 11:21:45 +02:00
Lauri Ojansivu cfcead6a1a CHANGELOG: the Firefox fixes - date range refusal, history Retry, whiteboard line width - and the browser test fixes.
Thanks to xet7 !
2026-11-01 09:23:34 +02:00
Lauri Ojansivu 40f60582b7 Whiteboard: a line is drawn at its own width, not the whiteboard's 800.
Inside `each strokes` the template drew stroke-width=width, and Spacebars
looks a name up among the template's helpers before the line's own
fields: the width helper is the drawing's width, so every line was drawn
800 wide - one black shape over the whole whiteboard, in every browser.
The lines are mapped with strokeWidth now.

whiteboard.test.cjs: every name the line's path uses is a field of the
line and none is a helper of the template, which would win - it fails on
the code before. whiteboard.e2e.js checks each drawn path is the pen's
width (4) in both browsers.

The same spec drew the second person in another context of the same
browser. In Firefox two contexts share the pointer capture the first pen
takes: one line zig-zagged between both paths and the other page drew
nothing, 4 of 4. The second person now has a browser of their own, as
two people do; both pens are still down at once. Firefox 6 of 6 and
Chromium 4 of 4 against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu 96845f6f4b History recovery: Retry is enabled only once the board lets this person write on it.
After a reload the notice for an unanswered keyboard undo or redo shows
as soon as its board is open - before the board and its members have
arrived. undoRedoLast does nothing while Utils.canModifyBoard() is false,
so a Retry clicked in that moment was dropped without a word and the
notice stayed (history-recovery-notice.e2e.js, Firefox).

Retry is disabled until the retry can act; Forget needs no right and
stays usable. For someone who may only read the board Retry stays
disabled, as it could never do anything for them.

historyRecoveryNotice.test.cjs pins the binding and the guard it mirrors,
and that Forget is not gated - it fails on the code before. The spec
waits for Retry to be enabled before it clicks; the spec passes in
Firefox against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu c5f5ad2338 Filters: a refused date range says why in the form, so Clear still works in Firefox.
The filter sidebar's two date-range forms (card dates, card movement)
reported a reversed range with reportValidity(). Firefox opens that
bubble just under the end date - over the Apply and Clear buttons - and
while the field has focus it takes every click on them: no mousedown and
no click reached the page, twice in a row, so a refused range could not
be cleared at all (card-date-range.e2e.js, 4 of 4 in Firefox; with the
bubble suppressed it passed).

The end date is still marked invalid with the reason (setCustomValidity,
for :invalid and screen readers), and the reason is shown in the form as
a role="alert" line under the buttons. Typing in the range or Clear takes
it back.

filterDateRangeRefusal.test.cjs: both forms report through rangeRefused,
show the line and take it back on input and Clear, and nothing in the
filter sidebar calls reportValidity() - it fails on the code before. The
card-date-range and card-movement-filter specs check the line appears
with the reason, is gone after Clear or an edit, and is absent for an
accepted range; both pass in Firefox against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu 0c8c254dc5 Tests: the viewport matrix cleans up only what it seeded.
viewport-matrix is Chromium-only (device emulation), so in Firefox and
WebKit every test of a viewport is skipped and its beforeAll never runs -
but its afterAll still does, and read board.boardId of a board that was
never seeded: each of the twelve viewports failed in EVERYTHING's Firefox
stage, on the last test of the group, in 0 ms.

afterAll returns when nothing was seeded. Firefox: 132 skipped, none
failed. Chromium: phone-portrait 11 of 11, and its board is cleaned up.

Thanks to xet7 !
2026-11-01 09:09:14 +02:00
Lauri Ojansivu 0a1f8584ee Tests: the board view popup is opened until it stays open for the Product Backlog.
The test switches language and then opens the board, and when the language
arrives the board header draws again and closes a popup opened before
that. In Firefox the click on the board view toggle often came first: no
popup at all, and no Product Backlog entry to open - 2 of 6 runs alone,
the st variant in EVERYTHING's Firefox stage.

The test opens the popup again until its Product Backlog entry is there.
9 of 9 in Firefox (st, en, haw, three times each).

Thanks to xet7 !
2026-11-01 09:01:09 +02:00
Lauri Ojansivu 2042fd5102 Tests: the Blocks drag test grabs the block's body, not its edge.
In Firefox a Khmer label overflows its Blocks block and widens the box the
test measures, so the first point of the block it found, scanning from the
left, was the block's outline - and a press there dragged nothing: km and
km-KH failed every time (4 of 4), every other language passed.

The grab point is now one with the block's own body on both sides of it.
The whole spec in Firefox: 104 of 104, Khmer, Georgian, Fijian,
Kinyarwanda (the long labels the search was written for) and Arabic
right-to-left among them.

Thanks to xet7 !
2026-11-01 08:23:41 +02:00
Lauri Ojansivu 0403e27c62 Tests: the notification delivery sections are opened until they stay open.
The member-scope test switches language just before it opens Notification
Settings, and when the language arrives the popup draws again with every
section closed. Under a whole-suite run in Firefox that came after the
click on the section's summary, so the text checks passed and the select
the test then used was hidden: vi and zh_SG failed, 101 others passed,
and vi passed 3 of 3 alone.

expand() opens the section when it is closed and waits until its content
shows, again if a redraw closes it. vi, zh_SG, en and fi twice each, and
the board-scope, webhook-form and tray tests: all pass.

Thanks to xet7 !
2026-11-01 08:04:55 +02:00
Lauri Ojansivu d9e48a909d Tests: the list-sync specs' mock servers are reachable from WeKan when the browsers run in Docker on macOS.
The list-sync specs answer as Jira or GitLab from a server in the test
process, and WeKan's server fetches from it. With the browsers in Docker
on macOS that server was on the container's loopback, which Docker
Desktop keeps from the host: "fetch failed", and every test of
list-sync-estimate, -planning, -source and -time-estimates failed in
EVERYTHING's Firefox stage.

build.sh already forwarded the test SMTP port from the host into the
container through docker exec; the same forwarder carries any TCP. On
macOS it now also forwards a small pool, 39101-39104 by default
(WEKAN_TEST_REVERSE_PORTS), and names it to the specs, which listen
through helpers/reachable-server.cjs: the first free port of the pool,
or any port when there is none, as before. Linux forwards nothing.

30 of 30 of those specs pass in Firefox in Docker now.
playwrightReachableServer.test.cjs: the pool is read strictly, a port in
use is skipped, no pool listens anywhere, and when every forwarded port
is taken it fails clearly instead of listening where WeKan cannot reach.
playwrightDockerHost.test.cjs: macOS forwards the SMTP port and the pool
(or the one configured), names it to the container and removes its ready
files; Linux forwards nothing.

Thanks to xet7 !
2026-11-01 07:50:40 +02:00
Lauri Ojansivu 0cea141702 Tests: the duplicate relation picker is opened once the card has opened.
The test clicked Add dependency straight after the minicard. Under a
whole-suite run in Firefox that click landed while the card was still
opening, and the picker it opened was closed again when the card's route
and details settled - no popup, and the wait for its type select ran out
(the ve-PP variant; 59 of 60 passed, and it passed 3 of 3 alone).

The test waits for the opened card, then opens the picker again until its
type select shows. 6 of 6 (en, ve-PP, tlh, twice each) beside the suite.

Thanks to xet7 !
2026-11-01 07:21:43 +02:00
Lauri Ojansivu 9188843369 Tests: the checklist drag tests drag once the opened card's rows hold still.
The opened card draws the sections above its checklist for a moment after
it opens, and they push the rows down: in Firefox they moved 50 px between
the test measuring them and releasing the mouse. With handles off the
release landed on item 1's own title and opened its editor, so the titles
read 2,3 (EVERYTHING's Firefox stage); with handles on the enabled drag
dropped item 1 between 2 and 3. 2 of 6 in a row failed that way.

drag() waits until two reads of the rows' positions 150 ms apart agree,
then measures and drags. 10 of 10, both variants five times, while the
whole suite ran beside it.

Thanks to xet7 !
2026-11-01 06:57:18 +02:00
Lauri Ojansivu dbf07aadf9 Tests: the activity recovery report is searched again until no worker lease covers it.
The report is a snapshot taken when the search runs, and the live
one-second recovery worker leases each pending intent while it looks at
it. A search made during that lease shows the seeded orphan as "Delivery
reservation active" - correct - and the page keeps showing it, so the
wait for "Original activity is missing" ran out in EVERYTHING's Firefox
stage (the en variant).

The test searches again until a snapshot falls between leases, up to 30 s,
and then asserts the same status and the disabled Retry. 10 of 10 in
Firefox, every test twice, while the whole suite ran beside it.

Thanks to xet7 !
2026-11-01 06:40:42 +02:00
Lauri Ojansivu e001c4f9c2 CHANGELOG: the Docker test proxy redials an unanswered connect.
Thanks to xet7 !
2026-11-01 06:23:55 +02:00
Lauri Ojansivu ffe207e191 Tests: the Docker test proxy dials the host again when a connect is never answered.
Even on the bridge network, EVERYTHING's Firefox stage lost a test now and
then to a page whose document never came (RuleBleed's cross-board test,
Red Strings, shared templates). Inside the container the cause was in
plain sight: a connect to host.docker.internal:3000 in SYN_SENT after nine
retransmits, while the next connection went straight through. Through the
container's proxy 9 of 1000 requests went unanswered; from the Mac itself
none did.

The proxy no longer waits on such a connect. It reads nothing from the
browser until a connection to the host is up (pauseOnConnect), drops a
dial that has not connected in 1.5 s - a normal one takes milliseconds -
and dials again, five times, so no byte is lost or sent twice. If none
connects the browser's connection is closed rather than left open.

playwrightDockerHostProxy.test.cjs: two unanswered dials, then one that
connects - the request arrives once and is answered, and the abandoned
dials are closed; when every dial is unanswered the client is closed after
exactly the attempts, in well under the old 30-60 s wait.

Thanks to xet7 !
2026-11-01 06:23:34 +02:00
Lauri Ojansivu b2198cb61b CHANGELOG: the Firefox stage's Docker network, host paths and phone invitation test.
Thanks to xet7 !
2026-11-01 06:10:30 +02:00
Lauri Ojansivu a6bec88266 Tests: the phone invitation tile is measured in one layout with its buttons.
43-mobile-allboards read the invitation tile's box, then each control's
box, one round trip at a time. Firefox draws the row above the board list
later than Chromium, and in between it moved the whole list down 29.4 px:
the Decline button (1102-1162) was compared with where the tile had been
(853-1139) and failed, 2 of 2, in EVERYTHING's Firefox stage. The tile was
never clipped - settled, it runs 882-1168 around all three controls.

The tile and its message and two buttons are measured in one layout now,
polled until they hold, and a missing or zero-height control fails too.
Firefox: 3 of 3. With the original #6488 bug put back (the tile forced to
4rem) it still fails, naming the message and both buttons.

Thanks to xet7 !
2026-11-01 06:10:03 +02:00
Lauri Ojansivu 8a1f7a14a9 Tests: the Playwright container sees the repo and the files root at their own host paths.
A spec and the server share files by path. admin-continuous-backup gives
the server a key file it wrote under .tools/tmp, and reads the stream.json
the server wrote to its target - and with the repository mounted at /repo
and the files root at /wekan-files, every such path named a file only one
side had: "The encryption key file cannot be read", and no stream.json, in
both of its tests in EVERYTHING's Firefox stage (the only browser there that
runs in Docker on this Mac, and on Linux arm64 every browser does).

Both are mounted at the path they have on the host now, the specs are told
that path in WEKAN_FILES_PATH, and the in-container script finds the proxy
and the Playwright binaries from its working directory. Both tests pass in
the container now.

playwrightDockerHost.test.cjs drives run_playwright_docker on macOS and
Linux: the repo and the files root are mounted at their own paths,
WEKAN_FILES_PATH and HOME follow, and the function names no container-only
path. It fails on the code before.

Thanks to xet7 !
2026-11-01 06:08:05 +02:00
Lauri Ojansivu 7f948bf438 Tests: the macOS Playwright container uses Docker's bridge network, not the host network.
Three Firefox tests of one EVERYTHING run waited the full 60 s in
waitForMeteor: their page's own document never arrived. Measured from
containers during that run, on Docker Desktop for macOS:

- host network, through the WeKan proxy:          3 of 600 requests lost
- host network, straight to host.docker.internal: 8 of 600 connects never
  completed
- bridge network, through the same proxy:          0 of 1000
- from the Mac itself:                             0 of 300

So it is Docker Desktop's host network, not WeKan and not the proxy. On
macOS nothing needs that network - WeKan and MongoDB are reached through
host.docker.internal and SMTP through docker exec - so the container runs
on the bridge network, where the proxy still serves localhost:3000 and the
browser keeps ROOT_URL's origin. Linux keeps the host network, which is
the real one there.

playwrightDockerHost.test.cjs drives run_playwright_docker with uname
stubbed: Darwin gets bridge and the proxy, Linux gets host and none, and
the function names no network of its own. It fails on the code before.

Thanks to xet7 !
2026-11-01 06:06:04 +02:00
Lauri Ojansivu 6d0ab6cf11 CHANGELOG: continuous backup, single-file builds and the EVERYTHING test fixes.
The seven commits since the Dependabot entries: two bug fixes (continuous
backup watching before its first scan; single-file Mach-O signing and
start/backup waiting for WeKan) and five test-run fixes, grouped by topic.
The short changelog guard is a group of its own now, so the developer-
tooling subsection is grouped throughout. CHANGELOG.md regenerated.

Thanks to xet7 !
2026-11-01 05:47:52 +02:00
Lauri Ojansivu 89b64966c6 Tests: the password minimum specs wait until the server has the policy they wrote.
password-min-length-signup-login.e2e.js writes the password policy straight
into the settings document. The server learns it from its settings observer,
which polls on the standalone test MongoDB EVERYTHING runs: for up to ten
seconds it still answered the old policy. A sign-up form loaded meanwhile kept
the old minimum (6 instead of 10), and an administrator's Set password was
refused under the minimum the test before had left enforced - two failures in
EVERYTHING's Chromium stage that never showed on a dev server, whose MongoDB
is a replica set.

untilServerHas() asks the server (getPasswordPolicy) until it has the policy
the test wrote; the sign-up form is loaded again after it. 6 of 6 with two
workers, each test twice.

Thanks to xet7 !
2026-11-01 05:01:18 +02:00
Lauri Ojansivu d5e3325e87 Tests: Card Settings section order waits for the published board before reading the card.
"with every field allowed, the card draws exactly the Card pane's
sections" writes the board's Scrum visibility straight to the database and
reloads. The board publication's cursor has limit: 1, which Meteor's
change-stream driver does not follow, so it polls: a reload could still be
served the board as it was for up to ten seconds, and the card drew no
Scrum section - failing every time in EVERYTHING's Chromium stage. Live in
the browser the update arrived after 9.8 s; in the app itself a write
through a method re-polls at once, so only this direct write waited. The
test waits for the published board (up to 15 s) before it reads the card.

Thanks to xet7 !
2026-11-01 03:27:46 +02:00
Lauri Ojansivu 897376ba54 Tests: the Board Settings button theme test checks the settings that still have action buttons.
button-theme-foreground.e2e.js opened Board Settings / Swimlane, List and
Card and asked each for its column-actions buttons. Card Settings has had
none since 3e5bc58105 ("Card Settings: a checkbox per section, ...":
card actions live in the card's own section hamburgers), so every theme
failed on Card, in EVERYTHING's Chromium stage. Swimlane and List are
checked, in all four themes, and the test says why Card is not.

Thanks to xet7 !
2026-11-01 03:17:38 +02:00
Lauri Ojansivu 16103f9ad2 Tests: the whole-suite run keeps its test database in .tools/, not beside the checkout.
build.sh and build.bat started the test mongod on :3001 with its dbpath
in the checkout's parent directory (../mongodb-test-3001). That is the
one thing in a test run that was written outside the repository, against
the rule that everything WeKan's scripts write stays in .tools/ - and a
sandboxed editor, which may write only the repository, could not create
it, so EVERYTHING's browser stages had no database to run against. It is
.tools/mongodb-test-<port> now, which git and Meteor already ignore.

toolsDirOnly.test.cjs pins the new path in both scripts and fails on a
test database beside the checkout.

Thanks to xet7 !
2026-11-01 02:51:41 +02:00
Lauri Ojansivu 8cbe2605fe Continuous backup: files are watched before the first scan, and a macOS start catches up once.
The file engine scanned its sources first and started watching them
after, so a change made between the two - after the scan had passed a
file, before the watcher began - waited for the next scan, five minutes
by default. And macOS reports file events through FSEvents, which
delivers nothing for a moment after a watcher is created: an EVERYTHING
run on a loaded Mac lost a log line appended right after the start
(tests/integration/continuousBackup.test.cjs, "the appended log line was
streamed by watching", 4 !== 5).

The watchers start first now; a change seen by the scan and a watcher
too is recorded once, as they share one queue. On macOS one catch-up
scan runs two seconds after the start. Not elsewhere: inotify is
watching when fs.watch returns, and a second full scan of a large store
would double the start-up cost.

A new test, on macOS and Linux, with a watcher that never reports:
watching starts before the first scan; on macOS the catch-up scan
records a change made right after the start; on Linux no second scan
runs. It fails on the code before this change.

Thanks to xet7 !
2026-11-01 02:42:03 +02:00
Lauri Ojansivu f5eb4ecf61 Mac smoke test: a process group's live members come from libproc, not ps.
When macOS refuses to signal a process group (EPERM, which it returns for
a group of zombies), the smoke test checked with `ps` whether any member
was still alive. A Mac that will not let the build run `ps` - "Operation
not permitted", as here, where EVERYTHING's node suites run - failed the
check itself, and with it a correct app: macAppPackaging.test.cjs, every
EVERYTHING run on this machine.

On macOS it asks the kernel through libproc now (proc_listpids for the
group, proc_pidinfo for each member: a zombie's info cannot be read, a
live one's status is not SZOMB), with no process started. Elsewhere it
uses ps exactly as before.

tests/mac-smoke-app.py: the ps cases run as the non-macOS path; on macOS
an EPERM asks libproc and never ps; and a real group - a live child, then
a zombie leader alone - is told apart. macAppPackaging.test.cjs passes,
three runs in a row.

Thanks to xet7 !
2026-11-01 02:32:31 +02:00
Lauri Ojansivu 8d4f4caa2f Single file: a macOS build is signed whatever its target is called, and start/backup return once WeKan runs.
EVERYTHING's node suites run under Meteor's Node.js, which has single
executable applications, so singleFile.test.cjs built and ran a real
single executable - and it failed twice:

- The builder ad-hoc signed only a target named mac*/darwin*. Built from
  this Mac's Node.js under another label (the test builds 'amd64'), the
  file is still a Mach-O binary, the injected archive had invalidated its
  signature, and macOS killed it (SIGKILL) before it printed anything:
  status null. It signs every Mach-O output now, read from the file's
  magic number.
- `start --background` and `backup` returned as soon as they had spawned
  WeKan, before it wrote its pid file - which it does only after reading
  its settings and extracting FerretDB. A `stop` right after them said
  "WeKan was not running" and left it running. Both wait now
  (status.waitUntilStarted) until the pid file names the process they
  started, give up at once if it ended, and say so if it did not start.

singleFile.test.cjs: 24 of 24 under Meteor's Node.js, the real single
executable included; a new test pins waitUntilStarted, and that both
commands use it.

Thanks to xet7 !
2026-11-01 02:23:36 +02:00
dependabot[bot] 9a74138a84 Bump glob
Bumps the npm_and_yarn group with 1 update in the / directory: [glob](https://github.com/isaacs/node-glob).


Updates `glob` from 13.0.6 to 14.0.3
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v13.0.6...v14.0.3)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 14.0.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 23:51:54 +00:00
Lauri Ojansivu fa7e447536 CHANGELOG: ten Dependabot updates, checked; node-saml 5.2.0 where logins run; @smithy/core 3.38.1.
Thanks to dependabot and xet7 !
2026-10-31 23:06:40 +02:00
Lauri Ojansivu 05cc2460ea Tests: the short changelog guard holds between releases too.
changelogShort.test.cjs asserted an Upcoming section always, and one
Upcoming in CHANGELOG.md - but right after a release there is none until
the next real entry (CLAUDE.md, Making a release), so it failed after
every release, v12.77 included. With no Upcoming it checks that the
newest release is first and that nothing links an Upcoming that does
not exist; with one, what it checked before.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu 5c50f38e25 SAML logins are verified by @node-saml/node-saml 5.2.0, as package.json says.
Dependabot's 5.1.0 -> 5.2.0 bump (#6850) changed the app's package.json,
but a SAML login is verified by the wekan-accounts-saml package's own
copy, pinned by its Npm.depends to 5.1.0 - so nothing that runs changed.
5.2.0 is a hardening release a login relies on: it rejects a message
with more than one Issuer, checks idpIssuer on the Response and its
assertion, rejects a parsed query whose RelayState is not the signed
one, and checks a POST LogoutResponse's status and issuer.

The package pins 5.2.0 now. samlLogin.test.cjs required '5.1.0' in one
place and '^5.1.0' in the other; it requires the package and package.json
to name the SAME version, and at least 5.2.0, so a bump of one alone
fails.

A login roundtrip with the package's own copy (node-saml 5.2.0,
xml-crypto 6.4.0) and WeKan's options: a signed Response and a signed
Assertion log in; a NameID changed after signing, a signature by another
key, an unsigned response, and an answer to a request WeKan never made
are refused. saml-popup-error.e2e.js completes a real signed login in
the browser.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu 338afc15f8 @smithy/core 3.38.1: the AWS SDK's logger redaction fix, and the telemetry gate passes again.
The @aws-sdk/client-s3 3.1159.0 bump (#6844) resolved @smithy/core to
3.37.0, which npm marks deprecated: "a bug in custom logger data
redaction" (aws/aws-sdk-js-v3#8393). With a custom logger passed to an
AWS client, sensitive fields nested in a list or map reached the logger
unredacted. @smithy/core 3.24.0 to 3.38.0 have it, so 3.36.2 before the
bump did too; 3.38.1 is the fix.

WeKan's only AWS client is the S3 backup (server/methods/backup.js), and
it passes no logger, so nothing was logged. Updated all the same, in the
lockfile only and within the ranges the SDK already allows - one package,
nothing else moved. Its tarball matches the lockfile's integrity hash,
and compared with 3.37.0 it adds no network host, telemetry, process
spawning or install script. A multipart upload to a local S3 stand-in
still arrives signed AWS4-HMAC-SHA256, whole.

releases/check-telemetry.py --source failed on it: the deprecation text
links github.com, a URL origin new to package-lock.json. It passes now.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu dd2082b0ea Merge pull request #6846 from wekan/dependabot/npm_and_yarn/isomorphic-git-1.44.8
Bump isomorphic-git from 1.44.3 to 1.44.8
2026-10-31 22:39:27 +02:00
Lauri Ojansivu 4a86e9c19e Merge pull request #6850 from wekan/dependabot/npm_and_yarn/node-saml/node-saml-5.2.0
Bump @node-saml/node-saml from 5.1.0 to 5.2.0
2026-10-31 22:38:24 +02:00
dependabot[bot] 568cd9b6ec Bump isomorphic-git from 1.44.3 to 1.44.8
Bumps [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) from 1.44.3 to 1.44.8.
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.44.3...v1.44.8)

---
updated-dependencies:
- dependency-name: isomorphic-git
  dependency-version: 1.44.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:37:49 +00:00
Lauri Ojansivu fea468471d Merge pull request #6849 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.72.1
Bump @typescript-eslint/eslint-plugin from 8.72.0 to 8.72.1
2026-10-31 22:37:36 +02:00
Lauri Ojansivu 83199b9af7 Merge pull request #6848 from wekan/dependabot/npm_and_yarn/rspack/dev-server-2.2.2
Bump @rspack/dev-server from 2.2.1 to 2.2.2
2026-10-31 22:37:10 +02:00
Lauri Ojansivu 3839ee698d Merge pull request #6845 from wekan/dependabot/npm_and_yarn/mailparser-3.12.4
Bump mailparser from 3.12.2 to 3.12.4
2026-10-31 22:36:48 +02:00
dependabot[bot] a60b996c92 Bump @node-saml/node-saml from 5.1.0 to 5.2.0
Bumps [@node-saml/node-saml](https://github.com/node-saml/node-saml) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/node-saml/node-saml/releases)
- [Changelog](https://github.com/node-saml/node-saml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-saml/node-saml/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: "@node-saml/node-saml"
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:36:39 +00:00
Lauri Ojansivu 1230164c16 Merge pull request #6847 from wekan/dependabot/npm_and_yarn/i18next-26.5.11
Bump i18next from 26.5.4 to 26.5.11
2026-10-31 22:36:29 +02:00
dependabot[bot] 8008f410fe Bump @typescript-eslint/eslint-plugin from 8.72.0 to 8.72.1
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.72.0 to 8.72.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.72.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:36:27 +00:00
Lauri Ojansivu bd90782da2 Merge pull request #6844 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1159.0
Bump @aws-sdk/client-s3 from 3.1155.0 to 3.1159.0
2026-10-31 22:35:45 +02:00
dependabot[bot] 5eadc45563 Bump @rspack/dev-server from 2.2.1 to 2.2.2
Bumps [@rspack/dev-server](https://github.com/rstackjs/rspack-dev-server) from 2.2.1 to 2.2.2.
- [Release notes](https://github.com/rstackjs/rspack-dev-server/releases)
- [Commits](https://github.com/rstackjs/rspack-dev-server/compare/v2.2.1...v2.2.2)

---
updated-dependencies:
- dependency-name: "@rspack/dev-server"
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:35:25 +00:00
Lauri Ojansivu 3785a13f6a Merge pull request #6842 from wekan/dependabot/npm_and_yarn/imapflow-2.9.1
Bump imapflow from 2.8.0 to 2.9.1
2026-10-31 22:35:18 +02:00
Lauri Ojansivu 97cabdeca4 Merge pull request #6843 from wekan/dependabot/github_actions/helm/chart-releaser-action-1.8.0
Bump helm/chart-releaser-action from 1.7.0 to 1.8.0
2026-10-31 22:34:58 +02:00
Lauri Ojansivu 2802326888 Merge pull request #6841 from wekan/dependabot/github_actions/docker/login-action-4.7.0
Bump docker/login-action from 4.6.0 to 4.7.0
2026-10-31 22:34:17 +02:00
dependabot[bot] 1f46af0639 Bump i18next from 26.5.4 to 26.5.11
Bumps [i18next](https://github.com/i18next/i18next) from 26.5.4 to 26.5.11.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.5.4...v26.5.11)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.5.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:51 +00:00
dependabot[bot] 1bcb1da92f Bump mailparser from 3.12.2 to 3.12.4
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.12.2 to 3.12.4.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.12.2...v3.12.4)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:31 +00:00
dependabot[bot] 067efac7f8 Bump @aws-sdk/client-s3 from 3.1155.0 to 3.1159.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1155.0 to 3.1159.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:21 +00:00
dependabot[bot] 2bdd16060e Bump imapflow from 2.8.0 to 2.9.1
Bumps [imapflow](https://github.com/postalsys/imapflow) from 2.8.0 to 2.9.1.
- [Release notes](https://github.com/postalsys/imapflow/releases)
- [Changelog](https://github.com/postalsys/imapflow/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/imapflow/compare/v2.8.0...v2.9.1)

---
updated-dependencies:
- dependency-name: imapflow
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:54 +00:00
dependabot[bot] 433bc62211 Bump helm/chart-releaser-action from 1.7.0 to 1.8.0
Bumps [helm/chart-releaser-action](https://github.com/helm/chart-releaser-action) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/helm/chart-releaser-action/releases)
- [Commits](https://github.com/helm/chart-releaser-action/compare/cae68fefc6b5f367a0275617c9f83181ba54714f...df287104677278f869ca5480bd260358397cc3f9)

---
updated-dependencies:
- dependency-name: helm/chart-releaser-action
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:54 +00:00
dependabot[bot] 29f9c62a64 Bump docker/login-action from 4.6.0 to 4.7.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:36 +00:00
github-actions[bot] 21766cf38d Bump versions for v12.77 2026-10-31 19:47:43 +00:00
Lauri Ojansivu 101cdb7579 Prepare v12.77 release 2026-10-31 21:46:40 +02:00
Lauri Ojansivu 54cf951d9c Merge remote-tracking branch 'origin/main' 2026-10-31 21:46:38 +02:00
Lauri Ojansivu 57a43ee8e1 CHANGELOG: the date-popup guards follow movable popups and the date picker's empty cells.
Thanks to xet7 !
2026-10-31 21:44:13 +02:00
Lauri Ojansivu ff2c0ed7f6 Tests: the date-popup guards follow movable popups and the date picker's empty cells.
Two node guards still described v12.76's behaviour before it changed on
purpose. calendarPopupResize asserted that a popup without a date editor
does not drag; every popup drags by its title bar now (#6839), and with
drag handles on only by its handle - both pinned instead, its mock no
longer answering every selector as if a drag handle were there.
calendarTodayMarker picked a month's days with filter(Boolean), which an
empty cell passes now that it is an object with its own _id (#6838); it
skips the cells marked empty.

Thanks to xet7 !
2026-10-31 21:44:11 +02:00
129 changed files with 3915 additions and 504 deletions

No files matched your search

+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -32,6 +32,6 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
uses: helm/chart-releaser-action@df287104677278f869ca5480bd260358397cc3f9 # v1.8.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+527 -7
View File
@@ -109,13 +109,6 @@ its reason.
<details>
<summary>Need the running app to reproduce/verify</summary>
- The browser test fixes of 2026-10-27 are not verified in a browser yet: the
second EVERYTHING run was stopped during its build, before any browser
stage. The first run's Chromium stage reached 3212 of 3703 tests, and every
failure it reported is fixed in the source; Firefox, the database
conformance runs and FerretDB's own suites did not run. Run `./build.sh`'s
EVERYTHING option again. The stopped build had started reinstalling
`node_modules`, so a clean install comes first.
- The WebKit stage cannot run in the Flatpak sandbox: MiniBrowser needs its
own sandbox, and turning that off is not allowed there. Run it on a host.
- [#6834](https://github.com/wekan/wekan/issues/6834) (the minicard due date is fixed; the board header bar wrapping to a second row and the list header icons under the card count did not reproduce on the current code at the same window width, in Persian, with the card count on - which WeKan release and browser zoom showed them is asked; wrapping the header rather than hiding buttons is by design)
@@ -131,6 +124,533 @@ its reason.
</details>
# v12.78 2026-11-02 WeKan ® release
**In short:** **CrossBoardExportBleed** and **UnassignedAttachBleed** are
fixed: a checklist scope stays on its board, every export obeys Disable all
export and Anonymize export users, and an assigned-only member changes
attachments only on their own cards. **SAML** logins use **node-saml 5.2.0**,
and **continuous backup** no longer misses a change made right after it starts.
**Keyboard shortcuts** keep working after a popup closes, and the Rules page has
a way back to the board.
This release fixes the following CRITICAL SECURITY ISSUES:
**Exports** - what a board export may contain, and whether it runs at all.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/be6727607c">A checklist scope stays on its board, and every export obeys Disable all export and Anonymize export users</a>. Thanks to ccsimplyspolit and xet7.</summary>
[CrossBoardExportBleed](https://wekan.fi/hall-of-fame/crossboardexportbleed/),
[GHSA-8885-xrq4-j47h](https://github.com/wekan/wekan/security/advisories/GHSA-8885-xrq4-j47h).
A board export scoped with `?checklistId=` streamed a checklist of another,
even private, board with its items and creator; it is now bound to the
exported board's cards. The streaming CSV/TSV export ignored Disable all
export and Anonymize export users, and so did the Excel, PDF, chart, calendar
and external-format exports: every export route now refuses with 403 when
export is disabled, and every exporter that names people anonymizes. Refusals
are shown in Admin Panel / Problems.
</details>
**Attachments** - who may add or delete a card's files through the API.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/532a96da11">An assigned-only member adds and deletes attachments only on cards assigned to them</a>. Thanks to Javier García Herrera and xet7.</summary>
[UnassignedAttachBleed](https://wekan.fi/hall-of-fame/unassignedattachbleed/),
[GHSA-w26g-rc43-q6cm](https://github.com/wekan/wekan/security/advisories/GHSA-w26g-rc43-q6cm).
A member with the Normal, assigned only role whose card assignment was removed
kept the card and attachment ids, and `POST /api/attachment/upload` and
`DELETE /api/attachment/delete/:id` still added a file to that card and
soft-deleted its existing one: both asked only for board write access. The
DDP methods, and copy and move onto a target card, had the same gap. All
eight handlers now apply the card rule the ordinary card Delete used, and a
refusal is shown in Admin Panel / Problems.
`tests/unassignedAttachBleed.test.cjs`
fails if any attachment API write lacks it.
</details>
and updates the following dependencies:
- **isomorphic-git 1.44.3 → 1.44.8** — the GitHub wiki sync
([#6846](https://github.com/wekan/wekan/pull/6846)).
- **@node-saml/node-saml 5.1.0 → 5.2.0** — SAML; see the fix below, without
which it changed nothing that runs ([#6850](https://github.com/wekan/wekan/pull/6850)).
- **@typescript-eslint/eslint-plugin 8.72.0 → 8.72.1** — lint only
([#6849](https://github.com/wekan/wekan/pull/6849)).
- **@rspack/dev-server 2.2.1 → 2.2.2** — development server only
([#6848](https://github.com/wekan/wekan/pull/6848)).
- **mailparser 3.12.2 → 3.12.4** — inbound email and bounces
([#6845](https://github.com/wekan/wekan/pull/6845)).
- **i18next 26.5.4 → 26.5.11** — translations
([#6847](https://github.com/wekan/wekan/pull/6847)).
- **@aws-sdk/client-s3 3.1155.0 → 3.1159.0** — S3 backups; see the
@smithy/core fix below ([#6844](https://github.com/wekan/wekan/pull/6844)).
- **imapflow 2.8.0 → 2.9.1** — the IMAP mailbox
([#6842](https://github.com/wekan/wekan/pull/6842)).
- **glob 13.0.6 → 14.0.3** — no WeKan code loads it: it is only the target of
an `archiver-utils` override that archiver 8 no longer needs; @babel/cli and
rimraf keep their own copies ([#6851](https://github.com/wekan/wekan/pull/6851)).
- **helm/chart-releaser-action 1.7.0 → 1.8.0** and **docker/login-action 4.6.0
→ 4.7.0** — CI, each still pinned to the commit its tag names
([#6843](https://github.com/wekan/wekan/pull/6843),
[#6841](https://github.com/wekan/wekan/pull/6841)).
Thanks to dependabot.
and fixes the following bugs:
**Dependencies** - what the updates above did, and did not, change.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/5c50f38e25">SAML logins are verified by node-saml 5.2.0, as package.json says</a>. Thanks to xet7.</summary>
The bump changed the app's `package.json`, but a login is verified by the
`wekan-accounts-saml` package's own copy, still pinned to 5.1.0 - so nothing
that runs changed. 5.2.0 rejects a message with more than one Issuer, checks
`idpIssuer`, rejects a `RelayState` that is not the signed one, and checks a
POST LogoutResponse's status and issuer. The package pins 5.2.0 now, and
`samlLogin.test.cjs` requires both to name the same version. A login
roundtrip with the package's copy logs in a signed Response and a signed
Assertion, and refuses a changed NameID, another key, an unsigned response and
an answer to a request WeKan never made.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/338afc15f8">@smithy/core 3.38.1, the AWS SDK's logger redaction fix</a>. Thanks to xet7.</summary>
The S3 SDK bump resolved `@smithy/core` 3.37.0, deprecated for a bug that
passed sensitive fields nested in lists or maps unredacted to a custom logger
([aws-sdk-js-v3#8393](https://github.com/aws/aws-sdk-js-v3/issues/8393));
3.36.2 before it had the bug too. WeKan's only AWS client, the S3 backup,
passes no logger, so nothing leaked. Updated in the lockfile only, one
package; the telemetry gate (`releases/check-telemetry.py`), which had failed
on the deprecation note's new github.com link, passes again. The ten updates
add no package, install script, network host or telemetry, and every tarball
matches its lockfile hash.
</details>
**Continuous backup** - what it sees right after it starts.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8cbe2605fe">Files are watched before the first scan, and a macOS start catches up once</a>. Thanks to xet7.</summary>
The file engine scanned its sources first and only then started watching, so
a change made between the two waited for the next scan, five minutes by
default. macOS FSEvents also delivers nothing for a moment after a watcher is
created. The watchers start first now, sharing one queue with the scan so a
change is recorded once, and on macOS one catch-up scan runs two seconds after
the start; Linux's inotify needs none. A new test with a silent watcher pins
the order, the macOS catch-up and that Linux runs no second scan.
</details>
**Single-file builds** - signing and the start/backup commands.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8d4f4caa2f">A macOS build is signed whatever its target is called, and start/backup return once WeKan runs</a>. Thanks to xet7.</summary>
Only a target named mac or darwin was ad-hoc signed, so a Mach-O built under
another label had the signature its injected archive invalidated, and macOS
killed it before it printed anything. Every Mach-O output is signed now, read
from the file's magic number. `start --background` and `backup` returned
before WeKan wrote its pid file, so a `stop` right after them said WeKan was
not running and left it running; both wait now until the pid file names the
process they started. `singleFile.test.cjs` passes 24 of 24, the real single
executable included.
</details>
**Release builds** - the bundles each platform downloads.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/24a7270b7f">Android bundles start again: npm 12 runs argon2's and bcrypt's install scripts</a>. Thanks to xet7.</summary>
npm 12 lets only declared dependencies run install scripts, and the rebuild
directories declared none, so argon2 and bcrypt were never compiled. Prebuilds
hid it everywhere except Android, whose bundles crash-looped on "No native build
was found for platform=android". Each rebuild directory now declares the
reviewed installers it holds, and the Android build loads both before starting
WeKan.
</details>
**Filters** - the filter sidebar's date ranges.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c5f5ad2338">A refused date range says why in the form, so Clear still works in Firefox</a>. Thanks to xet7.</summary>
A reversed card date or card movement range was reported with the browser's
validation bubble, which Firefox opens over the Apply and Clear buttons and
which took every click on them: the range could not be cleared. The end date
is still marked invalid, and the reason is a `role="alert"` line in the form;
editing the range or Clear takes it back.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/4ad13db5ef">The date-range forms are checked by WeKan, not refused by WebKit after Clear</a>. Thanks to xet7.</summary>
After Clear, WebKit kept a half-edited date in the From field and silently
refused the next submit, so a new range never applied. Both forms are
`novalidate` now; WeKan's own check already marks a refused range and says
why.
</details>
**History recovery** - an undo or redo that is still waiting for the server.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/96845f6f4b">Retry is enabled only once the board lets this person write on it</a>. Thanks to xet7.</summary>
After a reload the notice showed before the board and its members arrived,
and a Retry clicked then did nothing and the notice stayed. Retry is disabled
until it can act; Forget stays usable.
</details>
**Keyboard shortcuts** - when a focused button or link lets them through.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ba0de93f79">Shortcuts keep working after a click on a minicard or a closed popup</a>. Thanks to mimZD and xet7.</summary>
The shortcut filter refused every key while a link or button had the focus.
The minicard is itself a link, so clicking its date badge or menu left it
focused, and closing a popup returns focus to its opener: hovering a card and
pressing d did nothing until a reload. A focused control now keeps only its
own keys - Enter, Space for a button, the arrows for a tab - and every other
key is a shortcut again ([#6755](https://github.com/wekan/wekan/issues/6755)).
</details>
**Rules** - the way back from the Rules page.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/34d9817110">The Rules page has a Back to the board</a>. Thanks to mimZD and xet7.</summary>
The page lost its Back with its old sidebar, and only the trigger, action and
rule details pages kept one, to the rules list. The list and its Workflow,
Bots, Blocks and History views now link back to the board; the three
sub-pages keep only their own Back ([#6852](https://github.com/wekan/wekan/issues/6852)).
</details>
**Sign-in** - the error shown on the sign-in page.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3a2fef4dc7">Login errors are rendered from template state, so a redraw of the form keeps them</a>. Thanks to xet7.</summary>
The error region sits inside the form's loading switch, and an error written
into it was lost whenever that part of the form was drawn again. The template
now renders the message as escaped text from a reactive value. The SAML error
test also waits for the finished form before it clicks.
</details>
**Whiteboard** - how its lines are drawn.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/40f60582b7">A line is drawn at its own width, not the whiteboard's 800</a>. Thanks to xet7.</summary>
The line's `stroke-width=width` found the template's `width` helper - the
drawing's width - before the line's own field, so every line was drawn 800
wide, one black shape over the whole whiteboard. The lines use `strokeWidth`
now, and a test fails if any of their fields shares a helper's name.
</details>
and has the following developer-tooling fixes:
**The whole-suite run** - EVERYTHING, on a Mac and in a sandboxed editor.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/16103f9ad2">The test database is kept in .tools/, not beside the checkout</a>. Thanks to xet7.</summary>
`build.sh` and `build.bat` put the :3001 test database in the checkout's
parent directory, the one thing a test run wrote outside the repository, and a
sandboxed editor could not create it. It is `.tools/mongodb-test-<port>` now;
`toolsDirOnly.test.cjs` pins it in both scripts.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/7f948bf438">On macOS the Playwright container uses Docker's bridge network, not the host network</a>. Thanks to xet7.</summary>
Firefox tests waited the full 60 seconds for a page whose document never
came. Docker Desktop's host network lost about one connection in 75 to
`host.docker.internal` (8 of 600 connects never completed), while the bridge
network lost none in 1000. Nothing there needs the host network on macOS, so
the container uses the bridge network; Linux keeps the host network.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d9e48a909d">The list-sync specs' mock servers are reachable from WeKan when the browsers run in Docker on macOS</a>. Thanks to xet7.</summary>
WeKan fetched from mock Jira and GitLab servers on the container's loopback,
which Docker Desktop keeps from the host. build.sh already forwarded the test
SMTP port into the container; it now forwards a small pool of ports the same
way, and those specs take one of them.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ffe207e191">The Docker test proxy dials the host again when a connect is never answered</a>. Thanks to xet7.</summary>
On the bridge network too, Docker Desktop's gateway now and then left one
connect to the host unanswered through nine retransmits while the next went
straight through. The proxy reads nothing from the browser until a connection
is up, drops a dial that has not connected in 1.5 seconds and dials again, up
to five times, so no byte is lost or sent twice.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8a1f7a14a9">The Playwright container sees the repo and the files root at their own host paths</a>. Thanks to xet7.</summary>
Specs and the server share files by path. With the repository mounted at
`/repo`, the continuous backup tests gave the server a key file it could not
find and looked for a stream it wrote elsewhere. Both are mounted at their
host paths now, so a path names the same file on both sides.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a6bec88266">The phone invitation tile is measured in one layout with its buttons</a>. Thanks to xet7.</summary>
Measured one at a time, the tile and its buttons were taken from two layouts:
Firefox drew the row above the board list in between and moved the tile down
29 pixels. They are measured together now, and the test still fails when the
tile clips its buttons, the original #6488 bug.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f5eb4ecf61">The Mac smoke test asks libproc, not ps, whether a process group still lives</a>. Thanks to xet7.</summary>
When macOS refuses to signal a group of zombies, the smoke test asked `ps`
whether a member was alive, and a Mac that does not let the build run `ps`
failed a correct app. On macOS it asks the kernel through `proc_listpids` and
`proc_pidinfo` now, starting no process; elsewhere it uses `ps` as before.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/897376ba54">The button theme test checks the Board Settings that still have action buttons</a>. Thanks to xet7.</summary>
Card Settings has had no column actions since card actions moved into each
section's own menu, so the test failed on Card in every theme. Swimlane and
List are checked in all four themes, and the test says why Card is not.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d5e3325e87">Card Settings section order waits for the published board before reading the card</a>. Thanks to xet7.</summary>
The test writes the board straight to the database and reloads. The board
publication's cursor has `limit: 1`, so on the standalone test MongoDB it
polls and a reload could get the old board for up to ten seconds. The test
waits for the published board, up to 15 seconds, before it reads the card.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/89b64966c6">The password minimum specs wait until the server has the policy they wrote</a>. Thanks to xet7.</summary>
The specs write the password policy straight into the settings document, and
on the standalone test MongoDB the server's settings observer polls, so for up
to ten seconds it still enforced the old policy. `untilServerHas()` asks
`getPasswordPolicy` until the server has it, then reloads the sign-up form; 6
of 6 with two workers.
</details>
**Browser tests** - specs that read the page before it had settled, in Firefox.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/dbf07aadf9">The activity recovery report is searched again until no worker lease covers it</a>. Thanks to xet7.</summary>
The report is a snapshot, and the live recovery worker's short lease could
make it show a seeded orphan as being delivered. The test searches again
until the snapshot falls between leases.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/9188843369">The checklist drag tests drag once the opened card's rows hold still</a>. Thanks to xet7.</summary>
Sections drawn above the checklist moved its rows 50 pixels between measuring
and releasing, so the release opened an item's editor or dropped it in the
wrong place.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0cea141702">The duplicate relation picker is opened once the card has opened</a>. Thanks to xet7.</summary>
A picker opened while the card was still opening was closed again when the
card settled.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0403e27c62">The notification delivery sections are opened until they stay open</a>. Thanks to xet7.</summary>
The language arriving after the click drew the popup again with its sections
closed.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2042fd5102">The Blocks drag test grabs the block's body, not its edge</a>. Thanks to xet7.</summary>
A Khmer label overflows its block in Firefox, and the first point found was
the block's outline, where a press drags nothing.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a1f8584ee">The board view popup is opened until it stays open for the Product Backlog</a>. Thanks to xet7.</summary>
The language arriving after the click drew the header again and closed the
popup.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/efe0e9287c">A language switch is waited for, and WebKit does not freeze on asking for a push subscription</a>. Thanks to xet7.</summary>
A spec that switched language and clicked at once clicked into the redraw
that followed, which closed its popup; every page of the test context now
waits until it shows the new language. Playwright's WebKit froze the page on
asking for a push subscription, which Member Notification Settings does on
opening; there the page answers that it has none.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0c8c254dc5">The viewport matrix cleans up only what it seeded</a>. Thanks to xet7.</summary>
The matrix is Chromium-only. In Firefox and WebKit its setup never ran, but
its cleanup did, and failed every viewport.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/f241681e80">Background notification recovery checks its own mail</a>. Thanks to xet7.</summary>
The shared SMTP sink had caught another worker's mail first.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c71c875649">The custom field settings popup is opened until its Create button shows</a>. Thanks to xet7.</summary>
The opened card was still drawing and closed the popup again.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/4a9c3f21d2">The drawing surface's width is waited for</a>. Thanks to xet7.</summary>
The element's layout follows its viewBox a frame later.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/c8ed3e0fd0">The bulk export popup is opened until it stays</a>. Thanks to xet7.</summary>
All Boards was still drawing after the route changed, in all five Haitian
Creole tests.
</details>
**Test coverage** - October changes that had no test of their own.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/148d18d456">Server changes are tested: OpenSSL 4 decrypt refusal, the Scrum rollover lease, the backup catch-up scan</a>. Thanks to xet7.</summary>
Each with a negative test, and the lease with a tree-wide one: every lease
field is written only by its claim.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/79c40407d1">Admin Panel pane titles, audit log alignment, storage help, Data health and card reports are tested</a>. Thanks to xet7.</summary>
Unit and negative tests, and a Chromium browser test of the titles, the
right-to-left audit log, the storage help shown once and Data health's own
"Never".
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/121f37be91">The drawing refit, Due Cards scope, dependency import counts and SAML's one Issuer are tested</a>. Thanks to xet7.</summary>
A signed SAML Response with two Issuers is refused in the browser, which
node-saml 5.1.0 accepted; an assigned-only member's Due Cards show only their
own cards.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/3ccb465c3f">The keyboard shortcut browser test focuses the minicard link itself</a>. Thanks to mimZD and xet7.</summary>
Chromium does not leave the focus on the minicard after its menu is closed,
so the test makes the reported state directly; it passes in Chromium and
WebKit.
</details>
**The source-loaded server** - starting WeKan from source for development.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/15877dd1bc">A local package fork that a prebuilt package implies is loaded first</a>. Thanks to xet7.</summary>
The source server stopped at "isopack dynamic-import not yet loaded". It now
gets past that, and still stops later on "Cannot find module
'meteor/accounts-base'"; the browser tests ran against the bundle server.
</details>
**The changelog** - its guards between releases.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/05cc2460ea">The short changelog guard holds between releases too, when there is no Upcoming section yet</a>. Thanks to xet7.</summary>
`changelogShort.test.cjs` assumed an Upcoming section was always there, so it
failed right after every release, before anything new was written. With none
it checks that the newest release comes first and that nothing links an
Upcoming that does not exist.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.77 2026-10-31 WeKan ® release
**In short:** Two **node test guards** follow the movable popups and the date
picker of v12.76.
This release has the following developer-tooling fix:
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ff2c0ed7f6">The date-popup guards follow movable popups and the date picker's empty cells</a>. Thanks to xet7.</summary>
`calendarPopupResize.test.cjs` asserted that a popup without a date editor does
not drag, and `calendarTodayMarker.test.cjs` picked a month's days with
`filter(Boolean)`. Both described v12.76 before it changed on purpose: every
popup drags by its title bar, or by its handle alone with drag handles on, and
an empty picker cell is an object with its own `_id`. Both are pinned instead.
</details>
Thanks to above GitHub users for their contributions and translators for their translations.
# v12.76 2026-10-31 WeKan ® release
**In short:** Security features: a **password policy** with an optional
+19 -20
View File
@@ -5,6 +5,25 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v12.78 2026-11-02 WeKan ® release
**CrossBoardExportBleed** and **UnassignedAttachBleed** are
fixed: a checklist scope stays on its board, every export obeys Disable all
export and Anonymize export users, and an assigned-only member changes
attachments only on their own cards. **SAML** logins use **node-saml 5.2.0**,
and **continuous backup** no longer misses a change made right after it starts.
**Keyboard shortcuts** keep working after a popup closes, and the Rules page has
a way back to the board.
[All changes in v12.78](CHANGELOG-big.md#v1278-2026-11-02-wekan--release)
# v12.77 2026-10-31 WeKan ® release
Two **node test guards** follow the movable popups and the date
picker of v12.76.
[All changes in v12.77](CHANGELOG-big.md#v1277-2026-10-31-wekan--release)
# v12.76 2026-10-31 WeKan ® release
Security features: a **password policy** with an optional
@@ -32,23 +51,3 @@ open MPTCP listeners, which Linux kernel bug **CVE-2026-98432** is reachable
through. The real fix is a patched host kernel.
[All changes in v12.74](CHANGELOG-big.md#v1274-2026-10-30-wekan--release)
# v12.73 2026-10-30 WeKan ® release
**CHANGELOG.md** is now short: the In short of the upcoming
release and of the five newest releases, in plain Markdown. The status and every
change of each release are in **CHANGELOG-big.md**, which is the one that is
written. **CodeQL** alert #598 in the Sandstorm host test is fixed, and the
release script pulls before releasing, so a moved main no longer stops it.
[All changes in v12.73](CHANGELOG-big.md#v1273-2026-10-30-wekan--release)
# v12.72 2026-10-30 WeKan ® release
A card link can no longer launch an operating-system handler with
a code-execution history, such as **ms-msdt** (Follina) or **telnet**, even if
an administrator lists it, and an attempt to write one shows in **Admin Panel →
Problems**. The **Apache** guide follows the 2024 Confusion Attacks, and the
Sandstorm store build publishes to **both app markets**.
[All changes in v12.72](CHANGELOG-big.md#v1272-2026-10-30-wekan--release)
+1 -1
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.76
ARG VERSION=12.78
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.76.0"
appVersion: "v12.78.0"
files:
userUploads:
- README.md
+1 -1
View File
@@ -1980,7 +1980,7 @@ netstat -ano | findstr /r /c:":3001 .*LISTENING" >nul 2>&1
if not errorlevel 1 (
echo ==^> Reusing the MongoDB already listening on :3001 ^(not started or stopped by this run^).
) else (
for %%i in ("%REPO%\..\mongodb-test-3001") do set "DBPATH=%%~fi"
for %%i in ("%REPO%\.tools\mongodb-test-3001") do set "DBPATH=%%~fi"
if not exist "!DBPATH!" md "!DBPATH!"
echo ==^> Starting MongoDB ^(Meteor's mongod^) on :3001, dbpath !DBPATH!.
start "WekanTestMongo" /MIN "%MONGOD_BIN%" --port 3001 --dbpath "!DBPATH!" --bind_ip 127.0.0.1 --logpath "%RUN_LOGDIR%\wekan-test-mongod.log"
+61 -28
View File
@@ -652,8 +652,18 @@ function run_playwright_docker(){
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
local docker_host_proxy_port=""
local docker_network="host"
# Docker Desktop on macOS keeps the host outside the container's loopback.
if [ "$(uname -s)" = Darwin ]; then
# Its host network loses connections to host.docker.internal: from a
# container on that network about 1 connect in 75 never completed, so a
# test page waited 60 s for a document that never came (Firefox, 2026-11-01:
# 8 of 600 direct connects, against 0 of 1000 from the bridge network).
# Nothing here needs the host network on macOS - WeKan and Mongo are reached
# through host.docker.internal and SMTP through docker exec - so the
# container uses Docker's bridge network, where the proxy below still
# serves localhost.
docker_network="bridge"
# Meteor's dynamic imports use ROOT_URL (localhost in the test server).
# Keep the browser on that same origin and bridge its loopback to the host.
# Browsing host.docker.internal while ROOT_URL is localhost makes Firefox
@@ -677,35 +687,53 @@ function run_playwright_docker(){
mkdir -p "$filesroot"
echo "Running Playwright $browser in Docker ($image)."
echo "Expecting WeKan at $docker_base_url."
local smtp_proxy_pid="" smtp_proxy_ready="" container_name="wekan-playwright-${browser}-$$"
if [ "$(uname -s)" = Darwin ] && [ -n "${WEKAN_TEST_SMTP_PORT:-}" ]; then
smtp_proxy_ready="$(mktemp "$WEKAN_DIR/.tools/tmp/smtp-proxy.XXXXXX")"
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$WEKAN_TEST_SMTP_PORT" "$smtp_proxy_ready" &
smtp_proxy_pid=$!
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$smtp_proxy_ready" ] && break
kill -0 "$smtp_proxy_pid" 2>/dev/null || break
sleep 0.1
local container_name="wekan-playwright-${browser}-$$"
# Docker Desktop keeps the container's loopback from the host, so a server a
# spec starts in the container cannot be reached by WeKan outside it. Each
# port below is forwarded from the host into the container through docker
# exec (helpers/docker-smtp-proxy.cjs, which carries any TCP): the test SMTP
# server, and a small pool for the mock Jira/GitLab servers of the list-sync
# specs, which take a free port from WEKAN_TEST_REVERSE_PORTS
# (helpers/reachable-server.cjs). Elsewhere they listen on any port.
local forward_pids="" forward_ready="" reverse_ports=""
if [ "$(uname -s)" = Darwin ]; then
reverse_ports="${WEKAN_TEST_REVERSE_PORTS:-39101 39102 39103 39104}"
local fport fready fpid i
for fport in ${WEKAN_TEST_SMTP_PORT:-} $reverse_ports; do
fready="$(mktemp "$WEKAN_DIR/.tools/tmp/docker-port-proxy.XXXXXX")"
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$fport" "$fready" &
fpid=$!
forward_pids="$forward_pids $fpid"; forward_ready="$forward_ready $fready"
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$fready" ] && break
kill -0 "$fpid" 2>/dev/null || break
sleep 0.1
done
if [ ! -s "$fready" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
echo "ERROR: local test proxy for port $fport did not start."
return 1
fi
done
if [ ! -s "$smtp_proxy_ready" ]; then
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
rm -f "$smtp_proxy_ready"
echo "ERROR: local SMTP test proxy did not start."
return 1
fi
fi
# Mount the whole repo so specs that reach the repo-root node_modules
# (e.g. @wekanteam/exceljs) and .tools resolve; run from tests/playwright.
# The repo and the files root are mounted at their OWN host paths, so a path
# names the same file for the specs in here and for the server outside:
# admin-continuous-backup gives the server a key file it wrote and reads the
# stream the server wrote, and with the repo at /repo neither side found the
# other's file.
# Run as the host user (--user) with a writable HOME so the container does
# not leave root-owned files under test-results/ (which would later make
# native Chromium/Firefox runs fail with "EACCES: permission denied, mkdir
# .../test-results/.playwright-artifacts-N").
docker_exec run --rm --init --ipc=host --network host \
docker_exec run --rm --init --ipc=host --network "$docker_network" \
--name "$container_name" \
--label org.wekan.test-run=everything \
--user "$(id -u):$(id -g)" \
-e HOME=/repo/.tools/tmp \
-e TMPDIR=/repo/.tools/tmp \
-e HOME="$reporoot/.tools/tmp" \
-e TMPDIR="$reporoot/.tools/tmp" \
-e WEKAN_BASE_URL="$docker_base_url" \
-e WEKAN_DOCKER_HOST_PROXY_PORT="$docker_host_proxy_port" \
-e WEKAN_MONGO_URL="$docker_mongo_url" \
@@ -717,16 +745,17 @@ function run_playwright_docker(){
-e WEKAN_TEST_SERIAL="${WEKAN_TEST_SERIAL:-0}" \
-e WEKAN_TEST_SERVER_MODE="${WEKAN_TEST_SERVER_MODE:-bundle}" \
-e WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-}" \
-e WEKAN_TEST_REVERSE_PORTS="$reverse_ports" \
-e PLAYWRIGHT_HTML_OPEN=never \
-e WEKAN_FILES_PATH=/wekan-files \
-e WEKAN_FILES_PATH="$filesroot" \
-e PLAYWRIGHT_JSON_OUTPUT_NAME="${PLAYWRIGHT_JSON_OUTPUT_NAME:-}" \
-v "$filesroot":/wekan-files \
-v "$reporoot":/repo -w /repo/tests/playwright \
-v "$filesroot":"$filesroot" \
-v "$reporoot":"$reporoot" -w "$pwdir" \
"$image" \
sh -c '
export PATH=/repo/tests/playwright/node_modules/.bin:$PATH
export PATH="$PWD/node_modules/.bin:$PATH"
if [ -n "$WEKAN_DOCKER_HOST_PROXY_PORT" ]; then
node /repo/tests/playwright/helpers/docker-host-proxy.cjs &
node "$PWD/helpers/docker-host-proxy.cjs" &
proxy_pid=$!
trap '\''kill "$proxy_pid" 2>/dev/null || true; wait "$proxy_pid" 2>/dev/null || true'\'' EXIT
ready=0
@@ -739,9 +768,9 @@ function run_playwright_docker(){
npx playwright test --project="$0" "$@"
' "$browser" "$@"
local playwright_rc=$?
if [ -n "$smtp_proxy_pid" ]; then
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
rm -f "$smtp_proxy_ready"
if [ -n "$forward_pids" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
fi
return "$playwright_rc"
}
@@ -1513,7 +1542,11 @@ function run_all_tests(){
echo "ERROR: nothing is listening on :$TEST_DB_PORT and no mongod was found (Meteor dev_bundle or PATH)."
echo " Meteor's bundled mongod is normally at <dev_bundle>/mongodb/bin/mongod."; rm -rf "$STATDIR"; return 1
fi
local DBPATH="../mongodb-test-$TEST_DB_PORT"
# In .tools/, where everything this script writes lives (CLAUDE.md;
# tests/toolsDirOnly.test.cjs). It was in the checkout's PARENT directory,
# which a sandboxed editor cannot write - the test database never
# started there.
local DBPATH="$WEKAN_DIR/.tools/mongodb-test-$TEST_DB_PORT"
mkdir -p "$DBPATH"
echo "==> Starting MongoDB (Meteor's mongod) on :$TEST_DB_PORT, dbpath $DBPATH."
{ echo "===== mongod :$TEST_DB_PORT - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; exec "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
@@ -1541,7 +1574,7 @@ function run_all_tests(){
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
# ...and the same path for a LOCAL playwright run, not only the Docker one.
# Only WEKAN_FILES_PATH_HOST was exported, which is read when the browsers run
# in a container (it is mounted there as /wekan-files). A local run fell back
# in a container (it is mounted there at the same path). A local run fell back
# to the spec's own default of .build/bundle/files, so a spec that writes a
# stored attachment wrote it OUTSIDE the root this server reads - and then
# tested what WeKan does with a file it does not own.
+1 -1
View File
@@ -28,4 +28,4 @@ template(name="cardWhiteboard")
svg.card-whiteboard-surface.js-whiteboard-surface(viewBox="0 0 {{width}} {{height}}" preserveAspectRatio="xMidYMid meet" class="{{#if canDraw}}can-draw{{/if}}")
rect(width=width height=height fill="#ffffff")
each strokes
path(d=pathD stroke=color stroke-width=width fill="none" stroke-linecap="round" stroke-linejoin="round")
path(d=pathD stroke=color stroke-width=strokeWidth fill="none" stroke-linecap="round" stroke-linejoin="round")
+5 -1
View File
@@ -116,7 +116,11 @@ Template.cardWhiteboard.helpers({
const current = tpl.current.get();
return [...saved, ...pending, ...(current ? [current] : [])]
.filter(s => s && Array.isArray(s.points) && s.points.length)
.map(s => ({ color: s.color, width: s.width, pathD: pathD(s.points) }));
// strokeWidth, not width: inside `each strokes` Spacebars looks a name up
// among this template's helpers before the stroke's own fields, and the
// width helper is the drawing's (800) - every line was drawn 800 wide and
// covered the whiteboard.
.map(s => ({ color: s.color, strokeWidth: s.width, pathD: pathD(s.points) }));
},
saving() { return Template.instance().saving.get(); },
message() { return Template.instance().message.get(); },
@@ -7,7 +7,7 @@ template(name="historyRecoveryNotice")
.history-recovery-notice.js-history-recovery-notice(role="status" aria-live="polite")
p {{_ pending.messageKey}}
p.quiet {{_ 'history-request-hint'}}
button.primary.js-history-retry(type="button" disabled=busy) {{_ 'history-request-retry'}}
button.primary.js-history-retry(type="button" disabled=retryDisabled) {{_ 'history-request-retry'}}
button.js-history-forget(type="button" disabled=busy) {{_ 'history-request-forget'}}
if checkpoint
.history-recovery-notice.history-checkpoint-notice.js-history-checkpoint-notice(role="status" aria-live="polite" class=checkpointPlacement)
@@ -52,6 +52,13 @@ Template.historyRecoveryNotice.helpers({
busy() {
return Template.instance().busy.get();
},
// undoRedoLast does nothing for someone who may not write on the board, and
// right after a reload that is everyone until the board and its members
// arrive: the notice shows first, and a Retry clicked then was dropped
// without a word. It is enabled once the retry can act.
retryDisabled() {
return Template.instance().busy.get() || !Utils.canModifyBoard();
},
});
async function resolve(tpl, action) {
+1 -1
View File
@@ -55,7 +55,7 @@ template(name="userFormsLayout")
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")= loginErrorText
// Second sign-in step for accounts-2fa (issue #3058). Hidden unless a
// password login comes back with the "no-2fa-code" / "invalid-2fa-code"
// error - see decideTwoFactorLoginStep() in
+9 -5
View File
@@ -303,6 +303,7 @@ Template.userFormsLayout.onDestroyed(function () {
});
Template.userFormsLayout.helpers({
loginErrorText() { return loginErrorText.get(); },
passkeysSupported() { return require('/client/lib/passkeys').passkeysSupported(); },
passkeyMessage() { return Template.instance().passkeyMessage.get(); },
windowsSsoOffered() { return Meteor.settings?.public?.windowsSsoEnabled === true; },
@@ -557,20 +558,23 @@ Tracker.autorun(() => {
}
});
// The message in #login-error-message, rendered by the template as text. Not
// written into the element: it is inside `if isLoading`, and when that flips
// Blaze draws a new, empty element - a SAML popup error shown just before
// then was gone (saml-popup-error.e2e.js, Firefox). Text, never HTML.
const loginErrorText = new ReactiveVar('');
function showLoginError(err) {
const errorDiv = document.getElementById('login-error-message');
if (!errorDiv) return;
if (!err) {
errorDiv.textContent = '';
loginErrorText.set('');
return;
}
const reason = err.reason || err.message || '';
// #2460: an account its member locked to SQRL says so in their language.
const translated = ['oauth-account-conflict', 'sqrl-only', 'sqrl-hardlock']
.find(key => err.error === key || reason === key);
errorDiv.textContent = translated
loginErrorText.set(translated
? TAPi18n.__(translated)
: reason || String(err.error || err);
: reason || String(err.error || err));
}
Template.defaultLayout.events({
+27
View File
@@ -247,6 +247,33 @@
.rules-back .js-goback .fa-arrow-left {
color: #fff !important;
}
/* #6852: Back to the board, at the top of the Rules page. */
.rules-back-to-board {
margin-block: 8px;
margin-inline: 14px;
}
/* A link, so it opens the board as a link does (also in a new tab), drawn as
the primary buttons around it: forms.css styles only button.primary. */
.rules-back-to-board .js-rules-back-to-board {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 20px;
border-radius: 3px;
background: var(--theme-accent, #005377);
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
font-weight: 700;
text-decoration: none;
}
.rules-back-to-board .js-rules-back-to-board:hover,
.rules-back-to-board .js-rules-back-to-board:focus {
background: var(--theme-accent, #004766);
color: #fff;
}
.rules-back-to-board .js-rules-back-to-board .fa-arrow-left {
color: #fff !important;
}
.flex,
.triggers-content .triggers-body .triggers-side-menu ul li a {
display: -webkit-box;
+9
View File
@@ -6,6 +6,15 @@
template(name="rulesMain")
.wrapper
.rules-page(class=currentBoard.colorClass)
//- #6852: the way back to the board. The trigger, action and rule
details pages have their own Back, to the rules list, so this one is
shown only where there is none - two Backs going to two places would
be one too many.
if showBackToBoard
.rules-back-to-board
a.js-rules-back-to-board(href="{{pathFor 'board' id=currentBoard._id slug=currentBoard.slug}}" title="{{currentBoard.title}}")
i.fa.fa-arrow-left
| {{_ 'back'}}
//- #2217: who may edit sees every view; a member a board shows its Rules
to reads the rules list and rule details, with no control that
changes a rule; anybody else sees nothing (client/lib/rulesAccess.js).
+4
View File
@@ -44,6 +44,10 @@ Template.rulesMain.helpers({
rulesCurrentTab() {
return Template.instance().rulesCurrentTab;
},
// #6852: every Rules page but the three that already go Back to the list.
showBackToBoard() {
return !['trigger', 'action', 'ruleDetails'].includes(Template.instance().rulesCurrentTab.get());
},
ruleName() {
return Template.instance().ruleName;
},
+6
View File
@@ -750,6 +750,12 @@ body.sidebar-resizing-active {
margin-inline-end: 6px;
appearance: auto;
}
/* Why a date range was refused, below its buttons (sidebarFilters.js,
rangeRefused): the browser's own bubble covered those buttons in Firefox. */
.filter-date-range-error {
color: #c0392b;
margin: 6px 0 0;
}
.filter-native-group {
border: 0;
padding: 0;
@@ -140,7 +140,10 @@ template(name="filterSidebar")
| {{_ 'filter-dates-label' }}
each dateFilterProviderViews
+Template.dynamic(template=template data=data)
form.js-card-movement-range
//- novalidate: the range is checked in the submit handler (rangeRefused in
sidebarFilters.js). WebKit keeps a half-edited date segment after Clear
empties a field and then refused every submit of the form, with no message.
form.js-card-movement-range(novalidate)
h4 {{_ 'filter-movement-range'}}
label(for="filter-card-movement-from") {{_ 'filter-date-range-from'}}
input.js-card-movement-from(id="filter-card-movement-from" type="date" value=movementFrom)
@@ -148,7 +151,9 @@ template(name="filterSidebar")
input.js-card-movement-to(id="filter-card-movement-to" type="date" value=movementTo)
button.primary(type="submit") {{_ 'apply'}}
button.js-clear-movement-range(type="button") {{_ 'filter-clear'}}
form.js-card-date-range
if movementRangeError
p.js-card-movement-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
form.js-card-date-range(novalidate)
label(for="filter-card-date-field") {{_ 'filter-date-range-field'}}
select.js-card-date-field(id="filter-card-date-field")
each dateRangeFields
@@ -162,6 +167,8 @@ template(name="filterSidebar")
| {{_ 'filter-date-range-missing'}}
button.primary(type="submit") {{_ 'apply'}}
button.js-clear-date-range(type="button") {{_ 'filter-clear'}}
if dateRangeError
p.js-card-date-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
hr
fieldset.filter-native-group
legend {{_ 'due-date'}}
+26 -12
View File
@@ -13,9 +13,23 @@ import { getSidebarInstance } from '/client/features/sidebar/service';
import { CARD_DATE_RANGE_FIELDS, CARD_RECENCY_PRESETS } from '/models/lib/cardDateRange';
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
// A refused date range marks its end field invalid - so it is :invalid and
// screen readers announce the reason - and says why in the form itself. Not
// with reportValidity(): Firefox's validation bubble opens over the Apply and
// Clear buttons below the field and takes every click on them while the field
// has focus, so the range could not be cleared at all.
function rangeRefused(end, error, refused) {
end.setCustomValidity(refused ? TAPi18n.__('filter-date-range-invalid') : '');
error.set(refused);
}
Template.filterSidebar.onCreated(function () {
this.presetMessage = new ReactiveVar('');
this.presetBusy = new ReactiveVar(false);
// Why a date range was refused, shown in the form rather than in the
// browser's own validation bubble (see rangeRefused below).
this.movementRangeError = new ReactiveVar(false);
this.dateRangeError = new ReactiveVar(false);
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (Meteor.userId() && boardId) this.subscribe('savedCardFilters', boardId);
@@ -28,6 +42,8 @@ Template.filterSidebar.helpers({
},
presetMessage() { return Template.instance().presetMessage.get(); },
presetBusy() { return Template.instance().presetBusy.get(); },
movementRangeError() { return Template.instance().movementRangeError.get(); },
dateRangeError() { return Template.instance().dateRangeError.get(); },
filterProviderViews() { return Filter.providers.views(); },
dateFilterProviderViews() { return Filter.providers.views('dates'); },
dueUnrestricted() { return Filter.dueAt.state() === null; },
@@ -102,15 +118,15 @@ Template.filterSidebar.events({
const end = tpl.find('.js-card-movement-to');
const valid = Filter.movementDate.set({ field: 'createdAt',
from: tpl.find('.js-card-movement-from').value, to: end.value, includeMissing: false });
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
if (!valid) end.reportValidity();
else Filter.resetExceptions();
rangeRefused(end, tpl.movementRangeError, !valid);
if (valid) Filter.resetExceptions();
},
'input .js-card-movement-range input'(event, tpl) {
tpl.find('.js-card-movement-to').setCustomValidity('');
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
},
'click .js-clear-movement-range'(event, tpl) {
event.preventDefault(); tpl.find('.js-card-movement-to').setCustomValidity('');
event.preventDefault();
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
Filter.movementDate.reset(); Filter.resetExceptions();
},
'submit .js-card-date-range'(event, tpl) {
@@ -119,22 +135,20 @@ Template.filterSidebar.events({
const valid = Filter.dateRange.set({ field: tpl.find('.js-card-date-field').value,
from: tpl.find('.js-card-date-from').value, to: end.value,
includeMissing: tpl.find('.js-card-date-missing').checked });
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
if (!valid) end.reportValidity();
else Filter.resetExceptions();
rangeRefused(end, tpl.dateRangeError, !valid);
if (valid) Filter.resetExceptions();
},
'input .js-card-date-range input'(event, tpl) {
tpl.find('.js-card-date-to').setCustomValidity('');
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
},
'click .js-clear-date-range'(event, tpl) {
event.preventDefault();
// Reset the native date controls as well as the reactive model. WebKit can
// retain an edited date segment after Blaze changes the value attribute.
for (const selector of ['.js-card-date-from', '.js-card-date-to']) {
const input = tpl.find(selector);
input.value = '';
input.setCustomValidity('');
tpl.find(selector).value = '';
}
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
Filter.dateRange.reset(); Filter.resetExceptions();
},
'submit .js-column-age-filter'(event, tpl) {
+14 -1
View File
@@ -53,7 +53,20 @@ hotkeys.filter = (event) => {
if (currentElement instanceof HTMLInputElement || currentElement instanceof HTMLSelectElement || currentElement instanceof HTMLTextAreaElement)
return false;
if (currentElement.closest('button, a[href], summary, [role="button"], [role="tab"], [role="checkbox"]'))
// A focused control keeps the keys it uses itself, and only those: Enter
// activates any of them, Space a button, summary or checkbox (not a link),
// and End, Home and the arrows move between tabs (A01). Every other key is
// still a shortcut. Refusing ALL keys left shortcuts dead after a click on
// a minicard - the minicard is a link, and clicking its date badge or its
// menu leaves that link focused - and after any popup closed, because
// closing returns focus to the button that opened it (#6755).
const keysOwnedBy = {
'button, a[href], summary, [role="button"], [role="tab"], [role="checkbox"]': [13],
'button, summary, [role="button"], [role="checkbox"]': [32],
'[role="tab"]': [35, 36, 37, 38, 39, 40],
};
if (Object.keys(keysOwnedBy).some(controls =>
keysOwnedBy[controls].includes(event.keyCode) && currentElement.closest(controls)))
return false;
// We can trigger events!
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-12.76-win64.zip](https://github.com/wekan/wekan/releases/download/v12.76/wekan-12.76-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-12.78-win64.zip](https://github.com/wekan/wekan/releases/download/v12.78/wekan-12.78-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-12.76-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-12.78-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+15
View File
@@ -78,6 +78,8 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/export', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
@@ -190,6 +192,8 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken (omit for a public board)
*/
WebApp.handlers.get('/api/boards/:boardId/calendar.ics', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
@@ -241,6 +245,9 @@ if (Meteor.isServer) {
const cardSelector = await exporter._scopedCardSelector(boardId);
cardSelector.archived = false;
const cards = await ReactiveCache.getCards(cardSelector);
// Admin Panel / Anonymize export users: @mentions in what the feed carries
// (CrossBoardExportBleed - only the JSON export anonymized).
await require('/models/lib/importExportSecurity').anonymizeExportIdentities({ boardId, cards });
const cardShapes = cards
.filter(card => card.startAt || card.dueAt || card.endAt || card.receivedAt)
.map(card => ({
@@ -276,6 +283,8 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/exportZip', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
@@ -413,6 +422,8 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get(`/api/boards/:boardId/export/${format}`, safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
await serveExternalExport(req, res, format);
}));
});
@@ -440,6 +451,8 @@ if (Meteor.isServer) {
WebApp.handlers.get(
'/api/boards/:boardId/attachments/:attachmentId/export',
safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
const attachmentId = req.params.attachmentId;
let user = null;
@@ -541,6 +554,8 @@ if (Meteor.isServer) {
* @param {string} delimiter delimiter to use while building export. Default is comma ','
*/
WebApp.handlers.get('/api/boards/:boardId/export/csv', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
+4
View File
@@ -118,6 +118,8 @@ runOnServer(function() {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/charts/:chartKey/exportPDF', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
if (!CHART_KEYS.has(req.params.chartKey)) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Unknown chart');
@@ -149,6 +151,8 @@ runOnServer(function() {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/charts/:chartKey/exportExcel', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
if (!CHART_KEYS.has(req.params.chartKey)) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Unknown chart');
+2
View File
@@ -66,6 +66,8 @@ runOnServer(function() {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/exportExcel', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
+2
View File
@@ -32,6 +32,8 @@ runOnServer(function() {
WebApp.handlers.get(
'/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel',
safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
const paramListId = req.params.listId;
const paramCardId = req.params.cardId;
+4
View File
@@ -88,6 +88,8 @@ runOnServer(function() {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
const paramListId = req.params.listId;
const paramCardId = req.params.cardId;
@@ -190,6 +192,8 @@ runOnServer(function() {
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/exportPDF', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const boardId = req.params.boardId;
let user = null;
+29 -3
View File
@@ -11,6 +11,7 @@ const {
buildUserAnonymizationMap,
anonymizeUserDoc,
anonymizeBoardTextInPlace,
anonymizeExportIdentities,
} = require('./lib/importExportSecurity');
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
@@ -134,6 +135,19 @@ export class Exporter {
} else if (this._scope.checklistId) {
const checklist = await ReactiveCache.getChecklist(this._scope.checklistId);
selector._id = checklist ? checklist.cardId : '__no_such_card__';
// CrossBoardExportBleed (GHSA-8885-xrq4-j47h): a checklist on a card of
// ANOTHER board names nothing on this one. No WeKan screen asks for it -
// the export menus pass the open card's own checklist - so it is an
// attempt; a deleted checklist from an old link only matches nothing.
if (checklist) {
const card = await ReactiveCache.getCard(checklist.cardId);
if (!card || card.boardId !== boardId) {
try {
require('/server/lib/securityLog').record({ key: 'authz.export-checklist-scope', action: 'blocked',
source: 'export', detail: 'checklist scope from another board' });
} catch (e) { /* logging must never break the guard */ }
}
}
} else {
if (this._scope.swimlaneId) selector.swimlaneId = this._scope.swimlaneId;
if (this._scope.listId) selector.listId = this._scope.listId;
@@ -673,16 +687,19 @@ export class Exporter {
? { cardId: { $in: cardIds } }
: { $or: [{ boardId }, { cardId: { $in: cardIds } }] });
await streamArray('activities', activitiesRaw, activitySelector, noBoardId, d => userIds.add(d.userId));
// A checklist scope exports THAT checklist, on the card that holds it.
// A checklist scope exports THAT checklist, on the card that holds it - and
// only when that card is one of this board's exported cards. Without the
// cardId bound a checklistId from another board, even a private one, was
// streamed with every item and its creator (CrossBoardExportBleed).
const checklistSelector = !this.hasField('checklists')
? { _id: '__none__' }
: (this._scope.checklistId
? { _id: this._scope.checklistId }
? { _id: this._scope.checklistId, cardId: { $in: cardIds } }
: { cardId: { $in: cardIds } });
const checklistItemSelector = !this.hasField('checklists')
? { _id: '__none__' }
: (this._scope.checklistId
? { checklistId: this._scope.checklistId }
? { checklistId: this._scope.checklistId, cardId: { $in: cardIds } }
: { cardId: { $in: cardIds } });
await streamArray('checklists', checklistsRaw, checklistSelector, {}, d => userIds.add(d.userId));
await streamArray('checklistItems', checklistItemsRaw, checklistItemSelector, {});
@@ -842,6 +859,9 @@ export class Exporter {
// (strings only), pass 2 writes rows. Output matches buildCsv() exactly.
async buildCsvStream(res, userDelimiter = ',', userLanguage = 'en') {
await require('/server/lib/adminOnlyCustomFields').assertFieldExport(this._boardId, this._customFieldViewerId);
// CrossBoardExportBleed (GHSA-8885-xrq4-j47h): the streaming CSV/TSV asked
// neither the export switch nor the anonymization that build() applies.
await assertExportEnabled('export:csv');
const cardsRaw = require('/models/cards').default.rawCollection();
const cardSelector = { boardId: this._boardId, linkedId: { $in: ['', null] } };
@@ -909,11 +929,17 @@ export class Exporter {
{ _id: { $in: Array.from(userIds).filter(Boolean) } },
{ fields: { _id: 1, username: 1 } },
);
// The usernames in the owner/members/assignee/... columns, and below each
// card's @mentions and requested-by / assigned-by, as build() does.
const anonymized = await anonymizeExportIdentities({
boardId: this._boardId, users: lookup.users, language: userLanguage,
});
// Pass 2 — stream one row per card.
{
const cursor = cardsRaw.find(cardSelector);
for await (const card of cursor) {
if (anonymized) anonymizeBoardTextInPlace({ cards: [card] }, anonymized.byUsername);
await writeRow(applyMask(buildCsvCardRow(card, lookup, customFieldMap), columnMask));
}
}
+4
View File
@@ -105,6 +105,10 @@ async function collect(boardId, fields, format) {
if (want('people')) cards.forEach(c => [c.userId, ...(c.assignees || []), ...(c.members || [])].forEach(id => id && userIds.add(id)));
comments.forEach(c => c.userId && userIds.add(c.userId));
const users = userIds.size ? await ReactiveCache.getUsers({ _id: { $in: [...userIds] } }) : [];
// Admin Panel / Anonymize export users, for every external format: the names
// and the cards' and comments' text (CrossBoardExportBleed - only the JSON
// export anonymized).
await require('./importExportSecurity').anonymizeExportIdentities({ boardId, users, cards, comments });
const username = id => (users.find(u => u._id === id) || {}).username;
const items = cards.map(c => {
+53 -1
View File
@@ -24,16 +24,42 @@ export async function getImportExportSecuritySettings() {
};
}
// An export asked for while the admin has disabled all export: the menus are
// hidden then, so it is recorded in Admin Panel / Problems (never costing the
// account - see the catalog key). CrossBoardExportBleed (GHSA-8885-xrq4-j47h).
function recordDisabledExport(source) {
try {
require('/server/lib/securityLog').record({ key: 'authz.export-disabled', action: 'blocked',
source, detail: 'export while all export is disabled' });
} catch (e) { /* logging must never break the guard */ }
}
// Throw if the admin has disabled ALL export. Call at every export entry point.
export async function assertExportEnabled() {
export async function assertExportEnabled(source = 'export') {
const Settings = require('/models/settings').default;
const s = (await Settings.findOneAsync({})) || {};
if (s.disableAllExport) {
recordDisabledExport(source);
const { Meteor } = require('meteor/meteor');
throw new Meteor.Error('export-disabled', 'Export is disabled by the administrator.');
}
}
// The same switch for an HTTP export route, answered before anything is
// written: true when export is disabled and the 403 has been sent. Every export
// route calls it first - the streaming CSV/TSV, Excel, PDF, chart, calendar
// and external-format exporters did not ask at all, so "Disable all export"
// left them working (CrossBoardExportBleed). tests/exportDisabledEverywhere
// fails on an export route that does not call it.
export async function refuseDisabledExport(res, source = 'export') {
const s = await getImportExportSecuritySettings();
if (!s.disableAllExport) return false;
recordDisabledExport(source);
if (!res.headersSent) res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Export is disabled by the administrator.');
return true;
}
// Throw if the admin has disabled ALL import. Call at every import entry point.
export async function assertImportEnabled() {
const Settings = require('/models/settings').default;
@@ -166,3 +192,29 @@ export function anonymizeBoardTextInPlace(board, byUsername) {
if (comment) comment.text = rewriteMentionsInText(comment.text, byUsername);
});
}
// Anonymize what one exporter is about to write, when Admin Panel says so:
// the user docs it will name (username, full name, initials, avatar) and the
// @mentions and requested-by / assigned-by text of its cards and comments. The
// map is built as build() builds it - from the exported users and the board's
// members - so a member only mentioned in a title is anonymized too. Returns
// the map, or null when anonymization is off. Every exporter that writes a
// user identity calls it: only build() and buildStream() did, so the CSV/TSV,
// Excel, PDF and external formats carried real names (CrossBoardExportBleed).
export async function anonymizeExportIdentities({ boardId, users = [], cards = [], comments = [], language = 'en' } = {}) {
const security = await getImportExportSecuritySettings();
if (!security.anonymizeExportUsers) return null;
const { ReactiveCache } = require('/imports/reactiveCache');
const known = new Map();
for (const u of users) if (u && u._id) known.set(String(u._id), u);
const board = boardId ? await ReactiveCache.getBoard(boardId) : null;
const memberIds = ((board && board.members) || []).map(m => m && m.userId)
.filter(id => id && !known.has(String(id)));
const members = memberIds.length
? await ReactiveCache.getUsers({ _id: { $in: memberIds } }, { fields: { _id: 1, username: 1 } })
: [];
const map = buildUserAnonymizationMap([...known.values(), ...members], anonymizedUserWord(language));
for (const u of known.values()) anonymizeUserDoc(u, map);
anonymizeBoardTextInPlace({ cards, comments }, map.byUsername);
return map;
}
+11
View File
@@ -112,6 +112,13 @@ const CATALOG = {
'xss.handler-link': { category: 'xss', bleed: 'HandlerLink', severity: 'medium', cwe: 'CWE-939' },
'spoofing.xff': { category: 'spoofing', bleed: 'MetricsBleed', severity: 'medium', cwe: 'CWE-290' },
'authz.export': { category: 'authz', bleed: 'ImpersonateBleed', severity: 'high', cwe: 'CWE-863' },
// CrossBoardExportBleed (GHSA-8885-xrq4-j47h). A board export scoped to a
// checklist of ANOTHER board's card: no export menu asks for one.
'authz.export-checklist-scope': { category: 'authz', bleed: 'CrossBoardExportBleed', severity: 'medium', cwe: 'CWE-639' },
// An export asked for while Admin Panel turned all export off. The menus are
// hidden then, but a page opened before the switch still shows them, so it
// never costs the account.
'authz.export-disabled': { category: 'authz', bleed: 'CrossBoardExportBleed', severity: 'medium', cwe: 'CWE-863', blocksAccount: false },
'authn.import': { category: 'authn', bleed: 'ImportBleed', severity: 'critical', cwe: 'CWE-306' },
'authn.miniprofile': { category: 'authn', bleed: 'MiniProfileBleed', severity: 'medium', cwe: 'CWE-306' },
'authz.board': { category: 'authz', bleed: 'BoardBleed', severity: 'high', cwe: 'CWE-863' },
@@ -208,6 +215,10 @@ const CATALOG = {
'authz.readonly': { category: 'authz', bleed: 'ReadOnlyBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.calendar': { category: 'authz', bleed: 'CalendarBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.assigned': { category: 'authz', bleed: 'AssignedBleed', severity: 'medium', cwe: 'CWE-863' },
// UnassignedAttachBleed (GHSA-w26g-rc43-q6cm): an assigned-only member
// added or soft-deleted attachments, through the REST API, on a card that
// was no longer assigned to them. Only a caller with a kept card id reaches it.
'authz.assigned-attachment-write': { category: 'authz', bleed: 'UnassignedAttachBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.tenant': { category: 'authz', bleed: 'TenantBleed', severity: 'medium', cwe: 'CWE-862' },
'authz.search-session': { category: 'authz', bleed: 'SessionBleed', severity: 'medium', cwe: 'CWE-639' },
'authz.database': { category: 'authz', bleed: 'DatabaseBleed', severity: 'high', cwe: 'CWE-863' },
+8
View File
@@ -3,6 +3,7 @@ import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { TAPi18n } from '/imports/i18n';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { BOARD_EXPORT_FIELD_KEYS } from '/models/lib/exportFields';
import { anonymizeExportIdentities } from '/models/lib/importExportSecurity';
import { fileStoreStrategyFactory } from '/models/attachments.server';
// The layout, described once for both formats, and what a page makes of it.
import {
@@ -397,6 +398,10 @@ class ExporterCardPDF extends PDFExporterBase {
usersById[userId] = await ReactiveCache.getUser({ _id: userId });
}),
);
// Admin Panel / Anonymize export users (CrossBoardExportBleed - only the
// JSON export anonymized): the people named and the card's text.
await anonymizeExportIdentities({ boardId: this._boardId, users: Object.values(usersById).filter(Boolean),
cards: [card, ...subtasks], comments, language: this.userLanguage });
return {
board,
@@ -556,6 +561,9 @@ class ExporterBoardPDF extends PDFExporterBase {
await Promise.all([...userIds].filter(Boolean).map(async userId => {
usersById[userId] = await ReactiveCache.getUser({ _id: userId });
}));
// Admin Panel / Anonymize export users (CrossBoardExportBleed).
await anonymizeExportIdentities({ boardId: this._boardId, users: Object.values(usersById).filter(Boolean),
cards: [...cards, ...subtasks], comments, language: this.userLanguage });
const itemsByChecklist = byCard(checklistItems, 'checklistId');
+1 -1
View File
@@ -44,7 +44,7 @@ class ExporterChartExcel {
}
const data = ['scrumVelocity', 'scrumSprint', 'scrumDaily'].includes(this._chartKey)
? await loadScrumChartData(this.userId, this._boardId, this._chartKey, this.options)
: await loadBoardChartData(this._boardId, this._chartKey, this.options);
: await loadBoardChartData(this._boardId, this._chartKey, { ...this.options, forExport: true });
const { title, headers, rows, notices = [] } = chartExportRows(
this._chartKey, data || {}, (key, fallback) => this.__(key, fallback));
const details = require('/models/lib/flowAnalyticsRows').flowDetailRows(
+1 -1
View File
@@ -64,7 +64,7 @@ class ExporterChartPDF {
}
const data = ['scrumVelocity', 'scrumSprint', 'scrumDaily'].includes(this._chartKey)
? await loadScrumChartData(this.userId, this._boardId, this._chartKey, this.options)
: await loadBoardChartData(this._boardId, this._chartKey, this.options);
: await loadBoardChartData(this._boardId, this._chartKey, { ...this.options, forExport: true });
const { title, headers, rows, notices = [] } = chartExportRows(
this._chartKey, data || {}, (key, fallback) => this.__(key, fallback));
const details = require('/models/lib/flowAnalyticsRows').flowDetailRows(
+10
View File
@@ -3,6 +3,7 @@ import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { createWorkbook, createWorkbookWriter } from './createWorkbook';
import { attachmentDisposition, exportFilename } from '/models/lib/exportFilename';
import { anonymizeExportIdentities, anonymizeBoardTextInPlace, rewriteMentionsInText } from '/models/lib/importExportSecurity';
import {
formatDateTime,
formatDate,
@@ -117,6 +118,10 @@ class ExporterExcel {
const userFields = { fields: { _id: 1, username: 1, 'profile.initials': 1, 'profile.avatarUrl': 1 } };
const users = await ReactiveCache.getUsers({ _id: { $in: Array.from(userIds).filter(Boolean) } }, userFields);
// Admin Panel / Anonymize export users: every name below comes from these
// docs, and each card's and comment's text is rewritten as it is streamed
// (CrossBoardExportBleed - only the JSON export anonymized).
const anonymized = await anonymizeExportIdentities({ boardId: this._boardId, users, language: this.userLanguage });
// member/assignee username map + comma-joined member list, as before.
const jmeml = {};
let jmem = '';
@@ -553,6 +558,7 @@ class ExporterExcel {
{
const cursor = cardsRaw.find(cardSelector);
for await (const jcard of cursor) {
if (anonymized) anonymizeBoardTextInPlace({ cards: [jcard] }, anonymized.byUsername);
//get member info
let jcmem = '';
for (const j in jcard.members) {
@@ -805,6 +811,10 @@ class ExporterExcel {
for await (const jcomment of cursor) {
//card title (via id->title map, not an O(n²) scan)
jcomment.cardTitle = cardTitleById[jcomment.cardId] || '';
if (anonymized) {
anonymizeBoardTextInPlace({ comments: [jcomment] }, anonymized.byUsername);
jcomment.cardTitle = rewriteMentionsInText(jcomment.cardTitle, anonymized.byUsername);
}
if (jcomment.cardTitle == '') {
continue;
}
+5
View File
@@ -5,6 +5,7 @@ import { createWorkbookWriter } from './createWorkbook';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { ExporterExcelCard } from './ExporterExcelCard';
import { attachmentDisposition, exportFilename } from '/models/lib/exportFilename';
import { anonymizeExportIdentities } from '/models/lib/importExportSecurity';
const { groupByParents } = require('/models/lib/cardParents');
// A BOARD in the CARD export's layout (#1173).
@@ -147,6 +148,10 @@ class ExporterExcelBoard {
const users = await ReactiveCache.getUsers(
{ _id: { $in: [...userIds].filter(Boolean) } },
{ fields: { _id: 1, username: 1 } });
// Admin Panel / Anonymize export users, before any name is mapped
// (CrossBoardExportBleed - only the JSON export anonymized).
await anonymizeExportIdentities({ boardId: this._boardId, users: users || [],
cards: [...cards, ...subtasks], comments, language: this.userLanguage });
(users || []).forEach(u => { userMap[u._id] = u.username; });
return {
+19 -7
View File
@@ -10,6 +10,7 @@ import { buildExportCardDocument, formatExportFileSize } from '/models/lib/cardE
import { renderCardDocumentExcel } from './renderCardDocumentExcel';
import { accentOf } from '/models/lib/themeAccents';
import { attachmentDisposition, exportFilename } from '/models/lib/exportFilename';
import { anonymizeExportIdentities } from '/models/lib/importExportSecurity';
const { childrenSelector } = require('/models/lib/cardParents');
// ── Constants ────────────────────────────────────────────────────────────────
@@ -295,6 +296,7 @@ class ExporterExcelCard {
const swimlane = needsBoardInfo ? await ReactiveCache.getSwimlane(card.swimlaneId) : null;
const userMap = {};
const userDocs = [];
if (needsPeople || needsDates || needsComments || needsAttachments) {
const userIds = new Set();
if (card.userId) userIds.add(card.userId);
@@ -309,15 +311,9 @@ class ExporterExcelCard {
{ fields: { _id: 1, username: 1 } },
);
uDocs.forEach(u => { userMap[u._id] = u.username; });
userDocs.push(...uDocs);
}
const creatorName = userMap[card.userId] || '';
const ownerName = (card.members && card.members.length > 0)
? (userMap[card.members[0]] || creatorName)
: creatorName;
const memberNames = (card.members || []).map(id => userMap[id] || id).join(', ');
const assigneeNames = (card.assignees || []).map(id => userMap[id] || id).join(', ');
const checklists = needsChecklists ? await ReactiveCache.getChecklists({ cardId: this._cardId }) : [];
const checklistItems= needsChecklists ? await ReactiveCache.getChecklistItems({ cardId: this._cardId }) : [];
const subtasks = needsSubtasks ? await ReactiveCache.getCards({ boardId: this._boardId, ...childrenSelector(this._cardId) }) : [];
@@ -354,9 +350,25 @@ class ExporterExcelCard {
{ fields: { _id: 1, username: 1 } },
);
extra.forEach(u => { userMap[u._id] = u.username; });
userDocs.push(...extra);
}
}
// Admin Panel / Anonymize export users: the names, and the card's,
// subtasks' and comments' text (CrossBoardExportBleed - only the JSON
// export anonymized). The names below are built after it.
if (await anonymizeExportIdentities({ boardId: this._boardId, users: userDocs,
cards: [card, ...subtasks], comments, language: this.userLanguage })) {
userDocs.forEach(u => { userMap[u._id] = u.username; });
}
const creatorName = userMap[card.userId] || '';
const ownerName = (card.members && card.members.length > 0)
? (userMap[card.members[0]] || creatorName)
: creatorName;
const memberNames = (card.members || []).map(id => userMap[id] || id).join(', ');
const assigneeNames = (card.assignees || []).map(id => userMap[id] || id).join(', ');
// ── Workbook & worksheet setup ───────────────────────────────────────
// Streamed to the response as it is written, so the headers go first.
const filename = exportFilename('card', key => this.__(key), card.cardNumber || 1, 'xlsx');
+326 -272
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v12.76.0",
"version": "v12.78.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -41,9 +41,9 @@
"@rsdoctor/rspack-plugin": "^1.6.4",
"@rspack/cli": "^2.2.9",
"@rspack/core": "^2.2.9",
"@rspack/dev-server": "^2.2.1",
"@rspack/dev-server": "^2.2.2",
"@swc/core": "^1.16.18",
"@typescript-eslint/eslint-plugin": "^8.72.0",
"@typescript-eslint/eslint-plugin": "^8.72.1",
"@typescript-eslint/parser": "^8.72.0",
"chai": "^6.3.0",
"chai-as-promised": "^8.0.2",
@@ -56,7 +56,7 @@
"style-loader": "^4.0.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1155.0",
"@aws-sdk/client-s3": "3.1159.0",
"@aws-sdk/lib-storage": "~3.1155.0",
"@azure/storage-blob": "^12.33.0",
"@babel/parser": "^8.0.4",
@@ -64,7 +64,7 @@
"@fastify/busboy": "^3.2.4",
"@google-cloud/storage": "^8.0.1",
"@meteorjs/reify": "^0.25.4",
"@node-saml/node-saml": "^5.1.0",
"@node-saml/node-saml": "^5.2.0",
"@rwap/jquery-ui-touch-punch": "^1.0.11",
"@simplewebauthn/server": "14.0.3",
"@swc/helpers": "^0.5.23",
@@ -93,18 +93,18 @@
"file-type": "^22.2.0",
"filesize": "^11.0.28",
"frappe-gantt": "^1.2.2",
"glob": "^13.0.6",
"glob": "^14.0.3",
"highlight.js": "^11.12.0",
"hotkeys-js": "^4.0.8",
"htmlparser2": "^12.0.0",
"i18next": "^26.5.4",
"i18next": "^26.5.11",
"i18next-sprintf-postprocessor": "^0.2.2",
"imapflow": "2.8.0",
"isomorphic-git": "1.44.3",
"imapflow": "2.9.1",
"isomorphic-git": "1.44.8",
"jquery": "^4.0.0",
"jquery-ui": "^1.14.2",
"limax": "^4.2.3",
"mailparser": "3.12.2",
"mailparser": "3.12.4",
"markdown-it": "^15.0.3",
"markdown-it-emoji": "^3.0.0",
"markdown-it-math": "^6.0.0",
+4 -1
View File
@@ -29,7 +29,10 @@ Npm.depends({
// it does not reimplement any SAML/XML-signature handling itself. License
// verified directly from https://github.com/node-saml/node-saml/blob/master/LICENSE
// (MIT, (c) Henri Bergius, Michael Bosworth).
'@node-saml/node-saml': '5.1.0',
// The SAME version as the app's package.json: this copy is the one that
// verifies a login, so a dependency bump of the app's alone (as Dependabot
// makes) changes nothing that runs. tests/samlLogin.test.cjs.
'@node-saml/node-saml': '5.2.0',
// Parses the IdP's application/x-www-form-urlencoded POST to the ACS
// endpoint (saml_server.js's urlEncodedParser). A local Meteor package
// resolves npm dependencies only through its own Npm.depends - it happened
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v12.76</title>
<title>WeKan REST API v12.78</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -484,11 +484,11 @@ data-styled.g658[id="sc-hHttOM"]{content:"dKSNwp,"}/*!sc*/
<body>
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<div id="redoc"><div hidden=""><script>try{var m=localStorage.getItem("redoc.colorMode");if(m!=="light"&&m!=="dark")m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.classList.add(m)}catch(e){}</script></div><div style="--navbar-height:0px;--navbar-stack-height:0px" class="sc-kIbBWU fFQMWf"><div class="sc-cjUTkN dGsweu menu-content"><div class="sc-jqwVmx jjzLa-d"><div data-testid="search-trigger" class="sc-cToEys dffrbr"><div class="sc-cRcubC ddjiE"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" data-component-name="icons/SearchIcon/SearchIcon" class="sc-cHazmW gTzQi"><path d="M14.5 13.7931L10.724 10.017C11.6313 8.9277 12.0838 7.5305 11.9872 6.11608C11.8907 4.70165 11.2525 3.37891 10.2055 2.423C9.15857 1.4671 7.78336 0.951637 6.36602 0.983845C4.94867 1.01605 3.5983 1.59345 2.59582 2.59593C1.59335 3.5984 1.01595 4.94877 0.983738 6.36612C0.95153 7.78347 1.46699 9.15867 2.4229 10.2057C3.3788 11.2526 4.70155 11.8908 6.11597 11.9873C7.53039 12.0839 8.92759 11.6314 10.0169 10.7241L13.793 14.5001L14.5 13.7931ZM2.00001 6.50012C2.00001 5.6101 2.26393 4.74007 2.7584 4.00005C3.25286 3.26003 3.95567 2.68325 4.77794 2.34266C5.6002 2.00207 6.505 1.91295 7.37792 2.08658C8.25083 2.26022 9.05266 2.6888 9.68199 3.31814C10.3113 3.94747 10.7399 4.7493 10.9135 5.62221C11.0872 6.49513 10.9981 7.39993 10.6575 8.22219C10.3169 9.04446 9.7401 9.74726 9.00008 10.2417C8.26006 10.7362 7.39003 11.0001 6.50001 11.0001C5.30694 10.9988 4.16312 10.5243 3.31949 9.68064C2.47587 8.83701 2.00133 7.69319 2.00001 6.50012Z"></path></svg><span>Loading...</span></div></div></div><div class="sc-dNLPWG dtcefb"><ul data-component-name="Menu/Menu" class="sc-kBPbfe ctPApS"><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="true" class="sc-epufQc fYmDZR"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Wekan REST API" class="sc-eStdmu iyAVQU">Wekan REST API</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-default" role="listitem"><a class="sc-iJONRL iBHKJo" href="/section/production-security-concerns" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc itBMtf"><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Production Security Concerns" class="sc-eStdmu liVEkC">Production Security Concerns</span></span></div></li></a></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/login" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Login" class="sc-eStdmu liVEkC">Login</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/attachmentstoragesettings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="AttachmentStorageSettings" class="sc-eStdmu liVEkC">AttachmentStorageSettings</span></span></div></li></a><div style="display:none;height:0px;overflow:hidden" class="sc-hgJgCR dlNiXx"></div></div><div data-component-name="Menu/MenuItem" class="sc-gwVKcS fGSWQx menu-item-type-group" role="listitem"><a class="sc-iJONRL iBHKJo" href="/settings" data-discover="true"><li role="none" data-testid="menu-item-label" data-active="false" class="sc-epufQc hKPPiy"><div class="sc-jokKPH jtaKKl"><svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" color="--tree-content-color-default" data-component-name="icons/ChevronRightIcon/ChevronRightIcon" class="sc-iUlCMS kEEIsB"><path d="M11 8L6.00002 13L5.30002 12.3L9.60002 8L5.30002 3.7L6.00002 3L11 8Z"></path></svg></div><div class="sc-boLXHU jshptd"><span class="sc-bcyEQV hrIrOF"><span data-text="Settings" classLine truncated
<script type="module">
import { hydrate } from "https://cdn.redocly.com/redoc/v3.0.1/bundles/redoc.standalone.js";
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.76","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_definition = {"openapi":"3.2.0","info":{"title":"Wekan REST API","version":"v12.78","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `\u003cfield>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the global admin can call this. CountsLine truncated
const __redoc_options = {"skipBundle":true,"disableTelemetry":false};
hydrate(__redoc_definition, __redoc_options, document.getElementById('redoc'));
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v12.76
version: v12.78
description: |
The REST API allows you to control and extend Wekan with ease.
+14
View File
@@ -70,7 +70,21 @@ bcrypt="$server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
if [ -d "$bcrypt" ]; then
(cd "$bcrypt" && npm install --no-save node-addon-api@^8 >/dev/null 2>&1 || true)
fi
# Every rebuild directory declares the reviewed native installers it holds,
# so npm 12 runs their install scripts (releases/prepare-bundle-npm.mjs). A
# bundle released before that was declared blocked them: argon2 and bcrypt
# have no Android prebuild, were never compiled, and WeKan did not start. It
# is idempotent, so a bundle already prepared is left as it is.
"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"
(cd "$server" && npm install)
# accounts-password requires both at startup with no fallback. Load them here
# with Android's Node.js, so a missing addon stops the build with its name -
# check-addon-arch below passes Linux prebuilds, which Android does not load.
(cd "$server/npm/node_modules/meteor/accounts-password" && "$node" -e '
for (const name of ["argon2", "bcrypt"]) {
try { require(name); console.log(`${name} loads on Android`); }
catch (error) { console.error(`::error::${name} has no Android addon: ${error.message.split("\n")[0]}`); process.exit(1); }
}')
# sharp has no Android prebuilt: built from source against Termux's libvips.
if ! (cd "$appmods/sharp" && npm install --no-save --omit=dev node-addon-api node-gyp && "$node" install/build.js); then
echo "::warning::sharp could not be built against Termux's libvips here; it runs as WebAssembly (@img/sharp-wasm32)."
+47 -6
View File
@@ -6,9 +6,50 @@ import os
from pathlib import Path
import signal
import subprocess
import sys
import time
def live_group_members_darwin(pgid):
"""The live (not zombie) members of a process group, from libproc.
Asked of the kernel directly rather than by running `ps`, which a sandboxed
or locked-down Mac may refuse to start ("Operation not permitted": the
smoke test then failed a correct app). A member whose BSD info cannot be
read is a zombie or already gone."""
import ctypes
lib = ctypes.CDLL('/usr/lib/libproc.dylib')
proc_pgrp_only, proc_pidtbsdinfo, szomb = 2, 3, 5
class BSDInfo(ctypes.Structure):
_fields_ = [('pbi_flags', ctypes.c_uint32), ('pbi_status', ctypes.c_uint32),
('rest', ctypes.c_byte * 128)]
size = lib.proc_listpids(proc_pgrp_only, pgid, None, 0)
pids = (ctypes.c_int * (max(size, 0) // 4 + 64))()
size = lib.proc_listpids(proc_pgrp_only, pgid, pids, ctypes.sizeof(pids))
live = []
for member in pids[:max(size, 0) // 4]:
if not member:
continue
info = BSDInfo()
got = lib.proc_pidinfo(member, proc_pidtbsdinfo, 0, ctypes.byref(info), ctypes.sizeof(info))
if got > 0 and info.pbi_status != szomb:
live.append(member)
return live
def group_has_live_member_ps(pgid):
listing = subprocess.check_output(
['ps', '-axo', 'pgid=,stat='], text=True, timeout=3)
for line in listing.splitlines():
group, state = line.split()
if int(group) == pgid and not state.startswith('Z'):
return True
return False
def signal_group(pid, sig):
try:
os.killpg(pid, sig)
@@ -19,12 +60,12 @@ def signal_group(pid, sig):
# Darwin can return EPERM for a group containing only zombies. Verify
# that no live member remains before treating it as a stopped group;
# a real permission failure must still fail the smoke test.
listing = subprocess.check_output(
['ps', '-axo', 'pgid=,stat='], text=True, timeout=3)
for line in listing.splitlines():
group, state = line.split()
if int(group) == pid and not state.startswith('Z'):
raise
if sys.platform == 'darwin':
live = bool(live_group_members_darwin(pid))
else:
live = group_has_live_member_ps(pid)
if live:
raise
return False
+18 -1
View File
@@ -69,8 +69,25 @@ export function prepareBundleNpm(bundle) {
const manifest = join(directory, 'package.json');
const data = existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : { private: true };
const policy = { argon2: true, bcrypt: true, 'useragent-ng': true };
if (JSON.stringify(data.allowScripts) !== JSON.stringify(policy)) {
// npm 12 applies allowScripts to the project's DEPENDENCIES only. A
// package that is installed but not declared is "extraneous", and its
// install script stays blocked whatever allowScripts says - so argon2
// and bcrypt were never compiled by Meteor's npm rebuild. Prebuilds hid
// it on Linux, macOS and Windows; Android has none, and its bundles did
// not start: "No native build was found for platform=android ...
// loaded from: .../accounts-password/node_modules/argon2". Declare each
// reviewed installer this directory has installed, at that version.
const declared = { ...data.dependencies, ...data.optionalDependencies };
const dependencies = { ...data.dependencies };
for (const name of Object.keys(policy)) {
const installed = join(directory, 'node_modules', name, 'package.json');
if (declared[name] || !existsSync(installed)) continue;
dependencies[name] = JSON.parse(readFileSync(installed, 'utf8')).version;
}
const sameDependencies = JSON.stringify(dependencies) === JSON.stringify(data.dependencies || {});
if (JSON.stringify(data.allowScripts) !== JSON.stringify(policy) || !sameDependencies) {
data.allowScripts = policy;
if (!sameDependencies) data.dependencies = dependencies;
if (existsSync(manifest)) makeWritable(manifest);
writeFileSync(manifest, JSON.stringify(data, null, 2) + '\n');
changed = true;
+22 -1
View File
@@ -71,6 +71,23 @@ export function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
// A Mach-O executable (macOS), thin or universal, read from its first four
// bytes: 64-bit, 32-bit and fat magic numbers, either byte order.
const MACHO_MAGIC = new Set(['feedfacf', 'cffaedfe', 'feedface', 'cefaedfe', 'cafebabe', 'bebafeca']);
export function isMachO(file) {
try {
const fd = fs.openSync(file, 'r');
try {
const head = Buffer.alloc(4);
return fs.readSync(fd, head, 0, 4, 0) === 4 && MACHO_MAGIC.has(head.toString('hex'));
} finally {
fs.closeSync(fd);
}
} catch (e) {
return false;
}
}
export function isWindowsTarget(target) {
return /^win/.test(target);
}
@@ -260,7 +277,11 @@ export function build(opts) {
fs.chmodSync(out, 0o755);
// macOS will not run an arm64 binary whose signature no longer matches,
// and injecting the archive invalidated it. Ad-hoc signing needs a Mac.
if (/^mac|^darwin/.test(opts.target)) {
// Decided by what the file IS - a Mach-O executable - and not only by the
// target's name: built from this Mac's own Node.js under another target
// label (tests/singleFile.test.cjs builds 'amd64'), it is still a macOS
// binary, and unsigned it was killed (SIGKILL) before printing anything.
if (/^mac|^darwin/.test(opts.target) || isMachO(out)) {
if (process.platform === 'darwin') {
execFileSync('codesign', ['--sign', '-', '--force', out], { stdio: 'inherit' });
} else {
+13 -2
View File
@@ -166,7 +166,13 @@ async function cmdBackup(c, out) {
stack.prepareDirs(c.layout);
const done = backup.backupTo(c.layout);
out(`Backed up ${done.copied.join(', ')} to ${done.dest}`);
if (wasRunning) out(`Started WeKan again (pid ${stack.startBackground(c, c.layout)}).`);
if (!wasRunning) return 0;
const pid = stack.startBackground(c, c.layout);
if (!(await status.waitUntilStarted(c.layout, pid))) {
out(`WeKan did not start again; see ${c.layout.logFile}`);
return 1;
}
out(`Started WeKan again (pid ${pid}).`);
return 0;
}
@@ -218,7 +224,12 @@ async function startBackground(c, out) {
}
const pid = stack.startBackground(c, c.layout);
const cfg = buildEnv({ file: readEnvFile(c.layout), layout: c.layout });
out(`Starting WeKan in the background (pid ${pid}) at ${cfg.rootUrl}. Log: ${c.layout.logFile}`);
// Returns once it is running, so `stop` or `status` right after it finds it.
if (!(await status.waitUntilStarted(c.layout, pid))) {
out(`WeKan did not start; see ${c.layout.logFile}`);
return 1;
}
out(`Started WeKan in the background (pid ${pid}) at ${cfg.rootUrl}. Log: ${c.layout.logFile}`);
return 0;
}
+20 -1
View File
@@ -48,6 +48,25 @@ async function probe(url, timeoutMs = 5000) {
}
}
/**
* Waits until the WeKan just started in the background (`pid`) has written
* its pid file - it does so only after reading its settings and, the first
* time, extracting FerretDB - so the command that started it can return with
* WeKan really running: a `stop` right after `start --background` or `backup`
* found no pid file yet, said "WeKan was not running" and left it running.
* True once the file names `pid`; false if that process ended or time ran out.
*/
async function waitUntilStarted(layout, pid, { timeoutMs = 30000, stepMs = 100, kill } = {}) {
const until = Date.now() + timeoutMs;
while (Date.now() < until) {
const info = readPid(layout);
if (info && info.pid === pid) return true;
if (!isAlive(pid, kill)) return false;
await new Promise(r => setTimeout(r, stepMs));
}
return false;
}
async function stop(layout, { platform = process.platform, waitMs = 30000, log = () => {} } = {}) {
const info = running(layout);
if (!info) return false;
@@ -86,4 +105,4 @@ async function stop(layout, { platform = process.platform, waitMs = 30000, log =
return true;
}
module.exports = { readPid, isAlive, running, probe, stop };
module.exports = { readPid, isAlive, running, probe, stop, waitUntilStarted };
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1276,
appVersion = 1278,
# Increment this for every release.
appMarketingVersion = (defaultText = "12.76~2026-10-31"),
appMarketingVersion = (defaultText = "12.78~2026-11-02"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+18 -3
View File
@@ -123,9 +123,24 @@ require(path.join(tool, "tools/utils/fiber-helpers")).makeGlobalAsyncLocalStorag
await iso.initFromPath(name, standardPath(dir));
cache._isopacks[name] = iso;
}
for (const name of Object.keys(versions)) if (localCatalog.getPackageSource(name)) await cache._ensurePackageLoaded(name, {
[name]: true
});
// A prebuilt package can imply one of WeKan's local package forks -
// ecmascript implies the local dynamic-import - so compiling an earlier
// local package (ddp-server) can ask for a local one .meteor/versions lists
// after it: "isopack dynamic-import not yet loaded". Load that one first,
// then retry.
const loadLocal = async (name, asked = new Set()) => {
for (;;) {
try {
return await cache._ensurePackageLoaded(name, { [name]: true });
} catch (error) {
const missing = /isopack (\S+) not yet loaded/.exec(error && error.message);
if (!missing || asked.has(missing[1]) || !localCatalog.getPackageSource(missing[1])) throw error;
asked.add(missing[1]);
await loadLocal(missing[1], asked);
}
}
};
for (const name of Object.keys(versions)) if (localCatalog.getPackageSource(name)) await loadLocal(name);
const use = fs.readFileSync('.meteor/packages', 'utf8').split('\n').map(x => x.split('#')[0].trim().split('@')[0]).filter(x => x && !['rspack', 'standard-minifier-css', 'standard-minifier-js', 'meteortesting:mocha', 'hot-module-replacement'].includes(x));
const t = new mod.exports.SourceServer({
packageMap,
+24 -3
View File
@@ -15,7 +15,7 @@ import RecoveryEvents from '/models/recoveryEvents';
import { recordRecoveryAudit } from '/server/lib/recoveryAudit';
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { softDeleteAttachment } from '/server/attachmentSoftDelete';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment } from '/server/lib/assignedOnlyAttachments';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment, mayWriteCardAttachment } from '/server/lib/assignedOnlyAttachments';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const HARD_MAX_API_FILE_BYTES = 64 * 1024 * 1024;
@@ -123,6 +123,7 @@ async function assertAttachmentTarget(boardId, swimlaneId, listId, cardId) {
(swimlaneId && (!swimlane || swimlane.boardId !== boardId))) {
throw new Meteor.Error('invalid-target', 'The target card, list or swimlane is not on the target board');
}
return card;
}
Meteor.methods({
@@ -164,6 +165,10 @@ Meteor.methods({
if (!(await userHasBoardWriteAccess(board, this.userId))) {
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this card');
}
// UnassignedAttachBleed: and the card itself, for an assigned-only member.
if (!mayWriteCardAttachment(board, this.userId, card, 'attachment-method:upload')) {
throw new Meteor.Error('not-authorized', 'You do not have permission to modify this card');
}
// Check if board allows attachments
if (!board.allowsAttachments) {
@@ -617,7 +622,12 @@ Meteor.methods({
// The target card, list and swimlane must be on the target board: they
// were written unchecked, so a file could be planted on a card of a
// board the caller cannot write (2026-10-02).
await assertAttachmentTarget(targetBoardId, targetSwimlaneId, targetListId, targetCardId);
const targetCard = await assertAttachmentTarget(targetBoardId, targetSwimlaneId, targetListId, targetCardId);
// UnassignedAttachBleed: an assigned-only member writes only onto a card
// assigned to them.
if (!mayWriteCardAttachment(targetBoard, this.userId, targetCard, 'attachment-method:copy')) {
throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card');
}
// A copy creates a new attachment, so honour the admin API upload limits.
const {
@@ -749,7 +759,12 @@ Meteor.methods({
// The target card, list and swimlane must be on the target board: they
// were written unchecked, so a file could be planted on a card of a
// board the caller cannot write (2026-10-02).
await assertAttachmentTarget(targetBoardId, targetSwimlaneId, targetListId, targetCardId);
const targetCard = await assertAttachmentTarget(targetBoardId, targetSwimlaneId, targetListId, targetCardId);
// UnassignedAttachBleed: an assigned-only member writes only onto a card
// assigned to them.
if (!mayWriteCardAttachment(targetBoard, this.userId, targetCard, 'attachment-method:move')) {
throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card');
}
try {
// Update attachment metadata
@@ -796,6 +811,12 @@ Meteor.methods({
if (!board || !(await userHasBoardWriteAccess(board, this.userId))) {
throw new Meteor.Error('not-authorized', 'You do not have permission to delete this attachment');
}
// UnassignedAttachBleed: board write access is not the card's. An
// assigned-only member deletes only from a card assigned to them.
const card = attachment.meta.cardId ? await ReactiveCache.getCard(attachment.meta.cardId) : null;
if (!mayWriteCardAttachment(board, this.userId, card, 'attachment-method:delete')) {
throw new Meteor.Error('not-authorized', 'You do not have permission to delete this attachment');
}
try {
// A soft delete (History.md §12.3): the file stays, the card history
+23 -1
View File
@@ -1,6 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
const { isAssignedOnlyMember } = require('/models/lib/boardCardScope');
const { isAssignedOnlyMember, assignedOnlyAllowsCard } = require('/models/lib/boardCardScope');
// An assigned-only member (isReadAssignedOnly, isNormalAssignedOnly,
// isCommentAssignedOnly) sees only the cards assigned to them, and so only
@@ -56,3 +56,25 @@ export async function mayReadBoardAttachment(board, userId, attachment, source =
}
return allowed;
}
// May this member WRITE an attachment of this card - add one to it, soft-delete
// one of it, or copy or move one onto it? Board write access is not enough: an
// assigned-only member writes only on a card assigned to them, as on every
// other card path. The REST routes asked only for board write access, so a
// member whose assignment was removed kept adding and deleting files on that
// card with the ids they had seen (UnassignedAttachBleed,
// GHSA-w26g-rc43-q6cm). A board-level file has no card and is not theirs to
// change either. `source` names the refusing route for Admin Panel -> Problems.
export function mayWriteCardAttachment(board, userId, card, source) {
if (!isAssignedOnlyMember(board, userId)) return true;
const allowed = !!card && card.boardId === board._id && assignedOnlyAllowsCard(board, userId, card);
if (!allowed) {
try {
require('/server/lib/securityLog').record({
key: 'authz.assigned-attachment-write', action: 'blocked', source, userId,
detail: 'Assigned-only member tried to change an attachment of a card not assigned to them.',
});
} catch (e) { /* logging must never break the guard */ }
}
return allowed;
}
+7
View File
@@ -147,6 +147,13 @@ export async function loadBoardChartData(boardId, chartKey, options = {}) {
await Promise.all([...userIds].map(async id => {
usersById[id] = await ReactiveCache.getUser({ _id: id });
}));
// A chart EXPORT (PDF / Excel) follows Admin Panel / Anonymize export
// users like every other export; the chart views keep the names
// (CrossBoardExportBleed - only the JSON export anonymized).
if (options.forExport) {
await require('/models/lib/importExportSecurity').anonymizeExportIdentities({
boardId, users: Object.values(usersById).filter(Boolean), cards: allCards });
}
const nameOf = id => (usersById[id] && (usersById[id].profile?.fullname || usersById[id].username)) || id;
if (chartKey === 'time') {
+16 -2
View File
@@ -43,6 +43,9 @@ async function walk(root) {
// single-file source is the file itself.
const sourceFile = (source, relative) => (source.file ? source.path : path.join(source.path, ...relative.split('/')));
// How long after start the macOS catch-up scan runs (see start()).
const CATCH_UP_SCAN_MS = 2000;
class FileEngine {
// `sources`: [{ area, key, path, file }] - `key` names the source within
// its area ('attachments', 'logs', 'recovery-events.jsonl', ...).
@@ -57,14 +60,25 @@ class FileEngine {
async start() {
this.log = await new SegmentLog(path.join(this.generation.dir, 'files'), 'ndjson', this.cipher).open();
// Watch BEFORE the first scan: a change made after the scan had passed a
// file but before watching began was missed until the next scan, five
// minutes later by default. A change seen by both is recorded once - the
// scan and the watcher share one queue.
if (this.watchEnabled) for (const source of this.sources) this.watch(source);
await this.scan();
this.scanTimer = setInterval(() => { this.scan().catch(this.onError); }, this.scanMs);
this.sealTimer = setInterval(() => { this.flushDirty().catch(this.onError); }, this.sealMs);
if (this.watchEnabled) for (const source of this.sources) this.watch(source);
// macOS reports file events through FSEvents, which delivers nothing for a
// moment after a watcher is created: one more scan catches what changed
// then. Not elsewhere - inotify is watching when fs.watch returns, and a
// second full scan of a large store would double the start-up cost.
if (this.watchEnabled && process.platform === 'darwin') {
this.catchUpTimer = setTimeout(() => { this.scan().catch(this.onError); }, CATCH_UP_SCAN_MS);
}
return this;
}
async stop() {
clearInterval(this.scanTimer); clearInterval(this.sealTimer);
clearInterval(this.scanTimer); clearInterval(this.sealTimer); clearTimeout(this.catchUpTimer);
for (const watcher of this.watchers) watcher.close();
await this.busy?.catch(() => {});
await this.flushDirty();
+19 -1
View File
@@ -18,7 +18,7 @@ import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
import { tripCanary } from '/server/lib/canary';
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { softDeleteAttachment } from '/server/attachmentSoftDelete';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment } from '/server/lib/assignedOnlyAttachments';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment, mayWriteCardAttachment } from '/server/lib/assignedOnlyAttachments';
const HARD_MAX_API_FILE_BYTES = 64 * 1024 * 1024;
const HARD_MAX_API_UPLOAD_BODY_BYTES = 96 * 1024 * 1024;
@@ -204,6 +204,8 @@ async function checkCardUploadTarget({ boardId, swimlaneId, listId, cardId }, us
// Uploading requires write access, not just membership (read-only /
// comment-only / worker members cannot add attachments).
if (!(await userHasBoardWriteAccess(board, userId))) return { status: 403, message: 'You do not have permission to modify this card' };
// UnassignedAttachBleed: and the card itself, for an assigned-only member.
if (!mayWriteCardAttachment(board, userId, card, 'attachment-api:upload')) return { status: 403, message: 'You do not have permission to modify this card' };
if (!board.allowsAttachments) return { status: 403, message: 'Attachments are not allowed on this board' };
return { board, card };
}
@@ -970,6 +972,11 @@ WebApp.handlers.use('/api/boards/:boardId/attachments', async (req, res, next) =
if (targetCard.boardId !== targetBoardId) {
return sendErrorResponse(res, 400, 'Target card does not belong to the specified board');
}
// UnassignedAttachBleed: an assigned-only member writes only onto a
// card assigned to them.
if (!mayWriteCardAttachment(targetBoard, userId, targetCard, 'attachment-api:copy')) {
return sendErrorResponse(res, 403, 'You do not have permission to modify the target card');
}
// Verify that the target swimlaneId and listId match the card's actual swimlane and list
if (targetCard.swimlaneId !== targetSwimlaneId) {
@@ -1147,6 +1154,11 @@ WebApp.handlers.use('/api/boards/:boardId/attachments', async (req, res, next) =
if (targetCard.boardId !== targetBoardId) {
return sendErrorResponse(res, 400, 'Target card does not belong to the specified board');
}
// UnassignedAttachBleed: an assigned-only member writes only onto a
// card assigned to them.
if (!mayWriteCardAttachment(targetBoard, userId, targetCard, 'attachment-api:move')) {
return sendErrorResponse(res, 403, 'You do not have permission to modify the target card');
}
// Verify that the target swimlaneId and listId match the card's actual swimlane and list
if (targetCard.swimlaneId !== targetSwimlaneId) {
@@ -1223,6 +1235,12 @@ WebApp.handlers.use('/api/boards/:boardId/attachments', async (req, res, next) =
if (!board || !(await userHasBoardWriteAccess(board, userId))) {
return sendErrorResponse(res, 403, 'You do not have permission to delete this attachment');
}
// UnassignedAttachBleed: board write access is not the card's. An
// assigned-only member deletes only from a card assigned to them.
const card = attachment.meta.cardId ? await ReactiveCache.getCard(attachment.meta.cardId) : null;
if (!mayWriteCardAttachment(board, userId, card, 'attachment-api:delete')) {
return sendErrorResponse(res, 403, 'You do not have permission to delete this attachment');
}
// A soft delete (History.md §12.3): the file stays, the card history
// shows who deleted it and restores it. No API call hard-deletes.
+2
View File
@@ -78,6 +78,8 @@ async function boardFile(board, format, user, req) {
}
WebApp.handlers.get('/api/export-all-boards/:format', safeRoute(async function (req, res) {
// Before anything else: Admin Panel can turn all export off (CrossBoardExportBleed).
if (await require('/models/lib/importExportSecurity').refuseDisabledExport(res)) return;
const format = req.params.format;
if (!MASS_EXPORT_FORMATS.includes(format)) {
sendJsonResult(res, { code: 404, data: { error: 'Unknown export format' } });
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '12.76'
version: '12.78'
base: core24
confinement: strict
grade: stable
@@ -703,18 +703,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-12.76-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-12.78-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v12.76/wekan-12.76-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v12.78/wekan-12.78-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v12.76/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v12.78/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
python3 ../releases/check-telemetry.py --bundle bundle
rm "${WEKAN_ZIP}"
+20
View File
@@ -158,6 +158,26 @@ test('the trail is for site administrators, and the Admin Panel shows it', () =>
assert.match(read('client/components/settings/peopleBody.jade'), /\+adminAuditLog/);
});
// Commit 2017f2871f: the pane has its own address and title, and the table
// aligns to the START of the line so it reads right to left in RTL languages.
test('the audit log has its own URL and title, and its table follows the text direction', () => {
const { ADMIN_PAGES, adminPath, adminPaneTitle } = require('../models/lib/adminUrls');
assert.strictEqual(ADMIN_PAGES.people.panes['audit-log'], 'audit-log-setting');
assert.strictEqual(adminPath('people', 'audit-log-setting'), '/admin/people/audit-log');
assert.deepStrictEqual(adminPaneTitle('people', 'audit-log'), { titleKey: 'admin-audit-log' });
assert.match(read('docs/Features/Admin-Panel/Admin-Panel-URLs.md'),
/`\/admin\/people\/audit-log` \| Audit log \| `audit-log-setting`/);
const css = read('client/components/settings/adminAuditLog.css').replace(/\/\*[\s\S]*?\*\//g, '');
const cell = /\.admin-audit-log-table th, \.admin-audit-log-table td \{([^}]*)\}/.exec(css);
assert.ok(cell, 'the table cell rule exists');
assert.match(cell[1], /text-align:\s*start;/);
// negative: no physical direction anywhere in the audit log's styles - left
// or right would stay put when the page turns right to left.
assert.doesNotMatch(css, /text-align:\s*(left|right)\b/);
assert.doesNotMatch(css, /(margin|padding|border)-(left|right)\s*:/);
assert.doesNotMatch(css, /(^|[\s;{])(left|right)\s*:/);
});
(async () => {
for (const run of pending) await run();
console.log(`adminAudit: ${passed} tests passed`);
+20
View File
@@ -166,6 +166,26 @@ function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// (500: the boardsReport argument checks grew by the #3249 'instance' filter.)
assert.ok(/isAdmin/.test(src.slice(at, at + 500)), `${marker} must be admin-only`);
}
// Cards and Broken cards are instance-wide BY DECISION (maintainer,
// 2026-10-02, commit 69f3358154): a site admin manages every board, so they
// list cards of boards the admin is not a member of. The decision is written
// beside the code, and neither the publications nor their counts narrow to
// the admin's own boards, cards or memberships.
const cardsRaw = read('server/publications/cards.js');
assert.match(cardsRaw, /Instance-wide on purpose \(maintainer decision 2026-10-02\)/,
'the decision is recorded at the Cards report');
assert.match(cardsRaw, /The same holds\s*\n\/\/ for brokenCardsReport/,
'and names the Broken cards report too');
const cardsCode = code('server/publications/cards.js');
for (const marker of ["Meteor.publish('cardsReport'", "Meteor.publish('brokenCardsReport'",
'async getCardsReportCount(', 'async getBrokenCardsReportCount(']) {
const at = cardsCode.indexOf(marker);
assert.ok(at !== -1, `${marker} must exist`);
const body = cardsCode.slice(at, cardsCode.indexOf('\n});', at));
assert.ok(/isAdmin/.test(body.slice(0, 500)), `${marker} must be admin-only`);
assert.ok(!/userBoardIds|accessibleCardIds|accessibleBoardIds|members\.userId|'members'|isBoardMember|canSeeBoard/.test(body),
`${marker} must not narrow to the admin's own boards: instance-wide by decision`);
}
for (const [file, method] of [
['server/publications/boards.js', 'getBoardsReportCount'],
['server/publications/attachments.js', 'getAttachmentsReportCount'],
+15
View File
@@ -50,6 +50,21 @@ test('the bundle is rebuilt by the Android Node.js itself, checked and started',
assert.match(build, /releases\/bsd\/smoke\.sh" "\$root\/bundle"/);
});
test('argon2 and bcrypt are compiled for Android and must load before the bundle starts', () => {
// v12.78: npm 12 blocked their install scripts - allowScripts covers declared
// dependencies only - so nothing was compiled, check-addon-arch passed the
// Linux prebuilds, and every Android bundle crash-looped on "No native build
// was found for platform=android" until the 240 s smoke test gave up.
const build = read('releases/android/build-bundle.sh');
const prepare = build.indexOf('"$node" "$root/releases/prepare-bundle-npm.mjs" "$root/bundle"');
const install = build.indexOf('(cd "$server" && npm install)');
const loads = build.indexOf('for (const name of ["argon2", "bcrypt"])');
assert.ok(prepare > 0 && prepare < install, 'the bundle declares its installers before npm installs');
assert.ok(loads > install && loads < build.indexOf('check-addon-arch.mjs'), 'loaded with Android Node.js right after the install');
assert.match(build, /cd "\$server\/npm\/node_modules\/meteor\/accounts-password" && "\$node" -e/);
assert.match(build, /::error::\$\{name\} has no Android addon/);
});
test('negative: nothing is built from an unverified download, and a missing Node.js skips', () => {
const wf = read('.github/workflows/android.yml');
assert.match(wf, /sha256sum -c "\$zip\.sha256sum"/);
+3 -3
View File
@@ -29,7 +29,7 @@ const scope = require('../models/lib/boardCardScope');
function loadAttachments(cards) {
const src = read('server/lib/assignedOnlyAttachments.js')
.replace(/^import [^\n]*\n/gm, '').replace(/^const \{ isAssignedOnlyMember \} = require[^\n]*\n/m, '')
.replace(/^import [^\n]*\n/gm, '').replace(/^const \{ isAssignedOnlyMember[^}]*\} = require[^\n]*\n/m, '')
.replace(/^export /gm, '');
const ReactiveCache = {
getCards: async selector => cards.filter(c => c.boardId === selector.boardId && c.assignees.includes(selector.assignees)),
@@ -37,10 +37,10 @@ function loadAttachments(cards) {
};
const lib = { records: [] };
// eslint-disable-next-line no-new-func
new Function('exports', 'ReactiveCache', 'isAssignedOnlyMember', 'require',
new Function('exports', 'ReactiveCache', 'isAssignedOnlyMember', 'assignedOnlyAllowsCard', 'require',
`${src}\nexports.assignedOnlyAttachmentScope = assignedOnlyAttachmentScope;\nexports.mayReadBoardAttachment = mayReadBoardAttachment;` +
`\nexports.assignedOnlyCardIds = assignedOnlyCardIds;\nexports.isBoardLevelFile = isBoardLevelFile;`)(
lib, ReactiveCache, scope.isAssignedOnlyMember, () => ({ record: r => lib.records.push(r) }));
lib, ReactiveCache, scope.isAssignedOnlyMember, scope.assignedOnlyAllowsCard, () => ({ record: r => lib.records.push(r) }));
return lib;
}
@@ -42,6 +42,50 @@ test('each English line is followed by its translation, only when it differs', (
assert.ok(pairs >= 26, `the storage settings' descriptions and menu paths, ${pairs} found`);
});
// Commit e6f7d226ca: a leftover `small.form-text.text-muted {{_
// 'gcs-credentials-description'}}` from before the English+translated pairs
// showed the Google Cloud Storage key help twice under the field.
test('negative: a paired description is shown by its pair only, never by a leftover copy', () => {
const code = jade.filter(l => !/^\s*\/\/-/.test(l));
const paired = new Set();
for (const l of code) {
const m = l.match(/\{\{englishText '([^']+)'\}\}/);
if (m) paired.add(m[1]);
}
assert.ok(paired.has('gcs-credentials-description'), 'the GCS key help is a pair');
// Per .form-group: a field whose help is an English+translated pair shows the
// translation once, under the translatedDiffers guard, and no field shows one
// translated help string twice. (The same key on two fields - cloud-secret-keep-blank
// under every secret - is two fields, not a duplicate.)
const groups = [];
let current = null;
code.forEach((l, i) => {
if (/^\s*\.form-group\b/.test(l) || /^\s*template\(/.test(l)) { current = []; groups.push(current); }
if (current) current.push([l, i]);
});
const extra = [];
for (const group of groups) {
const seen = new Map();
// Help text only: a heading and the button under it may share a label.
for (const [l, i] of group.filter(([l]) => /^\s*(if \S+ '[^']+'\s*)?(small|p)\.form-text/.test(l))) {
for (const m of l.matchAll(/\{\{_ '([^']+)'\}\}/g)) {
seen.set(m[1], (seen.get(m[1]) || []).concat(i));
}
}
for (const [key, lines] of seen) {
if (lines.length > 1) extra.push(`${key}: shown ${lines.length} times in one field, line ${lines[0] + 1}`);
const pairedHere = group.some(([l]) => l.includes(`{{englishText '${key}'}}`));
if (pairedHere && !/^\s*if translatedDiffers '/.test(code[lines[0] - 1])) {
extra.push(`${key}: translated copy without its guard, line ${lines[0] + 1}`);
}
}
}
assert.ok(groups.length > 40, `the template's fields, ${groups.length} found`);
assert.deepStrictEqual(extra, []);
// The exact line the commit removed.
assert.ok(!code.some(l => /small\.form-text\.text-muted \{\{_ 'gcs-credentials-description'\}\}/.test(l)));
});
test('the helpers read English from en.i18n.json and follow the language', () => {
assert.match(blaze, /Blaze\.registerHelper\('englishText', key => \{[\s\S]*?TAPi18n\.__\(key, \{\}, 'en'\)/);
assert.match(blaze, /Blaze\.registerHelper\('translatedDiffers', key => \{\s*TAPi18n\.current\.get\(\);/);
+13 -2
View File
@@ -52,7 +52,8 @@ assert.equal(tpl._dateResize, null);
console.log('calendarPopupResize: captured dragging, keyboard resizing, content minimum and viewport limits passed');
context.window.innerWidth = 1280;
context.window.innerHeight = 900;
element.querySelector = () => ({});
// No drag handle (drag handles off): the title bar moves the popup (#6839).
element.querySelector = selector => (selector === '.js-popup-drag-handle' ? null : {});
const title = { closest: () => null };
const moveEvent = { ...event, target: title };
events['pointerdown .header'](moveEvent, tpl);
@@ -70,9 +71,19 @@ events['pointerup .header, pointercancel .header, lostpointercapture .header'](m
assert.equal(tpl._dateMove, null);
events['pointerdown .header']({ ...moveEvent, target: { closest: () => ({}) } }, tpl);
assert.equal(tpl._dateMove, null, 'header links and buttons do not start dragging');
// #6839 changed this on purpose: every popup moves by its title bar now, not
// only a date popup - and with drag handles on, by its handle alone.
element.querySelector = () => null;
events['pointerdown .header'](moveEvent, tpl);
assert.equal(tpl._dateMove, null, 'other popups retain their original behavior');
assert.ok(tpl._dateMove, 'any popup drags by its title bar while drag handles are off');
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
element.querySelector = selector => (selector === '.js-popup-drag-handle' ? {} : null);
events['pointerdown .header'](moveEvent, tpl);
assert.equal(tpl._dateMove, null, 'negative: with a drag handle, the title bar does not drag');
events['pointerdown .header']({ ...moveEvent, target: { closest: s => (s === '.js-popup-drag-handle' ? {} : null) } }, tpl);
assert.ok(tpl._dateMove, 'the handle does');
events['pointerup .header, pointercancel .header, lostpointercapture .header'](moveEvent, tpl);
element.querySelector = () => null;
console.log('Date popup title dragging, viewport limits and interactive header exclusions passed');
element.dataset.popup = 'boardViewSettingsPopup';
+3 -3
View File
@@ -50,7 +50,7 @@ for (const system of ['gregorian', 'jalali']) {
const { tpl, helpers } = loadPicker(system);
// Midnight may pass between the two clocks; accept either side of it.
const before = iso(new Date());
const cells = helpers.weeks().flatMap(row => row.days).filter(Boolean);
const cells = helpers.weeks().flatMap(row => row.days).filter(day => !day.empty);
const after = iso(new Date());
const marked = cells.filter(day => day.today);
assert.equal(marked.length, 1, `${system}: exactly one cell of the current month is today`);
@@ -68,7 +68,7 @@ for (const system of ['gregorian', 'jalali']) {
const yearAgo = new Date();
yearAgo.setFullYear(yearAgo.getFullYear() - 1);
tpl.shownDate.set(yearAgo);
const other = helpers.weeks().flatMap(row => row.days).filter(Boolean);
const other = helpers.weeks().flatMap(row => row.days).filter(day => !day.empty);
assert.ok(other.length >= 28);
assert.equal(other.filter(day => day.today || day.ariaCurrent).length, 0,
`${system}: a month without today marks no cell`);
@@ -78,7 +78,7 @@ for (const system of ['gregorian', 'jalali']) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tpl.selectedDate.set(tomorrow);
const withSelection = helpers.weeks().flatMap(row => row.days).filter(Boolean);
const withSelection = helpers.weeks().flatMap(row => row.days).filter(day => !day.empty);
const selected = withSelection.find(day => day.selected);
if (selected) assert.equal(selected.today, false, `${system}: the selected day (tomorrow) is not today`);
assert.ok(withSelection.filter(day => day.today).length <= 1);
+60
View File
@@ -102,4 +102,64 @@ test('negative: every locale has Eraser, in its own language', () => {
}
});
test('a new drawing is refitted when its room changes, until it is zoomed or resized by hand (2929b61c85)', () => {
// Opened from + Add or Add attachment, the popup was fitted at 820px and
// maximized only afterwards, so a new drawing kept ~784px of a 1280px window.
const js = read('client/components/cards/cardDrawing.js');
const start = js.indexOf("const stage = this.find('.js-drawing-stage');");
const end = js.indexOf('this.stageObserver.observe(stage);\n }', start);
assert.ok(start > 0 && end > start, 'the observer block of onRendered');
const block = js.slice(start, end + 'this.stageObserver.observe(stage);\n }'.length);
// Run the block itself with a stand-in ResizeObserver, room and fit.
const vm = require('vm');
let callback = null; let observed = null; const fits = [];
let room = { width: 784, height: 400 };
const tpl = {
view: { isDestroyed: false }, sizedByHand: false,
strokes: { value: [], get() { return this.value; } },
editingId: { value: null, get() { return this.value; } },
find: sel => (sel === '.js-drawing-stage' ? { stage: true } : null),
};
vm.runInNewContext(`(function () { const tpl = this; ${block} }).call(tpl);`, {
tpl,
ResizeObserver: class { constructor(cb) { callback = cb; } observe(el) { observed = el; } disconnect() {} },
stageRoom: () => room,
fitToStage: (t, size) => fits.push([t, size, `${room.width}x${room.height}`]),
});
assert.ok(callback && observed && observed.stage, 'the drawing stage is observed');
assert.ok(tpl.stageObserver, 'kept on the template, to disconnect');
callback();
assert.deepStrictEqual(fits.map(f => f[2]), ['784x400'], 'fitted to the room it has');
assert.strictEqual(fits[0][1], null, 'as a NEW drawing (no size of its own)');
callback();
assert.strictEqual(fits.length, 1, 'the same room is not fitted twice');
room = { width: 1240, height: 700 };
callback();
assert.deepStrictEqual(fits.map(f => f[2]), ['784x400', '1240x700'], 'maximized: refitted to the window');
// Negative: once there is a line, an edited drawing, a zoom or a resize by
// hand, a change of room keeps the size it has.
for (const [what, set, reset] of [
['a line', () => { tpl.strokes.value = [{}]; }, () => { tpl.strokes.value = []; }],
['an edited drawing', () => { tpl.editingId.value = 'd1'; }, () => { tpl.editingId.value = null; }],
['sized by hand', () => { tpl.sizedByHand = true; }, () => { tpl.sizedByHand = false; }],
['destroyed', () => { tpl.view.isDestroyed = true; }, () => { tpl.view.isDestroyed = false; }],
]) {
set();
room = { width: room.width - 10, height: room.height };
callback();
assert.strictEqual(fits.length, 2, `not refitted: ${what}`);
reset();
}
// Each way of choosing a size by hand marks it: zoom in, zoom out, the
// corner handle and its keyboard steps.
const events = js.slice(js.indexOf('Template.cardDrawingPopup.events({'));
assert.match(events, /'click \.js-drawing-zoom-in'\(event, tpl\) \{ event\.preventDefault\(\); tpl\.sizedByHand = true;/);
assert.match(events, /'click \.js-drawing-zoom-out'\(event, tpl\) \{ event\.preventDefault\(\); tpl\.sizedByHand = true;/);
const resize = events.slice(events.indexOf("'pointerdown .js-drawing-canvas-resize'"), events.indexOf("'pointermove .js-drawing-canvas-resize'"));
assert.match(resize, /tpl\.sizedByHand = true;\n\s+tpl\.canvasResize = /);
assert.match(events, /if \(!step\) return;\n\s+event\.preventDefault\(\);\n\s+tpl\.sizedByHand = true;/);
assert.strictEqual((js.match(/tpl\.sizedByHand = true;/g) || []).length, 4, 'and nothing else does');
assert.match(js, /Template\.cardDrawingPopup\.onDestroyed\(function \(\) \{\n\s+if \(this\.stageObserver\) this\.stageObserver\.disconnect\(\);/);
});
console.log(`cardDrawingPen: ${passed} passed`);
+12 -2
View File
@@ -40,7 +40,15 @@ const test = (name, fn) => tests.push([name, fn]);
test('the unreleased Upcoming comes first, with its In short, and links its section', () => {
const upcoming = short.upcomingOf(big);
assert.ok(upcoming !== null, 'CHANGELOG-big.md has an Upcoming section now');
// Right after a release there is no Upcoming until the next real entry
// (CLAUDE.md, Making a release): then the short file opens with the newest
// release and links no section that does not exist. This asserted an
// Upcoming always, and failed after every release.
if (upcoming === null) {
assert.match(small.match(/^# .+$/m)[0], /^# v\d+\.\d+ \d{4}-\d{2}-\d{2} WeKan ® release$/, 'the newest release is first');
assert.doesNotMatch(small, /# Upcoming WeKan ® release|upcoming-wekan--release/, 'negative: no Upcoming, and no link to one');
return;
}
const first = small.match(/^# .+$/m)[0];
assert.equal(first, '# Upcoming WeKan ® release', 'Upcoming is the first section');
const summary = short.inShortOf(upcoming);
@@ -66,7 +74,9 @@ const test = (name, fn) => tests.push([name, fn]);
test('negative: no Status or TODO Later, no details or summary tags, no HTML links', () => {
assert.doesNotMatch(small, /^# (?:Status|TODO Later)\b/m);
assert.equal((small.match(/^# Upcoming/gm) || []).length, 1, 'one Upcoming, never two');
// One Upcoming when there is one, never two; none right after a release.
assert.equal((small.match(/^# Upcoming/gm) || []).length, short.upcomingOf(big) === null ? 0 : 1,
'one Upcoming, never two');
assert.doesNotMatch(small, /More status info|Newest WeKan at these platforms|Carried to a future release/);
for (const tag of ['<details>', '</details>', '<summary>', '</summary>']) assert.ok(!small.includes(tag), tag);
assert.doesNotMatch(small, /<a\s|<\/a>|<details|<summary/i);
+40
View File
@@ -107,3 +107,43 @@ test('settings: the key file must be absolute and outside the target and every s
}
assert.equal(validateSettings({ ...base, encrypt: false, encryptionKeyFile: '' }, { sources }).encrypt, false);
});
// A GCM tag mismatch is "Unsupported state or unable to authenticate data" with
// OpenSSL 3 and ERR_OSSL_BAD_DECRYPT ("bad decrypt") with OpenSSL 4. Which one
// the running Node reports depends on what it is linked to, so both are forced
// here with a decipher that fails the way each one does - the test then means
// the same on every machine, not only on one with OpenSSL 4.
async function decryptFileFailingWith(t, failure) {
const dir = sandbox(); t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const sealed = path.join(dir, 'sealed'), opened = path.join(dir, 'opened');
// Empty contents: decryptFile() checks only the tag, with decipher.final().
await pipeline(Readable.from([]), cipher.encryptStream(), fs.createWriteStream(sealed));
const real = crypto.createDecipheriv;
crypto.createDecipheriv = () => ({ setAuthTag() {}, final() { throw failure(); } });
t.after(() => { crypto.createDecipheriv = real; });
try { await cipher.decryptFile(sealed, opened); return { error: null, leftOutput: fs.existsSync(opened) }; }
catch (error) { return { error, leftOutput: fs.existsSync(opened) }; }
}
const opensslError = (message, code) => Object.assign(new Error(message), { code });
test('a changed file is reported as changed with OpenSSL 4 (ERR_OSSL_BAD_DECRYPT) and OpenSSL 3', async t => {
for (const failure of [
() => opensslError('error:1C800064:Provider routines::bad decrypt', 'ERR_OSSL_BAD_DECRYPT'),
() => new Error('Unsupported state or unable to authenticate data'),
]) {
const { error, leftOutput } = await decryptFileFailingWith(t, failure);
assert.ok(error, 'refused');
assert.match(error.message, /changed or the key is wrong/, `not the raw OpenSSL error: ${error.message}`);
assert.equal(error.code, 'continuous-backup-key');
assert.equal(leftOutput, false, 'no output is left behind');
}
});
test('NEGATIVE: an unrelated failure while decrypting is not reported as a changed file', async t => {
// A full disk or any other error is passed through as it is - calling it
// "changed or the key is wrong" would send the administrator after the key.
const { error, leftOutput } = await decryptFileFailingWith(t, () => opensslError('no space left on device', 'ENOSPC'));
assert.equal(error && error.code, 'ENOSPC');
assert.doesNotMatch(error.message, /changed or the key is wrong/);
assert.equal(leftOutput, false);
});
+161
View File
@@ -0,0 +1,161 @@
'use strict';
// CrossBoardExportBleed (GHSA-8885-xrq4-j47h).
//
// 1. A board export scoped to a checklist (?checklistId=) streamed THAT
// checklist, its items and its creator even when the checklist was on a
// card of another, private board: the checklist selectors were not bound to
// the exported board's cards.
// 2. "Disable all export" and "Anonymize export users" were honoured by the
// JSON export only. The streaming CSV/TSV the advisory names - and, found
// while fixing it, the Excel, PDF, chart, calendar and external-format
// exporters - asked neither.
//
// The tests drive the shared helpers themselves, and then read the source so
// that no export route and no exporter that names people can skip them.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = rel => fs.readFileSync(path.join(root, rel), 'utf8');
// importExportSecurity.js is an ES module that requires Meteor paths at run
// time; a bare `require` in it resolves to this global stub.
const state = { settings: {}, board: null, users: [], records: [] };
globalThis.require = name => {
if (name === '/models/settings') return { default: { findOneAsync: async () => state.settings } };
if (name === '/imports/reactiveCache') return { ReactiveCache: {
getBoard: async () => state.board,
getUsers: async selector => state.users.filter(u => selector._id.$in.includes(u._id)).map(u => ({ ...u })),
} };
if (name === '/server/lib/securityLog') return { record: evt => state.records.push(evt) };
if (name === 'meteor/meteor') return { Meteor: { Error: class extends Error { constructor(error, reason) { super(reason); this.error = error; } } } };
throw new Error(`unexpected require ${name}`);
};
const helpers = import(`data:text/javascript;base64,${Buffer.from(read('models/lib/importExportSecurity.js')).toString('base64')}`);
const reset = (settings = {}) => Object.assign(state, { settings, board: null, users: [], records: [] });
function fakeRes() {
return { status: null, body: null, headersSent: false,
writeHead(code) { this.status = code; this.headersSent = true; },
end(text) { this.body = text; } };
}
test('Finding 1: a checklist scope only matches a checklist on one of the exported board\'s cards', () => {
const exporter = read('models/exporter.js');
assert.match(exporter, /\? \{ _id: this\._scope\.checklistId, cardId: \{ \$in: cardIds \} \}/);
assert.match(exporter, /\? \{ checklistId: this\._scope\.checklistId, cardId: \{ \$in: cardIds \} \}/);
// An attempt from another board is recorded; a deleted checklist is not.
assert.match(exporter, /if \(checklist\) \{\s*const card = await ReactiveCache\.getCard\(checklist\.cardId\);\s*if \(!card \|\| card\.boardId !== boardId\)[\s\S]{0,200}key: 'authz\.export-checklist-scope'/);
});
test('negative: nowhere is a scoped checklist or its items selected by id alone', () => {
const { execFileSync } = require('node:child_process');
const files = execFileSync('git', ['ls-files', 'models', 'server'], { cwd: root, encoding: 'utf8' })
.split('\n').filter(f => /\.(js|cjs|mjs)$/.test(f));
const offenders = [];
for (const file of files) {
const text = read(file);
for (const m of text.matchAll(/\{\s*(_id|checklistId):\s*[\w.]*scope\.checklistId\s*\}/g)) offenders.push(`${file}: ${m[0]}`);
}
assert.deepEqual(offenders, []);
});
test('the export switch: refused with 403 before anything is written, and recorded', async () => {
const { refuseDisabledExport, assertExportEnabled } = await helpers;
reset({ disableAllExport: false });
const open = fakeRes();
assert.equal(await refuseDisabledExport(open), false);
assert.equal(open.status, null, 'an allowed export is not touched');
assert.deepEqual(state.records, []);
reset({ disableAllExport: true });
const res = fakeRes();
assert.equal(await refuseDisabledExport(res, 'export:csv'), true);
assert.equal(res.status, 403);
assert.equal(res.body, 'Export is disabled by the administrator.');
assert.deepEqual(state.records.map(r => [r.key, r.action, r.source]), [['authz.export-disabled', 'blocked', 'export:csv']]);
await assert.rejects(assertExportEnabled('export:trello'), err => err.error === 'export-disabled');
assert.equal(state.records.at(-1).key, 'authz.export-disabled', 'the method path records it too');
});
test('the switch never costs the account: a page opened before it still shows the menus', () => {
const { categoryFor } = require('../models/lib/securityCategories.js');
assert.equal(categoryFor('authz.export-disabled').blocksAccount, false);
assert.equal(categoryFor('authz.export-disabled').bleed, 'CrossBoardExportBleed');
assert.equal(categoryFor('authz.export-checklist-scope').bleed, 'CrossBoardExportBleed');
});
test('negative: every HTTP export route asks the switch first', () => {
const { execFileSync } = require('node:child_process');
const files = execFileSync('git', ['ls-files', 'models', 'server'], { cwd: root, encoding: 'utf8' })
.split('\n').filter(f => /\.(js|cjs|mjs)$/.test(f));
const routes = [];
const offenders = [];
for (const file of files) {
const text = read(file);
const re = /WebApp\.handlers\.(?:get|use|post)\(\s*([`'"])([^`'"]+)\1\s*,\s*safeRoute\(async function \(req, res\) \{\n([^\n]*\n[^\n]*\n)/g;
for (const m of text.matchAll(re)) {
const route = m[2];
if (!/export|\.ics\b/i.test(route) || /\/import/.test(route)) continue;
routes.push(route);
if (!/refuseDisabledExport\(res\)\) return;/.test(m[3])) offenders.push(`${file}: ${route}`);
}
}
// The routes found are the ones known to exist, so the scan cannot pass by
// matching nothing.
for (const known of ['/api/boards/:boardId/export', '/api/boards/:boardId/export/csv', '/api/boards/:boardId/exportExcel',
'/api/boards/:boardId/exportPDF', '/api/boards/:boardId/calendar.ics', '/api/boards/:boardId/charts/:chartKey/exportExcel',
'/api/export-all-boards/:format']) assert.ok(routes.includes(known), `${known} is scanned`);
assert.ok(routes.length >= 13, routes.join('\n'));
assert.deepEqual(offenders, []);
});
test('anonymization: off leaves everything, on rewrites names, mentions of members, requested-by and comments', async () => {
const { anonymizeExportIdentities } = await helpers;
reset({ anonymizeExportUsers: false });
const plain = [{ _id: 'u1', username: 'alice' }];
assert.equal(await anonymizeExportIdentities({ boardId: 'b', users: plain }), null);
assert.equal(plain[0].username, 'alice');
reset({ anonymizeExportUsers: true });
state.board = { _id: 'b', members: [{ userId: 'u1' }, { userId: 'u2' }] };
state.users = [{ _id: 'u2', username: 'bob' }];
const users = [{ _id: 'u1', username: 'alice', profile: { fullname: 'Alice A', initials: 'AA', avatarUrl: '/a.png' } }];
const cards = [{ title: 'Ask @bob and @alice', description: 'cc @alice', requestedBy: 'alice', assignedBy: 'bob' }];
const comments = [{ text: 'thanks @bob' }];
const map = await anonymizeExportIdentities({ boardId: 'b', users, cards, comments });
assert.ok(map);
assert.equal(users[0].username, 'user1');
assert.equal(users[0].profile.fullname, 'user1');
assert.equal(users[0].profile.avatarUrl, undefined, 'an avatar identifies the person');
// bob is only a board member, named in text: anonymized as build() does.
assert.equal(cards[0].title, 'Ask @user2 and @user1');
assert.equal(cards[0].description, 'cc @user1');
assert.deepEqual([cards[0].requestedBy, cards[0].assignedBy], ['user1', 'user2']);
assert.equal(comments[0].text, 'thanks @user2');
assert.doesNotMatch(JSON.stringify({ users, cards, comments }), /alice|bob|Alice/);
});
test('negative: every exporter that names people anonymizes, and the CSV/TSV stream asks the switch', () => {
const exporter = read('models/exporter.js');
const csv = exporter.slice(exporter.indexOf('async buildCsvStream('), exporter.indexOf('async canExport('));
assert.match(csv, /await assertExportEnabled\('export:csv'\);/);
assert.match(csv, /await anonymizeExportIdentities\(\{\s*boardId: this\._boardId, users: lookup\.users/);
assert.match(csv, /if \(anonymized\) anonymizeBoardTextInPlace\(\{ cards: \[card\] \}, anonymized\.byUsername\);/);
// Any exporter that writes a username or full name has to anonymize: an
// exporter added later fails here until it does.
const { execFileSync } = require('node:child_process');
const exporters = execFileSync('git', ['ls-files', 'models/server', 'models/lib/externalExporters.js', 'server/lib/boardChartData.js'],
{ cwd: root, encoding: 'utf8' }).split('\n')
.filter(f => /(Exporter[A-Za-z]*\.js|externalExporters\.js|boardChartData\.js)$/.test(f));
const naming = exporters.filter(f => /\.username\b|profile\??\.fullname/.test(read(f)));
assert.ok(naming.length >= 6, naming.join(', '));
const missing = naming.filter(f => !/anonymizeExportIdentities\(/.test(read(f)));
assert.deepEqual(missing, []);
// The chart exports ask for it; the chart views keep the names.
for (const f of ['models/server/ExporterChartPDF.js', 'models/server/ExporterChartExcel.js']) {
assert.match(read(f), /loadBoardChartData\(this\._boardId, this\._chartKey, \{ \.\.\.this\.options, forExport: true \}\)/);
}
assert.match(read('server/lib/boardChartData.js'), /if \(options\.forExport\) \{\s*await require\('\/models\/lib\/importExportSecurity'\)\.anonymizeExportIdentities/);
});
+32
View File
@@ -460,6 +460,38 @@ async function test(name, fn) { await fn(); passed += 1; console.log(' ok -', n
assert.doesNotMatch(ay['data-health'], /qullqi/, 'negative: qullqi is money, not health');
});
// Commit 77956c98ed: "Last Run: Never" used List Sync's
// list-sync-last-synced-never, so a locale that words that one as "never
// synced" made Data health read wrong. It has a key of its own now.
await test('Data health\'s "Never" has a key of its own, in every locale', () => {
const js = read('client/components/settings/problemsSummary.js');
const at = js.indexOf('dataHealthStatus() {');
assert.notStrictEqual(at, -1, 'the status helper exists');
const body = js.slice(at, js.indexOf('\n },', at));
assert.match(body, /if \(!d \|\| !d\.at\) return TAPi18n\.__\('data-health-never-run'\);/,
'a scan that never ran says data-health-never-run');
const dir = path.join(ROOT, 'imports/i18n/data');
const en = JSON.parse(fs.readFileSync(path.join(dir, 'en.i18n.json'), 'utf8'));
assert.strictEqual(en['data-health-never-run'], 'Never');
const enKeys = Object.keys(en);
assert.strictEqual(enKeys.indexOf('data-health-never-run'), enKeys.indexOf('data-health-repair') + 1,
'added right after data-health-repair');
for (const file of fs.readdirSync(dir).filter(f => f.endsWith('.i18n.json'))) {
const j = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
assert.ok(typeof j['data-health-never-run'] === 'string' && j['data-health-never-run'].trim(),
`${file}: data-health-never-run`);
}
});
await test('negative: the Problems / Summary page borrows no List Sync string', () => {
// Comments stripped: the helper's own note names the key it replaced.
const strip = s => s.replace(/\/\/[^\n]*/g, '').replace(/^\s*\/\/-.*$/gm, '');
for (const rel of ['client/components/settings/problemsSummary.js',
'client/components/settings/problemsSummary.jade']) {
assert.doesNotMatch(strip(read(rel)), /list-sync-/, `${rel} must not reuse a list-sync-* key`);
}
});
// ---- 8. bounded lookups --------------------------------------------------------
await test('linked cards and subtasks are looked up a CHUNK at a time inside the card loop', async () => {
const data = sample();
+22
View File
@@ -135,3 +135,25 @@ test('every write goes through the rule, and the client cannot write My Dependen
assert.ok(doc.includes(phrase), phrase);
}
});
test('both import results say how many lines were already there (8a6aeba7d1)', () => {
// All Boards / Import / Dependencies reported only imported and unmatched
// lines after the import began to combine; it says the skipped ones too, in
// the same message as Member Settings / Import.
const sidebar = read('client/components/sidebar/sidebar.js');
const allBoards = sidebar.slice(sidebar.indexOf("'submit .js-import-dependencies-form'"),
sidebar.indexOf('Template.labelsWidget.events'));
const memberSettings = read('client/components/users/dependencyMenu.js');
for (const [name, src] of [['All Boards', allBoards], ['Member Settings', memberSettings]]) {
assert.ok(src.length > 0, name);
assert.match(src, /TAPi18n\.__\('import-dependencies-merged',\s*\{\s*imported: res\.imported,\s*skipped: res\.skipped,\s*unmatched: res\.unmatched,?\s*\}/, name);
// Negative: the old two-number message is not used any more.
assert.doesNotMatch(src, /'import-dependencies-done'/, name);
}
const en = JSON.parse(read('imports/i18n/data/en.i18n.json'));
for (const placeholder of ['__imported__', '__skipped__', '__unmatched__']) {
assert.ok(en['import-dependencies-merged'].includes(placeholder), placeholder);
}
// The server's answer carries all three.
assert.match(read('server/models/dependencies.js'), /return \{ imported, skipped, unmatched \};/);
});
+54
View File
@@ -0,0 +1,54 @@
'use strict';
// The filter sidebar's two date-range forms (card movement, card dates) say
// why a range was refused in the form itself. They used reportValidity(),
// whose bubble Firefox opens over the Apply and Clear buttons under the field,
// taking every click on them while the field had focus: a refused range could
// not be cleared (card-date-range.e2e.js, EVERYTHING's Firefox stage).
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const js = fs.readFileSync(path.join(root, 'client/components/sidebar/sidebarFilters.js'), 'utf8');
const jade = fs.readFileSync(path.join(root, 'client/components/sidebar/sidebarFilters.jade'), 'utf8');
let passed = 0;
const test = (name, fn) => { fn(); passed += 1; console.log(` ok - ${name}`); };
console.log('filterDateRangeRefusal:');
test('both range forms report a refusal through rangeRefused, in the form', () => {
for (const [form, error] of [['js-card-movement-range', 'movementRangeError'], ['js-card-date-range', 'dateRangeError']]) {
const submit = js.slice(js.indexOf(`'submit .${form}'`), js.indexOf(`'input .${form} input'`));
assert.match(submit, new RegExp(`rangeRefused\\(end, tpl\\.${error}, !valid\\)`), form);
// Typing and Clear take the refusal back.
const rest = js.slice(js.indexOf(`'input .${form} input'`));
assert.match(rest.slice(0, rest.indexOf('},') + 2), new RegExp(`rangeRefused\\([\\s\\S]*?tpl\\.${error}, false\\)`));
const shown = jade.slice(jade.indexOf(`form.${form}`));
assert.match(shown.slice(0, shown.indexOf('\n form.', 1) > 0 ? shown.indexOf('\n form.', 1) : 2000),
new RegExp(`if ${error}\\n\\s+p\\.${form}-error\\.filter-date-range-error\\(role="alert"\\) \\{\\{_ 'filter-date-range-invalid'\\}\\}`));
}
assert.match(js, /function rangeRefused\(end, error, refused\) \{\n\s+end\.setCustomValidity\(refused \? TAPi18n\.__\('filter-date-range-invalid'\) : ''\);\n\s+error\.set\(refused\);/);
});
test('Clear takes the refusal back for both forms', () => {
for (const [button, error] of [['js-clear-movement-range', 'movementRangeError'], ['js-clear-date-range', 'dateRangeError']]) {
const handler = js.slice(js.indexOf(`'click .${button}'`));
assert.match(handler.slice(0, handler.indexOf('\n },')), new RegExp(`rangeRefused\\([\\s\\S]*?tpl\\.${error}, false\\)`));
}
});
test('the browser does not refuse a range submit itself: the handler decides', () => {
// WebKit kept a half-edited date segment after Clear emptied the From date
// and fired `invalid` on it at submit, though its validity read valid: the
// form never submitted and the range never applied (card-date-range.e2e.js).
for (const form of ['js-card-movement-range', 'js-card-date-range']) {
assert.match(jade, new RegExp(`\\n form\\.${form}\\(novalidate\\)\\n`), form);
}
});
test('negative: no form with a Clear button in the filter sidebar opens the browser bubble', () => {
const code = js.replace(/\/\/.*$/gm, '');
assert.doesNotMatch(code, /reportValidity\s*\(/);
});
console.log(`\n${passed} tests passed`);
+17
View File
@@ -33,3 +33,20 @@ for (const file of ['client/lib/keyboard.js', 'client/lib/historyKeyRecovery.js'
execFileSync(process.execPath, ['--input-type=module', '--check'], { input: read(file) });
}
console.log(' ok - an unanswered undo/redo is shown on its board, retried with its own ID or forgotten');
// Retry acts only for someone who may write on the board (undoRedoLast returns
// early otherwise), so it is enabled only then: right after a reload the notice
// showed before the board's members arrived, and a Retry clicked in that window
// was dropped without a word (history-recovery-notice.e2e.js, Firefox).
{
const jade = read('client/components/main/historyRecoveryNotice.jade');
assert.match(jade, /button\.primary\.js-history-retry\(type="button" disabled=retryDisabled\)/);
assert.match(notice, /retryDisabled\(\) \{\n\s+return Template\.instance\(\)\.busy\.get\(\) \|\| !Utils\.canModifyBoard\(\);/);
assert.match(keyboard, /export function undoRedoLast\(direction\) \{\n\s+const boardId = Session\.get\('currentBoard'\);\n\s+if \(!boardId \|\| !Utils\.canModifyBoard\(\)\) \{/,
'the guard Retry mirrors');
// Negative: Forget needs no permission and stays usable; Retry is not left
// gated on busy alone.
assert.match(jade, /button\.js-history-forget\(type="button" disabled=busy\)/);
assert.doesNotMatch(jade, /js-history-retry\(type="button" disabled=busy\)/);
console.log(' ok - Retry is enabled only once the board lets this person write on it');
}
@@ -126,6 +126,78 @@ test('SQLite page engine: a WAL database written meanwhile, rebuilt at a moment
assert.equal(fs.existsSync(path.join(root, 'restore', 'tampered.sqlite')), false);
});
// The engine scanned FIRST and started watching after, so a change in between
// waited for the next scan (five minutes by default); and macOS's FSEvents
// delivers nothing for a moment after a watcher is created - an EVERYTHING
// run on a loaded Mac lost the appended log line below that way.
for (const platform of ['darwin', 'linux']) {
test(`watching starts before the first scan, and on macOS a catch-up scan follows (${platform})`, { timeout: 30000 }, async t => {
const root = tmpRoot();
const watch = fs.watch;
const real = Object.getOwnPropertyDescriptor(process, 'platform');
const calls = [];
// A watcher that never reports anything: FSEvents in its first moments.
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: platform });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
const target = await store.openTarget(path.join(root, 'target'));
const generation = await store.createGeneration(target, {});
const engine = new FileEngine({ sources: [{ area: 'attachments', key: 'attachments', path: live }], target, generation,
scanMs: 3600000, sealMs: 100, onError: error => { throw error; } });
const scan = engine.scan.bind(engine);
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
t.after(() => engine.stop());
assert.deepEqual(calls.slice(0, 2), ['watch', 'scan'], 'the watcher is up before the first scan');
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
for (let i = 0; i < 40 && engine.status().puts < 2; i += 1) await sleep(100);
if (platform === 'darwin') {
assert.equal(engine.status().puts, 2, 'the catch-up scan recorded what the silent watcher missed');
} else {
assert.equal(engine.status().puts, 1, 'negative: no second start-up scan where watching is live at once');
assert.equal(calls.filter(c => c === 'scan').length, 1);
}
});
}
// NEGATIVE for the macOS catch-up scan: it belongs to a watching engine that
// is still running. An engine stopped within the two seconds must not scan
// afterwards (stop() clears the timer), and one started without watching has
// no FSEvents gap to catch up on, so it scans once.
for (const [label, options, stopAt] of [['stopped before it fires', {}, 'at once'], ['watching disabled', { watch: false }, 'later']]) {
test(`negative: no catch-up scan on macOS when ${label}`, { timeout: 30000 }, async t => {
const root = tmpRoot();
const watch = fs.watch;
const real = Object.getOwnPropertyDescriptor(process, 'platform');
const calls = [];
fs.watch = () => { calls.push('watch'); const w = new (require('node:events'))(); w.close = () => {}; return w; };
Object.defineProperty(process, 'platform', { value: 'darwin' });
t.after(() => { fs.watch = watch; Object.defineProperty(process, 'platform', real); fs.rmSync(root, { recursive: true, force: true }); });
const live = path.join(root, 'live');
fs.mkdirSync(live, { recursive: true });
fs.writeFileSync(path.join(live, 'one.txt'), 'one');
const target = await store.openTarget(path.join(root, 'target'));
const generation = await store.createGeneration(target, {});
const errors = [];
const engine = new FileEngine({ sources: [{ area: 'attachments', key: 'attachments', path: live }], target, generation,
scanMs: 3600000, sealMs: 100, onError: error => { errors.push(error); }, ...options });
const scan = engine.scan.bind(engine);
engine.scan = () => { calls.push('scan'); return scan(); };
await engine.start();
let stopped = false;
t.after(() => (stopped ? undefined : engine.stop()));
if (stopAt === 'at once') { await engine.stop(); stopped = true; }
fs.writeFileSync(path.join(live, 'two.txt'), 'two');
await sleep(2600); // past the two-second catch-up
assert.equal(calls.filter(c => c === 'scan').length, 1, 'only the start-up scan ran');
assert.equal(calls.includes('watch'), options.watch !== false, 'watching as configured');
assert.deepEqual(errors, []);
});
}
test('file streams: attachments, avatars and logs, with watching, restored to a moment', { timeout: 60000 }, async t => {
const root = tmpRoot();
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
@@ -60,6 +60,10 @@ module.exports.startProvider = async ({ privateKey, certificate }) => {
const now = new Date().toISOString(), expires = new Date(Date.now() + (state.mode === 'expired' ? -600000 : 300000)).toISOString();
const uid = '_' + crypto.randomUUID();
let xml = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="${uid}" Version="2.0" IssueInstant="${now}" Destination="${esc(acs)}" InResponseTo="${esc(id)}"><saml:Issuer>fixture-idp</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status><saml:Assertion ID="_${crypto.randomUUID()}" Version="2.0" IssueInstant="${now}"><saml:Issuer>fixture-idp</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">${esc(state.samlNameID || `${state.user}.saml@example.invalid`)}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData InResponseTo="${esc(id)}" Recipient="${esc(acs)}" NotOnOrAfter="${expires}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="${new Date(Date.now()-60000).toISOString()}" NotOnOrAfter="${expires}"><saml:AudienceRestriction><saml:Audience>${esc(issuer)}</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="${now}" SessionIndex="fixture-session"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement>${state.samlEmail ? `<saml:AttributeStatement><saml:Attribute Name="email"><saml:AttributeValue>${esc(state.samlEmail)}</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>` : ''}<saml:AttributeStatement><saml:Attribute Name="displayName"><saml:AttributeValue>Alice SAML</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>`;
// 'two-issuers': a second Issuer on the Response, signed with it. SAML
// core allows one; @node-saml/node-saml 5.2.0 refuses more (5.1.0 let
// the order pick the issuer).
if (state.mode === 'two-issuers') xml = xml.replace('<saml:Issuer>fixture-idp</saml:Issuer>', '<saml:Issuer>fixture-idp</saml:Issuer><saml:Issuer>other-idp</saml:Issuer>');
if (state.mode !== 'unsigned') {
const signer = new SignedXml({ privateKey, publicCert: certificate,
signatureAlgorithm: 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', canonicalizationAlgorithm: 'http://www.w3.org/2001/10/xml-exc-c14n#' });
+40 -4
View File
@@ -5,9 +5,13 @@ const vm = require('node:vm');
const source = fs.readFileSync('client/lib/keyboard.js', 'utf8');
const filterSource = source.slice(source.indexOf('hotkeys.filter ='), source.indexOf('// Handle non-Latin keyboards'));
// `control` is the one selector this fake element matches - 'button',
// 'a[href]' (a link, such as the minicard itself), '[role="tab"]' - or none.
class Element {
constructor(control = false) { this.control = control; }
closest() { return this.control ? this : null; }
constructor(control = null) { this.control = control; }
closest(selectors) {
return this.control && selectors.split(',').map(s => s.trim()).includes(this.control) ? this : null;
}
}
class HTMLInputElement extends Element {}
class HTMLSelectElement extends Element {}
@@ -24,11 +28,43 @@ vm.runInNewContext(filterSource, {
const event = { target: document, keyCode: 87 };
assert.equal(hotkeys.filter(event), true, 'document-targeted shortcut uses the active body');
assert.equal(hotkeys.filter({ keyCode: 87 }), true, 'missing target uses the active element');
for (const control of [new HTMLInputElement(), new HTMLSelectElement(), new HTMLTextAreaElement(), new Element(true)]) {
for (const control of [new HTMLInputElement(), new HTMLSelectElement(), new HTMLTextAreaElement()]) {
document.activeElement = control;
assert.equal(hotkeys.filter(event), false, 'synthetic shortcuts respect focused controls');
assert.equal(hotkeys.filter(event), false, 'synthetic shortcuts respect focused text controls');
assert.equal(hotkeys.filter({ ...event, target: control }), false, 'native control targets stay protected');
}
// A01: a focused control keeps the keys it uses itself - Enter for all of
// them, Space for a button (so Space still activates it instead of toggling a
// card member), End/Home/arrows for a tab.
const ownKeys = [
['button', [13, 32]],
['a[href]', [13]],
['[role="tab"]', [13, 35, 36, 37, 38, 39, 40]],
];
for (const [kind, keys] of ownKeys) {
const control = new Element(kind);
document.activeElement = control;
for (const keyCode of keys) {
assert.equal(hotkeys.filter({ target: control, keyCode }), false, `focused ${kind} keeps key ${keyCode}`);
assert.equal(hotkeys.filter({ target: document, keyCode }), false, `synthetic key ${keyCode} respects the focused ${kind}`);
}
// #6755: closing a popup returns focus to the button that opened it, and a
// click on a minicard leaves the minicard's link focused. Letter, digit and
// ? shortcuts were then refused until a reload; they must pass.
for (const keyCode of [68, 81, 65, 87, 49, 191]) {
assert.equal(hotkeys.filter({ target: control, keyCode }), true, `#6755: key ${keyCode} works with a ${kind} focused`);
assert.equal(hotkeys.filter({ target: document, keyCode }), true, `#6755: synthetic key ${keyCode} works with a ${kind} focused`);
}
}
// #6755: Space is not a link's own key, so on a focused minicard it is the
// board's Space shortcut; the arrows are not a button's or a link's.
const link = new Element('a[href]');
assert.equal(hotkeys.filter({ target: link, keyCode: 32 }), true, '#6755: Space on a focused minicard link is a shortcut');
for (const keyCode of [38, 40]) {
assert.equal(hotkeys.filter({ target: link, keyCode }), true, `key ${keyCode} on a focused link is a shortcut`);
assert.equal(hotkeys.filter({ target: new Element('button'), keyCode }), true, `key ${keyCode} on a focused button is a shortcut`);
}
document.activeElement = new Element();
document.activeElement = Object.assign(new Element(), { isContentEditable: true });
assert.equal(hotkeys.filter(event), false);
document.activeElement = null;
+37
View File
@@ -30,7 +30,15 @@ class MacSmoke(unittest.TestCase):
capture.__enter__()
self.addCleanup(capture.__exit__, None, None, None)
def setUpPs(self):
# The ps fixtures below are the non-macOS path; on macOS the members
# come from libproc (live_group_members_darwin), tested further down.
platform = patch.object(smoke.sys, 'platform', 'linux')
platform.start()
self.addCleanup(platform.stop)
def test_permission_error_only_ignored_for_dead_groups(self):
self.setUpPs()
for sig in (0, signal.SIGTERM, signal.SIGKILL):
for listing in ('', '123 Z\n123 Z+\n456 S\n'):
with self.subTest(sig=sig, listing=listing), \
@@ -44,13 +52,42 @@ class MacSmoke(unittest.TestCase):
smoke.signal_group(123, sig)
def test_failed_process_inspection_is_not_success(self):
self.setUpPs()
with patch.object(smoke.os, 'killpg', side_effect=PermissionError(1, 'denied')), \
patch.object(smoke.subprocess, 'check_output',
side_effect=subprocess.CalledProcessError(1, ['ps'])):
with self.assertRaises(subprocess.CalledProcessError):
smoke.signal_group(123, 0)
def test_darwin_permission_error_asks_libproc_not_ps(self):
# A Mac that will not run ps ("Operation not permitted") still tells a
# zombie-only group from a live one: libproc is asked, ps never.
with patch.object(smoke.sys, 'platform', 'darwin'), \
patch.object(smoke.os, 'killpg', side_effect=PermissionError(1, 'denied')), \
patch.object(smoke.subprocess, 'check_output', side_effect=PermissionError(1, 'ps')) as ps:
with patch.object(smoke, 'live_group_members_darwin', return_value=[]):
self.assertFalse(smoke.signal_group(123, 0))
with patch.object(smoke, 'live_group_members_darwin', return_value=[124]):
with self.assertRaises(PermissionError):
smoke.signal_group(123, 0)
ps.assert_not_called()
@unittest.skipUnless(sys.platform == 'darwin', 'libproc is macOS')
def test_darwin_libproc_sees_live_members_and_not_zombies(self):
# A real group: the leader exits (a zombie until reaped) while its
# child sleeps on - live; killed, only the zombie leader is left.
process = subprocess.Popen(['sh', '-c', 'sleep 30 & sleep 0.2; exit 0'], start_new_session=True)
try:
time.sleep(0.8)
self.assertTrue(smoke.live_group_members_darwin(process.pid), 'the sleeping child is live')
os.killpg(process.pid, signal.SIGKILL)
time.sleep(0.5)
self.assertEqual(smoke.live_group_members_darwin(process.pid), [], 'negative: a zombie is not live')
finally:
process.wait(timeout=5)
def test_darwin_shutdown_race_reaps_launcher(self):
self.setUpPs()
process = Mock(pid=123)
# TERM succeeds, then both the existence probe and KILL see zombies.
with patch.object(smoke.os, 'killpg', side_effect=[None,
+8 -3
View File
@@ -320,13 +320,18 @@ test('the passwordless flow asks for the email, then the code, and logs in with
});
test('errors go to the same #login-error-message region as the password form, translated for the conflict', () => {
assert.match(js, /document\.getElementById\('login-error-message'\)/);
// The region renders the message itself (text from loginErrorText), so a
// redraw of the sign-in form keeps it: written into the element, a SAML
// popup error vanished when `if isLoading` drew a new one.
const layout = fs.readFileSync(path.join(__dirname, '..', 'client/components/main/layouts.jade'), 'utf8');
assert.match(layout, /div#login-error-message\([^)]*\)= loginErrorText/, 'escaped text, never != HTML');
assert.match(js, /loginErrorText\(\) \{ return loginErrorText\.get\(\); \}/);
// The conflict is one of the error codes shown translated (#2460 added
// SQRL's sqrl-only and sqrl-hardlock to the same list): the code, matched
// on err.error or the reason, is the translation key.
assert.match(js, /const translated = \['oauth-account-conflict', [^\]]*\]\s*\.find\(key => err\.error === key \|\| reason === key\);/);
assert.match(js, /errorDiv\.textContent = translated\s*\? TAPi18n\.__\(translated\)/);
assert.match(js, /errorDiv\.textContent =/, 'text, never innerHTML, for a provider-supplied message');
assert.match(js, /loginErrorText\.set\(translated\s*\? TAPi18n\.__\(translated\)/);
assert.doesNotMatch(js, /login-error-message[^\n]*innerHTML|errorDiv\.innerHTML/, 'text, never innerHTML, for a provider-supplied message');
});
test('the i18n keys the form and the catalog use exist in English', () => {
+12
View File
@@ -3,6 +3,8 @@
const { test: base } = require('@playwright/test');
const db = require('./helpers/db');
const { loginWithToken, openBoard } = require('./helpers/auth');
const { waitForLanguageOnSetLanguage } = require('./helpers/language.cjs');
const { noPushSubscriptionInWebKit } = require('./helpers/webkit-push.cjs');
/**
* Extended Playwright test fixture that seeds a fresh user + board for every
@@ -13,6 +15,16 @@ const { loginWithToken, openBoard } = require('./helpers/auth');
* test('my test', async ({ page, user, board }) => { ... });
*/
const test = base.extend({
// Every page of the test's context: a setLanguage call resolves once the
// page shows that language (helpers/language.cjs), so a spec that switches
// language and then clicks does not click into the redraw that follows.
context: async ({ context }, use) => {
await context.addInitScript(waitForLanguageOnSetLanguage);
// And WebKit does not freeze on asking for a push subscription.
await context.addInitScript(noPushSubscriptionInWebKit);
await use(context);
},
// A freshly-seeded admin user available for every test.
adminUser: async ({}, use) => {
const user = db.seedUser({ isAdmin: true });
+58 -18
View File
@@ -3,25 +3,65 @@
// Docker Desktop on macOS does not share the host loopback with containers.
// Keep the test browser's origin equal to the bundle's ROOT_URL while sending
// HTTP and WebSocket traffic to the host through Docker's gateway name.
//
// Docker Desktop's gateway now and then never answers one connect: under a
// whole-suite run a SYN to host.docker.internal sat unanswered through nine
// retransmits while the next connection went through at once. Waiting on it
// left a page with no document for the test's whole timeout. So a connect that
// has not completed in CONNECT_TIMEOUT_MS is dropped and dialled again. The
// browser's bytes are not read until a connection is up (pauseOnConnect), so
// nothing is lost or sent twice.
const net = require('node:net');
const port = Number(process.env.WEKAN_DOCKER_HOST_PROXY_PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error('Invalid WEKAN_DOCKER_HOST_PROXY_PORT');
process.exit(2);
const CONNECT_TIMEOUT_MS = 1500;
const ATTEMPTS = 5;
function createProxy({
port,
dial = () => net.connect(port, 'host.docker.internal'),
connectTimeoutMs = CONNECT_TIMEOUT_MS,
attempts = ATTEMPTS,
}) {
return net.createServer({ pauseOnConnect: true }, client => {
let upstream = null;
client.on('error', () => upstream && upstream.destroy());
client.on('close', () => upstream && upstream.destroy());
const attempt = n => {
if (client.destroyed) return;
const socket = dial();
upstream = socket;
const timer = setTimeout(() => {
socket.destroy();
if (n < attempts) attempt(n + 1);
else client.destroy();
}, connectTimeoutMs);
socket.once('connect', () => {
clearTimeout(timer);
socket.on('close', () => client.destroy());
client.pipe(socket);
socket.pipe(client);
});
socket.on('error', () => {
clearTimeout(timer);
client.destroy();
});
};
attempt(1);
});
}
const server = net.createServer(client => {
const upstream = net.connect(port, 'host.docker.internal');
client.pipe(upstream);
upstream.pipe(client);
client.on('error', () => upstream.destroy());
upstream.on('error', () => client.destroy());
client.on('close', () => upstream.destroy());
upstream.on('close', () => client.destroy());
});
server.on('error', error => {
console.error('WeKan Docker test proxy:', error);
process.exit(1);
});
server.listen(port, '127.0.0.1');
if (require.main === module) {
const port = Number(process.env.WEKAN_DOCKER_HOST_PROXY_PORT);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error('Invalid WEKAN_DOCKER_HOST_PROXY_PORT');
process.exit(2);
}
const server = createProxy({ port });
server.on('error', error => {
console.error('WeKan Docker test proxy:', error);
process.exit(1);
});
server.listen(port, '127.0.0.1');
}
module.exports = { createProxy, CONNECT_TIMEOUT_MS, ATTEMPTS };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
// Wait until the page shows the language a spec has just switched to.
//
// setLanguage saves the choice on the server; the client then loads that
// language and draws everything again - which closes a popup opened before
// that, and in WebKit keeps the page busy enough that a click waits it out.
// Specs that switched language and at once opened the board view popup, or a
// settings section, failed when the language arrived late (Latin, Corsican,
// Igbo ... in WebKit and Firefox). The client mirrors its current language into
// <html lang> (client/lib/i18n.js) in the same flush that redraws, so that
// attribute says when it has arrived.
// Runs in the page. Wraps Meteor.callAsync so that a 'setLanguage' call
// resolves only once <html lang> names the language and the redraw has run.
// It waits at most 10 s and then resolves anyway: a language the client
// resolves to another tag must not turn a wait into a failure.
function waitForLanguageOnSetLanguage() {
const tag = value => String(value || '').toLowerCase().replace(/_/g, '-');
const shown = wanted => new Promise(resolve => {
const deadline = Date.now() + 10000;
const check = () => {
if (tag(document.documentElement.lang) === tag(wanted) || Date.now() > deadline) {
requestAnimationFrame(() => requestAnimationFrame(resolve));
} else setTimeout(check, 50);
};
check();
});
const install = () => {
const meteor = window.Meteor;
if (!meteor || typeof meteor.callAsync !== 'function') return false;
if (meteor.callAsync.__waitsForLanguage) return true;
const callAsync = meteor.callAsync;
const wrapped = async function (name, ...args) {
const result = await callAsync.call(this, name, ...args);
if (name === 'setLanguage' && typeof args[0] === 'string') await shown(args[0]);
return result;
};
wrapped.__waitsForLanguage = true;
meteor.callAsync = wrapped;
return true;
};
if (!install()) {
const timer = setInterval(() => { if (install()) clearInterval(timer); }, 20);
}
}
// For a page of a context a spec makes itself, which the fixture's init script
// does not reach.
async function untilLanguageShown(page, language) {
await page.waitForFunction(wanted => {
const tag = value => String(value || '').toLowerCase().replace(/_/g, '-');
return tag(document.documentElement.lang) === tag(wanted);
}, language, { timeout: 15_000 });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}
module.exports = { waitForLanguageOnSetLanguage, untilLanguageShown };
@@ -0,0 +1,46 @@
'use strict';
// Start a spec's mock server (a Jira or GitLab the list-sync specs answer as)
// where the WeKan server can reach it, and return its base URL.
//
// Normally that is any free port on 127.0.0.1: the specs and WeKan share the
// loopback. When the browsers run in Docker on macOS they do not, and WeKan's
// fetch from the host failed ("fetch failed"). build.sh then forwards a small
// pool of ports from the host into the container and names them in
// WEKAN_TEST_REVERSE_PORTS; a server takes the first one no other is using.
function reversePorts(env = process.env) {
return String(env.WEKAN_TEST_REVERSE_PORTS || '').split(/[\s,]+/).filter(Boolean).map(Number)
.filter(port => Number.isInteger(port) && port > 0 && port < 65536);
}
function listenOn(server, port) {
return new Promise((resolve, reject) => {
const failed = error => { server.removeListener('listening', listening); reject(error); };
const listening = () => { server.removeListener('error', failed); resolve(); };
server.once('error', failed);
server.once('listening', listening);
server.listen(port, '127.0.0.1');
});
}
async function listenReachable(server, env = process.env) {
const pool = reversePorts(env);
if (pool.length === 0) {
await listenOn(server, 0);
} else {
let listening = false;
for (const port of pool) {
try {
await listenOn(server, port);
listening = true;
break;
} catch (error) {
if (error.code !== 'EADDRINUSE') throw error;
}
}
if (!listening) throw new Error(`Every forwarded test port is in use: ${pool.join(', ')}`);
}
return `http://127.0.0.1:${server.address().port}`;
}
module.exports = { listenReachable, reversePorts };
+15
View File
@@ -0,0 +1,15 @@
'use strict';
// Runs in the page. Playwright's WebKit freezes the page's main thread on
// PushManager.getSubscription(): once it is called not even a timer runs
// again. Member Notification Settings asks it on opening, to show whether
// this device gets browser push (client/lib/webPush.js), so in WebKit every
// spec that opened it hung - Firefox, Chromium and Safari itself answer. Here
// it answers as a device with no subscription does: null. No spec subscribes.
function noPushSubscriptionInWebKit() {
const ua = navigator.userAgent;
if (!/AppleWebKit/.test(ua) || /Chrome|Chromium|Edg\//.test(ua)) return;
if (typeof PushManager === 'undefined') return;
PushManager.prototype.getSubscription = function getSubscription() { return Promise.resolve(null); };
}
module.exports = { noPushSubscriptionInWebKit };
@@ -53,20 +53,18 @@ test.describe('All Boards – phone viewport (#6488)', () => {
await expect(invitation.getByText('You are just invited to this board')).toBeVisible();
await expect(invitation.locator('.js-accept-invite')).toBeVisible();
await expect(invitation.locator('.js-decline-invite')).toBeVisible();
const invitationBox = await invitation.boundingBox();
expect(invitationBox).toBeTruthy();
for (const control of [
invitation.getByText('You are just invited to this board'),
invitation.locator('.js-accept-invite'),
invitation.locator('.js-decline-invite'),
]) {
const box = await control.boundingBox();
expect(box).toBeTruthy();
expect(box.y).toBeGreaterThanOrEqual(invitationBox.y);
expect(box.y + box.height).toBeLessThanOrEqual(
invitationBox.y + invitationBox.height + 1,
);
}
// The tile and its controls are measured in ONE layout: measured one at a
// time, a row drawn above the list in between (the board counts) moved
// the whole tile down, and a control was compared with where the tile had
// been - 23 px "outside" it in Firefox, which drew that row later.
const outside = () => invitation.evaluate(tile => {
const t = tile.getBoundingClientRect();
return ['.board-list-item-desc', '.js-accept-invite', '.js-decline-invite']
.map(selector => [selector, tile.querySelector(selector)?.getBoundingClientRect()])
.filter(([, box]) => !box || box.height === 0 || box.top < t.top || box.bottom > t.bottom + 1)
.map(([selector, box]) => `${selector} ${box ? `${box.top}-${box.bottom}` : 'missing'} in ${t.top}-${t.bottom}`);
});
await expect.poll(outside, { timeout: 10_000 }).toEqual([]);
// At least 2 per row. Measure EVERY tile in the list (including the leading
// "+ Add board" tile, which occupies the first grid cell and offsets the
@@ -49,9 +49,16 @@ test(`Recovery paginates pending activity summaries, disables orphan retries and
const panel = page.locator('.activity-notification-recovery-reports');
await expect(panel).toContainText(strings['activity-recovery-heading']);
await panel.locator('.js-table-page-search').fill(prefix);
await panel.locator('.js-table-page-search').press('Enter');
await expect(panel.locator('tbody tr')).toHaveCount(10);
await expect(panel.locator('tbody tr').first()).toContainText(strings['activity-recovery-status-missing']);
// The report is a snapshot taken when the search runs, and the live
// one-second recovery worker leases each pending intent while it looks at
// it: a search made during that lease shows "Delivery reservation active",
// which is right, and keeps showing it. Search again until the snapshot
// falls between leases (Firefox in EVERYTHING caught one).
await expect(async () => {
await panel.locator('.js-table-page-search').press('Enter');
await expect(panel.locator('tbody tr')).toHaveCount(10, { timeout: 3000 });
await expect(panel.locator('tbody tr').first()).toContainText(strings['activity-recovery-status-missing'], { timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(panel.locator('.js-retry-activity-notification').first()).toBeDisabled();
await panel.locator('.js-table-page-next').click();
await expect(panel.locator('tbody tr')).toHaveCount(3);
@@ -0,0 +1,102 @@
'use strict';
// Admin Panel follow-ups that are visible in the browser:
//
// 1. Problems / Password length names itself in the title bar (85af731c1b):
// without its ADMIN_PANE_TITLES entry the path stopped at "Problems".
// 2. People / Audit log has its own address, /admin/people/audit-log, a title,
// and a table that aligns to the START of the line, so it reads right to
// left in Hebrew (2017f2871f): `text-align: left` kept it on the left.
// 3. Attachments / Google Cloud Storage shows the key help ONCE (e6f7d226ca):
// a leftover copy showed the same sentence twice under the field.
// 4. Problems / Summary's Data health "Never" has a key of its own
// (77956c98ed): an admin's custom translation of List Sync's "never synced"
// must not change what Data health's "Last Run" says.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken, navigateInApp } = require('../helpers/auth');
const setLanguage = (userId, language) =>
db.updateOne('users', { _id: userId }, { $set: { 'profile.language': language } });
test('Problems / Password length names itself in the title bar', async ({ page, adminUser }) => {
await loginWithToken(page, adminUser.id, adminUser.token);
await navigateInApp(page, '/admin/problems/password-length');
await expect(page.locator('.js-password-min-length')).toBeVisible({ timeout: 20_000 });
const title = page.locator('#header-quick-access .header-page-title, .header-page-title').first();
await expect(title).toHaveAttribute('title', /\/ Problems \/ Password length$/, { timeout: 10_000 });
});
test('People / Audit log has its own URL and title, and reads right to left', async ({ page, adminUser }) => {
await loginWithToken(page, adminUser.id, adminUser.token);
await navigateInApp(page, '/admin/people/audit-log');
const table = page.locator('table.admin-audit-log-table');
await expect(table).toBeVisible({ timeout: 20_000 });
await expect(page).toHaveURL(/\/admin\/people\/audit-log$/);
const title = page.locator('.header-page-title').first();
await expect(title).toHaveAttribute('title', /\/ People \/ Audit log$/, { timeout: 10_000 });
// Hebrew is written right to left: the header cells' text sits at the RIGHT
// edge of each cell, which `text-align: left` would not do.
setLanguage(adminUser.id, 'he');
await page.reload();
await navigateInApp(page, '/admin/people/audit-log');
await expect(table).toBeVisible({ timeout: 20_000 });
await expect.poll(() => page.evaluate(() => getComputedStyle(
document.querySelector('.admin-audit-log-table th')).direction), { timeout: 15_000 }).toBe('rtl');
const placement = await page.evaluate(() => {
const th = document.querySelector('.admin-audit-log-table th');
const style = getComputedStyle(th);
const range = document.createRange();
range.selectNodeContents(th);
const text = range.getBoundingClientRect();
const cell = th.getBoundingClientRect();
return {
textAlign: style.textAlign,
gapRight: cell.right - parseFloat(style.paddingRight) - text.right,
gapLeft: text.left - cell.left - parseFloat(style.paddingLeft),
};
});
expect(placement.textAlign).toBe('start');
expect(Math.abs(placement.gapRight)).toBeLessThan(2);
expect(placement.gapLeft).toBeGreaterThan(2);
});
test('Attachments / Google Cloud Storage shows the key help once', async ({ page, adminUser }) => {
await loginWithToken(page, adminUser.id, adminUser.token);
await navigateInApp(page, '/admin/attachments/gcs');
const field = page.locator('#gcs-credentials');
await expect(field).toBeAttached({ timeout: 20_000 });
const help = 'Optional: paste the service account JSON key. Leave blank to use the key file or default credentials.';
const group = page.locator('.form-group', { has: field });
await expect(group.getByText(help, { exact: true })).toHaveCount(1);
await expect(page.getByText(help, { exact: true })).toHaveCount(1);
});
test('Problems / Summary: Data health "Never" is not List Sync\'s "never synced"', async ({ page, adminUser }) => {
const scan = db.findOne('dataHealth', { _id: 'scan' });
const ids = [`e2e-never-sync-${adminUser.id}`, `e2e-never-run-${adminUser.id}`];
setLanguage(adminUser.id, 'fi');
try {
// A scan that never ran, and an admin's custom wording of BOTH keys: the
// List Sync one says "never synced", Data health's own says "never run".
// Data health must show its own - and showing it at all proves the custom
// wording reaches this page, so the List Sync one would have shown too had
// the page still borrowed that key.
if (scan) db.updateOne('dataHealth', { _id: 'scan' }, { $unset: { at: '' } });
const now = new Date();
db.insertMany('translation', [
{ _id: ids[0], language: 'fi', text: 'list-sync-last-synced-never',
translationText: 'Ei koskaan synkronoitu', createdAt: now, modifiedAt: now },
{ _id: ids[1], language: 'fi', text: 'data-health-never-run',
translationText: 'Ei koskaan ajettu', createdAt: now, modifiedAt: now },
]);
await loginWithToken(page, adminUser.id, adminUser.token);
await navigateInApp(page, '/admin/problems/summary');
const lastRun = page.locator('p.quiet', { hasText: 'Viimeksi ajettu:' }).first();
await expect(lastRun).toHaveText('Viimeksi ajettu: Ei koskaan ajettu', { timeout: 20_000 });
await expect(lastRun).not.toContainText('synkronoitu');
} finally {
db.deleteMany('translation', { _id: { $in: ids } });
if (scan && scan.at) db.updateOne('dataHealth', { _id: 'scan' }, { $set: { at: new Date(scan.at) } });
}
});
@@ -80,10 +80,21 @@ for (const handles of [false, true]) test(`disabled checklist items stay put on
for (let sort=0; sort<3; sort++) db.insertOne('checklistItems', {_id:db.uid('item'), cardId:card._id, boardId:board.boardId, checklistId:id, title:String(sort+1), sort, isFinished:false});
await page.evaluate(boardId => Meteor.callAsync('setBoardDragging', boardId, 'item', false), board.boardId);
await page.reload();
// Where the rows are, read twice 150 ms apart.
const rowsAt = items => items.evaluateAll(rows => rows.map(row => Math.round(row.getBoundingClientRect().top)).join(','));
const drag = async items => {
await expect(items).toHaveCount(3);
await items.last().scrollIntoViewIfNeeded();
await items.first().hover();
// The opened card is still drawing the sections above its checklist for a
// moment, and they push the rows down: in Firefox they moved 50 px between
// measuring and releasing, and the release landed on item 1's own title
// (opening its editor) or in the wrong slot. Drag once the rows hold still.
await expect.poll(async () => {
const before = await rowsAt(items);
await page.waitForTimeout(150);
return before === await rowsAt(items);
}, { timeout: 15000 }).toBe(true);
const from = await (handles ? items.first().locator('.checklistitem-handle') : items.first()).boundingBox();
const to = await items.last().boundingBox();
await page.mouse.move(from.x+from.width/2, from.y+from.height/2);
@@ -8,7 +8,11 @@ for (const theme of ['belize', 'nephritis', 'dark', 'cleanlight']) {
await loginWithToken(page, user.id, user.token);
await openBoard(page, board.boardId, board.slug);
await expect(page.locator(`#js-list-${board.listIds[0]}`)).toBeVisible();
for (const section of ['Swimlane', 'List', 'Card']) {
// Swimlane and List: their settings have a column-actions row. Card
// Settings has had none since 3e5bc58105 ("Card Settings: a checkbox per
// section ... Card actions live in the card's own section hamburgers"),
// so asking it for one failed in every theme.
for (const section of ['Swimlane', 'List']) {
await page.evaluate(section => {
Popup.close();
Popup.open(`board${section}Settings`)({ currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
@@ -25,13 +25,20 @@ test('date pickers filter fields without expressions, include missing dates, rej
await form.locator('.js-card-date-missing').check();
await form.locator('[type="submit"]').click();
await expectCards([match._id, missing._id]);
const refused = form.locator('.js-card-date-range-error');
await expect(refused, 'negative: an accepted range says nothing').toHaveCount(0);
await form.locator('.js-card-date-to').fill('2026-08-01');
await form.locator('[type="submit"]').click();
await expectCards([match._id, missing._id]);
await expect(form.locator('.js-card-date-to')).toHaveJSProperty('validationMessage', 'Choose valid dates, with the end on or after the start.');
// Said in the form, not in the browser's bubble: Firefox opened that over the
// Apply and Clear buttons, and Clear's clicks never reached the page.
await expect(refused).toHaveText('Choose valid dates, with the end on or after the start.');
await expect(refused).toHaveAttribute('role', 'alert');
await form.locator('.js-clear-date-range').click();
await expectCards(cards.map(card => card._id));
await expect(form.locator('.js-card-date-to')).toHaveJSProperty('validationMessage', '');
await expect(refused).toHaveCount(0);
await form.locator('select').selectOption('createdAt');
await form.locator('.js-card-date-to').fill('2026-08-01');
await form.locator('[type="submit"]').click();
@@ -46,8 +46,11 @@ test('the drawing area fills the window, grows from its corner and zooms; Save i
// The whole room: wider than the old 800 x 500 on a large window.
await expect.poll(async () => (await viewBox(page))[2]).toBeGreaterThan(Math.min(800, viewport.width * 0.6));
// The viewBox grows first and the element's layout follows a frame later:
// read once, the surface was still the old 800 px (Firefox, full run).
await expect.poll(async () => (await page.locator('.js-drawing-surface').boundingBox()).width)
.toBeGreaterThan(viewport.width * 0.85);
const surface = await page.locator('.js-drawing-surface').boundingBox();
expect(surface.width).toBeGreaterThan(viewport.width * 0.85);
expect(surface.height).toBeGreaterThan(viewport.height * 0.5);
// Save above the drawing, at the right end of the tools.
@@ -28,10 +28,18 @@ test('historical movement picker finds earlier moves, refreshes recorded events,
await from.fill('2026-09-01'); await to.fill('2026-09-02');
await form.locator('[type="submit"]').click();
await expect.poll(visible).toEqual([movedAgain._id, far._id].sort());
const refused = form.locator('.js-card-movement-range-error');
await expect(refused, 'negative: an accepted range says nothing').toHaveCount(0);
await to.fill('2026-08-01'); await form.locator('[type="submit"]').click();
await expect(to).toHaveJSProperty('validationMessage', 'Choose valid dates, with the end on or after the start.');
// Said in the form, not in the browser's bubble, which Firefox opened over
// the Apply and Clear buttons and which took their clicks.
await expect(refused).toHaveText('Choose valid dates, with the end on or after the start.');
await expect(refused).toHaveAttribute('role', 'alert');
await expect.poll(visible).toEqual([movedAgain._id, far._id].sort());
await to.fill('2026-09-02'); await form.locator('[type="submit"]').click();
await to.fill('2026-09-02');
await expect(refused, 'editing the range takes the refusal back').toHaveCount(0);
await form.locator('[type="submit"]').click();
db.insertOne('activities', event(unknown, 'new-history', '2026-09-02T14:00:00Z'));
await expect.poll(visible).toEqual([movedAgain._id, far._id, unknown._id].sort());
db.deleteOne('activities', { _id: `earlier-${board.boardId}` });
@@ -112,6 +112,12 @@ test('with every field allowed, the card draws exactly the Card pane\'s sections
allowsSubtasks: true, allowsAttachments: true, allowsComments: true, 'scrum.visibility': visibility,
} });
await page.reload();
// Written straight to the database, the board reaches the page by the
// publication's next poll: its cursor has a limit, which change streams do
// not follow, so a reload can still be served the board as it was for up to
// ten seconds - and the card drew no Scrum section yet.
await expect.poll(() => page.evaluate(id => Boolean(Meteor.connection._mongo_livedata_collections.boards
.findOne(id)?.scrum?.visibility?.cardSprint), board.boardId), { timeout: 15_000 }).toBe(true);
await openCard(page, board);
const onCard = await cardSections(page);
await openCardSettings(page);
@@ -244,6 +250,16 @@ test('the splitter: dragged it widens one pane, clamped, kept, reset by a double
await expect(splitter).toHaveAttribute('role', 'separator');
const mini = paneOf(page, 'minicard');
const card = paneOf(page, 'card');
// The popup is still placing itself just after it opens: in WebKit the
// press landed where the splitter had been and dragged nothing. Measure once
// the splitter and the panes hold still, read twice 150 ms apart.
const layout = () => page.evaluate(() => [...document.querySelectorAll('.js-card-settings-splitter, .js-card-settings-pane')]
.map(el => { const r = el.getBoundingClientRect(); return `${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)}`; }).join('|'));
await expect.poll(async () => {
const before = await layout();
await page.waitForTimeout(150);
return before === await layout();
}, { timeout: 15000 }).toBe(true);
const m0 = (await mini.boundingBox()).width;
const c0 = (await card.boundingBox()).width;
const s = await splitter.boundingBox();
@@ -0,0 +1,80 @@
'use strict';
// c64b3a3b1d: the dueCards publication reads only the _id and members of the
// boards it may show, and with no assigned-only board its board scope is a
// plain { boardId: { $in } }. What the page shows must not change: a board's
// due cards are listed, and on a board where the user is an assigned-only
// member only the cards assigned to them are - in "me" and in "all users"
// alike. (tests/dbSpeedTest.test.cjs pins the selector itself.)
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken, navigateInApp } = require('../helpers/auth');
const due = new Date(Date.now() + 3 * 24 * 3600 * 1000);
async function dueTitles(page) {
const titles = page.locator('.due-cards-list .result-card-wrapper .minicard-title-text');
await expect(titles.first()).toBeVisible({ timeout: 20_000 });
// The list settles once the subscription is ready; read it when it stops changing.
let last = '';
await expect.poll(async () => {
const now = (await titles.allTextContents()).map(t => t.trim()).sort().join('|');
const settled = now === last;
last = now;
return settled;
}, { intervals: [400] }).toBe(true);
return last.split('|');
}
test('Due Cards lists a board\'s due cards, and only the assigned ones of an assigned-only board', async ({ page, user, user2 }) => {
const stamp = Date.now();
const t = name => `${name} ${stamp}`;
// Where user2 is a normal member.
const open = db.seedBoard({ ownerId: user.id, title: 'Due scope open', listCount: 2,
cardTitlesPerList: [[t('Due Mine')], [t('Due Others')]] });
// Where user2 is an assigned-only member.
const narrow = db.seedBoard({ ownerId: user.id, title: 'Due scope narrow', listCount: 3,
cardTitlesPerList: [[t('Due Assigned')], [t('Due Member Only')], [t('Due Not Mine')]] });
try {
db.addBoardMember({ boardId: open.boardId, userId: user2.id });
db.updateOne('boards', { _id: narrow.boardId }, { $push: { members: {
userId: user2.id, isActive: true, isAdmin: false, isNormalAssignedOnly: true } } });
const id = (board, title) => db.findCardIdByTitle({ boardId: board.boardId, title: t(title) });
for (const [board, title, set] of [
[open, 'Due Mine', { members: [user2.id] }],
[open, 'Due Others', {}],
[narrow, 'Due Assigned', { assignees: [user2.id] }],
// A member of the card, but not assigned: the client's "me" filter would
// take it, so only the publication's board scope keeps it out.
[narrow, 'Due Member Only', { members: [user2.id] }],
[narrow, 'Due Not Mine', {}],
]) db.updateOne('cards', { _id: id(board, title) }, { $set: { dueAt: due, ...set } });
await loginWithToken(page, user2.id, user2.token);
await page.evaluate(() => { try { localStorage.setItem('dueCardsView', 'me'); } catch (e) { /* default is me */ } });
await navigateInApp(page, '/due-cards');
const mine = (await dueTitles(page)).filter(title => title.endsWith(String(stamp)));
expect(mine).toEqual([t('Due Assigned'), t('Due Mine')].sort());
// "All users": every due card of the open board, still only the assigned
// one of the narrow board (negative).
await page.evaluate(() => { localStorage.setItem('dueCardsView', 'all'); });
await page.reload();
await page.waitForFunction(() => typeof Meteor !== 'undefined' && Meteor.userId());
await navigateInApp(page, '/due-cards');
const all = (await dueTitles(page)).filter(title => title.endsWith(String(stamp)));
expect(all).toEqual([t('Due Assigned'), t('Due Mine'), t('Due Others')].sort());
expect(all).not.toContain(t('Due Member Only'));
expect(all).not.toContain(t('Due Not Mine'));
// The owner, with no assigned-only board (the plain boardId list): every
// due card of both boards.
await page.evaluate(() => { localStorage.setItem('dueCardsView', 'all'); });
await loginWithToken(page, user.id, user.token);
await navigateInApp(page, '/due-cards');
const owners = (await dueTitles(page)).filter(title => title.endsWith(String(stamp)));
expect(owners).toEqual(['Due Mine', 'Due Others', 'Due Assigned', 'Due Member Only', 'Due Not Mine'].map(t).sort());
} finally {
try { await page.evaluate(() => localStorage.removeItem('dueCardsView')); } catch (e) { /* page gone */ }
db.cleanup({ boardIds: [open.boardId, narrow.boardId] });
}
});
@@ -137,6 +137,61 @@ test.describe('#6750 due date keyboard shortcut', () => {
}
});
// #6755 comment 6559698117: after a popup opened from a minicard was closed,
// shortcuts did nothing until a reload. The minicard is a link, so the click
// on its menu left that link focused, and the shortcut filter refused every
// key while a link or button had the focus. No blur here - that is the point.
test('d still works after a minicard popup is opened and closed, with its link focused (#6755)', async ({ page, user, board }) => {
db.updateOne('users', { _id: user.id }, { $unset: { 'profile.keyboardShortcuts': '' } });
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
await loginWithToken(page, user.id, user.token);
await openBoard(page, board.boardId, board.slug);
const minicard = page.locator('.js-minicard', { hasText: 'Alpha Card' }).first();
await minicard.hover();
await minicard.locator('.js-open-minicard-details-menu').click();
const menu = page.locator('.js-pop-over');
await expect(menu).toBeVisible();
await page.keyboard.press('Escape');
await expect(menu).toHaveCount(0);
// The reported state: the minicard's own link keeps the focus (the blue
// outline in the report). Where the browser already left it there this
// changes nothing; where it did not, this is the same state made directly.
await minicard.focus();
expect(await page.evaluate(() => document.activeElement?.matches('a.js-minicard[href]')),
'the minicard link has the focus').toBe(true);
await minicard.hover();
await page.keyboard.press('d');
await expect(page).toHaveURL(new RegExp(`/${card._id}$`));
await expect(dueDatePopup(page)).toBeVisible({ timeout: 8_000 });
await page.keyboard.press('Escape');
});
// Negative (A01): Space on a focused BUTTON still activates the button and is
// not taken by the board's Space shortcut, which toggles the hovered card's
// member - the reason focused controls are guarded at all.
test('negative: Space on a focused button activates the button, not the Space shortcut (#6755)', async ({ page, user, board }) => {
db.updateOne('users', { _id: user.id }, { $unset: { 'profile.keyboardShortcuts': '' } });
await loginWithToken(page, user.id, user.token);
await openBoard(page, board.boardId, board.slug);
const membersBefore = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' }).members || [];
const clicked = await page.evaluate(() => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'probe';
button.addEventListener('click', () => { window.__probeClicks = (window.__probeClicks || 0) + 1; });
document.body.appendChild(button);
button.focus();
return document.activeElement === button;
});
expect(clicked).toBe(true);
await page.locator('.js-minicard', { hasText: 'Alpha Card' }).first().hover();
await page.evaluate(() => document.querySelector('body > button:last-of-type').focus());
await page.keyboard.press('Space');
await expect.poll(() => page.evaluate(() => window.__probeClicks || 0)).toBe(1);
await page.waitForTimeout(800);
expect(db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' }).members || []).toEqual(membersBefore);
});
test('negative: d over a minicard does nothing when shortcuts are disabled (#6755)', async ({ page, user, board }) => {
db.updateOne('users', { _id: user.id }, { $set: { 'profile.keyboardShortcuts': false } });
await loginWithToken(page, user.id, user.token);
@@ -2,6 +2,7 @@
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { openBoard } = require('../helpers/auth');
const CardPage = require('../pages/CardPage');
for (const language of ['en', 'ku', 'ckb', 'tt', 'so', 'ny', 'mi', 'sm', 'tk_TM', 'yi', 'bho', 'mai', 'or_IN', 'kok', 'pap', 'ary', 'st', 'tn', 'nso', 'zu', 'zu-ZA', 'xh', 'ss', 'nd', 'ts', 've', 'bi', 'tpi', 'fj', 'to', 'haw', 'om', 'rw', 'rn', 'lg', 'wa', 'wa-RR', 'ace', 'gv', 'se', 've-CC', 'rup', 'ak', 'bm', 'ee', 'wo', 'ff', 'ks', 'bua', 'cv', 'sah', 'bo', 'dz', 'ti', 'qu', 'ay', 'gn', 've-PP', 'vo', 'tlh', 'nah', 'wal', 'zgh', 'kl', 'iu', 'tig', 'chr']) {
const locale = require(`../../../imports/i18n/data/${language}.i18n.json`);
@@ -12,7 +13,14 @@ test(`duplicate relation picker, editing and History undo/redo retain typed link
db.setShowDependencies({ boardId: board.boardId, board: true });
await openBoard(page, board.boardId, board.slug);
await page.locator(`.js-minicard[data-card-id="${source._id}"]`).click();
await page.locator('.js-add-dependency').click();
await new CardPage(page).waitForOpen();
// A popup opened while the card is still opening is closed again when the
// card's route and details settle: under a whole-suite run in Firefox the
// picker never showed. Open it again until its type select is there.
await expect(async () => {
await page.locator('.js-add-dependency').click();
await expect(page.locator('.js-new-dependency-type')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 20000 });
await page.locator('.js-new-dependency-type').selectOption('duplicates');
await page.locator(`.js-pick-dependency[data-target-id="${target._id}"]`).click();
const type = () => db.findOne('cards', { _id: source._id }).cardDependencies?.[0]?.type;
@@ -6,9 +6,14 @@ test('Haitian Creole bulk export explains workbook and ZIP layouts', async ({ bo
await page.evaluate(() => Meteor.callAsync('setLanguage', 'ht'));
await navigateInApp(page, '/');
const settings = page.locator('.js-export-all-boards');
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
await settings.click();
const popup = page.locator('.js-pop-over');
// All Boards is still drawing just after the route changes, and in Firefox
// the popup opened then was closed again (full run). Open it until it stays.
await expect(async () => {
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
if (!(await popup.locator('.header-title').count())) await settings.click();
await expect(popup.locator('.header-title')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(popup.locator('.header-title')).toHaveText('Ekspòte tout tablo yo');
await expect(popup.locator('p.quiet')).toContainText('Tout tablo ou gen dwa ekspòte');
await expect(popup.locator('p.quiet')).toContainText('yon sèl liv travay ak yon fèy pou chak tablo');
@@ -20,9 +25,14 @@ test('Malagasy bulk export explains workbook and ZIP layouts', async ({ boardPag
await page.evaluate(() => Meteor.callAsync('setLanguage', 'mg'));
await navigateInApp(page, '/');
const settings = page.locator('.js-export-all-boards');
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
await settings.click();
const popup = page.locator('.js-pop-over');
// All Boards is still drawing just after the route changes, and in Firefox
// the popup opened then was closed again (full run). Open it until it stays.
await expect(async () => {
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
if (!(await popup.locator('.header-title').count())) await settings.click();
await expect(popup.locator('.header-title')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(popup.locator('.header-title')).toHaveText('Avoahy ny solaitra rehetra');
await expect(popup.locator('p.quiet')).toContainText('Ny solaitra rehetra azonao avoaka');
await expect(popup.locator('p.quiet')).toContainText('takelaka isaky ny solaitra');
@@ -34,9 +44,14 @@ test('Irish bulk export explains workbook and ZIP layouts', async ({ boardPage:
await page.evaluate(() => Meteor.callAsync('setLanguage', 'ga'));
await navigateInApp(page, '/');
const settings = page.locator('.js-export-all-boards');
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
await settings.click();
const popup = page.locator('.js-pop-over');
// All Boards is still drawing just after the route changes, and in Firefox
// the popup opened then was closed again (full run). Open it until it stays.
await expect(async () => {
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
if (!(await popup.locator('.header-title').count())) await settings.click();
await expect(popup.locator('.header-title')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(popup.locator('.header-title')).toHaveText('Easpórtáil gach clár');
await expect(popup.locator('p.quiet')).toContainText('Gach clár is féidir leat a easpórtáil');
await expect(popup.locator('p.quiet')).toContainText('bileog do gach clár');
@@ -48,9 +63,14 @@ test('Scottish Gaelic bulk export explains workbook and ZIP layouts', async ({ b
await page.evaluate(() => Meteor.callAsync('setLanguage', 'gd'));
await navigateInApp(page, '/');
const settings = page.locator('.js-export-all-boards');
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
await settings.click();
const popup = page.locator('.js-pop-over');
// All Boards is still drawing just after the route changes, and in Firefox
// the popup opened then was closed again (full run). Open it until it stays.
await expect(async () => {
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
if (!(await popup.locator('.header-title').count())) await settings.click();
await expect(popup.locator('.header-title')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(popup.locator('.header-title')).toHaveText('Às-phortaich a h-uile bòrd');
await expect(popup.locator('p.quiet')).toContainText('A h-uile bòrd as urrainn dhut às-phortadh');
await expect(popup.locator('p.quiet')).toContainText('siota airson gach bòrd');
@@ -62,9 +82,14 @@ test('Igbo bulk export explains workbook and ZIP layouts', async ({ boardPage: p
await page.evaluate(() => Meteor.callAsync('setLanguage', 'ig'));
await navigateInApp(page, '/');
const settings = page.locator('.js-export-all-boards');
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
await settings.click();
const popup = page.locator('.js-pop-over');
// All Boards is still drawing just after the route changes, and in Firefox
// the popup opened then was closed again (full run). Open it until it stays.
await expect(async () => {
if (!(await settings.isVisible())) await page.locator('.js-toggle-page-sidebar').first().click();
if (!(await popup.locator('.header-title').count())) await settings.click();
await expect(popup.locator('.header-title')).toBeVisible({ timeout: 3000 });
}).toPass({ timeout: 30000 });
await expect(popup.locator('.header-title')).toHaveText('Bupụ bọọdụ niile');
await expect(popup.locator('p.quiet')).toContainText('Bọọdụ ọ bụla ị nwere ike ibupụ');
await expect(popup.locator('p.quiet')).toContainText('mpempe akwụkwọ maka bọọdụ ọ bụla');
@@ -42,6 +42,9 @@ for (const language of ['en', 'ku', 'ckb', 'tt', 'tk_TM', 'yi', 'ary', 'bho', 'm
await plant(page, { boardId: board.boardId, direction: 'redo', requestId: 'key-retrythis000000000000000', at: Date.now() });
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(notice.locator('p').first()).toHaveText(locale['history-request-pending-redo']);
// Enabled once the board says this person may write on it: a Retry clicked
// before that did nothing, and the notice stayed (Firefox, EVERYTHING).
await expect(notice.locator('.js-history-retry')).toBeEnabled();
await notice.locator('.js-history-retry').click();
await expect(notice).toHaveCount(0);
expect(await stored(page)).toBeNull();
@@ -1,5 +1,6 @@
const http = require('node:http');
const { test, expect } = require('../fixtures');
const { listenReachable } = require('../helpers/reachable-server.cjs');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const call = (page, name, ...args) => page.evaluate(({ name, args }) => Meteor.callAsync(name, ...args), { name, args });
@@ -12,8 +13,8 @@ test.beforeAll(async () => {
...(value === undefined ? {} : { customfield_100: value }),
} }] }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
// Where WeKan can reach it, in Docker on macOS too (helpers/reachable-server.cjs).
base = await listenReachable(server);
});
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
@@ -4,6 +4,7 @@
// missing (models/lib/listSyncPlanning.js).
const http = require('node:http');
const { test, expect } = require('../fixtures');
const { listenReachable } = require('../helpers/reachable-server.cjs');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const call = (page, name, ...args) => page.evaluate(({ name, args }) => Meteor.callAsync(name, ...args), { name, args });
@@ -14,8 +15,8 @@ test.beforeAll(async () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(issues));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
// Where WeKan can reach it, in Docker on macOS too (helpers/reachable-server.cjs).
base = await listenReachable(server);
});
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
@@ -1,6 +1,7 @@
'use strict';
const http = require('node:http');
const { test, expect } = require('../fixtures');
const { listenReachable } = require('../helpers/reachable-server.cjs');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const { syncSourceKey } = require('../../../models/lib/listSyncSourceIdentity');
@@ -35,8 +36,8 @@ test.beforeAll(async () => {
heldResponses.push(respond);
} else respond();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
// Where WeKan can reach it, in Docker on macOS too (helpers/reachable-server.cjs).
base = await listenReachable(server);
});
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
@@ -1,5 +1,6 @@
const http = require('node:http');
const { test, expect } = require('../fixtures');
const { listenReachable } = require('../helpers/reachable-server.cjs');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
const call = (page, name, ...args) => page.evaluate(async ({ name, args }) => {
@@ -12,8 +13,8 @@ test.beforeAll(async () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ startAt: 0, total: 1, issues: [{ key: 'TIME-1', fields: { summary: 'Time estimates', ...fields } }] }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
base = `http://127.0.0.1:${server.address().port}`;
// Where WeKan can reach it, in Docker on macOS too (helpers/reachable-server.cjs).
base = await listenReachable(server);
});
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more