mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b98272272 | ||
|
|
ee22fb1a3b | ||
|
|
154c9a6e52 | ||
|
|
121f37be91 | ||
|
|
79c40407d1 | ||
|
|
148d18d456 | ||
|
|
15877dd1bc | ||
|
|
3ccb465c3f | ||
|
|
71d9776b61 | ||
|
|
34d9817110 | ||
|
|
aa939d7bec | ||
|
|
194e5b0a51 | ||
|
|
532a96da11 | ||
|
|
ba0de93f79 | ||
|
|
9a15491985 | ||
|
|
c8ed3e0fd0 | ||
|
|
4a9c3f21d2 | ||
|
|
3a2fef4dc7 | ||
|
|
f241681e80 | ||
|
|
c71c875649 | ||
|
|
0da86171e9 | ||
|
|
b80e21fd48 | ||
|
|
24a7270b7f | ||
|
|
73ea323da1 | ||
|
|
be6727607c | ||
|
|
9c5fa760c3 | ||
|
|
27040c6f2f | ||
|
|
c15e1ab4b2 | ||
|
|
b676cdc169 | ||
|
|
4ad13db5ef | ||
|
|
efe0e9287c | ||
|
|
cfcead6a1a | ||
|
|
40f60582b7 | ||
|
|
96845f6f4b | ||
|
|
c5f5ad2338 | ||
|
|
0c8c254dc5 | ||
|
|
0a1f8584ee | ||
|
|
2042fd5102 | ||
|
|
0403e27c62 | ||
|
|
d9e48a909d | ||
|
|
0cea141702 | ||
|
|
9188843369 | ||
|
|
dbf07aadf9 | ||
|
|
e001c4f9c2 | ||
|
|
ffe207e191 | ||
|
|
b2198cb61b | ||
|
|
a6bec88266 | ||
|
|
8a1f7a14a9 | ||
|
|
7f948bf438 | ||
|
|
6d0ab6cf11 | ||
|
|
89b64966c6 | ||
|
|
d5e3325e87 | ||
|
|
897376ba54 | ||
|
|
16103f9ad2 | ||
|
|
8cbe2605fe | ||
|
|
f5eb4ecf61 | ||
|
|
8d4f4caa2f | ||
|
|
9a74138a84 | ||
|
|
fa7e447536 | ||
|
|
05cc2460ea | ||
|
|
5c50f38e25 | ||
|
|
338afc15f8 | ||
|
|
dd2082b0ea | ||
|
|
4a86e9c19e | ||
|
|
568cd9b6ec | ||
|
|
fea468471d | ||
|
|
83199b9af7 | ||
|
|
3839ee698d | ||
|
|
a60b996c92 | ||
|
|
1230164c16 | ||
|
|
8008f410fe | ||
|
|
bd90782da2 | ||
|
|
5eadc45563 | ||
|
|
3785a13f6a | ||
|
|
97cabdeca4 | ||
|
|
2802326888 | ||
|
|
1f46af0639 | ||
|
|
1bcb1da92f | ||
|
|
067efac7f8 | ||
|
|
2bdd16060e | ||
|
|
433bc62211 | ||
|
|
29f9c62a64 | ||
|
|
21766cf38d | ||
|
|
101cdb7579 | ||
|
|
54cf951d9c | ||
|
|
57a43ee8e1 | ||
|
|
ff2c0ed7f6 |
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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'}}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Generated
+326
-272
File diff suppressed because it is too large.
Load diff
+10
-10
@@ -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",
|
||||
|
||||
@@ -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,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,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.
|
||||
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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\(\);/);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 \};/);
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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#' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
Reference in new issue
Block a user