Compare commits

..

130 Commits

Author SHA1 Message Date
link2xt
302aa5a5f7 chore(release): prepare for 1.155.4 2025-02-10 19:19:03 +00:00
bjoern
8bddd455a7 fix: accept QR codes with 'broken' JSON (#6528)
this converts old QR codes to the new format, in an hacky, but simple
way, see #6518 for more details and for code snippet


then QR code change is esp. bad as ppl will have different versions for
some days at least, weakening overall UX, esp. of first-time-users that
may come to delta because of praised, seamless multidevice ... :)


i tested in https://github.com/deltachat/deltachat-ios/pull/2595 that
this actually fixes the problem, and there is no deeper issue with
changed chashes or so - seemed not to be the case, at least, with this
hack, core accepts QR codes from the released 1.52-and-before series

this hack gives user time to update, it can be removed after some months
(we can also remove the old BACKUP qr code alltogether then)

we should still not wait too long with the PR as there are already
versions out with the "new/bad" QR code (and growing, as new iOS
installations will get the new format, one cannot revert a version, only
pause rollout)

---------

Co-authored-by: link2xt <link2xt@testrun.org>
2025-02-10 15:30:23 +00:00
link2xt
a0ff0d71bc fix: do not include CRLF before MIME boundary in the part body
This change adds a test and updates mailparse from 0.15.0 to 0.16.0.
mailparse 0.16.0 includes a fix for the bug
that resulted in CRLF being included at the end of the body.
Workaround for the bug in the `pk_validate` function is also removed.
2025-02-10 12:35:27 +00:00
link2xt
068726453e ci: upgrade Rust from 1.84.0 to 1.84.1 2025-02-09 17:34:41 +00:00
link2xt
0973a46245 fix: make vCard parsing more robust in case of trailing newlines
Contacts should be added only if there is an END:VCARD
detected, not because we found the end of file.
2025-02-06 22:25:47 +00:00
link2xt
e22d980845 fix: use CRLF newlines in vCards
This is a requirement from
<https://datatracker.ietf.org/doc/html/rfc6350#section-3.2>
2025-02-06 21:54:12 +00:00
Hocuri
0c0afead2c refactor: Move even more tests into their own files (#6521)
As always, I moved the tests from the biggest files. I left out
`mimefactory.rs` because @link2xt has an active PR modifying the tests.
2025-02-06 22:37:25 +01:00
WofWca
3eae9cb30c improvement: add MessageQuote.chat_id
For the "Reply Privately" feature.

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-02-05 10:42:32 +00:00
link2xt
4ef6788ffd chore(release): prepare for 1.155.3 2025-02-05 05:56:25 +00:00
link2xt
4198ed1efb fix: store device token in IMAP METADATA on each connection
APNS tokens never expire unless
the user uninstalls the application.
Because of this in most cases
the token remains valid forever
and chatmail server never removes the token
even if it is unencrypted
or the user has removed Delta Chat profile
from the device but not the whole application.

We want to modify chatmail servers
to remember the last time the token was stored
and remove them after some time.
Before we do this, we need to modify
the client to store the device token
each time so the server knows which tokens are used
and can update their timestamps.
2025-02-05 05:36:50 +00:00
link2xt
6f5620dad5 chore: update futures-concurrency
This removes two duplicate dependencies.
2025-02-05 03:21:13 +00:00
link2xt
1d55458781 chore: upgrade iroh from 0.31 to 0.32 2025-02-04 19:45:01 +00:00
link2xt
6297bb967a chore: upgrade iroh from 0.30 to 0.31 2025-02-04 19:16:50 +00:00
link2xt
0040c17892 test: make sure DCBACKUP2 compatibility does not break again
QR code format changed because `NodeAddr` serialization
changed between iroh 0.29.0 and iroh 0.30.0.
We have already released iroh 0.30.0,
so the test at least makes change we don't break compatibility again.
2025-02-04 19:03:46 +00:00
link2xt
258b5cde70 chore: update pgp to 0.15 2025-02-04 17:55:58 +00:00
dependabot[bot]
a58103ae4a Merge pull request #6502 from deltachat/dependabot/cargo/rustls-pki-types-1.11.0 2025-02-03 18:01:21 +00:00
dependabot[bot]
bf36a479db Merge pull request #6498 from deltachat/dependabot/cargo/log-0.4.25 2025-02-03 18:01:06 +00:00
dependabot[bot]
9a2924ed88 Merge pull request #6513 from deltachat/dependabot/cargo/uuid-1.12.1 2025-02-03 17:58:36 +00:00
dependabot[bot]
4be4a3c72f Merge pull request #6509 from deltachat/dependabot/cargo/serde_json-1.0.138 2025-02-03 17:58:16 +00:00
dependabot[bot]
7b6ba0e011 Merge pull request #6514 from deltachat/dependabot/cargo/data-encoding-2.7.0 2025-02-03 17:58:00 +00:00
dependabot[bot]
4e601c31b4 chore(cargo): bump data-encoding from 2.6.0 to 2.7.0
Bumps [data-encoding](https://github.com/ia0/data-encoding) from 2.6.0 to 2.7.0.
- [Commits](https://github.com/ia0/data-encoding/commits)

---
updated-dependencies:
- dependency-name: data-encoding
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:46:49 +00:00
dependabot[bot]
fa0382da2d chore(cargo): bump serde_json from 1.0.134 to 1.0.138
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.134 to 1.0.138.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.134...v1.0.138)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:44:22 +00:00
dependabot[bot]
64bd05aa44 chore(cargo): bump log from 0.4.22 to 0.4.25
Bumps [log](https://github.com/rust-lang/log) from 0.4.22 to 0.4.25.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.22...0.4.25)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:44:18 +00:00
dependabot[bot]
e651001a57 chore(cargo): bump rustls-pki-types from 1.10.1 to 1.11.0
Bumps [rustls-pki-types](https://github.com/rustls/pki-types) from 1.10.1 to 1.11.0.
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.10.1...v/1.11.0)

---
updated-dependencies:
- dependency-name: rustls-pki-types
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:44:06 +00:00
dependabot[bot]
8c251afeb1 Merge pull request #6506 from deltachat/dependabot/cargo/rustls-0.23.22 2025-02-03 16:15:14 +00:00
dependabot[bot]
8e7f1d83ec chore(cargo): bump uuid from 1.11.0 to 1.12.1
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.11.0 to 1.12.1.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/1.11.0...1.12.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:05:23 +00:00
dependabot[bot]
15fc12e525 chore(cargo): bump rustls from 0.23.20 to 0.23.22
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.20 to 0.23.22.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.20...v/0.23.22)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 15:41:34 +00:00
dependabot[bot]
81930c1731 Merge pull request #6516 from deltachat/dependabot/cargo/syn-2.0.98 2025-02-03 15:38:18 +00:00
dependabot[bot]
ee39615dbd chore(cargo): bump syn from 2.0.94 to 2.0.98
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.94 to 2.0.98.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.94...2.0.98)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 15:08:50 +00:00
dependabot[bot]
058ac3006c Merge pull request #6500 from deltachat/dependabot/cargo/tokio-1.43.0 2025-02-03 15:06:15 +00:00
dependabot[bot]
f0c4414d34 chore(cargo): bump tokio from 1.42.0 to 1.43.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.42.0 to 1.43.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.42.0...tokio-1.43.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 13:58:35 +00:00
link2xt
4e5125b98d chore: update OpenSSL to fix RUSTSEC-2025-0004 2025-02-03 13:53:34 +00:00
dependabot[bot]
8cb1ba5000 Merge pull request #6505 from deltachat/dependabot/cargo/futures-lite-2.6.0 2025-02-03 09:39:13 +00:00
dependabot[bot]
feac84c5fc Merge pull request #6512 from deltachat/dependabot/cargo/webpki-roots-0.26.8 2025-02-03 09:38:52 +00:00
dependabot[bot]
d762972c95 Merge pull request #6508 from deltachat/dependabot/cargo/hyper-1.6.0 2025-02-03 09:38:34 +00:00
dependabot[bot]
ae893d57a9 Merge pull request #6499 from deltachat/dependabot/cargo/dirs-6.0.0 2025-02-03 09:37:48 +00:00
dependabot[bot]
602d379aef Merge pull request #6497 from deltachat/dependabot/cargo/pin-project-1.1.8 2025-02-03 09:37:22 +00:00
dependabot[bot]
18c02f5bf9 Merge pull request #6510 from deltachat/dependabot/cargo/thiserror-2.0.9 2025-02-03 09:36:16 +00:00
B. Petersen
23033fb0a0 docs: assign docs to correct object 2025-02-02 16:48:43 +01:00
dependabot[bot]
5e65c19f00 chore(cargo): bump webpki-roots from 0.26.7 to 0.26.8
Bumps [webpki-roots](https://github.com/rustls/webpki-roots) from 0.26.7 to 0.26.8.
- [Release notes](https://github.com/rustls/webpki-roots/releases)
- [Commits](https://github.com/rustls/webpki-roots/compare/v/0.26.7...v/0.26.8)

---
updated-dependencies:
- dependency-name: webpki-roots
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:44:37 +00:00
dependabot[bot]
c23809ccd5 chore(cargo): bump thiserror from 1.0.69 to 2.0.9
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 1.0.69 to 2.0.9.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/1.0.69...2.0.9)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:43:56 +00:00
dependabot[bot]
54d3a2ad47 chore(cargo): bump hyper from 1.5.2 to 1.6.0
Bumps [hyper](https://github.com/hyperium/hyper) from 1.5.2 to 1.6.0.
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.5.2...v1.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:43:22 +00:00
dependabot[bot]
1f7e57181e chore(cargo): bump futures-lite from 2.5.0 to 2.6.0
Bumps [futures-lite](https://github.com/smol-rs/futures-lite) from 2.5.0 to 2.6.0.
- [Release notes](https://github.com/smol-rs/futures-lite/releases)
- [Changelog](https://github.com/smol-rs/futures-lite/blob/master/CHANGELOG.md)
- [Commits](https://github.com/smol-rs/futures-lite/compare/v2.5.0...v2.6.0)

---
updated-dependencies:
- dependency-name: futures-lite
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:42:05 +00:00
dependabot[bot]
7e886cbf2b chore(cargo): bump dirs from 5.0.1 to 6.0.0
Bumps [dirs](https://github.com/soc/dirs-rs) from 5.0.1 to 6.0.0.
- [Commits](https://github.com/soc/dirs-rs/commits)

---
updated-dependencies:
- dependency-name: dirs
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:40:24 +00:00
dependabot[bot]
ebeb742ba6 chore(cargo): bump pin-project from 1.1.7 to 1.1.8
Bumps [pin-project](https://github.com/taiki-e/pin-project) from 1.1.7 to 1.1.8.
- [Release notes](https://github.com/taiki-e/pin-project/releases)
- [Changelog](https://github.com/taiki-e/pin-project/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/pin-project/compare/v1.1.7...v1.1.8)

---
updated-dependencies:
- dependency-name: pin-project
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 21:39:26 +00:00
link2xt
ecbec41b97 chore(release): prepare for 1.155.2 2025-01-31 01:57:30 +00:00
l
c760e173fa fix: no implicit member changes when we are added to the group (#6493) 2025-01-30 18:30:06 +00:00
WofWca
0df042af49 docs(jsonrpc): add docs for some functions
For `get_message_ids()` and `get_first_unread_message_of_chat()`.
2025-01-30 09:51:55 +00:00
Nico de Haen
fcdbe3ff4a feat: add IncomingReaction.chat_id (#6459)
For the same reasons as mentioned in #6356 and to streamline the
"Incoming" Event API. (all have a chat_id)
2025-01-29 10:05:20 +01:00
dependabot[bot]
963576752b Merge pull request #6484 from deltachat/dependabot/github_actions/dependabot/fetch-metadata-2.3.0 2025-01-28 03:36:36 +00:00
dignifiedquire
5bde9b66d1 feat: upgrade to iroh@0.30.0 2025-01-28 03:26:57 +00:00
link2xt
14d048bea8 feat: improve logging around IMAP IDLE 2025-01-28 00:35:28 +00:00
dependabot[bot]
1cfa07726d chore(deps): bump dependabot/fetch-metadata from 2.2.0 to 2.3.0
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2.2.0 to 2.3.0.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 21:50:08 +00:00
Simon Laux
3b6369a8c8 docs(jsonrpc): update documentation for select_account and get_selected_account_id (#6483)
there are not really unused, desktop uses them

also see #4474
2025-01-27 21:29:28 +00:00
link2xt
a563c4851c fix: use BufReader when reading .xdc files 2025-01-27 19:23:07 +00:00
Hocuri
28e3fbfebb fix: Don't remove file extension when recoding avatars
There was a bug that file extensions were removed when recoding an
avatar. The problem was that `recode_avatar` used `name` to check for
the extension, but some functions passed an empty string.
There even were two tests from before the decision to keep the
extensions that tested for the faulty behavior.
2025-01-27 18:02:57 +01:00
Hocuri
60f8b68690 refactor: remove dead code 2025-01-27 18:02:57 +01:00
Hocuri
e6ea09641a feat: Deduplicate blob files in chat.rs, config.rs, and integration.rs
These were the last places in the `deltachat` crate where files were
stored without deduplication. The CFFI python bindings are the last
thing that's still missing.
2025-01-27 18:02:57 +01:00
link2xt
1fd6d80e6d chore(release): prepare for 1.155.1 2025-01-25 14:50:41 +00:00
link2xt
104cc3accf fix: use 0 timestamps if Chat-Group-Member-Timestamps is not set 2025-01-25 14:22:48 +00:00
link2xt
fc06351fa3 fix: only send Chat-Group-Member-Timestamps in groups 2025-01-25 14:11:57 +00:00
Hocuri
787f54feda refactor: Move more tests into their own files (#6473)
Follow-up to #6456 since this is working out well
2025-01-24 22:13:56 +01:00
Hocuri
b0c8d46762 refactor: Remove BlobObject::create(), use create_and_deduplicate_from_bytes() instead (#6467)
Part of #6332
2025-01-24 20:04:02 +01:00
link2xt
6430977670 fix: use non-empty To: field for "saved messages" 2025-01-24 13:29:14 +00:00
link2xt
8435f40dae fix: don't create tombstones when synchronizing broadcast list members 2025-01-23 17:56:08 +00:00
link2xt
49a0b2d948 feat: only accept SetContacts sync messages for broadcast lists
Delta Chat does not send synchronization
messages for group member lists,
so we don't need to maintain the code accepting it.
2025-01-23 17:56:08 +00:00
link2xt
7bc9dd6c98 chore(release): prepare for 1.155.0 2025-01-23 03:06:03 +00:00
link2xt
1a3a09dfc3 build: nix flake update fenix 2025-01-23 02:59:10 +00:00
link2xt
32459b3fdc Reapply "build: increase MSRV to 1.81.0"
This reverts commit 9d331483e9.
2025-01-23 02:59:10 +00:00
link2xt
52e9daaa1f Revert "build: downgrade Rust version used to build binaries"
This reverts commit d822da3c9f.
2025-01-23 02:59:10 +00:00
link2xt
a3734a5f87 Reapply "chore(cargo): bump rustyline from 14.0.0 to 15.0.0"
This reverts commit 6cd6aca7b8.
2025-01-23 02:59:10 +00:00
link2xt
30e1df0754 Revert "bulid(nix): use new fenix for dev shell"
This reverts commit 5069b585c8.
2025-01-23 02:59:10 +00:00
Hocuri
3959305b4a feat: Deduplicate in more places (#6464)
Deduplicate:
- In the REPL
- In `store_from_base64()`, which writes avatars received in headers
- In a few tests
- The saved messages, broadcast, device, archive icons
- The autocrypt setup message

1-2 more PRs, and we can get rid of `BlobObject::create`,
`sanitise_name()`, and some others
2025-01-22 20:39:18 +00:00
link2xt
744cab1553 feat: expire past members after 60 days 2025-01-22 20:39:15 +00:00
Hocuri
8f58c4777e feat: Keep file extension on deduplicated files (#6463)
fix #6461
2025-01-22 16:44:59 +01:00
link2xt
8dcd8aa69d api: add JSON-RPC API to get past members 2025-01-22 11:53:56 +00:00
Hocuri
65a9c4b79b File deduplication (#6332)
When receiving messages, blobs will be deduplicated with the new
function `create_and_deduplicate_from_bytes()`. For sending files, this
adds a new function `set_file_and_deduplicate()` instead of
deduplicating by default.

This is for
https://github.com/deltachat/deltachat-core-rust/issues/6265; read the
issue description there for more details.

TODO:
- [x] Set files as read-only
- [x] Don't do a write when the file is already identical
- [x] The first 32 chars or so of the 64-character hash are enough. I
calculated that if 10b people (i.e. all of humanity) use DC, and each of
them has 200k distinct blob files (I have 4k in my day-to-day account),
and we used 20 chars, then the expected value for the number of name
collisions would be ~0.0002 (and the probability that there is a least
one name collision is lower than that) [^1]. I added 12 more characters
to be on the super safe side, but this wouldn't be necessary and I could
also make it 20 instead of 32.
- Not 100% sure whether that's necessary at all - it would mainly be
necessary if we might hit a length limit on some file systems (the
blobdir is usually sth like
`accounts/2ff9fc096d2f46b6832b24a1ed99c0d6/dc.db-blobs` (53 chars), plus
64 chars for the filename would be 117).
- [x] "touch" the files to prevent them from being deleted
- [x] TODOs in the code

For later PRs:
- Replace `BlobObject::create(…)` with
`BlobObject::create_and_deduplicate(…)` in order to deduplicate
everytime core creates a file
- Modify JsonRPC to deduplicate blob files
- Possibly rename BlobObject.name to BlobObject.file in order to prevent
confusion (because `name` usually means "user-visible-name", not "name
of the file on disk").

[^1]: Calculated with both https://printfn.github.io/fend/ and
https://www.geogebra.org/calculator, both of which came to the same
result
([1](https://github.com/user-attachments/assets/bbb62550-3781-48b5-88b1-ba0e29c28c0d),

[2](https://github.com/user-attachments/assets/82171212-b797-4117-a39f-0e132eac7252))

---------

Co-authored-by: l <link2xt@testrun.org>
2025-01-21 19:42:19 +01:00
Hocuri
22a7cfe9c3 refactor: Extract group_changes_msgs() function (#6460) 2025-01-21 17:38:35 +01:00
Hocuri
1ebf2c1985 refactor: Move tests to their own files
- This [is said to lead improve compilation
  speed](https://matklad.github.io/2021/02/27/delete-cargo-integration-tests.html#Assorted-Tricks)
- When grepping for a function invocation, this makes it easy to see whether it's from a test or "real" code
- We're calling the files e.g. `chat_tests.rs` instead of `tests.rs` for the same reason why we moved `imap/mod.rs` to `imap.rs`: Otherwise, your editor always shows you that you're in the file `tests.rs` and you don't know which one.

This is only moving mimeparser and chat tests, because these were the
biggest files; we can move more files in subsequent PRs if we like it.
2025-01-20 23:10:24 +00:00
Hocuri
723ff25067 feat: Set BccSelf to true when receiving a sync message (#6434)
Fix https://github.com/deltachat/deltachat-core-rust/issues/6433

I at first only wanted to do it any outgoing messages, but @link2xt was
concerned that this may accidentally enable bcc_self, e.g. in the
following case:
- you send out a message
- it's deleted, e.g. via ephemeral messages
- Someone forwards this outgoing message to you again, e.g. via a
mailing list.
2025-01-20 22:05:29 +01:00
link2xt
2b5ce35c2d chore(release): prepare for 1.154.3 2025-01-20 20:22:40 +00:00
Hocuri
39bf3bee59 chore: Remove unused function delete_files_in_dir() (#6454) 2025-01-20 18:53:36 +01:00
link2xt
e3b9c9b209 build(nix): update rust-email hash in flake.nix 2025-01-20 16:55:47 +00:00
link2xt
74930e995d build: remove encoded-words from flake.nix 2025-01-20 16:42:04 +00:00
link2xt
8af6cdf49c chore(release): prepare for 1.154.2 2025-01-20 15:22:05 +00:00
link2xt
19a841657c fix: do not create tombstones for members removed from unpromoted groups
If we create an unpromoted group,
add a member there and then remove it
before we promote a group, there is no need to
add such member to the list of past members
and send the address of this member to the group
when it is promoted.
2025-01-20 15:12:35 +00:00
Hocuri
d4b1f8694f fix: Don't accidentally remove Self from groups (#6455) 2025-01-20 15:54:17 +01:00
bjoern
0d8c2ee9ff feat: add API to save messages (#5606)
> _took quite some time until i found the time to finish this PR and to
find a time window that does not disturb other developments too much,
but here we are:_

this PR enables UI to improve "Saved messages" hugely, bringing it on
WhatsApp's "Starred Messages" or Telegram's "Saved Messages" level. with
this PR, UIs can add the following functionality with few effort ([~100
loc on iOS](https://github.com/deltachat/deltachat-ios/pull/2527)):

- add a "Save" button in the messages context menu, allowing to save a
message
- show directly in the chat if a message was saved, eg. by a little star
★
- in "Saved Messages", show the message in its context - so with author,
avatar, date and keep incoming/outgoing state
- in "Saved Messages", a button to go to the original message can be
added
- if the original message was deleted, one can still go to the original
chat

these features were often requested, in the forum, but also in many
one-to-one discussions, recently at the global gathering.

moreover, in contrast to the old method with "forward to saved", no
traffic is wasted - the messages are saved locally, and only a small
sync messages is sent around

this is how it looks out on iOS:

<img width="260" alt="Screenshot 2025-01-17 at 00 02 51"
src="https://github.com/user-attachments/assets/902741b6-167f-4af1-9f9a-bd0439dd20d0"
/> &nbsp; &nbsp; <img width="353" alt="Screenshot 2025-01-17 at 00 05
33"
src="https://github.com/user-attachments/assets/97eceb79-6e43-4a89-9792-2e077e7141cd"
/>

technically, still a copy is done from the original message (with
already now deduplicated blobs), so that things work nicely with
deletion modes; eg. you can save an important message and preserve it
from deletion that way.

jsonrpc can be done in a subsequent PR, i was implementing the UI on iOS
where that was not needed (and most API were part of message object that
is not in use in jsonrpc atm)

@hpk42 the forward issue we discussed earller that day is already solved
(first implementation did not had an explict save_msgs() but was using
forward_msgs(SELF) as saving - with the disadvantage that forwarding to
SELF is not working, eg. if one wants the old behaviour) acutally, that
made the PR a lot nicer, as now very few existing code is changed

<details>
<summary>previous considerations and abandoned things</summary>

while working on this PR, there was also the idea to just set a flag
“starred” in the message table and not copy anything. however, while
tempting, that would result in more complexity, questions and drawbacks
in UI:

- delete-message, delete-chat, auto-deletion, clear-chat would raise
questions - what do do with the “Starred”? having a copy in “Saved
Messages” does not raise this question
- newly saved messages appear naturally as “new” in “Saved Messages”,
simply setting a flag would show them somewhere in between - unless we
do additional effort
- “Saved Messages” already has its place in the UI - and allows to
_directly_ save things there as well - not easily doable with “starring”
- one would need to re-do many things that already exist in “Saved
Messages”, at least in core
- one idea to solve some of the issues could be to have “Starred” as
well as “Saved Messages” - however, that would irritate user, one would
remember exactly what was done with a message, and understand the fine
differences

whatsapp does this “starred”, btw, so when original is deleted, starred
is deleted as well. Telegram does things similar to us, Signal does
nothing. Whatsapp has a per-chat view for starred messages - if needed,
we could do sth. like that as well, however, let’s first see how far the
“all view” goes (in contrast to whatsapp, we have profiles for
separation as well)

for the wording: “saving” is what we’re doing here, this is much more on
point as “starring” - which is more the idea of a “bookmark”, and i
think, whatsapp uses this wording to not raise false expectations
(still, i know of ppl that were quite upset that a “starred” message was
deleted when eg. the chat was cleared to save some memory)

wrt webxdc app updates: options that come into mind were: _empty_ (as
now), _snapshot_ (copy all updates) or _shortcut_ (always open
original). i am not sure what the best solution is, the easiest was
_empty_, so i went for that, as it is (a) obvious, and what is already
done with forwarding and (b) the original is easy to open now (in
contrast to forwarding).
so, might totally be that we need or want to tweak things here, but i
would leave that outside the first iteration, things are not worsened in
that area

wrt reactions: as things are detached, similar to webxdc updates, we do
not not to show the original reactions - that way, one can use reactions
for private markers (telegram is selling that feature :)

to the icon: a disk or a bookmark might be other options, but the star
is nicer and also know from other apps - and anyways a but vague UX
wise. so i think, it is fine

finally, known issue is that if a message was saved that does not exist
on another device, it does not get there. i think, that issue is a weak
one, and can be ignored mostly, most times, user will save messages soon
after receiving, and if on some devices auto-deletion is done, it is
maybe not even expected to have suddenly another copy there

</details>

EDIT: once this is merged, detailed issues about what to do should be
filed for android/desktop (however, they do not have urgency to adapt,
things will continue working as is)

---------

Co-authored-by: Hocuri <hocuri@gmx.de>
2025-01-19 15:44:57 +00:00
link2xt
3cbfb47b6e build: switch to non-git version of encoded-words 2025-01-19 10:51:46 +00:00
link2xt
0b9746b57e refactor: make memberlist update logic easier to follow 2025-01-18 22:22:19 +00:00
link2xt
fa016b36fb chore(release): prepare for 1.154.1 2025-01-15 19:23:27 +00:00
link2xt
69e01b5197 test: expect trashing of no-op "member added" in non_member_cannot_modify_member_list 2025-01-15 18:27:55 +00:00
link2xt
ffd2ec9424 chore(release): prepare for 1.154.0 2025-01-15 17:56:40 +00:00
link2xt
498979c608 fix: trash no-op addition messages
This partially restores the fix from c9cf2b7f2e
that was removed during the addition of new group consistency at de63527d94
but only for "Member added" messages.

Multiple "Member added" messages happen
when the same QR code is processed multiple times
by multiple devices.
2025-01-15 17:41:15 +00:00
link2xt
3e7b662796 feat: do not allow non-members to modify member list 2025-01-15 16:43:23 +00:00
Hocuri
6057b40910 fix: Clear the config cache after every migration (#6438)
Some migrations change the `config` table, but they don't update the
cache. While this wasn't the cause for
https://github.com/deltachat/deltachat-core-rust/issues/6432, it might
have caused a similar bug, so, let's clear the config cache after every
migration.
2025-01-15 12:35:41 +01:00
iequidoo
53572fce5c fix: migration: Set bcc_self=1 if it's unset and delete_server_after!=1 (#6432)
Users report that in a setup with Android (1.50.4 from F-Droid) and Desktop (1.48.0 x86_64 .deb
release) and chatmail account `bcc_self` was reverted to 0 on Android, resulting in messages sent
from Android not appearing on Desktop. This might happen because of the bug in migration #127, it
doesn't handle `delete_server_after` > 1. Existing chatmail configurations having
`delete_server_after` != 1 ("delete at once") should get `bcc_self` enabled, they may be multidevice
configurations:
- Before migration #127, `delete_server_after` was set to 0 upon a backup export, but
  then `bcc_self` is enabled instead (whose default is changed to 0 for chatmail).
- The user might set `delete_server_after` to a value other than 0 or 1 when that was
  possible in UIs.
So let's add another migration fixing this. But still don't check `is_chatmail` for simplicity.
2025-01-15 00:27:13 -03:00
iequidoo
53dca8ce1a refactor: Eliminate remaining repeat_vars() calls (#6359)
Using `repeat_vars()` to generate SQL statements led to some of them having more than
`SQLITE_MAX_VARIABLE_NUMBER` parameters and thus failing, so let's get rid of this pattern. But
let's not optimise for now and just repeat executing an SQL statement in a loop, all the places
where `repeat_vars()` is used seem not performance-critical and containing functions execute other
SQL statements in loops. If needed, performance can be improved by preparing a statement and
executing it in a loop. An exception is `lookup_chat_or_create_adhoc_group()` where `repeat_vars()`
can't be replaced with a query loop, there we need to replace the `SELECT` query with a read
transaction creating a temporary table which is used to perform the SELECT query then.
2025-01-14 01:14:09 -03:00
link2xt
29d7e0131e refactor: remove unnecessary is_contact_in_chat check 2025-01-14 00:27:37 +00:00
iequidoo
4ec50d1990 refactor: Add why_cant_send_ex() capable to only ignore specified conditions
Before, `Chat::why_cant_send()` just returned `CantSendReason` after the first unsuccessful check
allowing to handle the result and finally send the message if the condition is acceptable in which
case the remaining checks are not done. This didn't result in any bugs, but to make the code more
robust let's add a functional parameter to filter failed checks without early return.
2025-01-12 01:13:53 -03:00
link2xt
187274d7b7 fix: create new tombstone in chats_contacts if the row does not exist
Otherwise new members do not see past members
even if they receive info about them in every message.
2025-01-12 01:42:02 +00:00
Hocuri
5dc8788eab chore: Beta Clippy suggestions (#6422) 2025-01-11 17:58:38 +01:00
link2xt
de63527d94 feat: new group consistency algorithm
This implements new group consistency algorithm described in
<https://github.com/deltachat/deltachat-core-rust/issues/6401>

New `Chat-Group-Member-Timestamps` header is added
to send timestamps of member additions and removals.
Member is part of the chat if its addition timestamp
is greater or equal to the removal timestamp.
2025-01-11 07:52:49 +00:00
link2xt
cb43382896 ci: update Rust to 1.84.0 2025-01-10 01:58:08 +00:00
Simon Laux
a9e177f1e7 build!: remove jsonrpc feature flag
It is enabled everywhere by default since some time now. Breaking, because existing build scripts might need to be adjusted.
2025-01-09 15:56:07 +00:00
link2xt
6e8668e348 build: increase minimum supported Python version to 3.8
Python 3.7 is not supported on GitHub Actions ubuntu-latest runner:
https://github.com/actions/setup-python/issues/962

Python 3.7 has reached EOL more than 1 year ago anyway,
so not worth the effort supporting it.
2025-01-09 14:58:01 +00:00
link2xt
7f7c76f706 test: use assert_eq! to compare chatlist length 2025-01-05 23:44:34 +00:00
link2xt
3fe9a7b17f refactor: use let..else 2025-01-05 23:44:28 +00:00
link2xt
fff4020013 chore(release): prepare for 1.153.0 2025-01-05 09:08:23 +00:00
link2xt
4ffc0ca047 refactor: don't ignore get_for_contact errors 2025-01-05 02:52:19 +00:00
link2xt
3d19996f34 test: fix test_logged_ac_process_ffi_failure flakiness
This test keeps failing on macOS CI,
capturing events like `DC_EVENT_ACCOUNTS_ITEM_CHANGED`
before FailPlugin is setup.
These CI runners likely get less resources
because there is a limited number of them,
and this triggers this race condition.

Race is fixed by setting up fail plugin
before starting to capture events.
2025-01-05 01:36:09 +00:00
link2xt
7e5cec66ba refactor: simplify self_sent condition 2025-01-05 00:36:10 +00:00
link2xt
a7eab13ad6 test: message with empty To: field should have a valid to_id 2025-01-05 00:36:10 +00:00
link2xt
d26a27484b fix: default to_id to self instead of 0
If message has empty `To` field
it is a self-sent message like a message
in Saved Messages chat or a sync message.
2025-01-05 00:36:10 +00:00
link2xt
ed2a3a76b4 test: messages without recipients are assigned to self chat
Previously such messages were assigned to trash.
2025-01-05 00:36:10 +00:00
link2xt
49f5523b67 fix: allow empty To field for self-sent messages
Currently Delta Chat puts self address in the To field
to avoid the To field being empty.
There is a plan to put empty `hidden-recipients`
group there, this fix prepares the receiver for such messages.
2025-01-05 00:36:10 +00:00
link2xt
548fadc84a fix: prioritize mailing list over self-sent messages
New Delta Chat is going to send self-sent messages
with undisclosed recipients instead of placing self into the `To` field.
To avoid assigning broadcast list messages to Saved Messages chat,
we should check the mailing list headers
before attempting to assign to Saved Messages.
2025-01-05 00:36:10 +00:00
iequidoo
2bce4466d7 fix: Prefer to encrypt if E2eeEnabled even if peers have EncryptPreference::NoPreference
First of all, chatmail servers normally forbid to send unencrypted mail, so if we know the peer's
key, we should encrypt to it. Chatmail setups have `E2eeEnabled=1` by default and this isn't
possible to change in UIs, so this change fixes the chatmail case. Additionally, for chatmail, if a
peer has `EncryptPreference::Reset`, let's handle it as `EncryptPreference::NoPreference` for the
reason above. Still, if `E2eeEnabled` is 0 for a chatmail setup somehow, e.g. the user set it via
environment, let's assume that the user knows what they do and ignore `IsChatmail` flag.

NB:
- If we don't know the peer's key, we should try to send an unencrypted message as before for a
  chatmail setup.
- This change doesn't remove the "majority rule", but now the majority with
  `EncryptPreference::NoPreference` can't disable encryption if the local preference is `Mutual`. To
  disable encryption, some peer should have a missing peerstate or, for the non-chatmail case, the
  majority should have `EncryptPreference::Reset`.
2025-01-04 20:16:38 -03:00
link2xt
f31e86d203 chore: lockfile update 2025-01-04 06:49:46 +00:00
link2xt
8ec098210e fix: update shadowsocks crate to 1.22.0 to avoid panic when parsing some QR codes
`aead-cipher` feature has become optional
and is disabled by default.
We enable it to avoid breaking compatibility.
2025-01-03 23:56:47 +00:00
dependabot[bot]
62e22286bb chore(cargo): bump testdir from 0.9.1 to 0.9.3
Bumps [testdir](https://github.com/flub/testdir) from 0.9.1 to 0.9.3.
- [Changelog](https://github.com/flub/testdir/blob/main/CHANGELOG.md)
- [Commits](https://github.com/flub/testdir/compare/v0.9.1...v0.9.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 20:50:30 -03:00
iequidoo
c596bfc44e refactor: add_parts: Remove excessive is_mdn checks 2025-01-03 00:44:55 -03:00
dependabot[bot]
379b31835b Merge pull request #6395 from deltachat/dependabot/cargo/serde-1.0.217 2025-01-03 02:27:19 +00:00
dependabot[bot]
5a69d9c355 chore(cargo): bump serde from 1.0.215 to 1.0.217
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.215 to 1.0.217.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.215...v1.0.217)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 01:57:45 +00:00
dependabot[bot]
e689db4376 Merge pull request #6387 from deltachat/dependabot/cargo/serde_json-1.0.134 2025-01-03 01:56:37 +00:00
dependabot[bot]
2d173512af Merge pull request #6396 from deltachat/dependabot/cargo/rustls-0.23.20 2025-01-03 01:56:18 +00:00
dependabot[bot]
adddc8e4ad Merge pull request #6388 from deltachat/dependabot/cargo/tokio-1.42.0 2025-01-03 01:23:31 +00:00
dependabot[bot]
8a27c3edf0 chore(cargo): bump rustls from 0.23.19 to 0.23.20
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.19 to 0.23.20.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.19...v/0.23.20)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-03 00:04:17 +00:00
dependabot[bot]
7164786165 chore(cargo): bump tokio from 1.41.1 to 1.42.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.41.1 to 1.42.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.41.1...tokio-1.42.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-02 23:50:08 +00:00
dependabot[bot]
0cfd84d803 chore(cargo): bump serde_json from 1.0.133 to 1.0.134
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.133 to 1.0.134.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.133...v1.0.134)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-02 23:47:43 +00:00
109 changed files with 18516 additions and 16192 deletions

View File

@@ -24,7 +24,7 @@ jobs:
name: Lint Rust
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: 1.83.0
RUSTUP_TOOLCHAIN: 1.84.1
steps:
- uses: actions/checkout@v4
with:
@@ -97,15 +97,15 @@ jobs:
matrix:
include:
- os: ubuntu-latest
rust: 1.83.0
rust: 1.84.1
- os: windows-latest
rust: 1.83.0
rust: 1.84.1
- os: macos-latest
rust: 1.83.0
rust: 1.84.1
# Minimum Supported Rust Version = 1.77.0
# Minimum Supported Rust Version = 1.81.0
- os: ubuntu-latest
rust: 1.77.0
rust: 1.81.0
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -152,7 +152,7 @@ jobs:
uses: swatinem/rust-cache@v2
- name: Build C library
run: cargo build -p deltachat_ffi --features jsonrpc
run: cargo build -p deltachat_ffi
- name: Upload C library
uses: actions/upload-artifact@v4
@@ -223,11 +223,11 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.7
# Minimum Supported Python Version = 3.8
# This is the minimum version for which manylinux Python wheels are
# built. Test it with minimum supported Rust version.
- os: ubuntu-latest
python: 3.7
python: 3.8
runs-on: ${{ matrix.os }}
steps:
@@ -277,9 +277,9 @@ jobs:
- os: macos-latest
python: pypy3.10
# Minimum Supported Python Version = 3.7
# Minimum Supported Python Version = 3.8
- os: ubuntu-latest
python: 3.7
python: 3.8
runs-on: ${{ matrix.os }}
steps:

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.2.0
uses: dependabot/fetch-metadata@v2.3.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve a PR

View File

@@ -1,5 +1,241 @@
# Changelog
## [1.155.4] - 2025-02-10
### CI
- Upgrade Rust from 1.84.0 to 1.84.1.
### Fixes
- Use CRLF newlines in vCards.
- Make vCard parsing more robust in case of trailing newlines.
- Do not include CRLF before MIME boundary in the part body.
- Accept QR codes with 'broken' JSON ([#6528](https://github.com/deltachat/deltachat-core-rust/pull/6528)).
### Other
- Add `MessageQuote.chat_id`.
### Refactor
- Move even more tests into their own files ([#6521](https://github.com/deltachat/deltachat-core-rust/pull/6521)).
## [1.155.3] - 2025-02-05
### Fixes
- Store device token in IMAP METADATA on each connection.
### Miscellaneous Tasks
- Upgrade iroh from 0.30 to 0.32.
- Update `pgp` to 0.15.
- cargo: Bump thiserror from 1.0.69 to 2.0.9.
- cargo: Bump pin-project from 1.1.7 to 1.1.8.
- cargo: Bump dirs from 5.0.1 to 6.0.0.
- cargo: Bump hyper from 1.5.2 to 1.6.0.
- cargo: Bump webpki-roots from 0.26.7 to 0.26.8.
- cargo: Bump futures-lite from 2.5.0 to 2.6.0.
- Update OpenSSL to fix RUSTSEC-2025-0004.
- cargo: Bump tokio from 1.42.0 to 1.43.0.
- cargo: Bump syn from 2.0.94 to 2.0.98.
- cargo: Bump rustls from 0.23.20 to 0.23.22.
- cargo: Bump data-encoding from 2.6.0 to 2.7.0.
- cargo: Bump serde_json from 1.0.134 to 1.0.138.
- cargo: Bump uuid from 1.11.0 to 1.12.1.
- cargo: Bump log from 0.4.22 to 0.4.25.
- cargo: Bump rustls-pki-types from 1.10.1 to 1.11.0.
- Update futures-concurrency.
### Documentation
- Assign docs to correct object.
### Tests
- Make sure DCBACKUP2 compatibility does not break again.
## [1.155.2] - 2025-01-31
This release accidentally broke compatibility
with previous versions of `DCBACKUP2` QR codes
due to iroh upgrade.
### API-Changes
- Add `IncomingReaction.chat_id` ([#6459](https://github.com/deltachat/deltachat-core-rust/pull/6459)).
### Features / Changes
- Deduplicate blob files in `chat.rs`, `config.rs`, and `integration.rs`.
- Improve logging around IMAP IDLE.
- Upgrade to iroh@0.30.0.
### Fixes
- Don't remove file extension when recoding avatars.
- Use `BufReader` when reading .xdc files.
- No implicit member changes when we are added to the group ([#6493](https://github.com/deltachat/deltachat-core-rust/pull/6493)).
### Documentation
- jsonrpc: Update documentation for `select_account` and `get_selected_account_id` ([#6483](https://github.com/deltachat/deltachat-core-rust/pull/6483)).
- jsonrpc: Add docs for some functions.
## [1.155.1] - 2025-01-25
### Features / Changes
- Only accept SetContacts sync messages for broadcast lists.
### Fixes
- Don't create tombstones when synchronizing broadcast list members.
- Use non-empty `To:` field for "saved messages".
- Only send Chat-Group-Member-Timestamps in groups.
- Use 0 timestamps if Chat-Group-Member-Timestamps is not set.
### Refactor
- Remove BlobObject::create(), use create_and_deduplicate_from_bytes() instead ([#6467](https://github.com/deltachat/deltachat-core-rust/pull/6467)).
- Move more tests into their own files ([#6473](https://github.com/deltachat/deltachat-core-rust/pull/6473)).
## [1.155.0] - 2025-01-23
### API-Changes
- Add JSON-RPC API to get past members.
### Build system
- Update Rust.
- Increase MSRV to 1.81.0
### Features / Changes
- feat: Set BccSelf to true when receiving a sync message ([#6434](https://github.com/deltachat/deltachat-core-rust/pull/6434))
- File deduplication ([#6332](https://github.com/deltachat/deltachat-core-rust/pull/6332))
### Refactor
- Move tests to their own files.
- Extract `group_changes_msgs()` function ([#6460](https://github.com/deltachat/deltachat-core-rust/pull/6460)).
## [1.154.3] - 2025-01-20
### Build system
- Remove encoded-words from flake.nix.
- nix: Update rust-email hash in flake.nix.
### Miscellaneous Tasks
- Remove unused function delete_files_in_dir() ([#6454](https://github.com/deltachat/deltachat-core-rust/pull/6454)).
## [1.154.2] - 2025-01-20
### Features / Changes
- Add API to save messages ([#5606](https://github.com/deltachat/deltachat-core-rust/pull/5606)).
### Fixes
- fix: Don't accidentally remove Self from groups ([#6455](https://github.com/deltachat/deltachat-core-rust/pull/6455)).
- Do not create tombstones for members removed from unpromoted groups.
### Build system
- Switch to non-git version of encoded-words.
### Refactor
- Make memberlist update logic easier to follow.
## [1.154.1] - 2025-01-15
### Tests
- Expect trashing of no-op "member added" in non_member_cannot_modify_member_list.
## [1.154.0] - 2025-01-15
### Features / Changes
- New group consistency algorithm.
### Fixes
- Migration: Set bcc_self=1 if it's unset and delete_server_after!=1 ([#6432](https://github.com/deltachat/deltachat-core-rust/pull/6432)).
- Clear the config cache after every migration ([#6438](https://github.com/deltachat/deltachat-core-rust/pull/6438)).
### Build system
- Increase minimum supported Python version to 3.8.
- [**breaking**] Remove jsonrpc feature flag.
### CI
- Update Rust to 1.84.0.
### Miscellaneous Tasks
- Beta Clippy suggestions ([#6422](https://github.com/deltachat/deltachat-core-rust/pull/6422)).
### Refactor
- Use let..else.
- Add why_cant_send_ex() capable to only ignore specified conditions.
- Remove unnecessary is_contact_in_chat check.
- Eliminate remaining repeat_vars() calls ([#6359](https://github.com/deltachat/deltachat-core-rust/pull/6359)).
### Tests
- Use assert_eq! to compare chatlist length.
## [1.153.0] - 2025-01-05
### Features / Changes
- Remove "jobs" from imap_markseen if folder doesn't exist ([#5870](https://github.com/deltachat/deltachat-core-rust/pull/5870)).
- Delete `vg-request-with-auth` from IMAP after processing ([#6208](https://github.com/deltachat/deltachat-core-rust/pull/6208)).
### API-Changes
- Add `IncomingWebxdcNotify.chat_id` ([#6356](https://github.com/deltachat/deltachat-core-rust/pull/6356)).
- rpc-client: Add INCOMING_REACTION to const.EventType ([#6349](https://github.com/deltachat/deltachat-core-rust/pull/6349)).
### Documentation
- Viewtype::Sticker may be changed to Image and how to disable that ([#6352](https://github.com/deltachat/deltachat-core-rust/pull/6352)).
### Fixes
- Never change Viewtype::Sticker to Image if file has non-image extension ([#6352](https://github.com/deltachat/deltachat-core-rust/pull/6352)).
- Change BccSelf default to 0 for chatmail ([#6340](https://github.com/deltachat/deltachat-core-rust/pull/6340)).
- Mark holiday notice messages as bot-generated.
- Don't treat location-only and sync messages as bot ones ([#6357](https://github.com/deltachat/deltachat-core-rust/pull/6357)).
- Update shadowsocks crate to 1.22.0 to avoid panic when parsing some QR codes.
- Prefer to encrypt if E2eeEnabled even if peers have EncryptPreference::NoPreference.
- Prioritize mailing list over self-sent messages.
- Allow empty `To` field for self-sent messages.
- Default `to_id` to self instead of 0.
### Refactor
- Remove unused parameter and return value from `build_body_file(…)` ([#6369](https://github.com/deltachat/deltachat-core-rust/pull/6369)).
- Deprecate Param::ErroneousE2ee.
- Add `emit_msgs_changed_without_msg_id`.
- Add_parts: Remove excessive `is_mdn` checks.
- Simplify `self_sent` condition.
- Don't ignore get_for_contact errors.
### Tests
- Messages without recipients are assigned to self chat.
- Message with empty To: field should have a valid to_id.
- Fix `test_logged_ac_process_ffi_failure` flakiness.
## [1.152.2] - 2024-12-24
### Features / Changes
@@ -5541,3 +5777,13 @@ https://github.com/deltachat/deltachat-core-rust/pulls?q=is%3Apr+is%3Aclosed
[1.152.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.151.6..v1.152.0
[1.152.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.0..v1.152.1
[1.152.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.1..v1.152.2
[1.153.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.152.2..v1.153.0
[1.154.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.153.0..v1.154.0
[1.154.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.0..v1.154.1
[1.154.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.1..v1.154.2
[1.154.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.2..v1.154.3
[1.155.0]: https://github.com/deltachat/deltachat-core-rust/compare/v1.154.3..v1.155.0
[1.155.1]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.0..v1.155.1
[1.155.2]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.1..v1.155.2
[1.155.3]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.2..v1.155.3
[1.155.4]: https://github.com/deltachat/deltachat-core-rust/compare/v1.155.3..v1.155.4

View File

@@ -27,7 +27,7 @@ add_custom_command(
PREFIX=${CMAKE_INSTALL_PREFIX}
LIBDIR=${CMAKE_INSTALL_FULL_LIBDIR}
INCLUDEDIR=${CMAKE_INSTALL_FULL_INCLUDEDIR}
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release --features jsonrpc
${CARGO} build --target-dir=${CMAKE_BINARY_DIR}/target --release
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/deltachat-ffi
)

1604
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[package]
name = "deltachat"
version = "1.152.2"
version = "1.155.4"
edition = "2021"
license = "MPL-2.0"
rust-version = "1.77"
rust-version = "1.81"
repository = "https://github.com/deltachat/deltachat-core-rust"
[profile.dev]
@@ -49,26 +49,27 @@ base64 = { workspace = true }
brotli = { version = "7", default-features=false, features = ["std"] }
bytes = "1"
chrono = { workspace = true, features = ["alloc", "clock", "std"] }
data-encoding = "2.7.0"
email = { git = "https://github.com/deltachat/rust-email", branch = "master" }
encoded-words = { git = "https://github.com/async-email/encoded-words", branch = "master" }
encoded-words = "0.2"
escaper = "0.1"
fast-socks5 = "0.10"
fd-lock = "4"
futures-lite = { workspace = true }
futures = { workspace = true }
hex = "0.4.0"
hickory-resolver = "=0.25.0-alpha.2"
hickory-resolver = "=0.25.0-alpha.4"
http-body-util = "0.1.2"
humansize = "2"
hyper = "1"
hyper-util = "0.1.10"
image = { version = "0.25.5", default-features=false, features = ["gif", "jpeg", "ico", "png", "pnm", "webp", "bmp"] }
iroh-gossip = { version = "0.28.1", default-features = false, features = ["net"] }
iroh-net = { version = "0.28.1", default-features = false }
iroh-gossip = { version = "0.32", default-features = false, features = ["net"] }
iroh = { version = "0.32", default-features = false }
kamadak-exif = "0.6.1"
lettre_email = { git = "https://github.com/deltachat/lettre", branch = "master" }
libc = { workspace = true }
mailparse = "0.15"
mailparse = "0.16"
mime = "0.3.17"
num_cpus = "1.16"
num-derive = "0.4"
@@ -76,7 +77,7 @@ num-traits = { workspace = true }
once_cell = { workspace = true }
parking_lot = "0.12"
percent-encoding = "2.3"
pgp = { version = "0.14.2", default-features = false }
pgp = { version = "0.15.0", default-features = false }
pin-project = "1"
qrcodegen = "1.7.0"
quick-xml = "0.37"
@@ -85,15 +86,15 @@ rand = { workspace = true }
regex = { workspace = true }
rusqlite = { workspace = true, features = ["sqlcipher"] }
rust-hsluv = "0.1"
rustls-pki-types = "1.10.1"
rustls = { version = "0.23.19", default-features = false }
rustls-pki-types = "1.11.0"
rustls = { version = "0.23.22", default-features = false }
sanitize-filename = { workspace = true }
serde_json = { workspace = true }
serde_urlencoded = "0.7.1"
serde = { workspace = true, features = ["derive"] }
sha-1 = "0.10"
sha2 = "0.10"
shadowsocks = { version = "1.21.0", default-features = false, features = ["aead-cipher-2022"] }
shadowsocks = { version = "1.22.0", default-features = false, features = ["aead-cipher", "aead-cipher-2022"] }
smallvec = "1.13.2"
strum = "0.26"
strum_macros = "0.26"
@@ -109,7 +110,8 @@ tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
toml = "0.8"
url = "2"
uuid = { version = "1", features = ["serde", "v4"] }
webpki-roots = "0.26.7"
webpki-roots = "0.26.8"
blake3 = "1.5.5"
[dev-dependencies]
anyhow = { workspace = true, features = ["backtrace"] } # Enable `backtrace` feature in tests.
@@ -120,7 +122,7 @@ nu-ansi-term = { workspace = true }
pretty_assertions = "1.4.1"
proptest = { version = "1", default-features = false, features = ["std"] }
tempfile = { workspace = true }
testdir = "0.9.0"
testdir = "0.9.3"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
[workspace]
@@ -174,7 +176,7 @@ deltachat-contact-tools = { path = "deltachat-contact-tools" }
deltachat-jsonrpc = { path = "deltachat-jsonrpc", default-features = false }
deltachat = { path = ".", default-features = false }
futures = "0.3.31"
futures-lite = "2.5.0"
futures-lite = "2.6.0"
libc = "0.2"
log = "0.4"
nu-ansi-term = "0.46"
@@ -187,7 +189,7 @@ sanitize-filename = "0.5"
serde = "1.0"
serde_json = "1"
tempfile = "3.14.0"
thiserror = "1"
thiserror = "2"
tokio = "1"
tokio-util = "0.7.13"
tracing-subscriber = "0.3"

View File

@@ -78,21 +78,21 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
let addr = &c.addr;
let display_name = c.display_name();
res += &format!(
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:{addr}\n\
FN:{display_name}\n"
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:{addr}\r\n\
FN:{display_name}\r\n"
);
if let Some(key) = &c.key {
res += &format!("KEY:data:application/pgp-keys;base64,{key}\n");
res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n");
}
if let Some(profile_image) = &c.profile_image {
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\n");
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n");
}
if let Some(timestamp) = format_timestamp(c) {
res += &format!("REV:{timestamp}\n");
res += &format!("REV:{timestamp}\r\n");
}
res += "END:VCARD\n";
res += "END:VCARD\r\n";
}
res
}
@@ -206,22 +206,21 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
} else if let Some(rev) = vcard_property(line, "rev") {
datetime.get_or_insert(rev);
} else if line.eq_ignore_ascii_case("END:VCARD") {
let (authname, addr) =
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
contacts.push(VcardContact {
authname,
addr,
key: key.map(|s| s.to_string()),
profile_image: photo.map(|s| s.to_string()),
timestamp: datetime
.context("No timestamp in vcard")
.and_then(parse_datetime),
});
break;
}
}
let (authname, addr) =
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
contacts.push(VcardContact {
authname,
addr,
key: key.map(|s| s.to_string()),
profile_image: photo.map(|s| s.to_string()),
timestamp: datetime
.context("No timestamp in vcard")
.and_then(parse_datetime),
});
}
contacts
@@ -540,6 +539,30 @@ END:VCARD",
assert_eq!(contacts.len(), 1);
}
#[test]
fn test_vcard_with_trailing_newline() {
let contacts = parse_vcard(
"BEGIN:VCARD\r
VERSION:4.0\r
FN:Alice Wonderland\r
N:Wonderland;Alice;;;Ms.\r
GENDER:W\r
EMAIL;TYPE=work:alice@example.com\r
KEY;TYPE=PGP;ENCODING=b:[base64-data]\r
REV:20240418T184242Z\r
END:VCARD\r
\r",
);
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
assert_eq!(contacts[0].profile_image, None);
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
assert_eq!(contacts.len(), 1);
}
#[test]
fn test_make_and_parse_vcard() {
let contacts = [
@@ -559,20 +582,20 @@ END:VCARD",
},
];
let items = [
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:alice@example.org\n\
FN:Alice Wonderland\n\
KEY:data:application/pgp-keys;base64,[base64-data]\n\
PHOTO:data:image/jpeg;base64,image in Base64\n\
REV:20240418T184242Z\n\
END:VCARD\n",
"BEGIN:VCARD\n\
VERSION:4.0\n\
EMAIL:bob@example.com\n\
FN:bob@example.com\n\
REV:19700101T000000Z\n\
END:VCARD\n",
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:alice@example.org\r\n\
FN:Alice Wonderland\r\n\
KEY:data:application/pgp-keys;base64,[base64-data]\r\n\
PHOTO:data:image/jpeg;base64,image in Base64\r\n\
REV:20240418T184242Z\r\n\
END:VCARD\r\n",
"BEGIN:VCARD\r\n\
VERSION:4.0\r\n\
EMAIL:bob@example.com\r\n\
FN:bob@example.com\r\n\
REV:19700101T000000Z\r\n\
END:VCARD\r\n",
];
let mut expected = "".to_string();
for len in 0..=contacts.len() {

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat_ffi"
version = "1.152.2"
version = "1.155.4"
description = "Deltachat FFI"
edition = "2018"
readme = "README.md"
@@ -15,7 +15,7 @@ crate-type = ["cdylib", "staticlib"]
[dependencies]
deltachat = { workspace = true, default-features = false }
deltachat-jsonrpc = { workspace = true, optional = true }
deltachat-jsonrpc = { workspace = true }
libc = { workspace = true }
human-panic = { version = "2", default-features = false }
num-traits = { workspace = true }
@@ -30,5 +30,4 @@ yerpc = { workspace = true, features = ["anyhow_expose"] }
[features]
default = ["vendored"]
vendored = ["deltachat/vendored", "deltachat-jsonrpc/vendored"]
jsonrpc = ["dep:deltachat-jsonrpc"]

View File

@@ -1974,6 +1974,36 @@ void dc_delete_msgs (dc_context_t* context, const uint3
void dc_forward_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt, uint32_t chat_id);
/**
* Save a copy of messages in "Saved Messages".
*
* In contrast to forwarding messages,
* information as author, date and origin are preserved.
* The action completes locally, so "Saved Messages" do not show sending errors in case one is offline.
* Still, a sync message is emitted, so that other devices will save the same message,
* as long as not deleted before.
*
* To check if a message was saved, use dc_msg_get_saved_msg_id(),
* UI may show an indicator and offer an "Unsave" instead of a "Save" button then.
*
* The other way round, from inside the "Saved Messages" chat,
* UI may show the indicator and "Unsave" button checking dc_msg_get_original_msg_id()
* and offer a button to go the original message.
*
* "Unsave" is done by deleting the saved message.
* Webxdc updates are not copied on purpose.
*
* For performance reasons, esp. when saving lots of messages,
* UI should call this function from a background thread.
*
* @memberof dc_context_t
* @param context The context object.
* @param msg_ids An array of uint32_t containing all message IDs that should be saved.
* @param msg_cnt The number of messages IDs in the msg_ids array.
*/
void dc_save_msgs (dc_context_t* context, const uint32_t* msg_ids, int msg_cnt);
/**
* Resend messages and make information available for newly added chat members.
* Resending sends out the original message, however, recipients and webxdc-status may differ.
@@ -4722,10 +4752,36 @@ void dc_msg_set_override_sender_name(dc_msg_t* msg, const char* name)
* @param file If the message object is used in dc_send_msg() later,
* this must be the full path of the image file to send.
* @param filemime The MIME type of the file. NULL if you don't know or don't care.
* @deprecated 2025-01-21 Use dc_msg_set_file_and_deduplicate instead
*/
void dc_msg_set_file (dc_msg_t* msg, const char* file, const char* filemime);
/**
* Sets the file associated with a message.
*
* If `name` is non-null, it is used as the file name
* and the actual current name of the file is ignored.
*
* If the source file is already in the blobdir, it will be renamed,
* otherwise it will be copied to the blobdir first.
*
* In order to deduplicate files that contain the same data,
* the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
*
* NOTE:
* - This function will rename the file. To get the new file path, call `get_file()`.
* - The file must not be modified after this function was called.
*
* @memberof dc_msg_t
* @param msg The message object. Must not be NULL.
* @param file The path of the file to attach. Must not be NULL.
* @param name The original filename of the attachment. If NULL, the current name of `file` will be used instead.
* @param filemime The MIME type of the file. NULL if you don't know or don't care.
*/
void dc_msg_set_file_and_deduplicate(dc_msg_t* msg, const char* file, const char* name, const char* filemime);
/**
* Set the dimensions associated with message object.
* Typically this is the width and the height of an image or video associated using dc_msg_set_file().
@@ -4868,6 +4924,35 @@ dc_msg_t* dc_msg_get_quoted_msg (const dc_msg_t* msg);
dc_msg_t* dc_msg_get_parent (const dc_msg_t* msg);
/**
* Get original message ID for a saved message from the "Saved Messages" chat.
*
* Can be used by UI to show a button to go the original message
* and an option to "Unsave" the message.
*
* @memberof dc_msg_t
* @param msg The message object. Usually, this refers to a a message inside "Saved Messages".
* @return The message ID of the original message.
* 0 if the given message object is not a "Saved Message"
* or if the original message does no longer exist.
*/
uint32_t dc_msg_get_original_msg_id (const dc_msg_t* msg);
/**
* Check if a message was saved and return its ID inside "Saved Messages".
*
* Deleting the returned message will un-save the message.
* The state "is saved" can be used to show some icon to indicate that a message was saved.
*
* @memberof dc_msg_t
* @param msg The message object. Usually, this refers to a a message outside "Saved Messages".
* @return The message ID inside "Saved Messages", if any.
* 0 if the given message object is not saved.
*/
uint32_t dc_msg_get_saved_msg_id (const dc_msg_t* msg);
/**
* Force the message to be sent in plain text.
*

View File

@@ -35,6 +35,8 @@ use deltachat::stock_str::StockMessage;
use deltachat::webxdc::StatusUpdateSerial;
use deltachat::*;
use deltachat::{accounts::Accounts, log::LogExt};
use deltachat_jsonrpc::api::CommandApi;
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
use num_traits::{FromPrimitive, ToPrimitive};
use once_cell::sync::Lazy;
use rand::Rng;
@@ -1977,6 +1979,26 @@ pub unsafe extern "C" fn dc_forward_msgs(
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_save_msgs(
context: *mut dc_context_t,
msg_ids: *const u32,
msg_cnt: libc::c_int,
) {
if context.is_null() || msg_ids.is_null() || msg_cnt <= 0 {
eprintln!("ignoring careless call to dc_save_msgs()");
return;
}
let msg_ids = convert_and_prune_message_ids(msg_ids, msg_cnt);
let ctx = &*context;
block_on(async move {
chat::save_msgs(ctx, &msg_ids[..])
.await
.unwrap_or_log_default(ctx, "Failed to save message")
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_resend_msgs(
context: *mut dc_context_t,
@@ -3813,6 +3835,33 @@ pub unsafe extern "C" fn dc_msg_set_file(
)
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_file_and_deduplicate(
msg: *mut dc_msg_t,
file: *const libc::c_char,
name: *const libc::c_char,
filemime: *const libc::c_char,
) {
if msg.is_null() || file.is_null() {
eprintln!("ignoring careless call to dc_msg_set_file_and_deduplicate()");
return;
}
let ffi_msg = &mut *msg;
let ctx = &*ffi_msg.context;
ffi_msg
.message
.set_file_and_deduplicate(
ctx,
as_path(file),
to_opt_string_lossy(name).as_deref(),
to_opt_string_lossy(filemime).as_deref(),
)
.context("Failed to set file")
.log_err(&*ffi_msg.context)
.ok();
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_set_dimension(
msg: *mut dc_msg_t,
@@ -3978,6 +4027,48 @@ pub unsafe extern "C" fn dc_msg_get_parent(msg: *const dc_msg_t) -> *mut dc_msg_
}
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_original_msg_id(msg: *const dc_msg_t) -> u32 {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_original_msg_id()");
return 0;
}
let ffi_msg: &MessageWrapper = &*msg;
let context = &*ffi_msg.context;
block_on(async move {
ffi_msg
.message
.get_original_msg_id(context)
.await
.context("failed to get original message")
.log_err(context)
.unwrap_or_default()
.map(|id| id.to_u32())
.unwrap_or(0)
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_get_saved_msg_id(msg: *const dc_msg_t) -> u32 {
if msg.is_null() {
eprintln!("ignoring careless call to dc_msg_get_saved_msg_id()");
return 0;
}
let ffi_msg: &MessageWrapper = &*msg;
let context = &*ffi_msg.context;
block_on(async move {
ffi_msg
.message
.get_saved_msg_id(context)
.await
.context("failed to get original message")
.log_err(context)
.unwrap_or_default()
.map(|id| id.to_u32())
.unwrap_or(0)
})
}
#[no_mangle]
pub unsafe extern "C" fn dc_msg_force_plaintext(msg: *mut dc_msg_t) {
if msg.is_null() {
@@ -4930,105 +5021,97 @@ pub unsafe extern "C" fn dc_accounts_get_event_emitter(
Box::into_raw(Box::new(emitter))
}
#[cfg(feature = "jsonrpc")]
mod jsonrpc {
use deltachat_jsonrpc::api::CommandApi;
use deltachat_jsonrpc::yerpc::{OutReceiver, RpcClient, RpcSession};
pub struct dc_jsonrpc_instance_t {
receiver: OutReceiver,
handle: RpcSession<CommandApi>,
}
use super::*;
pub struct dc_jsonrpc_instance_t {
receiver: OutReceiver,
handle: RpcSession<CommandApi>,
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_init(
account_manager: *mut dc_accounts_t,
) -> *mut dc_jsonrpc_instance_t {
if account_manager.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_init()");
return ptr::null_mut();
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_init(
account_manager: *mut dc_accounts_t,
) -> *mut dc_jsonrpc_instance_t {
if account_manager.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_init()");
return ptr::null_mut();
}
let account_manager = &*account_manager;
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
account_manager.inner.clone(),
));
let account_manager = &*account_manager;
let cmd_api = block_on(deltachat_jsonrpc::api::CommandApi::from_arc(
account_manager.inner.clone(),
));
let (request_handle, receiver) = RpcClient::new();
let handle = RpcSession::new(request_handle, cmd_api);
let (request_handle, receiver) = RpcClient::new();
let handle = RpcSession::new(request_handle, cmd_api);
let instance = dc_jsonrpc_instance_t { receiver, handle };
let instance = dc_jsonrpc_instance_t { receiver, handle };
Box::into_raw(Box::new(instance))
}
Box::into_raw(Box::new(instance))
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_unref(jsonrpc_instance: *mut dc_jsonrpc_instance_t) {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_unref()");
return;
}
drop(Box::from_raw(jsonrpc_instance));
}
fn spawn_handle_jsonrpc_request(handle: RpcSession<CommandApi>, request: String) {
spawn(async move {
handle.handle_incoming(&request).await;
});
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_request(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
request: *const libc::c_char,
) {
if jsonrpc_instance.is_null() || request.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_request()");
return;
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_unref(jsonrpc_instance: *mut dc_jsonrpc_instance_t) {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_unref()");
return;
}
drop(Box::from_raw(jsonrpc_instance));
let handle = &(*jsonrpc_instance).handle;
let request = to_string_lossy(request);
spawn_handle_jsonrpc_request(handle.clone(), request);
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_next_response(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
) -> *mut libc::c_char {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_next_response()");
return ptr::null_mut();
}
let api = &*jsonrpc_instance;
block_on(api.receiver.recv())
.map(|result| serde_json::to_string(&result).unwrap_or_default().strdup())
.unwrap_or(ptr::null_mut())
}
fn spawn_handle_jsonrpc_request(handle: RpcSession<CommandApi>, request: String) {
spawn(async move {
handle.handle_incoming(&request).await;
});
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_blocking_call(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
input: *const libc::c_char,
) -> *mut libc::c_char {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_blocking_call()");
return ptr::null_mut();
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_request(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
request: *const libc::c_char,
) {
if jsonrpc_instance.is_null() || request.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_request()");
return;
}
let handle = &(*jsonrpc_instance).handle;
let request = to_string_lossy(request);
spawn_handle_jsonrpc_request(handle.clone(), request);
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_next_response(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
) -> *mut libc::c_char {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_next_response()");
return ptr::null_mut();
}
let api = &*jsonrpc_instance;
block_on(api.receiver.recv())
.map(|result| serde_json::to_string(&result).unwrap_or_default().strdup())
.unwrap_or(ptr::null_mut())
}
#[no_mangle]
pub unsafe extern "C" fn dc_jsonrpc_blocking_call(
jsonrpc_instance: *mut dc_jsonrpc_instance_t,
input: *const libc::c_char,
) -> *mut libc::c_char {
if jsonrpc_instance.is_null() {
eprintln!("ignoring careless call to dc_jsonrpc_blocking_call()");
return ptr::null_mut();
}
let api = &*jsonrpc_instance;
let input = to_string_lossy(input);
let res = block_on(api.handle.process_incoming(&input));
match res {
Some(message) => {
if let Ok(message) = serde_json::to_string(&message) {
message.strdup()
} else {
ptr::null_mut()
}
let api = &*jsonrpc_instance;
let input = to_string_lossy(input);
let res = block_on(api.handle.process_incoming(&input));
match res {
Some(message) => {
if let Ok(message) = serde_json::to_string(&message) {
message.strdup()
} else {
ptr::null_mut()
}
None => ptr::null_mut(),
}
None => ptr::null_mut(),
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-jsonrpc"
version = "1.152.2"
version = "1.155.4"
description = "DeltaChat JSON-RPC API"
edition = "2021"
default-run = "deltachat-jsonrpc-server"

View File

@@ -212,14 +212,12 @@ impl CommandApi {
self.accounts.read().await.get_all()
}
/// Select account id for internally selected state.
/// TODO: Likely this is deprecated as all methods take an account id now.
/// Select account in account manager, this saves the last used account to accounts.toml
async fn select_account(&self, id: u32) -> Result<()> {
self.accounts.write().await.select_account(id).await
}
/// Get the selected account id of the internal state..
/// TODO: Likely this is deprecated as all methods take an account id now.
/// Get the selected account from the account manager (on startup it is read from accounts.toml)
async fn get_selected_account_id(&self) -> Option<u32> {
self.accounts.read().await.get_selected_account_id()
}
@@ -836,6 +834,13 @@ impl CommandApi {
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Returns contact IDs of the past chat members.
async fn get_past_chat_contacts(&self, account_id: u32, chat_id: u32) -> Result<Vec<u32>> {
let ctx = self.get_context(account_id).await?;
let contacts = chat::get_past_chat_contacts(&ctx, ChatId::new(chat_id)).await?;
Ok(contacts.iter().map(|id| id.to_u32()).collect::<Vec<u32>>())
}
/// Create a new group chat.
///
/// After creation,
@@ -993,6 +998,12 @@ impl CommandApi {
marknoticed_chat(&ctx, ChatId::new(chat_id)).await
}
/// Returns the message that is immediately followed by the last seen
/// message.
/// From the point of view of the user this is effectively
/// "first unread", but in reality in the database a seen message
/// _can_ be followed by a fresh (unseen) message
/// if that message has not been individually marked as seen.
async fn get_first_unread_message_of_chat(
&self,
account_id: u32,
@@ -1081,6 +1092,9 @@ impl CommandApi {
markseen_msgs(&ctx, msg_ids.into_iter().map(MsgId::new).collect()).await
}
/// Returns all messages of a particular chat.
/// If `add_daymarker` is `true`, it will return them as
/// `DC_MSG_ID_DAYMARKER`, e.g. [1234, 1237, 9, 1239].
async fn get_message_ids(
&self,
account_id: u32,

View File

@@ -1,7 +1,7 @@
use std::time::{Duration, SystemTime};
use anyhow::{bail, Context as _, Result};
use deltachat::chat::{self, get_chat_contacts, ChatVisibility};
use deltachat::chat::{self, get_chat_contacts, get_past_chat_contacts, ChatVisibility};
use deltachat::chat::{Chat, ChatId};
use deltachat::constants::Chattype;
use deltachat::contact::{Contact, ContactId};
@@ -39,6 +39,10 @@ pub struct FullChat {
is_self_talk: bool,
contacts: Vec<ContactObject>,
contact_ids: Vec<u32>,
/// Contact IDs of the past chat members.
past_contact_ids: Vec<u32>,
color: String,
fresh_message_counter: usize,
// is_group - please check over chat.type in frontend instead
@@ -59,6 +63,7 @@ impl FullChat {
let chat = Chat::load_from_db(context, rust_chat_id).await?;
let contact_ids = get_chat_contacts(context, rust_chat_id).await?;
let past_contact_ids = get_past_chat_contacts(context, rust_chat_id).await?;
let mut contacts = Vec::with_capacity(contact_ids.len());
@@ -111,6 +116,7 @@ impl FullChat {
is_self_talk: chat.is_self_talk(),
contacts,
contact_ids: contact_ids.iter().map(|id| id.to_u32()).collect(),
past_contact_ids: past_contact_ids.iter().map(|id| id.to_u32()).collect(),
color,
fresh_message_counter,
is_contact_request: chat.is_contact_request(),

View File

@@ -101,6 +101,7 @@ pub enum EventType {
/// Incoming reaction, should be notified.
#[serde(rename_all = "camelCase")]
IncomingReaction {
chat_id: u32,
contact_id: u32,
msg_id: u32,
reaction: String,
@@ -335,10 +336,12 @@ impl From<CoreEventType> for EventType {
contact_id: contact_id.to_u32(),
},
CoreEventType::IncomingReaction {
chat_id,
contact_id,
msg_id,
reaction,
} => IncomingReaction {
chat_id: chat_id.to_u32(),
contact_id: contact_id.to_u32(),
msg_id: msg_id.to_u32(),
reaction: reaction.as_str().to_string(),

View File

@@ -104,6 +104,9 @@ enum MessageQuote {
WithMessage {
text: String,
message_id: u32,
/// The quoted message does not always belong
/// to the same chat, e.g. when "Reply Privately" is used.
chat_id: u32,
author_display_name: String,
author_display_color: String,
override_sender_name: Option<String>,
@@ -147,6 +150,7 @@ impl MessageObject {
Some(MessageQuote::WithMessage {
text: quoted_text,
message_id: quote.get_id().to_u32(),
chat_id: quote.get_chat_id().to_u32(),
author_display_name: quote_author.get_display_name().to_owned(),
author_display_color: color_int_to_hex_string(quote_author.get_color()),
override_sender_name: quote.get_override_sender_name(),

View File

@@ -58,5 +58,5 @@
},
"type": "module",
"types": "dist/deltachat.d.ts",
"version": "1.152.2"
"version": "1.155.4"
}

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-repl"
version = "1.152.2"
version = "1.155.4"
license = "MPL-2.0"
edition = "2021"
repository = "https://github.com/deltachat/deltachat-core-rust"
@@ -8,12 +8,12 @@ repository = "https://github.com/deltachat/deltachat-core-rust"
[dependencies]
anyhow = { workspace = true }
deltachat = { workspace = true, features = ["internals"]}
dirs = "5"
dirs = "6"
log = { workspace = true }
nu-ansi-term = { workspace = true }
qr2term = "0.3.3"
rusqlite = { workspace = true }
rustyline = "14"
rustyline = "15"
tokio = { workspace = true, features = ["fs", "rt-multi-thread", "macros"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }

View File

@@ -939,7 +939,7 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
} else {
Viewtype::File
});
msg.set_file(arg1, None);
msg.set_file_and_deduplicate(&context, Path::new(arg1), None, None)?;
msg.set_text(arg2.to_string());
chat::send_msg(&context, sel_chat.as_ref().unwrap().get_id(), &mut msg).await?;
}

View File

@@ -22,7 +22,7 @@ use log::{error, info, warn};
use nu_ansi_term::Color;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{Highlighter, MatchingBracketHighlighter};
use rustyline::highlight::{CmdKind as HighlightCmdKind, Highlighter, MatchingBracketHighlighter};
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::validate::Validator;
use rustyline::{
@@ -298,8 +298,8 @@ impl Highlighter for DcHelper {
self.highlighter.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize, forced: bool) -> bool {
self.highlighter.highlight_char(line, pos, forced)
fn highlight_char(&self, line: &str, pos: usize, kind: HighlightCmdKind) -> bool {
self.highlighter.highlight_char(line, pos, kind)
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat-rpc-client"
version = "1.152.2"
version = "1.155.4"
description = "Python client for Delta Chat core JSON-RPC interface"
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -13,7 +13,6 @@ classifiers = [
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -24,6 +23,7 @@ classifiers = [
"Topic :: Communications :: Email"
]
readme = "README.md"
requires-python = ">=3.8"
[tool.setuptools.package-data]
deltachat_rpc_client = [

View File

@@ -238,6 +238,11 @@ class Chat:
contacts = self._rpc.get_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in contacts]
def get_past_contacts(self) -> list[Contact]:
"""Get past contacts for this chat."""
past_contacts = self._rpc.get_past_chat_contacts(self.account.id, self.id)
return [Contact(self.account, contact_id) for contact_id in past_contacts]
def set_image(self, path: str) -> None:
"""Set profile image of this chat.

View File

@@ -131,10 +131,7 @@ class Rpc:
def reader_loop(self) -> None:
try:
while True:
line = self.process.stdout.readline()
if not line: # EOF
break
while line := self.process.stdout.readline():
response = json.loads(line)
if "id" in response:
response_id = response["id"]
@@ -150,10 +147,7 @@ class Rpc:
def writer_loop(self) -> None:
"""Writer loop ensuring only a single thread writes requests."""
try:
while True:
request = self.request_queue.get()
if not request:
break
while request := self.request_queue.get():
data = (json.dumps(request) + "\n").encode()
self.process.stdin.write(data)
self.process.stdin.flush()

View File

@@ -231,7 +231,9 @@ def test_chat(acfactory) -> None:
group.get_fresh_message_count()
group.mark_noticed()
assert group.get_contacts()
group.remove_contact(alice_chat_bob)
assert group.get_past_contacts() == []
group.remove_contact(alice_contact_bob)
assert len(group.get_past_contacts()) == 1
group.get_locations()

View File

@@ -1,6 +1,6 @@
[package]
name = "deltachat-rpc-server"
version = "1.152.2"
version = "1.155.4"
description = "DeltaChat JSON-RPC server"
edition = "2021"
readme = "README.md"

View File

@@ -15,5 +15,5 @@
},
"type": "module",
"types": "index.d.ts",
"version": "1.152.2"
"version": "1.155.4"
}

View File

@@ -12,15 +12,8 @@ ignore = [
# Unmaintained encoding
"RUSTSEC-2021-0153",
# Unmaintained proc-macro-error
# <https://rustsec.org/advisories/RUSTSEC-2024-0370>
"RUSTSEC-2024-0370",
# Unmaintained instant
"RUSTSEC-2024-0384",
# idna 0.5.0
"RUSTSEC-2024-0421",
]
[bans]
@@ -33,15 +26,15 @@ skip = [
{ name = "base64", version = "<0.21" },
{ name = "base64", version = "0.21.7" },
{ name = "bitflags", version = "1.3.2" },
{ name = "core-foundation", version = "0.9.4" },
{ name = "event-listener", version = "2.5.3" },
{ name = "event-listener", version = "4.0.3" },
{ name = "fastrand", version = "1.9.0" },
{ name = "fiat-crypto", version = "0.1.20" },
{ name = "futures-lite", version = "1.13.0" },
{ name = "generator", version = "0.7.5" },
{ name = "getrandom", version = "<0.2" },
{ name = "http", version = "0.2.12" },
{ name = "idna", version = "0.5.0" },
{ name = "loom", version = "0.5.6" },
{ name = "netlink-packet-route", version = "0.17.1" },
{ name = "nix", version = "0.26.4" },
{ name = "nix", version = "0.27.1" },
{ name = "quick-error", version = "<2.0" },
{ name = "rand_chacha", version = "<0.3" },
{ name = "rand_core", version = "<0.6" },
@@ -49,22 +42,33 @@ skip = [
{ name = "redox_syscall", version = "0.3.5" },
{ name = "regex-automata", version = "0.1.10" },
{ name = "regex-syntax", version = "0.6.29" },
{ name = "rtnetlink", version = "0.13.1" },
{ name = "security-framework", version = "2.11.1" },
{ name = "sync_wrapper", version = "0.1.2" },
{ name = "syn", version = "1.0.109" },
{ name = "thiserror-impl", version = "1.0.69" },
{ name = "thiserror", version = "1.0.69" },
{ name = "time", version = "<0.3" },
{ name = "tokio-tungstenite", version = "0.21.0" },
{ name = "tungstenite", version = "0.21.0" },
{ name = "unicode-width", version = "0.1.11" },
{ name = "wasi", version = "<0.11" },
{ name = "windows_aarch64_gnullvm", version = "<0.52" },
{ name = "windows_aarch64_msvc", version = "<0.52" },
{ name = "windows-core", version = "<0.54.0" },
{ name = "windows_i686_gnu", version = "<0.52" },
{ name = "windows_i686_msvc", version = "<0.52" },
{ name = "windows-sys", version = "<0.59" },
{ name = "windows-targets", version = "<0.52" },
{ name = "windows", version = "<0.54.0" },
{ name = "windows_x86_64_gnullvm", version = "<0.52" },
{ name = "windows_x86_64_gnu", version = "<0.52" },
{ name = "windows_x86_64_msvc", version = "<0.52" },
{ name = "winreg", version = "0.50.0" },
{ name = "windows" },
{ name = "windows_aarch64_gnullvm" },
{ name = "windows_aarch64_msvc" },
{ name = "windows-core" },
{ name = "windows_i686_gnu" },
{ name = "windows_i686_gnullvm" },
{ name = "windows_i686_msvc" },
{ name = "windows-implement" },
{ name = "windows-interface" },
{ name = "windows-result" },
{ name = "windows-strings" },
{ name = "windows-sys" },
{ name = "windows-targets" },
{ name = "windows_x86_64_gnu" },
{ name = "windows_x86_64_gnullvm" },
{ name = "windows_x86_64_msvc" },
]
@@ -95,6 +99,5 @@ license-files = [
[sources.allow-org]
# Organisations which we allow git sources from.
github = [
"async-email",
"deltachat",
]

74
flake.lock generated
View File

@@ -47,17 +47,16 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1711088506,
"narHash": "sha256-USdlY7Tx2oJWqFBpp10+03+h7eVhpkQ4s9t1ERjeIJE=",
"lastModified": 1737527504,
"narHash": "sha256-Z8S5gLPdIYeKwBXDaSxlJ72ZmiilYhu3418h3RSQZA0=",
"owner": "nix-community",
"repo": "fenix",
"rev": "85f4139f3c092cf4afd9f9906d7ed218ef262c97",
"rev": "aa13f23e3e91b95377a693ac655bbc6545ebec0d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"rev": "85f4139f3c092cf4afd9f9906d7ed218ef262c97",
"type": "github"
}
},
@@ -115,25 +114,6 @@
"type": "github"
}
},
"new-fenix": {
"inputs": {
"nixpkgs": "nixpkgs_4",
"rust-analyzer-src": "rust-analyzer-src_2"
},
"locked": {
"lastModified": 1734417396,
"narHash": "sha256-32x1Z+Pz3Jv0cK9EG56cFTKXy/mZ/c+Ikxw+aVfKHp4=",
"owner": "nix-community",
"repo": "fenix",
"rev": "a18d41b26e998e95a598858fdb86ba22fb5da47d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"nix-filter": {
"locked": {
"lastModified": 1730207686,
@@ -167,11 +147,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
"lastModified": 1737469691,
"narHash": "sha256-nmKOgAU48S41dTPIXAq0AHZSehWUn6ZPrUKijHAMmIk=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2",
"rev": "9e4d5190a9482a1fb9d18adf0bdb83c6e506eaab",
"type": "github"
},
"original": {
@@ -194,22 +174,6 @@
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1734119587,
"narHash": "sha256-AKU6qqskl0yf2+JdRdD0cfxX4b9x3KKV5RqA6wijmPM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3566ab7246670a43abd2ffa913cc62dad9cdf7d5",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_5": {
"locked": {
"lastModified": 1731139594,
"narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
@@ -231,36 +195,18 @@
"fenix": "fenix",
"flake-utils": "flake-utils_2",
"naersk": "naersk",
"new-fenix": "new-fenix",
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs_5"
"nixpkgs": "nixpkgs_4"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1731342671,
"narHash": "sha256-36eYDHoPzjavnpmEpc2MXdzMk557S0YooGms07mDuKk=",
"lastModified": 1737453499,
"narHash": "sha256-fa5AJI9mjFU2oVXqdCq2oA2pripAXbHzkUkewJRQpxA=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "fc98e0657abf3ce07eed513e38274c89bbb2f8ad",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"rust-analyzer-src_2": {
"flake": false,
"locked": {
"lastModified": 1734386068,
"narHash": "sha256-Py025JiD9lcPmldB7X1AEjq3WBTS60jZUJRtTDonmaE=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "0a706f7d2ac093985eae317781200689cfd48b78",
"rev": "0b68402d781955d526b80e5d479e9e47addb4075",
"type": "github"
},
"original": {

View File

@@ -1,19 +1,14 @@
{
description = "Delta Chat core";
inputs = {
# Old Rust to build releases.
fenix.url = "github:nix-community/fenix?rev=85f4139f3c092cf4afd9f9906d7ed218ef262c97";
# New Rust for development shell.
new-fenix.url = "github:nix-community/fenix";
fenix.url = "github:nix-community/fenix";
flake-utils.url = "github:numtide/flake-utils";
naersk.url = "github:nix-community/naersk";
nix-filter.url = "github:numtide/nix-filter";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
android.url = "github:tadfisher/android-nixpkgs";
};
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, new-fenix, android }:
outputs = { self, nixpkgs, flake-utils, nix-filter, naersk, fenix, android }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
@@ -93,8 +88,7 @@
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"email-0.0.20" = "sha256-rV4Uzqt2Qdrfi5Ti1r+Si1c2iW1kKyWLwOgLkQ5JGGw=";
"encoded-words-0.2.0" = "sha256-KK9st0hLFh4dsrnLd6D8lC6pRFFs8W+WpZSGMGJcosk=";
"email-0.0.20" = "sha256-cfR3D5jFQpw32bGsgapK2Uwuxmht+rRK/n1ZUmCb2WA=";
"lettre-0.9.2" = "sha256-+hU1cFacyyeC9UGVBpS14BWlJjHy90i/3ynMkKAzclk=";
};
};
@@ -544,13 +538,13 @@
let
pkgs = import nixpkgs {
system = system;
overlays = [ new-fenix.overlays.default ];
overlays = [ fenix.overlays.default ];
};
in
pkgs.mkShell {
buildInputs = with pkgs; [
(new-fenix.packages.${system}.complete.withComponents [
(fenix.packages.${system}.complete.withComponents [
"cargo"
"clippy"
"rust-src"

View File

@@ -9,7 +9,7 @@ const buildArgs = [
'build',
'--release',
'--features',
'vendored,jsonrpc',
'vendored',
'-p',
'deltachat_ffi'
]

View File

@@ -8,7 +8,7 @@ import { EventId2EventName, C } from '../dist/constants.js'
import { join } from 'path'
import { statSync } from 'fs'
import { Context } from '../dist/context.js'
import {fileURLToPath} from 'url';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -444,7 +444,7 @@ describe('Offline Tests with unconfigured account', function () {
context.setChatProfileImage(chatId, imagePath)
const blobPath = context.getChat(chatId).getProfileImage()
expect(blobPath.startsWith(blobs)).to.be.true
expect(blobPath.includes('image')).to.be.true
expect(blobPath.includes('image')).to.be.false
expect(blobPath.endsWith('.jpeg')).to.be.true
context.setChatProfileImage(chatId, null)

View File

@@ -55,5 +55,5 @@
"test:mocha": "mocha node/test/test.mjs --growl --reporter=spec --bail --exit"
},
"types": "node/dist/index.d.ts",
"version": "1.152.2"
"version": "1.155.4"
}

View File

@@ -52,10 +52,7 @@ python3-venv` should give you a usable python installation.
First, build the core library::
cargo build --release -p deltachat_ffi --features jsonrpc
`jsonrpc` feature is required even if not used by the bindings
because `deltachat.h` includes JSON-RPC functions unconditionally.
cargo build --release -p deltachat_ffi
Create the virtual environment and activate it::

View File

@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "deltachat"
version = "1.152.2"
version = "1.155.4"
description = "Python bindings for the Delta Chat Core library using CFFI against the Rust-implemented libdeltachat"
readme = "README.rst"
requires-python = ">=3.7"
requires-python = ">=3.8"
authors = [
{ name = "holger krekel, Floris Bruynooghe, Bjoern Petersen and contributors" },
]

View File

@@ -108,7 +108,9 @@ class Message:
@props.with_doc
def filename(self):
"""filename if there was an attachment, otherwise empty string."""
"""file path if there was an attachment, otherwise empty string.
If you want to get the file extension or a user-visible string,
use `basename` instead."""
return from_dc_charpointer(lib.dc_msg_get_file(self._dc_msg))
def set_file(self, path, mime_type=None):
@@ -120,7 +122,8 @@ class Message:
@props.with_doc
def basename(self) -> str:
"""basename of the attachment if it exists, otherwise empty string."""
"""The user-visible name of the attachment (incl. extension)
if it exists, otherwise empty string."""
# FIXME, it does not return basename
return from_dc_charpointer(lib.dc_msg_get_filename(self._dc_msg))

View File

@@ -181,15 +181,16 @@ def test_send_file_twice_unicode_filename_mangling(tmp_path, acfactory, lp):
msg = send_and_receive_message()
assert msg.text == "withfile"
assert open(msg.filename).read() == "some data"
msg.filename.index(basename)
assert msg.filename.endswith(ext)
msg.basename.index(basename)
assert msg.basename.endswith(ext)
msg2 = send_and_receive_message()
assert msg2.text == "withfile"
assert open(msg2.filename).read() == "some data"
msg2.filename.index(basename)
assert msg2.filename.endswith(ext)
assert msg.filename != msg2.filename
msg2.basename.index(basename)
assert msg2.basename.endswith(ext)
assert msg.filename == msg2.filename # The file is deduplicated
assert msg.basename == msg2.basename
def test_send_file_html_attachment(tmp_path, acfactory, lp):
@@ -214,8 +215,8 @@ def test_send_file_html_attachment(tmp_path, acfactory, lp):
msg = ac2.get_message_by_id(ev.data2)
assert open(msg.filename).read() == content
msg.filename.index(basename)
assert msg.filename.endswith(ext)
msg.basename.index(basename)
assert msg.basename.endswith(ext)
def test_html_message(acfactory, lp):
@@ -1253,7 +1254,10 @@ def test_no_old_msg_is_fresh(acfactory, lp):
def test_prefer_encrypt(acfactory, lp):
"""Test quorum rule for encryption preference in 1:1 and group chat."""
ac1, ac2, ac3 = acfactory.get_online_accounts(3)
ac1 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
ac2 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
ac3 = acfactory.new_online_configuring_account(fix_is_chatmail=True)
acfactory.bring_accounts_online()
ac1.set_config("e2ee_enabled", "0")
ac2.set_config("e2ee_enabled", "1")
ac3.set_config("e2ee_enabled", "0")
@@ -1276,7 +1280,8 @@ def test_prefer_encrypt(acfactory, lp):
lp.sec("ac2: sending message to ac1")
chat2 = ac2.create_chat(ac1)
msg2 = chat2.send_text("message2")
assert not msg2.is_encrypted()
# Own preference is `Mutual` and we have the peer's key.
assert msg2.is_encrypted()
ac1._evtracker.wait_next_incoming_message()
lp.sec("ac1: sending message to group chat with ac2 and ac3")
@@ -1292,8 +1297,8 @@ def test_prefer_encrypt(acfactory, lp):
ac3.set_config("e2ee_enabled", "1")
chat3 = ac3.create_chat(ac1)
msg4 = chat3.send_text("message4")
# ac1 still does not prefer encryption
assert not msg4.is_encrypted()
# Own preference is `Mutual` and we have the peer's key.
assert msg4.is_encrypted()
ac1._evtracker.wait_next_incoming_message()
lp.sec("ac1: sending another message to group chat with ac2 and ac3")
@@ -1978,7 +1983,7 @@ def test_fetch_deleted_msg(acfactory, lp):
if ev.name == "DC_EVENT_MSGS_CHANGED":
pytest.fail("A deleted message was shown to the user")
if ev.name == "DC_EVENT_INFO" and "INBOX: Idle entering wait-on-remote state" in ev.data2:
if ev.name == "DC_EVENT_INFO" and 'IDLE entering wait-on-remote state in folder "INBOX".' in ev.data2:
break # DC is done with reading messages

View File

@@ -212,11 +212,16 @@ def test_logged_ac_process_ffi_failure(acfactory):
0 / 0
cap = Queue()
ac1.log = cap.put
# Make sure the next attempt to log an event fails.
ac1.add_account_plugin(FailPlugin())
# Start capturing events.
ac1.log = cap.put
# cause any event eg contact added/changed
ac1.create_contact("something@example.org")
res = cap.get(timeout=600)
res = cap.get(timeout=10)
assert "ac_process_ffi_event" in res
assert "ZeroDivisionError" in res
assert "Traceback" in res

View File

@@ -1 +1 @@
2024-12-24
2025-02-10

View File

@@ -7,7 +7,7 @@ set -euo pipefail
#
# Avoid using rustup here as it depends on reading /proc/self/exe and
# has problems running under QEMU.
RUST_VERSION=1.83.0
RUST_VERSION=1.84.1
ARCH="$(uname -m)"
test -f "/lib/libc.musl-$ARCH.so.1" && LIBC=musl || LIBC=gnu

View File

@@ -11,7 +11,7 @@ set -euo pipefail
export DCC_RS_TARGET=debug
export DCC_RS_DEV="$PWD"
cargo build -p deltachat_ffi --features jsonrpc
cargo build -p deltachat_ffi
tox -c python -e py --devenv venv
venv/bin/pip install --upgrade pip

View File

@@ -12,7 +12,7 @@ export DCC_RS_DEV=`pwd`
cd python
cargo build -p deltachat_ffi --features jsonrpc
cargo build -p deltachat_ffi
# remove and inhibit writing PYC files
rm -rf tests/__pycache__

View File

@@ -8,7 +8,7 @@ set -e -x
# compile core lib
cargo build --release -p deltachat_ffi --features jsonrpc
cargo build --release -p deltachat_ffi
# Statically link against libdeltachat.a.
export DCC_RS_DEV="$PWD"
@@ -31,6 +31,6 @@ unset CHATMAIL_DOMAIN
# Try to build wheels for a range of interpreters, but don't fail if they are not available.
# E.g. musllinux_1_1 does not have PyPy interpreters as of 2022-07-10
tox --workdir "$TOXWORKDIR" -e py37,py38,py39,py310,py311,py312,py313,pypy37,pypy38,pypy39,pypy310 --skip-missing-interpreters true
tox --workdir "$TOXWORKDIR" -e py38,py39,py310,py311,py312,py313,pypy38,pypy39,pypy310 --skip-missing-interpreters true
auditwheel repair "$TOXWORKDIR"/wheelhouse/deltachat* -w "$TOXWORKDIR/wheelhouse"

File diff suppressed because it is too large Load Diff

810
src/blob/blob_tests.rs Normal file
View File

@@ -0,0 +1,810 @@
use std::time::Duration;
use super::*;
use crate::message::{Message, Viewtype};
use crate::sql;
use crate::test_utils::{self, TestContext};
use crate::tools::SystemTime;
fn check_image_size(path: impl AsRef<Path>, width: u32, height: u32) -> image::DynamicImage {
tokio::task::block_in_place(move || {
let img = ImageReader::open(path)
.expect("failed to open image")
.with_guessed_format()
.expect("failed to guess format")
.decode()
.expect("failed to decode image");
assert_eq!(img.width(), width, "invalid width");
assert_eq!(img.height(), height, "invalid height");
img
})
}
const FILE_BYTES: &[u8] = b"hello";
const FILE_DEDUPLICATED: &str = "ea8f163db38682925e4491c5e58d4bb.txt";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create() {
let t = TestContext::new().await;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
let fname = t.get_blobdir().join(FILE_DEDUPLICATED);
let data = fs::read(fname).await.unwrap();
assert_eq!(data, FILE_BYTES);
assert_eq!(blob.as_name(), format!("$BLOBDIR/{FILE_DEDUPLICATED}"));
assert_eq!(blob.to_abs_path(), t.get_blobdir().join(FILE_DEDUPLICATED));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lowercase_ext() {
let t = TestContext::new().await;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.TXT").unwrap();
assert!(
blob.as_name().ends_with(".txt"),
"Blob {blob:?} should end with .txt"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_as_file_name() {
let t = TestContext::new().await;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
assert_eq!(blob.as_file_name(), FILE_DEDUPLICATED);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_as_rel_path() {
let t = TestContext::new().await;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
assert_eq!(blob.as_rel_path(), Path::new(FILE_DEDUPLICATED));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_suffix() {
let t = TestContext::new().await;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
assert_eq!(blob.suffix(), Some("txt"));
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "bar").unwrap();
assert_eq!(blob.suffix(), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_dup() {
let t = TestContext::new().await;
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap();
let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED);
assert!(foo_path.exists());
BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.txt").unwrap();
let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap();
while let Ok(Some(dirent)) = dir.next_entry().await {
let fname = dirent.file_name();
if fname == foo_path.file_name().unwrap() {
assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES);
} else {
let name = fname.to_str().unwrap();
assert!(name.ends_with(".txt"));
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_double_ext() {
let t = TestContext::new().await;
BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.tar.gz").unwrap();
let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED).with_extension("gz");
assert!(foo_path.exists());
BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.tar.gz").unwrap();
let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap();
while let Ok(Some(dirent)) = dir.next_entry().await {
let fname = dirent.file_name();
if fname == foo_path.file_name().unwrap() {
assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES);
} else {
let name = fname.to_str().unwrap();
println!("{name}");
assert_eq!(name.starts_with("foo"), false);
assert_eq!(name.ends_with(".tar.gz"), false);
assert!(name.ends_with(".gz"));
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_long_names() {
let t = TestContext::new().await;
let s = format!("file.{}", "a".repeat(100));
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"data", &s).unwrap();
let blobname = blob.as_name().split('/').last().unwrap();
assert!(blobname.len() < 70);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_copy() {
let t = TestContext::new().await;
let src = t.dir.path().join("src");
fs::write(&src, b"boo").await.unwrap();
let blob = BlobObject::create_and_copy(&t, src.as_ref()).await.unwrap();
assert_eq!(blob.as_name(), "$BLOBDIR/src");
let data = fs::read(blob.to_abs_path()).await.unwrap();
assert_eq!(data, b"boo");
let whoops = t.dir.path().join("whoops");
assert!(BlobObject::create_and_copy(&t, whoops.as_ref())
.await
.is_err());
let whoops = t.get_blobdir().join("whoops");
assert!(!whoops.exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_from_path() {
let t = TestContext::new().await;
let src_ext = t.dir.path().join("external");
fs::write(&src_ext, b"boo").await.unwrap();
let blob = BlobObject::new_from_path(&t, src_ext.as_ref())
.await
.unwrap();
assert_eq!(blob.as_name(), "$BLOBDIR/external");
let data = fs::read(blob.to_abs_path()).await.unwrap();
assert_eq!(data, b"boo");
let src_int = t.get_blobdir().join("internal");
fs::write(&src_int, b"boo").await.unwrap();
let blob = BlobObject::new_from_path(&t, &src_int).await.unwrap();
assert_eq!(blob.as_name(), "$BLOBDIR/internal");
let data = fs::read(blob.to_abs_path()).await.unwrap();
assert_eq!(data, b"boo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_from_name_long() {
let t = TestContext::new().await;
let src_ext = t.dir.path().join("autocrypt-setup-message-4137848473.html");
fs::write(&src_ext, b"boo").await.unwrap();
let blob = BlobObject::new_from_path(&t, src_ext.as_ref())
.await
.unwrap();
assert_eq!(
blob.as_name(),
"$BLOBDIR/autocrypt-setup-message-4137848473.html"
);
}
#[test]
fn test_is_blob_name() {
assert!(BlobObject::is_acceptible_blob_name("foo"));
assert!(BlobObject::is_acceptible_blob_name("foo.txt"));
assert!(BlobObject::is_acceptible_blob_name("f".repeat(128)));
assert!(!BlobObject::is_acceptible_blob_name("foo/bar"));
assert!(!BlobObject::is_acceptible_blob_name("foo\\bar"));
assert!(!BlobObject::is_acceptible_blob_name("foo\x00bar"));
}
#[test]
fn test_sanitise_name() {
let (stem, ext) = BlobObject::sanitise_name("Я ЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯ.txt");
assert_eq!(ext, ".txt");
assert!(!stem.is_empty());
// the extensions are kept together as between stem and extension a number may be added -
// and `foo.tar.gz` should become `foo-1234.tar.gz` and not `foo.tar-1234.gz`
let (stem, ext) = BlobObject::sanitise_name("wot.tar.gz");
assert_eq!(stem, "wot");
assert_eq!(ext, ".tar.gz");
let (stem, ext) = BlobObject::sanitise_name(".foo.bar");
assert_eq!(stem, "");
assert_eq!(ext, ".foo.bar");
let (stem, ext) = BlobObject::sanitise_name("foo?.bar");
assert!(stem.contains("foo"));
assert!(!stem.contains('?'));
assert_eq!(ext, ".bar");
let (stem, ext) = BlobObject::sanitise_name("no-extension");
assert_eq!(stem, "no-extension");
assert_eq!(ext, "");
let (stem, ext) = BlobObject::sanitise_name("path/ignored\\this: is* forbidden?.c");
assert_eq!(ext, ".c");
assert!(!stem.contains("path"));
assert!(!stem.contains("ignored"));
assert!(stem.contains("this"));
assert!(stem.contains("forbidden"));
assert!(!stem.contains('/'));
assert!(!stem.contains('\\'));
assert!(!stem.contains(':'));
assert!(!stem.contains('*'));
assert!(!stem.contains('?'));
let (stem, ext) = BlobObject::sanitise_name(
"file.with_lots_of_characters_behind_point_and_double_ending.tar.gz",
);
assert_eq!(
stem,
"file.with_lots_of_characters_behind_point_and_double_ending"
);
assert_eq!(ext, ".tar.gz");
let (stem, ext) = BlobObject::sanitise_name("a. tar.tar.gz");
assert_eq!(stem, "a. tar");
assert_eq!(ext, ".tar.gz");
let (stem, ext) = BlobObject::sanitise_name("Guia_uso_GNB (v0.8).pdf");
assert_eq!(stem, "Guia_uso_GNB (v0.8)");
assert_eq!(ext, ".pdf");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_add_white_bg() {
let t = TestContext::new().await;
let bytes0 = include_bytes!("../../test-data/image/logo.png").as_slice();
let bytes1 = include_bytes!("../../test-data/image/avatar900x900.png").as_slice();
for (bytes, color) in [
(bytes0, [255u8, 255, 255, 255]),
(bytes1, [253u8, 198, 0, 255]),
] {
let avatar_src = t.dir.path().join("avatar.png");
fs::write(&avatar_src, bytes).await.unwrap();
let mut blob = BlobObject::new_from_path(&t, &avatar_src).await.unwrap();
let img_wh = 128;
let maybe_sticker = &mut false;
let strict_limits = true;
blob.recode_to_size(&t, None, maybe_sticker, img_wh, 20_000, strict_limits)
.unwrap();
tokio::task::block_in_place(move || {
let img = ImageReader::open(blob.to_abs_path())
.unwrap()
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
assert!(img.width() == img_wh);
assert!(img.height() == img_wh);
assert_eq!(img.get_pixel(0, 0), Rgba(color));
});
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_selfavatar_outside_blobdir() {
async fn file_size(path_buf: &Path) -> u64 {
fs::metadata(path_buf).await.unwrap().len()
}
let t = TestContext::new().await;
let avatar_src = t.dir.path().join("avatar.jpg");
let avatar_bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
let avatar_path = Path::new(&avatar_blob);
assert!(
avatar_blob.ends_with("d98cd30ed8f2129bf3968420208849d.jpg"),
"The avatar filename should be its hash, put instead it's {avatar_blob}"
);
let scaled_avatar_size = file_size(avatar_path).await;
assert!(scaled_avatar_size < avatar_bytes.len() as u64);
check_image_size(avatar_src, 1000, 1000);
check_image_size(
&avatar_blob,
constants::BALANCED_AVATAR_SIZE,
constants::BALANCED_AVATAR_SIZE,
);
let mut blob = BlobObject::new_from_path(&t, avatar_path).await.unwrap();
let maybe_sticker = &mut false;
let strict_limits = true;
blob.recode_to_size(&t, None, maybe_sticker, 1000, 3000, strict_limits)
.unwrap();
let new_file_size = file_size(&blob.to_abs_path()).await;
assert!(new_file_size <= 3000);
assert!(new_file_size > 2000);
// The new file should be smaller:
assert!(new_file_size < scaled_avatar_size);
// And the original file should not be touched:
assert_eq!(file_size(avatar_path).await, scaled_avatar_size);
tokio::task::block_in_place(move || {
let img = ImageReader::open(blob.to_abs_path())
.unwrap()
.with_guessed_format()
.unwrap()
.decode()
.unwrap();
assert!(img.width() > 130);
assert_eq!(img.width(), img.height());
});
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_selfavatar_in_blobdir() {
let t = TestContext::new().await;
let avatar_src = t.get_blobdir().join("avatar.png");
fs::write(&avatar_src, test_utils::AVATAR_900x900_BYTES)
.await
.unwrap();
check_image_size(&avatar_src, 900, 900);
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert!(
avatar_cfg.ends_with("9e7f409ac5c92b942cc4f31cee2770a.png"),
"Avatar file name {avatar_cfg} should end with its hash"
);
check_image_size(
avatar_cfg,
constants::BALANCED_AVATAR_SIZE,
constants::BALANCED_AVATAR_SIZE,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_selfavatar_copy_without_recode() {
let t = TestContext::new().await;
let avatar_src = t.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
fs::write(&avatar_src, avatar_bytes).await.unwrap();
let avatar_blob = t.get_blobdir().join("e9b6c7a78aa2e4f415644f55a553e73.png");
assert!(!avatar_blob.exists());
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
assert!(avatar_blob.exists());
assert_eq!(
fs::metadata(&avatar_blob).await.unwrap().len(),
avatar_bytes.len() as u64
);
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap();
assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_1() {
let bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
has_exif: true,
original_width: 1000,
original_height: 1000,
compressed_width: 1000,
compressed_height: 1000,
..Default::default()
}
.test()
.await
.unwrap();
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "1",
bytes,
extension: "jpg",
has_exif: true,
original_width: 1000,
original_height: 1000,
compressed_width: 1000,
compressed_height: 1000,
..Default::default()
}
.test()
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_2() {
// The "-rotated" files are rotated by 270 degrees using the Exif metadata
let bytes = include_bytes!("../../test-data/image/rectangle2000x1800-rotated.jpg");
let img_rotated = SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
has_exif: true,
original_width: 2000,
original_height: 1800,
orientation: 270,
compressed_width: 1800,
compressed_height: 2000,
..Default::default()
}
.test()
.await
.unwrap();
assert_correct_rotation(&img_rotated);
let mut buf = Cursor::new(vec![]);
img_rotated.write_to(&mut buf, ImageFormat::Jpeg).unwrap();
let bytes = buf.into_inner();
let img_rotated = SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "1",
bytes: &bytes,
extension: "jpg",
original_width: 1800,
original_height: 2000,
compressed_width: 1800,
compressed_height: 2000,
..Default::default()
}
.test()
.await
.unwrap();
assert_correct_rotation(&img_rotated);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_balanced_png() {
let bytes = include_bytes!("../../test-data/image/screenshot.png");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: 1920,
compressed_height: 1080,
..Default::default()
}
.test()
.await
.unwrap();
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "1",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: constants::WORSE_IMAGE_SIZE,
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
..Default::default()
}
.test()
.await
.unwrap();
SendImageCheckMediaquality {
viewtype: Viewtype::File,
media_quality_config: "1",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: 1920,
compressed_height: 1080,
..Default::default()
}
.test()
.await
.unwrap();
SendImageCheckMediaquality {
viewtype: Viewtype::File,
media_quality_config: "1",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: 1920,
compressed_height: 1080,
set_draft: true,
..Default::default()
}
.test()
.await
.unwrap();
// This will be sent as Image, see [`BlobObject::maybe_sticker`] for explanation.
SendImageCheckMediaquality {
viewtype: Viewtype::Sticker,
media_quality_config: "0",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: 1920,
compressed_height: 1080,
..Default::default()
}
.test()
.await
.unwrap();
}
/// Tests that RGBA PNG can be recoded into JPEG
/// by dropping alpha channel.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_rgba_png_to_jpeg() {
let bytes = include_bytes!("../../test-data/image/screenshot-rgba.png");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "1",
bytes,
extension: "png",
original_width: 1920,
original_height: 1080,
compressed_width: constants::WORSE_IMAGE_SIZE,
compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920,
..Default::default()
}
.test()
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recode_image_huge_jpg() {
let bytes = include_bytes!("../../test-data/image/screenshot.jpg");
SendImageCheckMediaquality {
viewtype: Viewtype::Image,
media_quality_config: "0",
bytes,
extension: "jpg",
has_exif: true,
original_width: 1920,
original_height: 1080,
compressed_width: constants::BALANCED_IMAGE_SIZE,
compressed_height: constants::BALANCED_IMAGE_SIZE * 1080 / 1920,
..Default::default()
}
.test()
.await
.unwrap();
}
fn assert_correct_rotation(img: &DynamicImage) {
// The test images are black in the bottom left corner after correctly applying
// the EXIF orientation
let [luma] = img.get_pixel(10, 10).to_luma().0;
assert_eq!(luma, 255);
let [luma] = img.get_pixel(img.width() - 10, 10).to_luma().0;
assert_eq!(luma, 255);
let [luma] = img
.get_pixel(img.width() - 10, img.height() - 10)
.to_luma()
.0;
assert_eq!(luma, 255);
let [luma] = img.get_pixel(10, img.height() - 10).to_luma().0;
assert_eq!(luma, 0);
}
#[derive(Default)]
struct SendImageCheckMediaquality<'a> {
pub(crate) viewtype: Viewtype,
pub(crate) media_quality_config: &'a str,
pub(crate) bytes: &'a [u8],
pub(crate) extension: &'a str,
pub(crate) has_exif: bool,
pub(crate) original_width: u32,
pub(crate) original_height: u32,
pub(crate) orientation: i32,
pub(crate) compressed_width: u32,
pub(crate) compressed_height: u32,
pub(crate) set_draft: bool,
}
impl SendImageCheckMediaquality<'_> {
pub(crate) async fn test(self) -> anyhow::Result<DynamicImage> {
let viewtype = self.viewtype;
let media_quality_config = self.media_quality_config;
let bytes = self.bytes;
let extension = self.extension;
let has_exif = self.has_exif;
let original_width = self.original_width;
let original_height = self.original_height;
let orientation = self.orientation;
let compressed_width = self.compressed_width;
let compressed_height = self.compressed_height;
let set_draft = self.set_draft;
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
alice
.set_config(Config::MediaQuality, Some(media_quality_config))
.await?;
let file = alice.get_blobdir().join("file").with_extension(extension);
let file_name = format!("file.{extension}");
fs::write(&file, &bytes)
.await
.context("failed to write file")?;
check_image_size(&file, original_width, original_height);
let (_, exif) = image_metadata(&std::fs::File::open(&file)?)?;
if has_exif {
let exif = exif.unwrap();
assert_eq!(exif_orientation(&exif, &alice), orientation);
} else {
assert!(exif.is_none());
}
let mut msg = Message::new(viewtype);
msg.set_file_and_deduplicate(&alice, &file, Some(&file_name), None)?;
let chat = alice.create_chat(&bob).await;
if set_draft {
chat.id.set_draft(&alice, Some(&mut msg)).await.unwrap();
msg = chat.id.get_draft(&alice).await.unwrap().unwrap();
assert_eq!(msg.get_viewtype(), Viewtype::File);
}
let sent = alice.send_msg(chat.id, &mut msg).await;
let alice_msg = alice.get_last_msg().await;
assert_eq!(alice_msg.get_width() as u32, compressed_width);
assert_eq!(alice_msg.get_height() as u32, compressed_height);
let file_saved = alice
.get_blobdir()
.join("saved-".to_string() + &alice_msg.get_filename().unwrap());
alice_msg.save_file(&alice, &file_saved).await?;
check_image_size(file_saved, compressed_width, compressed_height);
let bob_msg = bob.recv_msg(&sent).await;
assert_eq!(bob_msg.get_viewtype(), Viewtype::Image);
assert_eq!(bob_msg.get_width() as u32, compressed_width);
assert_eq!(bob_msg.get_height() as u32, compressed_height);
let file_saved = bob
.get_blobdir()
.join("saved-".to_string() + &bob_msg.get_filename().unwrap());
bob_msg.save_file(&bob, &file_saved).await?;
if viewtype == Viewtype::File {
assert_eq!(file_saved.extension().unwrap(), extension);
let bytes1 = fs::read(&file_saved).await?;
assert_eq!(&bytes1, bytes);
}
let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?;
assert!(exif.is_none());
let img = check_image_size(file_saved, compressed_width, compressed_height);
Ok(img)
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_big_gif_as_image() -> Result<()> {
let bytes = include_bytes!("../../test-data/image/screenshot.gif");
let (width, height) = (1920u32, 1080u32);
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
alice
.set_config(
Config::MediaQuality,
Some(&(MediaQuality::Worse as i32).to_string()),
)
.await?;
let file = alice.get_blobdir().join("file").with_extension("gif");
fs::write(&file, &bytes)
.await
.context("failed to write file")?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_and_deduplicate(&alice, &file, Some("file.gif"), None)?;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let bob_msg = bob.recv_msg(&sent).await;
// DC must detect the image as GIF and send it w/o reencoding.
assert_eq!(bob_msg.get_viewtype(), Viewtype::Gif);
assert_eq!(bob_msg.get_width() as u32, width);
assert_eq!(bob_msg.get_height() as u32, height);
let file_saved = bob
.get_blobdir()
.join("saved-".to_string() + &bob_msg.get_filename().unwrap());
bob_msg.save_file(&bob, &file_saved).await?;
let (file_size, _) = image_metadata(&std::fs::File::open(&file_saved)?)?;
assert_eq!(file_size, bytes.len() as u64);
check_image_size(file_saved, width, height);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_gif_as_sticker() -> Result<()> {
let bytes = include_bytes!("../../test-data/image/image100x50.gif");
let alice = &TestContext::new_alice().await;
let file = alice.get_blobdir().join("file").with_extension("gif");
fs::write(&file, &bytes)
.await
.context("failed to write file")?;
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file_and_deduplicate(alice, &file, None, None)?;
let chat = alice.get_self_chat().await;
let sent = alice.send_msg(chat.id, &mut msg).await;
let msg = Message::load_from_db(alice, sent.sender_msg_id).await?;
// Message::force_sticker() wasn't used, still Viewtype::Sticker is preserved because of the
// extension.
assert_eq!(msg.get_viewtype(), Viewtype::Sticker);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate() -> Result<()> {
let t = TestContext::new().await;
let path = t.get_blobdir().join("anyfile.dat");
fs::write(&path, b"bla").await?;
let blob = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f.dat");
assert_eq!(path.exists(), false);
assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla");
fs::write(&path, b"bla").await?;
let blob2 = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_eq!(blob2.name, blob.name);
let path_outside_blobdir = t.dir.path().join("anyfile.dat");
fs::write(&path_outside_blobdir, b"bla").await?;
let blob3 =
BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?;
assert!(path_outside_blobdir.exists());
assert_eq!(blob3.name, blob.name);
fs::write(&path, b"blabla").await?;
let blob4 = BlobObject::create_and_deduplicate(&t, &path, &path)?;
assert_ne!(blob4.name, blob.name);
fs::remove_dir_all(t.get_blobdir()).await?;
let blob5 =
BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?;
assert_eq!(blob5.name, blob.name);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_and_deduplicate_from_bytes() -> Result<()> {
let t = TestContext::new().await;
fs::remove_dir(t.get_blobdir()).await?;
let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?;
assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f");
assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla");
let modified1 = blob.to_abs_path().metadata()?.modified()?;
// Test that the modification time of the file is updated when a new file is created
// so that it's not deleted during housekeeping.
// We can't use SystemTime::shift() here because file creation uses the actual OS time,
// which we can't mock from our code.
tokio::time::sleep(Duration::from_millis(1100)).await;
let blob2 = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?;
assert_eq!(blob2.name, blob.name);
let modified2 = blob.to_abs_path().metadata()?.modified()?;
assert_ne!(modified1, modified2);
sql::housekeeping(&t).await?;
assert!(blob2.to_abs_path().exists());
// If we do shift the time by more than 1h, the blob file will be deleted during housekeeping:
SystemTime::shift(Duration::from_secs(65 * 60));
sql::housekeeping(&t).await?;
assert_eq!(blob2.to_abs_path().exists(), false);
let blob3 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?;
assert_ne!(blob3.name, blob.name);
{
// If something goes wrong and the blob file is overwritten,
// the correct content should be restored:
fs::write(blob3.to_abs_path(), b"bloblo").await?;
let blob4 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?;
let blob4_content = fs::read(blob4.to_abs_path()).await?;
assert_eq!(blob4_content, b"blabla");
}
Ok(())
}

File diff suppressed because it is too large Load Diff

3549
src/chat/chat_tests.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -144,7 +144,7 @@ impl Chatlist {
ORDER BY timestamp DESC, id DESC LIMIT 1)
WHERE c.id>9
AND c.blocked!=1
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2)
AND c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?2 AND add_timestamp >= remove_timestamp)
GROUP BY c.id
ORDER BY c.archived=?3 DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(MessageState::OutDraft, query_contact_id, ChatVisibility::Pinned),
@@ -261,7 +261,7 @@ impl Chatlist {
WHERE c.id>9 AND c.id!=?
AND c.blocked=0
AND NOT c.archived=?
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=?))
AND (c.type!=? OR c.id IN(SELECT chat_id FROM chats_contacts WHERE contact_id=? AND add_timestamp >= remove_timestamp))
GROUP BY c.id
ORDER BY c.id=? DESC, c.archived=? DESC, IFNULL(m.timestamp,c.created_timestamp) DESC, m.id DESC;",
(
@@ -550,7 +550,7 @@ mod tests {
let chats = Chatlist::try_load(&t, 0, Some("is:unread"), None)
.await
.unwrap();
assert!(chats.len() == 1);
assert_eq!(chats.len(), 1);
let chats = Chatlist::try_load(&t, DC_GCL_ARCHIVED_ONLY, None, None)
.await
@@ -576,7 +576,7 @@ mod tests {
.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert!(chats.len() == 3);
assert_eq!(chats.len(), 3);
assert!(!Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
.await
.unwrap()
@@ -585,7 +585,7 @@ mod tests {
let chats = Chatlist::try_load(&t, DC_GCL_FOR_FORWARDING, None, None)
.await
.unwrap();
assert!(chats.len() == 2); // device chat cannot be written and is skipped on forwarding
assert_eq!(chats.len(), 2); // device chat cannot be written and is skipped on forwarding
assert!(Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
.await
.unwrap()
@@ -597,7 +597,7 @@ mod tests {
let chats = Chatlist::try_load(&t, DC_GCL_FOR_FORWARDING, None, None)
.await
.unwrap();
assert!(chats.len() == 1);
assert_eq!(chats.len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -143,7 +143,10 @@ pub enum Config {
/// Send BCC copy to self.
///
/// Should be enabled for multidevice setups.
/// Default is 0 for chatmail accounts before a backup export, 1 otherwise.
/// Default is 0 for chatmail accounts, 1 otherwise.
///
/// This is automatically enabled when importing/exporting a backup,
/// setting up a second device, or receiving a sync message.
BccSelf,
/// True if encryption is preferred according to Autocrypt standard.
@@ -384,6 +387,11 @@ pub enum Config {
/// Enable sending and executing (applying) sync messages. Sending requires `BccSelf` to be set
/// and `Bot` unset.
///
/// On real devices, this is usually always enabled and `BccSelf` is the only setting
/// that controls whether sync messages are sent.
///
/// In tests, this is usually disabled.
#[strum(props(default = "1"))]
SyncMsgs,
@@ -447,6 +455,13 @@ pub enum Config {
/// If it has not changed, we do not store
/// the device token again.
DeviceToken,
/// Device token encrypted with OpenPGP.
///
/// We store encrypted token next to `device_token`
/// to avoid encrypting it differently and
/// storing the same token multiple times on the server.
EncryptedDeviceToken,
}
impl Config {
@@ -678,7 +693,7 @@ impl Context {
let value = match key {
Config::Selfavatar if value.is_empty() => None,
Config::Selfavatar => {
config_value = BlobObject::store_from_base64(self, value, "avatar").await?;
config_value = BlobObject::store_from_base64(self, value)?;
Some(config_value.as_str())
}
_ => Some(value),
@@ -756,7 +771,8 @@ impl Context {
.await?;
match value {
Some(path) => {
let mut blob = BlobObject::new_from_path(self, path.as_ref()).await?;
let path = get_abs_path(self, Path::new(path));
let mut blob = BlobObject::create_and_deduplicate(self, &path, &path)?;
blob.recode_to_avatar_size(self).await?;
self.sql
.set_raw_config(key.as_ref(), Some(blob.as_name()))
@@ -1135,6 +1151,8 @@ mod tests {
Ok(())
}
const SAVED_MESSAGES_DEDUPLICATED_FILE: &str = "969142cb84015bc135767bc2370934a.png";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sync() -> Result<()> {
let alice0 = TestContext::new_alice().await;
@@ -1209,7 +1227,7 @@ mod tests {
let self_chat_avatar_path = self_chat.get_profile_image(&alice0).await?.unwrap();
assert_eq!(
self_chat_avatar_path,
alice0.get_blobdir().join("icon-saved-messages.png")
alice0.get_blobdir().join(SAVED_MESSAGES_DEDUPLICATED_FILE)
);
assert!(alice1
.get_config(Config::Selfavatar)

View File

@@ -61,10 +61,7 @@ macro_rules! progress {
impl Context {
/// Checks if the context is already configured.
pub async fn is_configured(&self) -> Result<bool> {
self.sql
.get_raw_config_bool("configured")
.await
.map_err(Into::into)
self.sql.get_raw_config_bool("configured").await
}
/// Configures this account with the currently set parameters.

File diff suppressed because it is too large Load Diff

1273
src/contact/contact_tests.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1480,653 +1480,4 @@ pub fn get_version_str() -> &'static str {
}
#[cfg(test)]
mod tests {
use anyhow::Context as _;
use strum::IntoEnumIterator;
use tempfile::tempdir;
use super::*;
use crate::chat::{get_chat_contacts, get_chat_msgs, send_msg, set_muted, Chat, MuteDuration};
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{get_chat_msg, TestContext};
use crate::tools::{create_outgoing_rfc724_mid, SystemTime};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_wrong_db() -> Result<()> {
let tmp = tempfile::tempdir()?;
let dbfile = tmp.path().join("db.sqlite");
tokio::fs::write(&dbfile, b"123").await?;
let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await?;
// Broken database is indistinguishable from encrypted one.
assert_eq!(res.is_open().await, false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs() {
let t = TestContext::new().await;
let fresh = t.get_fresh_msgs().await.unwrap();
assert!(fresh.is_empty())
}
async fn receive_msg(t: &TestContext, chat: &Chat) {
let members = get_chat_contacts(t, chat.id).await.unwrap();
let contact = Contact::get_by_id(t, *members.first().unwrap())
.await
.unwrap();
let msg = format!(
"From: {}\n\
To: alice@example.org\n\
Message-ID: <{}>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
contact.get_addr(),
create_outgoing_rfc724_mid()
);
println!("{msg}");
receive_imf(t, msg.as_bytes(), false).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs_and_muted_chats() {
// receive various mails in 3 chats
let t = TestContext::new_alice().await;
let bob = t.create_chat_with_contact("", "bob@g.it").await;
let claire = t.create_chat_with_contact("", "claire@g.it").await;
let dave = t.create_chat_with_contact("", "dave@g.it").await;
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
receive_msg(&t, &bob).await;
assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1);
assert_eq!(bob.id.get_fresh_msg_cnt(&t).await.unwrap(), 1);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
receive_msg(&t, &claire).await;
receive_msg(&t, &claire).await;
assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 2);
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 3);
receive_msg(&t, &dave).await;
receive_msg(&t, &dave).await;
receive_msg(&t, &dave).await;
assert_eq!(get_chat_msgs(&t, dave.id).await.unwrap().len(), 3);
assert_eq!(dave.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6);
// mute one of the chats
set_muted(&t, claire.id, MuteDuration::Forever)
.await
.unwrap();
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 4); // muted claires messages are no longer counted
// receive more messages
receive_msg(&t, &bob).await;
receive_msg(&t, &claire).await;
receive_msg(&t, &dave).await;
assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 3);
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); // muted claire is not counted
// unmute claire again
set_muted(&t, claire.id, MuteDuration::NotMuted)
.await
.unwrap();
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 9); // claire is counted again
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs_and_muted_until() {
let t = TestContext::new_alice().await;
let bob = t.create_chat_with_contact("", "bob@g.it").await;
receive_msg(&t, &bob).await;
assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1);
// chat is unmuted by default, here and in the following assert(),
// we check mainly that the SQL-statements in is_muted() and get_fresh_msgs()
// have the same view to the database.
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
// test get_fresh_msgs() with mute_until in the future
set_muted(
&t,
bob.id,
MuteDuration::Until(SystemTime::now() + Duration::from_secs(3600)),
)
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
// to test get_fresh_msgs() with mute_until in the past,
// we need to modify the database directly
t.sql
.execute(
"UPDATE chats SET muted_until=? WHERE id=?;",
(time() - 3600, bob.id),
)
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
// test get_fresh_msgs() with "forever" mute_until
set_muted(&t, bob.id, MuteDuration::Forever).await.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
// to test get_fresh_msgs() with invalid mute_until (everything < -1),
// that results in "muted forever" by definition.
t.sql
.execute("UPDATE chats SET muted_until=-2 WHERE id=?;", (bob.id,))
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_muted_context() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
t.set_config(Config::IsMuted, Some("1")).await?;
let chat = t.create_chat_with_contact("", "bob@g.it").await;
receive_msg(&t, &chat).await;
// muted contexts should still show dimmed badge counters eg. in the sidebars,
// (same as muted chats show dimmed badge counters in the chatlist)
// therefore the fresh messages count should not be affected.
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_blobdir_exists() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
Context::new(&dbfile, 1, Events::new(), StockStrings::new())
.await
.unwrap();
let blobdir = tmp.path().join("db.sqlite-blobs");
assert!(blobdir.is_dir());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_wrong_blogdir() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = tmp.path().join("db.sqlite-blobs");
tokio::fs::write(&blobdir, b"123").await.unwrap();
let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await;
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sqlite_parent_not_exists() {
let tmp = tempfile::tempdir().unwrap();
let subdir = tmp.path().join("subdir");
let dbfile = subdir.join("db.sqlite");
let dbfile2 = dbfile.clone();
Context::new(&dbfile, 1, Events::new(), StockStrings::new())
.await
.unwrap();
assert!(subdir.is_dir());
assert!(dbfile2.is_file());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_empty_blobdir() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = PathBuf::new();
let res = Context::with_blobdir(
dbfile,
blobdir,
1,
Events::new(),
StockStrings::new(),
Default::default(),
);
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_blobdir_not_exists() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = tmp.path().join("blobs");
let res = Context::with_blobdir(
dbfile,
blobdir,
1,
Events::new(),
StockStrings::new(),
Default::default(),
);
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_crashes_on_context_deref() {
let t = TestContext::new().await;
std::mem::drop(t);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_info() {
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
assert!(info.contains_key("database_dir"));
}
#[test]
fn test_get_info_no_context() {
let info = get_info();
assert!(info.contains_key("deltachat_core_version"));
assert!(!info.contains_key("database_dir"));
assert_eq!(info.get("level").unwrap(), "awesome");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_info_completeness() {
// For easier debugging,
// get_info() shall return all important information configurable by the Config-values.
//
// There are exceptions for Config-values considered to be unimportant,
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"displayname",
"imap_certificate_checks",
"mail_server",
"mail_user",
"mail_pw",
"mail_port",
"mail_security",
"notify_about_wrong_pw",
"self_reporting_id",
"selfstatus",
"send_server",
"send_user",
"send_pw",
"send_port",
"send_security",
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",
"socks5_port",
"socks5_user",
"socks5_password",
"key_id",
"webxdc_integration",
"device_token",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
for key in Config::iter() {
let key: String = key.to_string();
if !skip_from_get_info.contains(&&*key)
&& !key.starts_with("configured")
&& !key.starts_with("sys.")
{
assert!(
info.contains_key(&*key),
"'{key}' missing in get_info() output"
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let self_talk = ChatId::create_for_contact(&alice, ContactId::SELF).await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
// Global search finds nothing.
let res = alice.search_msgs(None, "foo").await?;
assert!(res.is_empty());
// Search in chat with Bob finds nothing.
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert!(res.is_empty());
// Add messages to chat with Bob.
let mut msg1 = Message::new_text("foobar".to_string());
send_msg(&alice, chat.id, &mut msg1).await?;
let mut msg2 = Message::new_text("barbaz".to_string());
send_msg(&alice, chat.id, &mut msg2).await?;
alice.send_text(chat.id, "Δ-Chat").await;
// Global search with a part of text finds the message.
let res = alice.search_msgs(None, "ob").await?;
assert_eq!(res.len(), 1);
// Global search for "bar" matches both "foobar" and "barbaz".
let res = alice.search_msgs(None, "bar").await?;
assert_eq!(res.len(), 2);
// Message added later is returned first.
assert_eq!(res.first(), Some(&msg2.id));
assert_eq!(res.get(1), Some(&msg1.id));
// Search is case-insensitive.
for chat_id in [None, Some(chat.id)] {
let res = alice.search_msgs(chat_id, "δ-chat").await?;
assert_eq!(res.len(), 1);
}
// Global search with longer text does not find any message.
let res = alice.search_msgs(None, "foobarbaz").await?;
assert!(res.is_empty());
// Search for random string finds nothing.
let res = alice.search_msgs(None, "abc").await?;
assert!(res.is_empty());
// Search in chat with Bob finds the message.
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert_eq!(res.len(), 1);
// Search in Saved Messages does not find the message.
let res = alice.search_msgs(Some(self_talk), "foo").await?;
assert!(res.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_unaccepted_requests() -> Result<()> {
let t = TestContext::new_alice().await;
receive_imf(
&t,
b"From: BobBar <bob@example.org>\n\
To: alice@example.org\n\
Subject: foo\n\
Message-ID: <msg1234@example.org>\n\
Chat-Version: 1.0\n\
Date: Tue, 25 Oct 2022 13:37:00 +0000\n\
\n\
hello bob, foobar test!\n",
false,
)
.await?;
let chat_id = t.get_last_msg().await.get_chat_id();
let chat = Chat::load_from_db(&t, chat_id).await?;
assert_eq!(chat.get_type(), Chattype::Single);
assert!(chat.is_contact_request());
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1);
chat_id.block(&t).await?;
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 0);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
0
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 0);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 0);
let contact_ids = get_chat_contacts(&t, chat_id).await?;
Contact::unblock(&t, *contact_ids.first().unwrap()).await?;
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_limit_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
// Add 999 messages
let mut msg = Message::new_text("foobar".to_string());
for _ in 0..999 {
send_msg(&alice, chat.id, &mut msg).await?;
}
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 999);
// Add one more message, no limit yet
send_msg(&alice, chat.id, &mut msg).await?;
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 1000);
// Add one more message, that one is truncated then
send_msg(&alice, chat.id, &mut msg).await?;
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 1000);
// In-chat should not be not limited
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert_eq!(res.len(), 1001);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_check_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile.clone())
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
drop(context);
let context = ContextBuilder::new(dbfile)
.with_id(2)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.is_open().await, false);
assert_eq!(context.check_passphrase("bar".to_string()).await?, false);
assert_eq!(context.open("false".to_string()).await?, false);
assert_eq!(context.open("foo".to_string()).await?, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_context_change_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile)
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
context
.set_config(Config::Addr, Some("alice@example.org"))
.await?;
context
.change_passphrase("bar".to_string())
.await
.context("Failed to change passphrase")?;
assert_eq!(
context.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ongoing() -> Result<()> {
let context = TestContext::new().await;
// No ongoing process allocated.
assert!(context.shall_stop_ongoing().await);
let receiver = context.alloc_ongoing().await?;
// Cannot allocate another ongoing process while the first one is running.
assert!(context.alloc_ongoing().await.is_err());
// Stop signal is not sent yet.
assert!(receiver.try_recv().is_err());
assert!(!context.shall_stop_ongoing().await);
// Send the stop signal.
context.stop_ongoing().await;
// Receive stop signal.
receiver.recv().await?;
assert!(context.shall_stop_ongoing().await);
// Ongoing process is still running even though stop signal was received,
// so another one cannot be allocated.
assert!(context.alloc_ongoing().await.is_err());
context.free_ongoing().await;
// No ongoing process allocated, should have been stopped already.
assert!(context.shall_stop_ongoing().await);
// Another ongoing process can be allocated now.
let _receiver = context.alloc_ongoing().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_next_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
assert!(alice.get_next_msgs().await?.is_empty());
assert!(bob.get_next_msgs().await?.is_empty());
let sent_msg = alice.send_text(alice_chat.id, "Hi Bob").await;
let received_msg = bob.recv_msg(&sent_msg).await;
let bob_next_msg_ids = bob.get_next_msgs().await?;
assert_eq!(bob_next_msg_ids.len(), 1);
assert_eq!(bob_next_msg_ids.first(), Some(&received_msg.id));
bob.set_config_u32(Config::LastMsgId, received_msg.id.to_u32())
.await?;
assert!(bob.get_next_msgs().await?.is_empty());
// Next messages include self-sent messages.
let alice_next_msg_ids = alice.get_next_msgs().await?;
assert_eq!(alice_next_msg_ids.len(), 1);
assert_eq!(alice_next_msg_ids.first(), Some(&sent_msg.sender_msg_id));
alice
.set_config_u32(Config::LastMsgId, sent_msg.sender_msg_id.to_u32())
.await?;
assert!(alice.get_next_msgs().await?.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_draft_self_report() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = alice.draft_self_report().await?;
let msg = get_chat_msg(&alice, chat_id, 0, 1).await;
assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled);
let chat = Chat::load_from_db(&alice, chat_id).await?;
assert!(chat.is_protected());
let mut draft = chat_id.get_draft(&alice).await?.unwrap();
assert!(draft.text.starts_with("core_version"));
// Test that sending into the protected chat works:
let _sent = alice.send_msg(chat_id, &mut draft).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
let alice = TestContext::new_alice().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Change the config circumventing the cache
// This simulates what the notification plugin on iOS might do
// because it runs in a different process
alice
.sql
.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')",
(),
)
.await?;
// Alice's Delta Chat doesn't know about it yet:
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Starting IO will fail of course because no server settings are configured,
// but it should invalidate the caches:
alice.start_io().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("0".to_string())
);
Ok(())
}
}
mod context_tests;

View File

@@ -0,0 +1,649 @@
use anyhow::Context as _;
use strum::IntoEnumIterator;
use tempfile::tempdir;
use super::*;
use crate::chat::{get_chat_contacts, get_chat_msgs, send_msg, set_muted, Chat, MuteDuration};
use crate::chatlist::Chatlist;
use crate::constants::Chattype;
use crate::mimeparser::SystemMessage;
use crate::receive_imf::receive_imf;
use crate::test_utils::{get_chat_msg, TestContext};
use crate::tools::{create_outgoing_rfc724_mid, SystemTime};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_wrong_db() -> Result<()> {
let tmp = tempfile::tempdir()?;
let dbfile = tmp.path().join("db.sqlite");
tokio::fs::write(&dbfile, b"123").await?;
let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await?;
// Broken database is indistinguishable from encrypted one.
assert_eq!(res.is_open().await, false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs() {
let t = TestContext::new().await;
let fresh = t.get_fresh_msgs().await.unwrap();
assert!(fresh.is_empty())
}
async fn receive_msg(t: &TestContext, chat: &Chat) {
let members = get_chat_contacts(t, chat.id).await.unwrap();
let contact = Contact::get_by_id(t, *members.first().unwrap())
.await
.unwrap();
let msg = format!(
"From: {}\n\
To: alice@example.org\n\
Message-ID: <{}>\n\
Chat-Version: 1.0\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
contact.get_addr(),
create_outgoing_rfc724_mid()
);
println!("{msg}");
receive_imf(t, msg.as_bytes(), false).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs_and_muted_chats() {
// receive various mails in 3 chats
let t = TestContext::new_alice().await;
let bob = t.create_chat_with_contact("", "bob@g.it").await;
let claire = t.create_chat_with_contact("", "claire@g.it").await;
let dave = t.create_chat_with_contact("", "dave@g.it").await;
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
receive_msg(&t, &bob).await;
assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1);
assert_eq!(bob.id.get_fresh_msg_cnt(&t).await.unwrap(), 1);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
receive_msg(&t, &claire).await;
receive_msg(&t, &claire).await;
assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 2);
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 3);
receive_msg(&t, &dave).await;
receive_msg(&t, &dave).await;
receive_msg(&t, &dave).await;
assert_eq!(get_chat_msgs(&t, dave.id).await.unwrap().len(), 3);
assert_eq!(dave.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6);
// mute one of the chats
set_muted(&t, claire.id, MuteDuration::Forever)
.await
.unwrap();
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 4); // muted claires messages are no longer counted
// receive more messages
receive_msg(&t, &bob).await;
receive_msg(&t, &claire).await;
receive_msg(&t, &dave).await;
assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 3);
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); // muted claire is not counted
// unmute claire again
set_muted(&t, claire.id, MuteDuration::NotMuted)
.await
.unwrap();
assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3);
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 9); // claire is counted again
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_fresh_msgs_and_muted_until() {
let t = TestContext::new_alice().await;
let bob = t.create_chat_with_contact("", "bob@g.it").await;
receive_msg(&t, &bob).await;
assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1);
// chat is unmuted by default, here and in the following assert(),
// we check mainly that the SQL-statements in is_muted() and get_fresh_msgs()
// have the same view to the database.
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
// test get_fresh_msgs() with mute_until in the future
set_muted(
&t,
bob.id,
MuteDuration::Until(SystemTime::now() + Duration::from_secs(3600)),
)
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
// to test get_fresh_msgs() with mute_until in the past,
// we need to modify the database directly
t.sql
.execute(
"UPDATE chats SET muted_until=? WHERE id=?;",
(time() - 3600, bob.id),
)
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
// test get_fresh_msgs() with "forever" mute_until
set_muted(&t, bob.id, MuteDuration::Forever).await.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
// to test get_fresh_msgs() with invalid mute_until (everything < -1),
// that results in "muted forever" by definition.
t.sql
.execute("UPDATE chats SET muted_until=-2 WHERE id=?;", (bob.id,))
.await
.unwrap();
let bob = Chat::load_from_db(&t, bob.id).await.unwrap();
assert!(!bob.is_muted());
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_muted_context() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0);
t.set_config(Config::IsMuted, Some("1")).await?;
let chat = t.create_chat_with_contact("", "bob@g.it").await;
receive_msg(&t, &chat).await;
// muted contexts should still show dimmed badge counters eg. in the sidebars,
// (same as muted chats show dimmed badge counters in the chatlist)
// therefore the fresh messages count should not be affected.
assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_blobdir_exists() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
Context::new(&dbfile, 1, Events::new(), StockStrings::new())
.await
.unwrap();
let blobdir = tmp.path().join("db.sqlite-blobs");
assert!(blobdir.is_dir());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_wrong_blogdir() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = tmp.path().join("db.sqlite-blobs");
tokio::fs::write(&blobdir, b"123").await.unwrap();
let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await;
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sqlite_parent_not_exists() {
let tmp = tempfile::tempdir().unwrap();
let subdir = tmp.path().join("subdir");
let dbfile = subdir.join("db.sqlite");
let dbfile2 = dbfile.clone();
Context::new(&dbfile, 1, Events::new(), StockStrings::new())
.await
.unwrap();
assert!(subdir.is_dir());
assert!(dbfile2.is_file());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_empty_blobdir() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = PathBuf::new();
let res = Context::with_blobdir(
dbfile,
blobdir,
1,
Events::new(),
StockStrings::new(),
Default::default(),
);
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_with_blobdir_not_exists() {
let tmp = tempfile::tempdir().unwrap();
let dbfile = tmp.path().join("db.sqlite");
let blobdir = tmp.path().join("blobs");
let res = Context::with_blobdir(
dbfile,
blobdir,
1,
Events::new(),
StockStrings::new(),
Default::default(),
);
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_crashes_on_context_deref() {
let t = TestContext::new().await;
std::mem::drop(t);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_info() {
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
assert!(info.contains_key("database_dir"));
}
#[test]
fn test_get_info_no_context() {
let info = get_info();
assert!(info.contains_key("deltachat_core_version"));
assert!(!info.contains_key("database_dir"));
assert_eq!(info.get("level").unwrap(), "awesome");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_info_completeness() {
// For easier debugging,
// get_info() shall return all important information configurable by the Config-values.
//
// There are exceptions for Config-values considered to be unimportant,
// too sensitive or summarized in another item.
let skip_from_get_info = vec![
"addr",
"displayname",
"imap_certificate_checks",
"mail_server",
"mail_user",
"mail_pw",
"mail_port",
"mail_security",
"notify_about_wrong_pw",
"self_reporting_id",
"selfstatus",
"send_server",
"send_user",
"send_pw",
"send_port",
"send_security",
"server_flags",
"skip_start_messages",
"smtp_certificate_checks",
"proxy_url", // May contain passwords, don't leak it to the logs.
"socks5_enabled", // SOCKS5 options are deprecated.
"socks5_host",
"socks5_port",
"socks5_user",
"socks5_password",
"key_id",
"webxdc_integration",
"device_token",
"encrypted_device_token",
];
let t = TestContext::new().await;
let info = t.get_info().await.unwrap();
for key in Config::iter() {
let key: String = key.to_string();
if !skip_from_get_info.contains(&&*key)
&& !key.starts_with("configured")
&& !key.starts_with("sys.")
{
assert!(
info.contains_key(&*key),
"'{key}' missing in get_info() output"
);
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let self_talk = ChatId::create_for_contact(&alice, ContactId::SELF).await?;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
// Global search finds nothing.
let res = alice.search_msgs(None, "foo").await?;
assert!(res.is_empty());
// Search in chat with Bob finds nothing.
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert!(res.is_empty());
// Add messages to chat with Bob.
let mut msg1 = Message::new_text("foobar".to_string());
send_msg(&alice, chat.id, &mut msg1).await?;
let mut msg2 = Message::new_text("barbaz".to_string());
send_msg(&alice, chat.id, &mut msg2).await?;
alice.send_text(chat.id, "Δ-Chat").await;
// Global search with a part of text finds the message.
let res = alice.search_msgs(None, "ob").await?;
assert_eq!(res.len(), 1);
// Global search for "bar" matches both "foobar" and "barbaz".
let res = alice.search_msgs(None, "bar").await?;
assert_eq!(res.len(), 2);
// Message added later is returned first.
assert_eq!(res.first(), Some(&msg2.id));
assert_eq!(res.get(1), Some(&msg1.id));
// Search is case-insensitive.
for chat_id in [None, Some(chat.id)] {
let res = alice.search_msgs(chat_id, "δ-chat").await?;
assert_eq!(res.len(), 1);
}
// Global search with longer text does not find any message.
let res = alice.search_msgs(None, "foobarbaz").await?;
assert!(res.is_empty());
// Search for random string finds nothing.
let res = alice.search_msgs(None, "abc").await?;
assert!(res.is_empty());
// Search in chat with Bob finds the message.
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert_eq!(res.len(), 1);
// Search in Saved Messages does not find the message.
let res = alice.search_msgs(Some(self_talk), "foo").await?;
assert!(res.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_search_unaccepted_requests() -> Result<()> {
let t = TestContext::new_alice().await;
receive_imf(
&t,
b"From: BobBar <bob@example.org>\n\
To: alice@example.org\n\
Subject: foo\n\
Message-ID: <msg1234@example.org>\n\
Chat-Version: 1.0\n\
Date: Tue, 25 Oct 2022 13:37:00 +0000\n\
\n\
hello bob, foobar test!\n",
false,
)
.await?;
let chat_id = t.get_last_msg().await.get_chat_id();
let chat = Chat::load_from_db(&t, chat_id).await?;
assert_eq!(chat.get_type(), Chattype::Single);
assert!(chat.is_contact_request());
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1);
chat_id.block(&t).await?;
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 0);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
0
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 0);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 0);
let contact_ids = get_chat_contacts(&t, chat_id).await?;
Contact::unblock(&t, *contact_ids.first().unwrap()).await?;
assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1);
assert_eq!(
Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(),
1
);
assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1);
assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_limit_search_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
// Add 999 messages
let mut msg = Message::new_text("foobar".to_string());
for _ in 0..999 {
send_msg(&alice, chat.id, &mut msg).await?;
}
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 999);
// Add one more message, no limit yet
send_msg(&alice, chat.id, &mut msg).await?;
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 1000);
// Add one more message, that one is truncated then
send_msg(&alice, chat.id, &mut msg).await?;
let res = alice.search_msgs(None, "foo").await?;
assert_eq!(res.len(), 1000);
// In-chat should not be not limited
let res = alice.search_msgs(Some(chat.id), "foo").await?;
assert_eq!(res.len(), 1001);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_check_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile.clone())
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
drop(context);
let context = ContextBuilder::new(dbfile)
.with_id(2)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.is_open().await, false);
assert_eq!(context.check_passphrase("bar".to_string()).await?, false);
assert_eq!(context.open("false".to_string()).await?, false);
assert_eq!(context.open("foo".to_string()).await?, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_context_change_passphrase() -> Result<()> {
let dir = tempdir()?;
let dbfile = dir.path().join("db.sqlite");
let context = ContextBuilder::new(dbfile)
.with_id(1)
.build()
.await
.context("failed to create context")?;
assert_eq!(context.open("foo".to_string()).await?, true);
assert_eq!(context.is_open().await, true);
context
.set_config(Config::Addr, Some("alice@example.org"))
.await?;
context
.change_passphrase("bar".to_string())
.await
.context("Failed to change passphrase")?;
assert_eq!(
context.get_config(Config::Addr).await?.unwrap(),
"alice@example.org"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ongoing() -> Result<()> {
let context = TestContext::new().await;
// No ongoing process allocated.
assert!(context.shall_stop_ongoing().await);
let receiver = context.alloc_ongoing().await?;
// Cannot allocate another ongoing process while the first one is running.
assert!(context.alloc_ongoing().await.is_err());
// Stop signal is not sent yet.
assert!(receiver.try_recv().is_err());
assert!(!context.shall_stop_ongoing().await);
// Send the stop signal.
context.stop_ongoing().await;
// Receive stop signal.
receiver.recv().await?;
assert!(context.shall_stop_ongoing().await);
// Ongoing process is still running even though stop signal was received,
// so another one cannot be allocated.
assert!(context.alloc_ongoing().await.is_err());
context.free_ongoing().await;
// No ongoing process allocated, should have been stopped already.
assert!(context.shall_stop_ongoing().await);
// Another ongoing process can be allocated now.
let _receiver = context.alloc_ongoing().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_next_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
assert!(alice.get_next_msgs().await?.is_empty());
assert!(bob.get_next_msgs().await?.is_empty());
let sent_msg = alice.send_text(alice_chat.id, "Hi Bob").await;
let received_msg = bob.recv_msg(&sent_msg).await;
let bob_next_msg_ids = bob.get_next_msgs().await?;
assert_eq!(bob_next_msg_ids.len(), 1);
assert_eq!(bob_next_msg_ids.first(), Some(&received_msg.id));
bob.set_config_u32(Config::LastMsgId, received_msg.id.to_u32())
.await?;
assert!(bob.get_next_msgs().await?.is_empty());
// Next messages include self-sent messages.
let alice_next_msg_ids = alice.get_next_msgs().await?;
assert_eq!(alice_next_msg_ids.len(), 1);
assert_eq!(alice_next_msg_ids.first(), Some(&sent_msg.sender_msg_id));
alice
.set_config_u32(Config::LastMsgId, sent_msg.sender_msg_id.to_u32())
.await?;
assert!(alice.get_next_msgs().await?.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_draft_self_report() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = alice.draft_self_report().await?;
let msg = get_chat_msg(&alice, chat_id, 0, 1).await;
assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled);
let chat = Chat::load_from_db(&alice, chat_id).await?;
assert!(chat.is_protected());
let mut draft = chat_id.get_draft(&alice).await?.unwrap();
assert!(draft.text.starts_with("core_version"));
// Test that sending into the protected chat works:
let _sent = alice.send_msg(chat_id, &mut draft).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cache_is_cleared_when_io_is_started() -> Result<()> {
let alice = TestContext::new_alice().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Change the config circumventing the cache
// This simulates what the notification plugin on iOS might do
// because it runs in a different process
alice
.sql
.execute(
"INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')",
(),
)
.await?;
// Alice's Delta Chat doesn't know about it yet:
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("2".to_string())
);
// Starting IO will fail of course because no server settings are configured,
// but it should invalidate the caches:
alice.start_io().await;
assert_eq!(
alice.get_config(Config::ShowEmails).await?,
Some("0".to_string())
);
Ok(())
}

View File

@@ -100,7 +100,9 @@ pub async fn maybe_set_logging_xdc(
context,
msg.get_viewtype(),
chat_id,
msg.param.get_path(Param::File, context).unwrap_or_default(),
msg.param
.get_path(Param::Filename, context)
.unwrap_or_default(),
msg.get_id(),
)
.await?;
@@ -113,11 +115,11 @@ pub async fn maybe_set_logging_xdc_inner(
context: &Context,
viewtype: Viewtype,
chat_id: ChatId,
file: Option<PathBuf>,
filename: Option<PathBuf>,
msg_id: MsgId,
) -> anyhow::Result<()> {
if viewtype == Viewtype::Webxdc {
if let Some(file) = file {
if let Some(file) = filename {
if let Some(file_name) = file.file_name().and_then(|name| name.to_str()) {
if file_name.starts_with("debug_logging")
&& file_name.ends_with(".xdc")

View File

@@ -440,7 +440,7 @@ mod tests {
let file = alice.get_blobdir().join("minimal.xdc");
tokio::fs::write(&file, include_bytes!("../test-data/webxdc/minimal.xdc")).await?;
let mut instance = Message::new(Viewtype::File);
instance.set_file(file.to_str().unwrap(), None);
instance.set_file_and_deduplicate(&alice, &file, None, None)?;
let _sent1 = alice.send_msg(chat_id, &mut instance).await;
alice

View File

@@ -40,20 +40,17 @@ impl EncryptHelper {
/// Determines if we can and should encrypt.
///
/// For encryption to be enabled, `e2ee_guaranteed` should be true, or strictly more than a half
/// of peerstates should prefer encryption. Own preference is counted equally to peer
/// preferences, even if message copy is not sent to self.
///
/// `e2ee_guaranteed` should be set to true for replies to encrypted messages (as required by
/// Autocrypt Level 1, version 1.1) and for messages sent in protected groups.
///
/// Returns an error if `e2ee_guaranteed` is true, but one or more keys are missing.
pub fn should_encrypt(
pub(crate) async fn should_encrypt(
&self,
context: &Context,
e2ee_guaranteed: bool,
peerstates: &[(Option<Peerstate>, String)],
) -> Result<bool> {
let is_chatmail = context.is_chatmail().await?;
let mut prefer_encrypt_count = if self.prefer_encrypt == EncryptPreference::Mutual {
1
} else {
@@ -64,10 +61,15 @@ impl EncryptHelper {
Some(peerstate) => {
let prefer_encrypt = peerstate.prefer_encrypt;
info!(context, "Peerstate for {addr:?} is {prefer_encrypt}.");
match peerstate.prefer_encrypt {
EncryptPreference::NoPreference | EncryptPreference::Reset => {}
EncryptPreference::Mutual => prefer_encrypt_count += 1,
};
if match peerstate.prefer_encrypt {
EncryptPreference::NoPreference | EncryptPreference::Reset => {
(peerstate.prefer_encrypt != EncryptPreference::Reset || is_chatmail)
&& self.prefer_encrypt == EncryptPreference::Mutual
}
EncryptPreference::Mutual => true,
} {
prefer_encrypt_count += 1;
}
}
None => {
let msg = format!("Peerstate for {addr:?} missing, cannot encrypt");
@@ -170,9 +172,11 @@ pub async fn ensure_secret_key_exists(context: &Context) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::send_text_msg;
use crate::key::DcKey;
use crate::message::{Message, Viewtype};
use crate::param::Param;
use crate::receive_imf::receive_imf;
use crate::test_utils::{bob_keypair, TestContext, TestContextManager};
mod ensure_secret_key_exists {
@@ -320,29 +324,109 @@ Sent with my Delta Chat Messenger: https://delta.chat";
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_should_encrypt() {
async fn test_should_encrypt() -> Result<()> {
let t = TestContext::new_alice().await;
assert!(t.get_config_bool(Config::E2eeEnabled).await?);
let encrypt_helper = EncryptHelper::new(&t).await.unwrap();
// test with EncryptPreference::NoPreference:
// if e2ee_eguaranteed is unset, there is no encryption as not more than half of peers want encryption
let ps = new_peerstates(EncryptPreference::NoPreference);
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
// Own preference is `Mutual` and we have the peer's key.
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
// test with EncryptPreference::Reset
let ps = new_peerstates(EncryptPreference::Reset);
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
// test with EncryptPreference::Mutual (self is also Mutual)
let ps = new_peerstates(EncryptPreference::Mutual);
assert!(encrypt_helper.should_encrypt(&t, true, &ps).unwrap());
assert!(encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await?);
assert!(encrypt_helper.should_encrypt(&t, false, &ps).await?);
// test with missing peerstate
let ps = vec![(None, "bob@foo.bar".to_string())];
assert!(encrypt_helper.should_encrypt(&t, true, &ps).is_err());
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).unwrap());
assert!(encrypt_helper.should_encrypt(&t, true, &ps).await.is_err());
assert!(!encrypt_helper.should_encrypt(&t, false, &ps).await?);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_should_encrypt_e2ee_disabled() -> Result<()> {
let t = &TestContext::new_alice().await;
t.set_config_bool(Config::E2eeEnabled, false).await?;
let encrypt_helper = EncryptHelper::new(t).await.unwrap();
let ps = new_peerstates(EncryptPreference::NoPreference);
assert!(!encrypt_helper.should_encrypt(t, false, &ps).await?);
let ps = new_peerstates(EncryptPreference::Reset);
assert!(encrypt_helper.should_encrypt(t, true, &ps).await?);
let mut ps = new_peerstates(EncryptPreference::Mutual);
// Own preference is `NoPreference` and there's no majority with `Mutual`.
assert!(!encrypt_helper.should_encrypt(t, false, &ps).await?);
// Now the majority wants to encrypt. Let's encrypt, anyway there are other cases when we
// can't send unencrypted, e.g. protected groups.
ps.push(ps[0].clone());
assert!(encrypt_helper.should_encrypt(t, false, &ps).await?);
// Test with missing peerstate.
let ps = vec![(None, "bob@foo.bar".to_string())];
assert!(encrypt_helper.should_encrypt(t, true, &ps).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chatmail_prefers_to_encrypt() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
bob.set_config_bool(Config::IsChatmail, true).await?;
let bob_chat_id = tcm
.send_recv_accept(alice, bob, "Hello from DC")
.await
.chat_id;
receive_imf(
bob,
b"From: alice@example.org\n\
To: bob@example.net\n\
Message-ID: <2222@example.org>\n\
Date: Sun, 22 Mar 3000 22:37:58 +0000\n\
\n\
Hello from another MUA\n",
false,
)
.await?;
send_text_msg(bob, bob_chat_id, "hi".to_string()).await?;
let sent_msg = bob.pop_sent_msg().await;
let msg = Message::load_from_db(bob, sent_msg.sender_msg_id).await?;
assert!(msg.get_showpadlock());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_chatmail_can_send_unencrypted() -> Result<()> {
let mut tcm = TestContextManager::new();
let bob = &tcm.bob().await;
bob.set_config_bool(Config::IsChatmail, true).await?;
let bob_chat_id = receive_imf(
bob,
b"From: alice@example.org\n\
To: bob@example.net\n\
Message-ID: <2222@example.org>\n\
Date: Sun, 22 Mar 3000 22:37:58 +0000\n\
\n\
Hello\n",
false,
)
.await?
.unwrap()
.chat_id;
bob_chat_id.accept(bob).await?;
send_text_msg(bob, bob_chat_id, "hi".to_string()).await?;
let sent_msg = bob.pop_sent_msg().await;
let msg = Message::load_from_db(bob, sent_msg.sender_msg_id).await?;
assert!(!msg.get_showpadlock());
Ok(())
}
}

View File

@@ -713,808 +713,4 @@ pub(crate) async fn start_ephemeral_timers(context: &Context) -> Result<()> {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::{marknoticed_chat, set_muted, ChatVisibility, MuteDuration};
use crate::config::Config;
use crate::constants::DC_CHAT_ID_ARCHIVED_LINK;
use crate::download::DownloadState;
use crate::location;
use crate::message::markseen_msgs;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
use crate::{
chat::{self, create_group_chat, send_text_msg, Chat, ChatItem, ProtectionStatus},
tools::IsNoneOrEmpty,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_ephemeral_messages() {
let context = TestContext::new().await;
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Disabled, ContactId::SELF).await,
"You disabled message deletion timer."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 1 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1 s."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 30 },
ContactId::SELF
)
.await,
"You set message deletion timer to 30 s."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 60 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1 minute."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 90 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1.5 minutes."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 30 * 60 },
ContactId::SELF
)
.await,
"You set message deletion timer to 30 minutes."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 60 * 60 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1 hour."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 5400 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1.5 hours."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 2 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 2 hours."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 1 day."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 2 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 2 days."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 7 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 1 week."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 4 * 7 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 4 weeks."
);
}
/// Test enabling and disabling ephemeral timer remotely.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_disable() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
Ok(())
}
/// Test that enabling ephemeral timer in unpromoted group does not send a message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_unpromoted() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id =
create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?;
// Group is unpromoted, the timer can be changed without sending a message.
assert!(chat_id.is_unpromoted(&alice).await?);
chat_id
.set_ephemeral_timer(&alice, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_none());
assert_eq!(
chat_id.get_ephemeral_timer(&alice).await?,
Timer::Enabled { duration: 60 }
);
// Promote the group.
send_text_msg(&alice, chat_id, "hi!".to_string()).await?;
assert!(chat_id.is_promoted(&alice).await?);
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_some());
chat_id
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_some());
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
Ok(())
}
/// Test that timer is enabled even if the message explicitly enabling the timer is lost.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_lost() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
// Alice enables the timer.
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.await?;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Enabled { duration: 60 }
);
// The message enabling the timer is lost.
let _sent = alice.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled,
);
// Alice sends a text message.
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
// Bob receives text message and enables the timer, even though explicit timer update was
// lost previously.
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
Ok(())
}
/// Test that Alice replying to the chat without a timer at the same time as Bob enables the
/// timer does not result in disabling the timer on the Bob's side.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_rollback() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
// Alice sends message to Bob
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
// Alice sends second message to Bob, with no timer
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
// Bob sets ephemeral timer and sends a message about timer change
chat_bob
.set_ephemeral_timer(&bob.ctx, Timer::Enabled { duration: 60 })
.await?;
let sent_timer_change = bob.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Bob receives message from Alice.
// Alice message has no timer. However, Bob should not disable timer,
// because Alice replies to old message.
bob.recv_msg(&sent).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Disabled
);
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Alice receives message from Bob
alice.recv_msg(&sent_timer_change).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Bob disables the chat timer.
// Note that the last message in the Bob's chat is from Alice and has no timer,
// but the chat timer is enabled.
chat_bob
.set_ephemeral_timer(&bob.ctx, Timer::Disabled)
.await?;
alice.recv_msg(&bob.pop_sent_msg().await).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Disabled
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_delete_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
let self_chat = t.get_self_chat().await;
assert_eq!(next_expiration_timestamp(&t).await, None);
t.send_text(self_chat.id, "Saved message, which we delete manually")
.await;
let msg = t.get_last_msg_in(self_chat.id).await;
msg.id.trash(&t, false).await?;
check_msg_is_deleted(&t, &self_chat, msg.id).await;
self_chat
.id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 3600 })
.await
.unwrap();
// Send a saved message which will be deleted after 3600s
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601)
.await
.unwrap();
// Set DeleteDeviceAfter to 1800s. Then send a saved message which will
// still be deleted after 3600s because DeleteDeviceAfter doesn't apply to saved messages.
t.set_config(Config::DeleteDeviceAfter, Some("1800"))
.await?;
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601)
.await
.unwrap();
// Send a message to Bob which will be deleted after 1800s because of DeleteDeviceAfter.
let bob_chat = t.create_chat_with_contact("", "bob@example.net").await;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(
&t,
msg.sender_msg_id,
&bob_chat,
now + 1799,
// The message may appear to be sent MAX_SECONDS_TO_LEND_FROM_FUTURE later and
// therefore be deleted MAX_SECONDS_TO_LEND_FROM_FUTURE later.
time() + 1801 + MAX_SECONDS_TO_LEND_FROM_FUTURE,
)
.await
.unwrap();
// Enable ephemeral messages with Bob -> message will be deleted after 60s.
// This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time).
bob_chat
.id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
.await?;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61)
.await
.unwrap();
Ok(())
}
async fn check_msg_will_be_deleted(
t: &TestContext,
msg_id: MsgId,
chat: &Chat,
not_deleted_at: i64,
deleted_at: i64,
) -> Result<()> {
let next_expiration = next_expiration_timestamp(t).await.unwrap();
assert!(next_expiration > not_deleted_at);
delete_expired_messages(t, not_deleted_at).await?;
let loaded = Message::load_from_db(t, msg_id).await?;
assert!(!loaded.text.is_empty());
assert_eq!(loaded.chat_id, chat.id);
assert!(next_expiration < deleted_at);
delete_expired_messages(t, deleted_at).await?;
t.evtracker
.get_matching(|evt| {
if let EventType::MsgDeleted {
msg_id: event_msg_id,
..
} = evt
{
*event_msg_id == msg_id
} else {
false
}
})
.await;
let loaded = Message::load_from_db_optional(t, msg_id).await?;
assert!(loaded.is_none());
// Check that the msg was deleted locally.
check_msg_is_deleted(t, chat, msg_id).await;
Ok(())
}
async fn check_msg_is_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) {
let chat_items = chat::get_chat_msgs(t, chat.id).await.unwrap();
// Check that the chat is empty except for possibly info messages:
for item in &chat_items {
if let ChatItem::Message { msg_id } = item {
let msg = Message::load_from_db(t, *msg_id).await.unwrap();
assert!(msg.is_info())
}
}
// Check that if there is a message left, the text and metadata are gone
if let Ok(msg) = Message::load_from_db(t, msg_id).await {
assert_eq!(msg.from_id, ContactId::UNDEFINED);
assert_eq!(msg.to_id, ContactId::UNDEFINED);
assert_eq!(msg.text, "");
let rawtxt: Option<String> = t
.sql
.query_get_value("SELECT txt_raw FROM msgs WHERE id=?;", (msg_id,))
.await
.unwrap();
assert!(rawtxt.is_none_or_empty(), "{rawtxt:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_expired_imap_messages() -> Result<()> {
let t = TestContext::new_alice().await;
const HOUR: i64 = 60 * 60;
let now = time();
for (id, timestamp, ephemeral_timestamp) in &[
(900, now - 2 * HOUR, 0),
(1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0),
(1010, now - 23 * HOUR, 0),
(1020, now - 21 * HOUR, 0),
(1030, now - 19 * HOUR, 0),
(2000, now - 18 * HOUR, now - HOUR),
(2020, now - 17 * HOUR, now + HOUR),
(3000, now + HOUR, 0),
] {
let message_id = id.to_string();
t.sql
.execute(
"INSERT INTO msgs (id, rfc724_mid, timestamp, ephemeral_timestamp) VALUES (?,?,?,?);",
(id, &message_id, timestamp, ephemeral_timestamp),
)
.await?;
t.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');",
(&message_id, id),
)
.await?;
}
async fn test_marked_for_deletion(context: &Context, id: u32) -> Result<()> {
assert_eq!(
context
.sql
.count(
"SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?",
(id.to_string(),),
)
.await?,
1
);
Ok(())
}
async fn remove_uid(context: &Context, id: u32) -> Result<()> {
context
.sql
.execute("DELETE FROM imap WHERE rfc724_mid=?", (id.to_string(),))
.await?;
Ok(())
}
// This should mark message 2000 for deletion.
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 2000).await?;
remove_uid(&t, 2000).await?;
// No other messages are marked for deletion.
assert_eq!(
t.sql
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
t.set_config(Config::DeleteServerAfter, Some(&*(25 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?;
MsgId::new(1000)
.update_download_state(&t, DownloadState::Available)
.await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1000'", ())
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?; // Delete downloadable anyway.
remove_uid(&t, 1000).await?;
t.set_config(Config::DeleteServerAfter, Some(&*(22 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1010).await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1010'", ())
.await?;
MsgId::new(1010)
.update_download_state(&t, DownloadState::Available)
.await?;
delete_expired_imap_messages(&t).await?;
// Keep downloadable for now.
assert_eq!(
t.sql
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
t.set_config(Config::DeleteServerAfter, Some("1")).await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 3000).await?;
Ok(())
}
// Regression test for a bug in the timer rollback protection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_references() -> Result<()> {
let alice = TestContext::new_alice().await;
// Message with Message-ID <first@example.com> and no timer is received.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <first@example.com>\n\
Date: Sun, 22 Mar 2020 00:10:00 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
// Message with Message-ID <second@example.com> is received.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <second@example.com>\n\
Date: Sun, 22 Mar 2020 00:11:00 +0000\n\
Ephemeral-Timer: 60\n\
\n\
second message\n",
false,
)
.await?;
assert_eq!(
chat_id.get_ephemeral_timer(&alice).await?,
Timer::Enabled { duration: 60 }
);
let msg = alice.get_last_msg().await;
// Message is deleted when its timer expires.
msg.id.trash(&alice, false).await?;
// Message with Message-ID <third@example.com>, referencing <first@example.com> and
// <second@example.com>, is received. The message <second@example.come> is not in the
// database anymore, so the timer should be applied unconditionally without rollback
// protection.
//
// Previously Delta Chat fallen back to using <first@example.com> in this case and
// compared received timer value to the timer value of the <first@example.com>. Because
// their timer values are the same ("disabled"), Delta Chat assumed that the timer was not
// changed explicitly and the change should be ignored.
//
// The message also contains a quote of the first message to test that only References:
// header and not In-Reply-To: is consulted by the rollback protection.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <third@example.com>\n\
Date: Sun, 22 Mar 2020 00:12:00 +0000\n\
References: <first@example.com> <second@example.com>\n\
In-Reply-To: <first@example.com>\n\
\n\
> hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(
msg.chat_id.get_ephemeral_timer(&alice).await?,
Timer::Disabled
);
Ok(())
}
// Tests that if we are offline for a time longer than the ephemeral timer duration, the message
// is deleted from the chat but is still in the "smtp" table, i.e. will be sent upon a
// successful reconnection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_msg_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let duration = 60;
chat.id
.set_ephemeral_timer(&alice, Timer::Enabled { duration })
.await?;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
let now = time();
check_msg_will_be_deleted(&alice, msg.id, &chat, now, now + i64::from(duration) + 1)
.await?;
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}
/// Tests that POI location is deleted when ephemeral message expires.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_poi_location() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
let mut poi_msg = Message::new_text("Here".to_string());
poi_msg.set_location(10.0, 20.0);
let alice_sent_message = alice.send_msg(chat.id, &mut poi_msg).await;
let bob_received_message = bob.recv_msg(&alice_sent_message).await;
markseen_msgs(bob, vec![bob_received_message.id]).await?;
for account in [alice, bob] {
let locations = location::get_range(account, None, None, 0, 0).await?;
assert_eq!(locations.len(), 1);
}
SystemTime::shift(Duration::from_secs(100));
for account in [alice, bob] {
delete_expired_messages(account, time()).await?;
let locations = location::get_range(account, None, None, 0, 0).await?;
assert_eq!(locations.len(), 0);
}
Ok(())
}
/// Tests that `.get_ephemeral_timer()` returns an error for invalid chat ID.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_ephemeral_timer_wrong_chat_id() -> Result<()> {
let context = TestContext::new().await;
let chat_id = ChatId::new(12345);
assert!(chat_id.get_ephemeral_timer(&context).await.is_err());
Ok(())
}
/// Tests that ephemeral timer is started when the chat is noticed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_noticed_ephemeral_timer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await;
marknoticed_chat(bob, bob_received_message.chat_id).await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(Message::load_from_db_optional(bob, bob_received_message.id)
.await?
.is_none());
Ok(())
}
/// Tests that archiving the chat starts ephemeral timer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archived_ephemeral_timer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await;
bob_received_message
.chat_id
.set_visibility(bob, ChatVisibility::Archived)
.await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(Message::load_from_db_optional(bob, bob_received_message.id)
.await?
.is_none());
// Bob mutes the chat so it is not unarchived.
set_muted(bob, bob_received_message.chat_id, MuteDuration::Forever).await?;
// Now test that for already archived chat
// timer is started if all archived chats are marked as noticed.
let bob_received_message_2 = tcm.send_recv(alice, bob, "Hello again!").await;
assert_eq!(bob_received_message_2.state, MessageState::InFresh);
marknoticed_chat(bob, DC_CHAT_ID_ARCHIVED_LINK).await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(
Message::load_from_db_optional(bob, bob_received_message_2.id)
.await?
.is_none()
);
Ok(())
}
}
mod ephemeral_tests;

View File

@@ -0,0 +1,781 @@
use super::*;
use crate::chat::{marknoticed_chat, set_muted, ChatVisibility, MuteDuration};
use crate::config::Config;
use crate::constants::DC_CHAT_ID_ARCHIVED_LINK;
use crate::download::DownloadState;
use crate::location;
use crate::message::markseen_msgs;
use crate::receive_imf::receive_imf;
use crate::test_utils::{TestContext, TestContextManager};
use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE;
use crate::{
chat::{self, create_group_chat, send_text_msg, Chat, ChatItem, ProtectionStatus},
tools::IsNoneOrEmpty,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_ephemeral_messages() {
let context = TestContext::new().await;
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Disabled, ContactId::SELF).await,
"You disabled message deletion timer."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 1 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 30 }, ContactId::SELF)
.await,
"You set message deletion timer to 30 s."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF)
.await,
"You set message deletion timer to 1 minute."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF)
.await,
"You set message deletion timer to 1.5 minutes."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 30 * 60 },
ContactId::SELF
)
.await,
"You set message deletion timer to 30 minutes."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled { duration: 60 * 60 },
ContactId::SELF
)
.await,
"You set message deletion timer to 1 hour."
);
assert_eq!(
stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 5400 }, ContactId::SELF)
.await,
"You set message deletion timer to 1.5 hours."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 2 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 2 hours."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 1 day."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 2 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 2 days."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 7 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 1 week."
);
assert_eq!(
stock_ephemeral_timer_changed(
&context,
Timer::Enabled {
duration: 4 * 7 * 24 * 60 * 60
},
ContactId::SELF
)
.await,
"You set message deletion timer to 4 weeks."
);
}
/// Test enabling and disabling ephemeral timer remotely.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_disable() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
Ok(())
}
/// Test that enabling ephemeral timer in unpromoted group does not send a message.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_unpromoted() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?;
// Group is unpromoted, the timer can be changed without sending a message.
assert!(chat_id.is_unpromoted(&alice).await?);
chat_id
.set_ephemeral_timer(&alice, Timer::Enabled { duration: 60 })
.await?;
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_none());
assert_eq!(
chat_id.get_ephemeral_timer(&alice).await?,
Timer::Enabled { duration: 60 }
);
// Promote the group.
send_text_msg(&alice, chat_id, "hi!".to_string()).await?;
assert!(chat_id.is_promoted(&alice).await?);
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_some());
chat_id
.set_ephemeral_timer(&alice.ctx, Timer::Disabled)
.await?;
let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await;
assert!(sent.is_some());
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
Ok(())
}
/// Test that timer is enabled even if the message explicitly enabling the timer is lost.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_enable_lost() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
// Alice enables the timer.
chat_alice
.set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 })
.await?;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Enabled { duration: 60 }
);
// The message enabling the timer is lost.
let _sent = alice.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled,
);
// Alice sends a text message.
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
// Bob receives text message and enables the timer, even though explicit timer update was
// lost previously.
bob.recv_msg(&sent).await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
Ok(())
}
/// Test that Alice replying to the chat without a timer at the same time as Bob enables the
/// timer does not result in disabling the timer on the Bob's side.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_rollback() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_alice = alice.create_chat(&bob).await.id;
let chat_bob = bob.create_chat(&alice).await.id;
// Alice sends message to Bob
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
// Alice sends second message to Bob, with no timer
let mut msg = Message::new(Viewtype::Text);
chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?;
let sent = alice.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Disabled
);
// Bob sets ephemeral timer and sends a message about timer change
chat_bob
.set_ephemeral_timer(&bob.ctx, Timer::Enabled { duration: 60 })
.await?;
let sent_timer_change = bob.pop_sent_msg().await;
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Bob receives message from Alice.
// Alice message has no timer. However, Bob should not disable timer,
// because Alice replies to old message.
bob.recv_msg(&sent).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Disabled
);
assert_eq!(
chat_bob.get_ephemeral_timer(&bob.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Alice receives message from Bob
alice.recv_msg(&sent_timer_change).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Enabled { duration: 60 }
);
// Bob disables the chat timer.
// Note that the last message in the Bob's chat is from Alice and has no timer,
// but the chat timer is enabled.
chat_bob
.set_ephemeral_timer(&bob.ctx, Timer::Disabled)
.await?;
alice.recv_msg(&bob.pop_sent_msg().await).await;
assert_eq!(
chat_alice.get_ephemeral_timer(&alice.ctx).await?,
Timer::Disabled
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_delete_msgs() -> Result<()> {
let t = TestContext::new_alice().await;
let self_chat = t.get_self_chat().await;
assert_eq!(next_expiration_timestamp(&t).await, None);
t.send_text(self_chat.id, "Saved message, which we delete manually")
.await;
let msg = t.get_last_msg_in(self_chat.id).await;
msg.id.trash(&t, false).await?;
check_msg_is_deleted(&t, &self_chat, msg.id).await;
self_chat
.id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 3600 })
.await
.unwrap();
// Send a saved message which will be deleted after 3600s
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601)
.await
.unwrap();
// Set DeleteDeviceAfter to 1800s. Then send a saved message which will
// still be deleted after 3600s because DeleteDeviceAfter doesn't apply to saved messages.
t.set_config(Config::DeleteDeviceAfter, Some("1800"))
.await?;
let now = time();
let msg = t.send_text(self_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601)
.await
.unwrap();
// Send a message to Bob which will be deleted after 1800s because of DeleteDeviceAfter.
let bob_chat = t.create_chat_with_contact("", "bob@example.net").await;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(
&t,
msg.sender_msg_id,
&bob_chat,
now + 1799,
// The message may appear to be sent MAX_SECONDS_TO_LEND_FROM_FUTURE later and
// therefore be deleted MAX_SECONDS_TO_LEND_FROM_FUTURE later.
time() + 1801 + MAX_SECONDS_TO_LEND_FROM_FUTURE,
)
.await
.unwrap();
// Enable ephemeral messages with Bob -> message will be deleted after 60s.
// This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time).
bob_chat
.id
.set_ephemeral_timer(&t, Timer::Enabled { duration: 60 })
.await?;
let now = time();
let msg = t.send_text(bob_chat.id, "Message text").await;
check_msg_will_be_deleted(&t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61)
.await
.unwrap();
Ok(())
}
async fn check_msg_will_be_deleted(
t: &TestContext,
msg_id: MsgId,
chat: &Chat,
not_deleted_at: i64,
deleted_at: i64,
) -> Result<()> {
let next_expiration = next_expiration_timestamp(t).await.unwrap();
assert!(next_expiration > not_deleted_at);
delete_expired_messages(t, not_deleted_at).await?;
let loaded = Message::load_from_db(t, msg_id).await?;
assert!(!loaded.text.is_empty());
assert_eq!(loaded.chat_id, chat.id);
assert!(next_expiration < deleted_at);
delete_expired_messages(t, deleted_at).await?;
t.evtracker
.get_matching(|evt| {
if let EventType::MsgDeleted {
msg_id: event_msg_id,
..
} = evt
{
*event_msg_id == msg_id
} else {
false
}
})
.await;
let loaded = Message::load_from_db_optional(t, msg_id).await?;
assert!(loaded.is_none());
// Check that the msg was deleted locally.
check_msg_is_deleted(t, chat, msg_id).await;
Ok(())
}
async fn check_msg_is_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) {
let chat_items = chat::get_chat_msgs(t, chat.id).await.unwrap();
// Check that the chat is empty except for possibly info messages:
for item in &chat_items {
if let ChatItem::Message { msg_id } = item {
let msg = Message::load_from_db(t, *msg_id).await.unwrap();
assert!(msg.is_info())
}
}
// Check that if there is a message left, the text and metadata are gone
if let Ok(msg) = Message::load_from_db(t, msg_id).await {
assert_eq!(msg.from_id, ContactId::UNDEFINED);
assert_eq!(msg.to_id, ContactId::UNDEFINED);
assert_eq!(msg.text, "");
let rawtxt: Option<String> = t
.sql
.query_get_value("SELECT txt_raw FROM msgs WHERE id=?;", (msg_id,))
.await
.unwrap();
assert!(rawtxt.is_none_or_empty(), "{rawtxt:?}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_expired_imap_messages() -> Result<()> {
let t = TestContext::new_alice().await;
const HOUR: i64 = 60 * 60;
let now = time();
for (id, timestamp, ephemeral_timestamp) in &[
(900, now - 2 * HOUR, 0),
(1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0),
(1010, now - 23 * HOUR, 0),
(1020, now - 21 * HOUR, 0),
(1030, now - 19 * HOUR, 0),
(2000, now - 18 * HOUR, now - HOUR),
(2020, now - 17 * HOUR, now + HOUR),
(3000, now + HOUR, 0),
] {
let message_id = id.to_string();
t.sql
.execute(
"INSERT INTO msgs (id, rfc724_mid, timestamp, ephemeral_timestamp) VALUES (?,?,?,?);",
(id, &message_id, timestamp, ephemeral_timestamp),
)
.await?;
t.sql
.execute(
"INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');",
(&message_id, id),
)
.await?;
}
async fn test_marked_for_deletion(context: &Context, id: u32) -> Result<()> {
assert_eq!(
context
.sql
.count(
"SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?",
(id.to_string(),),
)
.await?,
1
);
Ok(())
}
async fn remove_uid(context: &Context, id: u32) -> Result<()> {
context
.sql
.execute("DELETE FROM imap WHERE rfc724_mid=?", (id.to_string(),))
.await?;
Ok(())
}
// This should mark message 2000 for deletion.
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 2000).await?;
remove_uid(&t, 2000).await?;
// No other messages are marked for deletion.
assert_eq!(
t.sql
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
t.set_config(Config::DeleteServerAfter, Some(&*(25 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?;
MsgId::new(1000)
.update_download_state(&t, DownloadState::Available)
.await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1000'", ())
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1000).await?; // Delete downloadable anyway.
remove_uid(&t, 1000).await?;
t.set_config(Config::DeleteServerAfter, Some(&*(22 * HOUR).to_string()))
.await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 1010).await?;
t.sql
.execute("UPDATE imap SET target=folder WHERE rfc724_mid='1010'", ())
.await?;
MsgId::new(1010)
.update_download_state(&t, DownloadState::Available)
.await?;
delete_expired_imap_messages(&t).await?;
// Keep downloadable for now.
assert_eq!(
t.sql
.count("SELECT COUNT(*) FROM imap WHERE target=''", ())
.await?,
0
);
t.set_config(Config::DeleteServerAfter, Some("1")).await?;
delete_expired_imap_messages(&t).await?;
test_marked_for_deletion(&t, 3000).await?;
Ok(())
}
// Regression test for a bug in the timer rollback protection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_timer_references() -> Result<()> {
let alice = TestContext::new_alice().await;
// Message with Message-ID <first@example.com> and no timer is received.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <first@example.com>\n\
Date: Sun, 22 Mar 2020 00:10:00 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
let chat_id = msg.chat_id;
assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled);
// Message with Message-ID <second@example.com> is received.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <second@example.com>\n\
Date: Sun, 22 Mar 2020 00:11:00 +0000\n\
Ephemeral-Timer: 60\n\
\n\
second message\n",
false,
)
.await?;
assert_eq!(
chat_id.get_ephemeral_timer(&alice).await?,
Timer::Enabled { duration: 60 }
);
let msg = alice.get_last_msg().await;
// Message is deleted when its timer expires.
msg.id.trash(&alice, false).await?;
// Message with Message-ID <third@example.com>, referencing <first@example.com> and
// <second@example.com>, is received. The message <second@example.come> is not in the
// database anymore, so the timer should be applied unconditionally without rollback
// protection.
//
// Previously Delta Chat fallen back to using <first@example.com> in this case and
// compared received timer value to the timer value of the <first@example.com>. Because
// their timer values are the same ("disabled"), Delta Chat assumed that the timer was not
// changed explicitly and the change should be ignored.
//
// The message also contains a quote of the first message to test that only References:
// header and not In-Reply-To: is consulted by the rollback protection.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: Alice <alice@example.org>\n\
Chat-Version: 1.0\n\
Subject: Subject\n\
Message-ID: <third@example.com>\n\
Date: Sun, 22 Mar 2020 00:12:00 +0000\n\
References: <first@example.com> <second@example.com>\n\
In-Reply-To: <first@example.com>\n\
\n\
> hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(
msg.chat_id.get_ephemeral_timer(&alice).await?,
Timer::Disabled
);
Ok(())
}
// Tests that if we are offline for a time longer than the ephemeral timer duration, the message
// is deleted from the chat but is still in the "smtp" table, i.e. will be sent upon a
// successful reconnection.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_msg_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let duration = 60;
chat.id
.set_ephemeral_timer(&alice, Timer::Enabled { duration })
.await?;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
let now = time();
check_msg_will_be_deleted(&alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?;
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}
/// Tests that POI location is deleted when ephemeral message expires.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_ephemeral_poi_location() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let sent = alice.pop_sent_msg().await;
bob.recv_msg(&sent).await;
let mut poi_msg = Message::new_text("Here".to_string());
poi_msg.set_location(10.0, 20.0);
let alice_sent_message = alice.send_msg(chat.id, &mut poi_msg).await;
let bob_received_message = bob.recv_msg(&alice_sent_message).await;
markseen_msgs(bob, vec![bob_received_message.id]).await?;
for account in [alice, bob] {
let locations = location::get_range(account, None, None, 0, 0).await?;
assert_eq!(locations.len(), 1);
}
SystemTime::shift(Duration::from_secs(100));
for account in [alice, bob] {
delete_expired_messages(account, time()).await?;
let locations = location::get_range(account, None, None, 0, 0).await?;
assert_eq!(locations.len(), 0);
}
Ok(())
}
/// Tests that `.get_ephemeral_timer()` returns an error for invalid chat ID.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_ephemeral_timer_wrong_chat_id() -> Result<()> {
let context = TestContext::new().await;
let chat_id = ChatId::new(12345);
assert!(chat_id.get_ephemeral_timer(&context).await.is_err());
Ok(())
}
/// Tests that ephemeral timer is started when the chat is noticed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_noticed_ephemeral_timer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await;
marknoticed_chat(bob, bob_received_message.chat_id).await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(Message::load_from_db_optional(bob, bob_received_message.id)
.await?
.is_none());
Ok(())
}
/// Tests that archiving the chat starts ephemeral timer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_archived_ephemeral_timer() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat = alice.create_chat(bob).await;
let duration = 60;
chat.id
.set_ephemeral_timer(alice, Timer::Enabled { duration })
.await?;
let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await;
bob_received_message
.chat_id
.set_visibility(bob, ChatVisibility::Archived)
.await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(Message::load_from_db_optional(bob, bob_received_message.id)
.await?
.is_none());
// Bob mutes the chat so it is not unarchived.
set_muted(bob, bob_received_message.chat_id, MuteDuration::Forever).await?;
// Now test that for already archived chat
// timer is started if all archived chats are marked as noticed.
let bob_received_message_2 = tcm.send_recv(alice, bob, "Hello again!").await;
assert_eq!(bob_received_message_2.state, MessageState::InFresh);
marknoticed_chat(bob, DC_CHAT_ID_ARCHIVED_LINK).await?;
SystemTime::shift(Duration::from_secs(100));
delete_expired_messages(bob, time()).await?;
assert!(
Message::load_from_db_optional(bob, bob_received_message_2.id)
.await?
.is_none()
);
Ok(())
}

View File

@@ -97,6 +97,9 @@ pub enum EventType {
/// Reactions for the message changed.
IncomingReaction {
/// ID of the chat which the message belongs to.
chat_id: ChatId,
/// ID of the contact whose reaction set is changed.
contact_id: ContactId,

View File

@@ -65,6 +65,15 @@ pub enum HeaderDef {
ChatGroupMemberAdded,
ChatContent,
/// Past members of the group.
ChatGroupPastMembers,
/// Space-separated timestamps of member addition
/// for members listed in the `To` field
/// followed by timestamps of member removal
/// for members listed in the `Chat-Group-Past-Members` field.
ChatGroupMemberTimestamps,
/// Duration of the attached media file.
ChatDuration,

View File

@@ -291,7 +291,7 @@ pub fn new_html_mimepart(html: String) -> PartBuilder {
mod tests {
use super::*;
use crate::chat;
use crate::chat::forward_msgs;
use crate::chat::{forward_msgs, save_msgs};
use crate::config::Config;
use crate::contact::ContactId;
use crate::message::{MessengerMessage, Viewtype};
@@ -371,7 +371,6 @@ and will be wrapped as usual.<br/>
mime-modified should not be set set as there is no html and no special stuff;<br/>
although not being a delta-message.<br/>
test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x27; :)<br/>
<br/>
</body></html>
"#
);
@@ -405,7 +404,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
r##"<html>
<p>mime-modified <b>set</b>; simplify is always regarded as lossy.</p>
</html>
"##
);
}
@@ -422,7 +420,6 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
this is <b>html</b>
</p>
</html>
"##
);
}
@@ -499,6 +496,38 @@ test some special html-characters as &lt; &gt; and &amp; but also &quot; and &#x
assert!(html.contains("this is <b>html</b>"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_save_msg() -> Result<()> {
// Alice receives a non-delta html-message
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("", "sender@testrun.org")
.await;
let raw = include_bytes!("../test-data/message/text_alt_plain_html.eml");
receive_imf(&alice, raw, false).await?;
let msg = alice.get_last_msg_in(chat.get_id()).await;
// Alice saves the message
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[msg.id]).await?;
let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
assert_ne!(saved_msg.id, msg.id);
assert_eq!(
saved_msg.get_original_msg_id(&alice).await?.unwrap(),
msg.id
);
assert!(!saved_msg.is_forwarded()); // UI should not flag "saved messages" as "forwarded"
assert_ne!(saved_msg.get_from_id(), ContactId::SELF);
assert_eq!(saved_msg.get_from_id(), msg.get_from_id());
assert_eq!(saved_msg.is_dc_message, MessengerMessage::No);
assert!(saved_msg.get_text().contains("this is plain"));
assert!(saved_msg.has_html());
let html = saved_msg.get_id().get_html(&alice).await?.unwrap();
assert!(html.contains("this is <b>html</b>"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_html_forwarding_encrypted() {
// Alice receives a non-delta html-message

View File

@@ -1331,7 +1331,7 @@ impl Session {
/// Returns the last UID fetched successfully and the info about each downloaded message.
/// If the message is incorrect or there is a failure to write a message to the database,
/// it is skipped and the error is logged.
#[allow(clippy::too_many_arguments)]
#[expect(clippy::too_many_arguments)]
pub(crate) async fn fetch_many_msgs(
&mut self,
context: &Context,
@@ -1452,9 +1452,7 @@ impl Session {
let is_seen = fetch_response.flags().any(|flag| flag == Flag::Seen);
let rfc724_mid = if let Some(rfc724_mid) = uid_message_ids.get(&request_uid) {
rfc724_mid
} else {
let Some(rfc724_mid) = uid_message_ids.get(&request_uid) else {
error!(
context,
"No Message-ID corresponding to UID {} passed in uid_messsage_ids.",
@@ -1591,17 +1589,15 @@ impl Session {
};
if self.can_metadata() && self.can_push() {
let device_token_changed = context
.get_config(Config::DeviceToken)
.await?
.map_or(true, |config_token| device_token != config_token);
let old_encrypted_device_token =
context.get_config(Config::EncryptedDeviceToken).await?;
// Whether we need to update encrypted device token.
let device_token_changed = old_encrypted_device_token.is_none()
|| context.get_config(Config::DeviceToken).await?.as_ref() != Some(&device_token);
let new_encrypted_device_token;
if device_token_changed {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
let encrypted_device_token = encrypt_device_token(&device_token)
.context("Failed to encrypt device token")?;
@@ -1610,22 +1606,23 @@ impl Session {
// <https://www.rfc-editor.org/rfc/rfc7888>.
let encrypted_device_token_len = encrypted_device_token.len();
if encrypted_device_token_len <= 4096 {
self.run_command_and_check_ok(&format_setmetadata(
&folder,
&encrypted_device_token,
))
.await
.context("SETMETADATA command failed")?;
// Store device token saved on the server
// to prevent storing duplicate tokens.
// The server cannot deduplicate on its own
// because encryption gives a different
// result each time.
context
.set_config_internal(Config::DeviceToken, Some(&device_token))
.await?;
context
.set_config_internal(
Config::EncryptedDeviceToken,
Some(&encrypted_device_token),
)
.await?;
// Store device token saved on the server
// to prevent storing duplicate tokens.
// The server cannot deduplicate on its own
// because encryption gives a different
// result each time.
context
.set_config_internal(Config::DeviceToken, Some(&device_token))
.await?;
if encrypted_device_token_len <= 4096 {
new_encrypted_device_token = Some(encrypted_device_token);
} else {
// If Apple or Google (FCM) gives us a very large token,
// do not even try to give it to IMAP servers.
@@ -1637,9 +1634,29 @@ impl Session {
// of any length, but there is no reason for tokens
// to be that large even after OpenPGP encryption.
warn!(context, "Device token is too long for LITERAL-, ignoring.");
new_encrypted_device_token = None;
}
} else {
new_encrypted_device_token = old_encrypted_device_token;
}
// Store new encrypted device token on the server
// even if it is the same as the old one.
if let Some(encrypted_device_token) = new_encrypted_device_token {
let folder = context
.get_config(Config::ConfiguredInboxFolder)
.await?
.context("INBOX is not configured")?;
self.run_command_and_check_ok(&format_setmetadata(
&folder,
&encrypted_device_token,
))
.await
.context("SETMETADATA command failed")?;
context.push_subscribed.store(true, Ordering::Relaxed);
}
context.push_subscribed.store(true, Ordering::Relaxed);
} else if !context.push_subscriber.heartbeat_subscribed().await {
let context = context.clone();
// Subscribe for heartbeat notifications.
@@ -2624,343 +2641,4 @@ async fn add_all_recipients_as_contacts(
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::TestContext;
#[test]
fn test_get_folder_meaning_by_name() {
assert_eq!(get_folder_meaning_by_name("Gesendet"), FolderMeaning::Sent);
assert_eq!(get_folder_meaning_by_name("GESENDET"), FolderMeaning::Sent);
assert_eq!(get_folder_meaning_by_name("gesendet"), FolderMeaning::Sent);
assert_eq!(
get_folder_meaning_by_name("Messages envoyés"),
FolderMeaning::Sent
);
assert_eq!(
get_folder_meaning_by_name("mEsSaGes envoyÉs"),
FolderMeaning::Sent
);
assert_eq!(get_folder_meaning_by_name("xxx"), FolderMeaning::Unknown);
assert_eq!(get_folder_meaning_by_name("SPAM"), FolderMeaning::Spam);
assert_eq!(get_folder_meaning_by_name("Trash"), FolderMeaning::Trash);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_uid_next_validity() {
let t = TestContext::new_alice().await;
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6);
}
#[test]
fn test_build_sequence_sets() {
assert_eq!(build_sequence_sets(&[]).unwrap(), vec![]);
let cases = vec![
(vec![1], "1"),
(vec![3291], "3291"),
(vec![1, 3, 5, 7, 9, 11], "1,3,5,7,9,11"),
(vec![1, 2, 3], "1:3"),
(vec![1, 4, 5, 6], "1,4:6"),
((1..=500).collect(), "1:500"),
(vec![3, 4, 8, 9, 10, 11, 39, 50, 2], "3:4,8:11,39,50,2"),
];
for (input, s) in cases {
assert_eq!(
build_sequence_sets(&input).unwrap(),
vec![(input, s.into())]
);
}
let has_number = |(uids, s): &(Vec<u32>, String), number| {
uids.iter().any(|&n| n == number)
&& s.split(',').any(|n| n.parse::<u32>().unwrap() == number)
};
let numbers: Vec<_> = (2..=500).step_by(2).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
assert!(result.len() == 1); // these UIDs fit in one set
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
let numbers: Vec<_> = (1..=1000).step_by(3).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
let (last_uids, last_str) = result.last().unwrap();
assert_eq!(
last_uids.get((last_uids.len() - 2)..).unwrap(),
&[997, 1000]
);
assert!(last_str.ends_with("997,1000"));
assert!(result.len() == 2); // This time we need 2 sets
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
let numbers: Vec<_> = (30000000..=30002500).step_by(4).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
assert_eq!(result.len(), 6);
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
}
#[allow(clippy::too_many_arguments)]
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!("Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}");
let t = TestContext::new_alice().await;
t.ctx
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
.await?;
t.ctx
.set_config(Config::ConfiguredSentboxFolder, Some("Sent"))
.await?;
t.ctx
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
.await?;
if accepted_chat {
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
ChatId::create_for_contact(&t.ctx, contact_id).await?;
}
let temp;
let bytes = if setupmessage {
include_bytes!("../test-data/message/AutocryptSetupMessage.eml")
} else {
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
Subject: foo\n\
Message-ID: <abc@example.com>\n\
{}\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
if outgoing {
"From: alice@example.org\nTo: bob@example.net\n"
} else {
"From: bob@example.net\nTo: alice@example.org\n"
},
if chat_msg { "Chat-Version: 1.0\n" } else { "" },
);
temp.as_bytes()
};
let (headers, _) = mailparse::parse_headers(bytes)?;
let actual = if let Some(config) =
target_folder_cfg(&t, folder, get_folder_meaning_by_name(folder), &headers).await?
{
t.get_config(config).await?
} else {
None
};
let expected = if expected_destination == folder {
None
} else {
Some(expected_destination)
};
assert_eq!(expected, actual.as_deref(), "For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}");
Ok(())
}
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
];
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),
("Spam", true, true, "DeltaChat"),
];
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
false,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
false,
false,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
true,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_imap_search_command() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"FROM "alice@example.org""#
);
t.ctx.set_primary_self_addr("alice@another.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
);
t.ctx.set_primary_self_addr("alice@third.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"#
);
Ok(())
}
#[test]
fn test_uid_grouper() {
// Input: sequence of (rowid: i64, uid: u32, target: String)
// Output: sequence of (target: String, rowid_set: Vec<i64>, uid_set: String)
let grouper = UidGrouper::from([(1, 2, "INBOX".to_string())]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(res, vec![("INBOX".to_string(), vec![1], "2".to_string())]);
let grouper = UidGrouper::from([(1, 2, "INBOX".to_string()), (2, 3, "INBOX".to_string())]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(
res,
vec![("INBOX".to_string(), vec![1, 2], "2:3".to_string())]
);
let grouper = UidGrouper::from([
(1, 2, "INBOX".to_string()),
(2, 2, "INBOX".to_string()),
(3, 3, "INBOX".to_string()),
]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(
res,
vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())]
);
}
#[test]
fn test_setmetadata_device_token() {
assert_eq!(
format_setmetadata("INBOX", "foobarbaz"),
"SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)"
);
assert_eq!(
format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"),
"SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)"
);
}
}
mod imap_tests;

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use anyhow::{bail, Context as _, Result};
use anyhow::{Context as _, Result};
use async_channel::Receiver;
use async_imap::extensions::idle::IdleResponse;
use futures_lite::FutureExt;
@@ -38,18 +38,23 @@ impl Session {
}
if self.new_mail {
info!(
context,
"Skipping IDLE in {folder:?} because there may be new mail."
);
return Ok(self);
}
if let Ok(()) = idle_interrupt_receiver.try_recv() {
info!(context, "skip idle, got interrupt");
info!(context, "Skip IDLE in {folder:?} because we got interrupt.");
return Ok(self);
}
let mut handle = self.inner.idle();
if let Err(err) = handle.init().await {
bail!("IMAP IDLE protocol failed to init/complete: {}", err);
}
handle
.init()
.await
.with_context(|| format!("IMAP IDLE protocol failed to init in folder {folder:?}"))?;
// At this point IDLE command was sent and we received a "+ idling" response. We will now
// read from the stream without getting any data for up to `IDLE_TIMEOUT`. If we don't
@@ -63,7 +68,10 @@ impl Session {
Interrupt,
}
info!(context, "{folder}: Idle entering wait-on-remote state");
info!(
context,
"IDLE entering wait-on-remote state in folder {folder:?}."
);
let fut = idle_wait.map(|ev| ev.map(Event::IdleResponse)).race(async {
idle_interrupt_receiver.recv().await.ok();
@@ -75,19 +83,19 @@ impl Session {
match fut.await {
Ok(Event::IdleResponse(IdleResponse::NewData(x))) => {
info!(context, "{folder}: Idle has NewData {:?}", x);
info!(context, "{folder:?}: Idle has NewData {x:?}");
}
Ok(Event::IdleResponse(IdleResponse::Timeout)) => {
info!(context, "{folder}: Idle-wait timeout or interruption");
info!(context, "{folder:?}: Idle-wait timeout or interruption.");
}
Ok(Event::IdleResponse(IdleResponse::ManualInterrupt)) => {
info!(context, "{folder}: Idle wait was interrupted manually");
info!(context, "{folder:?}: Idle wait was interrupted manually.");
}
Ok(Event::Interrupt) => {
info!(context, "{folder}: Idle wait was interrupted");
info!(context, "{folder:?}: Idle wait was interrupted.");
}
Err(err) => {
warn!(context, "{folder}: Idle wait errored: {err:?}");
warn!(context, "{folder:?}: Idle wait errored: {err:?}.");
}
}

337
src/imap/imap_tests.rs Normal file
View File

@@ -0,0 +1,337 @@
use super::*;
use crate::test_utils::TestContext;
#[test]
fn test_get_folder_meaning_by_name() {
assert_eq!(get_folder_meaning_by_name("Gesendet"), FolderMeaning::Sent);
assert_eq!(get_folder_meaning_by_name("GESENDET"), FolderMeaning::Sent);
assert_eq!(get_folder_meaning_by_name("gesendet"), FolderMeaning::Sent);
assert_eq!(
get_folder_meaning_by_name("Messages envoyés"),
FolderMeaning::Sent
);
assert_eq!(
get_folder_meaning_by_name("mEsSaGes envoyÉs"),
FolderMeaning::Sent
);
assert_eq!(get_folder_meaning_by_name("xxx"), FolderMeaning::Unknown);
assert_eq!(get_folder_meaning_by_name("SPAM"), FolderMeaning::Spam);
assert_eq!(get_folder_meaning_by_name("Trash"), FolderMeaning::Trash);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_uid_next_validity() {
let t = TestContext::new_alice().await;
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0);
set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap();
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7);
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0);
set_uid_next(&t.ctx, "Inbox", 5).await.unwrap();
set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap();
assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5);
assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6);
}
#[test]
fn test_build_sequence_sets() {
assert_eq!(build_sequence_sets(&[]).unwrap(), vec![]);
let cases = vec![
(vec![1], "1"),
(vec![3291], "3291"),
(vec![1, 3, 5, 7, 9, 11], "1,3,5,7,9,11"),
(vec![1, 2, 3], "1:3"),
(vec![1, 4, 5, 6], "1,4:6"),
((1..=500).collect(), "1:500"),
(vec![3, 4, 8, 9, 10, 11, 39, 50, 2], "3:4,8:11,39,50,2"),
];
for (input, s) in cases {
assert_eq!(
build_sequence_sets(&input).unwrap(),
vec![(input, s.into())]
);
}
let has_number = |(uids, s): &(Vec<u32>, String), number| {
uids.iter().any(|&n| n == number)
&& s.split(',').any(|n| n.parse::<u32>().unwrap() == number)
};
let numbers: Vec<_> = (2..=500).step_by(2).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
assert!(result.len() == 1); // these UIDs fit in one set
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
let numbers: Vec<_> = (1..=1000).step_by(3).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
let (last_uids, last_str) = result.last().unwrap();
assert_eq!(
last_uids.get((last_uids.len() - 2)..).unwrap(),
&[997, 1000]
);
assert!(last_str.ends_with("997,1000"));
assert!(result.len() == 2); // This time we need 2 sets
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
let numbers: Vec<_> = (30000000..=30002500).step_by(4).collect();
let result = build_sequence_sets(&numbers).unwrap();
for (_, set) in &result {
assert!(set.len() < 1010);
assert!(!set.ends_with(','));
assert!(!set.starts_with(','));
}
assert_eq!(result.len(), 6);
for &number in &numbers {
assert!(result.iter().any(|r| has_number(r, number)));
}
}
async fn check_target_folder_combination(
folder: &str,
mvbox_move: bool,
chat_msg: bool,
expected_destination: &str,
accepted_chat: bool,
outgoing: bool,
setupmessage: bool,
) -> Result<()> {
println!("Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}");
let t = TestContext::new_alice().await;
t.ctx
.set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat"))
.await?;
t.ctx
.set_config(Config::ConfiguredSentboxFolder, Some("Sent"))
.await?;
t.ctx
.set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" }))
.await?;
if accepted_chat {
let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?;
ChatId::create_for_contact(&t.ctx, contact_id).await?;
}
let temp;
let bytes = if setupmessage {
include_bytes!("../../test-data/message/AutocryptSetupMessage.eml")
} else {
temp = format!(
"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
{}\
Subject: foo\n\
Message-ID: <abc@example.com>\n\
{}\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
if outgoing {
"From: alice@example.org\nTo: bob@example.net\n"
} else {
"From: bob@example.net\nTo: alice@example.org\n"
},
if chat_msg { "Chat-Version: 1.0\n" } else { "" },
);
temp.as_bytes()
};
let (headers, _) = mailparse::parse_headers(bytes)?;
let actual = if let Some(config) =
target_folder_cfg(&t, folder, get_folder_meaning_by_name(folder), &headers).await?
{
t.get_config(config).await?
} else {
None
};
let expected = if expected_destination == folder {
None
} else {
Some(expected_destination)
};
assert_eq!(expected, actual.as_deref(), "For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}");
Ok(())
}
// chat_msg means that the message was sent by Delta Chat
// The tuples are (folder, mvbox_move, chat_msg, expected_destination)
const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", false, true, "INBOX"),
("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs
("Spam", true, true, "DeltaChat"),
];
// These are the same as above, but non-chat messages in Spam stay in Spam
const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[
("INBOX", false, false, "INBOX"),
("INBOX", false, true, "INBOX"),
("INBOX", true, false, "INBOX"),
("INBOX", true, true, "DeltaChat"),
("Sent", false, false, "Sent"),
("Sent", false, true, "Sent"),
("Sent", true, false, "Sent"),
("Sent", true, true, "DeltaChat"),
("Spam", false, false, "Spam"),
("Spam", false, true, "INBOX"),
("Spam", true, false, "Spam"),
("Spam", true, true, "DeltaChat"),
];
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_accepted() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
false,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_incoming_request() -> Result<()> {
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
false,
false,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_outgoing() -> Result<()> {
// Test outgoing emails
for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
expected_destination,
true,
true,
false,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_target_folder_setupmsg() -> Result<()> {
// Test setupmessages
for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT {
check_target_folder_combination(
folder,
*mvbox_move,
*chat_msg,
if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam"
false,
true,
true,
)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_imap_search_command() -> Result<()> {
let t = TestContext::new_alice().await;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"FROM "alice@example.org""#
);
t.ctx.set_primary_self_addr("alice@another.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"#
);
t.ctx.set_primary_self_addr("alice@third.com").await?;
assert_eq!(
get_imap_self_sent_search_command(&t.ctx).await?,
r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"#
);
Ok(())
}
#[test]
fn test_uid_grouper() {
// Input: sequence of (rowid: i64, uid: u32, target: String)
// Output: sequence of (target: String, rowid_set: Vec<i64>, uid_set: String)
let grouper = UidGrouper::from([(1, 2, "INBOX".to_string())]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(res, vec![("INBOX".to_string(), vec![1], "2".to_string())]);
let grouper = UidGrouper::from([(1, 2, "INBOX".to_string()), (2, 3, "INBOX".to_string())]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(
res,
vec![("INBOX".to_string(), vec![1, 2], "2:3".to_string())]
);
let grouper = UidGrouper::from([
(1, 2, "INBOX".to_string()),
(2, 2, "INBOX".to_string()),
(3, 3, "INBOX".to_string()),
]);
let res: Vec<(String, Vec<i64>, String)> = grouper.into_iter().collect();
assert_eq!(
res,
vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())]
);
}
#[test]
fn test_setmetadata_device_token() {
assert_eq!(
format_setmetadata("INBOX", "foobarbaz"),
"SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)"
);
assert_eq!(
format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"),
"SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)"
);
}

View File

@@ -26,12 +26,11 @@ pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
/* this may require a keypair to be created. this may take a second ... */
let setup_file_content = render_setup_file(context, &setup_code).await?;
/* encrypting may also take a while ... */
let setup_file_blob = BlobObject::create(
let setup_file_blob = BlobObject::create_and_deduplicate_from_bytes(
context,
"autocrypt-setup-message.html",
setup_file_content.as_bytes(),
)
.await?;
"autocrypt-setup-message.html",
)?;
let chat_id = ChatId::create_for_contact(context, ContactId::SELF).await?;
let mut msg = Message {
@@ -39,6 +38,8 @@ pub async fn initiate_key_transfer(context: &Context) -> Result<String> {
..Default::default()
};
msg.param.set(Param::File, setup_file_blob.as_name());
msg.param
.set(Param::Filename, "autocrypt-setup-message.html");
msg.subject = stock_str::ac_setup_msg_subject(context).await;
msg.param
.set(Param::MimeType, "application/autocrypt-setup");

View File

@@ -33,8 +33,7 @@ use std::task::Poll;
use anyhow::{bail, format_err, Context as _, Result};
use futures_lite::FutureExt;
use iroh_net::relay::RelayMode;
use iroh_net::Endpoint;
use iroh::{Endpoint, RelayMode};
use tokio::fs;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@@ -65,11 +64,11 @@ const BACKUP_ALPN: &[u8] = b"/deltachat/backup";
/// task use the [`Context::stop_ongoing`] mechanism.
#[derive(Debug)]
pub struct BackupProvider {
/// iroh-net endpoint.
/// iroh endpoint.
_endpoint: Endpoint,
/// iroh-net address.
node_addr: iroh_net::NodeAddr,
/// iroh address.
node_addr: iroh::NodeAddr,
/// Authentication token that should be submitted
/// to retrieve the backup.
@@ -162,7 +161,7 @@ impl BackupProvider {
async fn handle_connection(
context: Context,
conn: iroh_net::endpoint::Connecting,
conn: iroh::endpoint::Connecting,
auth_token: String,
dbfile: Arc<TempPathGuard>,
) -> Result<()> {
@@ -292,7 +291,7 @@ impl Future for BackupProvider {
pub async fn get_backup2(
context: &Context,
node_addr: iroh_net::NodeAddr,
node_addr: iroh::NodeAddr,
auth_token: String,
) -> Result<()> {
let relay_mode = RelayMode::Disabled;
@@ -342,7 +341,7 @@ pub async fn get_backup2(
/// This is a long running operation which will return only when completed.
///
/// Using [`Qr`] as argument is a bit odd as it only accepts specific variant of it. It
/// does avoid having [`iroh_net::NodeAddr`] in the primary API however, without
/// does avoid having [`iroh::NodeAddr`] in the primary API however, without
/// having to revert to untyped bytes.
pub async fn get_backup(context: &Context, qr: Qr) -> Result<()> {
match qr {
@@ -394,7 +393,8 @@ mod tests {
let file = ctx0.get_blobdir().join("hello.txt");
fs::write(&file, "i am attachment").await.unwrap();
let mut msg = Message::new(Viewtype::File);
msg.set_file(file.to_str().unwrap(), Some("text/plain"));
msg.set_file_and_deduplicate(&ctx0, &file, Some("hello.txt"), Some("text/plain"))
.unwrap();
send_msg(&ctx0, self_chat.id, &mut msg).await.unwrap();
// Prepare to transfer backup.
@@ -428,7 +428,12 @@ mod tests {
let msg = Message::load_from_db(&ctx1, *msgid).await.unwrap();
let path = msg.get_file(&ctx1).unwrap();
assert_eq!(path.with_file_name("hello.txt"), path);
assert_eq!(
// That's the hash of the file:
path.with_file_name("ac1d2d284757656a8d41dc40aae4136.txt"),
path
);
assert_eq!("hello.txt", msg.get_filename().unwrap());
let text = fs::read_to_string(&path).await.unwrap();
assert_eq!(text, "i am attachment");

View File

@@ -1074,7 +1074,7 @@ Content-Disposition: attachment; filename="location.kml"
let file = alice.get_blobdir().join(file_name);
tokio::fs::write(&file, bytes).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file(file.to_str().unwrap(), None);
msg.set_file_and_deduplicate(&alice, &file, Some("logo.png"), None)?;
let sent = alice.send_msg(alice_chat.id, &mut msg).await;
let alice_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert_eq!(alice_msg.has_location(), false);

View File

@@ -470,6 +470,7 @@ pub struct Message {
/// `In-Reply-To` header value.
pub(crate) in_reply_to: Option<String>,
pub(crate) is_dc_message: MessengerMessage,
pub(crate) original_msg_id: MsgId,
pub(crate) mime_modified: bool,
pub(crate) chat_blocked: Blocked,
pub(crate) location_id: u32,
@@ -536,6 +537,7 @@ impl Message {
" m.download_state AS download_state,",
" m.error AS error,",
" m.msgrmsg AS msgrmsg,",
" m.starred AS original_msg_id,",
" m.mime_modified AS mime_modified,",
" m.txt AS txt,",
" m.subject AS subject,",
@@ -592,6 +594,7 @@ impl Message {
error: Some(row.get::<_, String>("error")?)
.filter(|error| !error.is_empty()),
is_dc_message: row.get("msgrmsg")?,
original_msg_id: row.get("original_msg_id")?,
mime_modified: row.get("mime_modified")?,
text,
subject: row.get("subject")?,
@@ -620,8 +623,8 @@ impl Message {
pub fn get_filemime(&self) -> Option<String> {
if let Some(m) = self.param.get(Param::MimeType) {
return Some(m.to_string());
} else if let Some(file) = self.param.get(Param::File) {
if let Some((_, mime)) = guess_msgtype_from_suffix(Path::new(file)) {
} else if self.param.exists(Param::File) {
if let Some((_, mime)) = guess_msgtype_from_suffix(self) {
return Some(mime.to_string());
}
// we have a file but no mimetype, let's use a generic one
@@ -1082,18 +1085,62 @@ impl Message {
self.param.set_optional(Param::MimeType, filemime);
}
/// Creates a new blob and sets it as a file associated with a message.
pub async fn set_file_from_bytes(
/// Sets the file associated with a message, deduplicating files with the same name.
///
/// If `name` is Some, it is used as the file name
/// and the actual current name of the file is ignored.
///
/// If the source file is already in the blobdir, it will be renamed,
/// otherwise it will be copied to the blobdir first.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
///
/// NOTE:
/// - This function will rename the file. To get the new file path, call `get_file()`.
/// - The file must not be modified after this function was called.
pub fn set_file_and_deduplicate(
&mut self,
context: &Context,
suggested_name: &str,
file: &Path,
name: Option<&str>,
filemime: Option<&str>,
) -> Result<()> {
let name = if let Some(name) = name {
name.to_string()
} else {
file.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown_file".to_string())
};
let blob = BlobObject::create_and_deduplicate(context, file, Path::new(&name))?;
self.param.set(Param::File, blob.as_name());
self.param.set(Param::Filename, name);
self.param.set_optional(Param::MimeType, filemime);
Ok(())
}
/// Creates a new blob and sets it as a file associated with a message.
///
/// In order to deduplicate files that contain the same data,
/// the file will be named `<hash>.<extension>`, e.g. `ce940175885d7b78f7b7e9f1396611f.jpg`.
///
/// NOTE: The file must not be modified after this function was called.
pub fn set_file_from_bytes(
&mut self,
context: &Context,
name: &str,
data: &[u8],
filemime: Option<&str>,
) -> Result<()> {
let blob = BlobObject::create(context, suggested_name, data).await?;
self.param.set(Param::Filename, suggested_name);
let blob = BlobObject::create_and_deduplicate_from_bytes(context, data, name)?;
self.param.set(Param::Filename, name);
self.param.set(Param::File, blob.as_name());
self.param.set_optional(Param::MimeType, filemime);
Ok(())
}
@@ -1106,12 +1153,13 @@ impl Message {
);
let vcard = contact::make_vcard(context, contacts).await?;
self.set_file_from_bytes(context, "vcard.vcf", vcard.as_bytes(), None)
.await
}
/// Updates message state from the vCard attachment.
pub(crate) async fn try_set_vcard(&mut self, context: &Context, path: &Path) -> Result<()> {
let vcard = fs::read(path).await.context("Could not read {path}")?;
let vcard = fs::read(path)
.await
.with_context(|| format!("Could not read {path:?}"))?;
if let Some(summary) = get_vcard_summary(&vcard) {
self.param.set(Param::Summary1, summary);
} else {
@@ -1254,6 +1302,35 @@ impl Message {
Ok(None)
}
/// Returns original message ID for message from "Saved Messages".
pub async fn get_original_msg_id(&self, context: &Context) -> Result<Option<MsgId>> {
if !self.original_msg_id.is_special() {
if let Some(msg) = Message::load_from_db_optional(context, self.original_msg_id).await?
{
return if msg.chat_id.is_trash() {
Ok(None)
} else {
Ok(Some(msg.id))
};
}
}
Ok(None)
}
/// Check if the message was saved and returns the corresponding message inside "Saved Messages".
/// UI can use this to show a symbol beside the message, indicating it was saved.
/// The message can be un-saved by deleting the returned message.
pub async fn get_saved_msg_id(&self, context: &Context) -> Result<Option<MsgId>> {
let res: Option<MsgId> = context
.sql
.query_get_value(
"SELECT id FROM msgs WHERE starred=? AND chat_id!=?",
(self.id, DC_CHAT_ID_TRASH),
)
.await?;
Ok(res)
}
/// Force the message to be sent in plain text.
pub fn force_plaintext(&mut self) {
self.param.set_int(Param::ForcePlaintext, 1);
@@ -1433,7 +1510,14 @@ pub async fn get_msg_read_receipts(
.await
}
pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)> {
pub(crate) fn guess_msgtype_from_suffix(msg: &Message) -> Option<(Viewtype, &'static str)> {
msg.param
.get(Param::Filename)
.or_else(|| msg.param.get(Param::File))
.and_then(|file| guess_msgtype_from_path_suffix(Path::new(file)))
}
pub(crate) fn guess_msgtype_from_path_suffix(path: &Path) -> Option<(Viewtype, &'static str)> {
let extension: &str = &path.extension()?.to_str()?.to_lowercase();
let info = match extension {
// before using viewtype other than Viewtype::File,
@@ -1667,12 +1751,12 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
.set_config_internal(Config::LastMsgId, Some(&last_msg_id.to_u32().to_string()))
.await?;
let msgs = context
.sql
.query_map(
&format!(
let mut msgs = Vec::with_capacity(msg_ids.len());
for &id in &msg_ids {
if let Some(msg) = context
.sql
.query_row_optional(
"SELECT
m.id AS id,
m.chat_id AS chat_id,
m.state AS state,
m.download_state as download_state,
@@ -1683,39 +1767,39 @@ pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()>
c.archived AS archived,
c.blocked AS blocked
FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
WHERE m.id IN ({}) AND m.chat_id>9",
sql::repeat_vars(msg_ids.len())
),
rusqlite::params_from_iter(&msg_ids),
|row| {
let id: MsgId = row.get("id")?;
let chat_id: ChatId = row.get("chat_id")?;
let state: MessageState = row.get("state")?;
let download_state: DownloadState = row.get("download_state")?;
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
let from_id: ContactId = row.get("from_id")?;
let rfc724_mid: String = row.get("rfc724_mid")?;
let visibility: ChatVisibility = row.get("archived")?;
let blocked: Option<Blocked> = row.get("blocked")?;
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
Ok((
(
id,
chat_id,
state,
download_state,
param,
from_id,
rfc724_mid,
visibility,
blocked.unwrap_or_default(),
),
ephemeral_timer,
))
},
|rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
)
.await?;
WHERE m.id=? AND m.chat_id>9",
(id,),
|row| {
let chat_id: ChatId = row.get("chat_id")?;
let state: MessageState = row.get("state")?;
let download_state: DownloadState = row.get("download_state")?;
let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
let from_id: ContactId = row.get("from_id")?;
let rfc724_mid: String = row.get("rfc724_mid")?;
let visibility: ChatVisibility = row.get("archived")?;
let blocked: Option<Blocked> = row.get("blocked")?;
let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
Ok((
(
id,
chat_id,
state,
download_state,
param,
from_id,
rfc724_mid,
visibility,
blocked.unwrap_or_default(),
),
ephemeral_timer,
))
},
)
.await?
{
msgs.push(msg);
}
}
if msgs
.iter()
@@ -2161,729 +2245,4 @@ pub(crate) fn normalize_text(text: &str) -> Option<String> {
}
#[cfg(test)]
mod tests {
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{
self, add_contact_to_chat, marknoticed_chat, send_text_msg, ChatItem, ProtectionStatus,
};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils as test;
use crate::test_utils::{TestContext, TestContextManager};
#[test]
fn test_guess_msgtype_from_suffix() {
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/bar-sth.mp3")),
Some((Viewtype::Audio, "audio/mpeg"))
);
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/file.html")),
Some((Viewtype::File, "text/html"))
);
assert_eq!(
guess_msgtype_from_suffix(Path::new("foo/file.xdc")),
Some((Viewtype::Webxdc, "application/webxdc+zip"))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webrtc_instance() {
let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "https://foo/bar");
let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "url");
let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
assert_eq!(webrtc_type, VideochatType::Unknown);
assert_eq!(url, "https://foo/bar?key=val#key=val");
let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
assert_eq!(webrtc_type, VideochatType::Jitsi);
assert_eq!(url, "https://j.si/foo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
assert_eq!(instance, "https://meet.jit.si/123");
let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
assert_eq!(instance, "https://meet.jit.si/456");
let instance = Message::create_webrtc_instance("meet.jit.si", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance("bla.foo?", "123");
assert_eq!(instance, "https://bla.foo?123");
let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
assert_eq!(instance, "jitsi:https://bla.foo#456");
let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
assert_eq!(instance, "https://bla.foo#room=789");
let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
assert_eq!(instance, "https://bla.foo#room/123");
let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
assert_eq!(instance, "https://bla.foo#room123");
let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
assert_eq!(instance, "https://bla.foo#room=234&after=cont");
let instance = Message::create_webrtc_instance(" meet.jit .si ", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance_noroom() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123");
assert_eq!(instance, "https://bla.foo/?a=b");
// $ROOM has a higher precedence
let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_width_height() {
let t = test::TestContext::new().await;
// test that get_width() and get_height() are returning some dimensions for images;
// (as the device-chat contains a welcome-images, we check that)
t.update_device_chats().await.ok();
let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE)
.await
.unwrap();
let mut has_image = false;
let chatitems = chat::get_chat_msgs(&t, device_chat_id).await.unwrap();
for chatitem in chatitems {
if let ChatItem::Message { msg_id } = chatitem {
if let Ok(msg) = Message::load_from_db(&t, msg_id).await {
if msg.get_viewtype() == Viewtype::Image {
has_image = true;
// just check that width/height are inside some reasonable ranges
assert!(msg.get_width() > 100);
assert!(msg.get_height() > 100);
assert!(msg.get_width() < 4000);
assert!(msg.get_height() < 4000);
}
}
}
}
assert!(has_image);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote() {
let d = test::TestContext::new().await;
let ctx = &d.ctx;
ctx.set_config(Config::ConfiguredAddr, Some("self@example.com"))
.await
.unwrap();
let chat = d.create_chat_with_contact("", "dest@example.com").await;
let mut msg = Message::new_text("Quoted message".to_string());
// Send message, so it gets a Message-Id.
assert!(msg.rfc724_mid.is_empty());
let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
assert!(!msg.rfc724_mid.is_empty());
let mut msg2 = Message::new(Viewtype::Text);
msg2.set_quote(ctx, Some(&msg))
.await
.expect("can't set quote");
assert_eq!(msg2.quoted_text().unwrap(), msg.get_text());
let quoted_msg = msg2
.quoted_message(ctx)
.await
.expect("error while retrieving quoted message")
.expect("quoted message not found");
assert_eq!(quoted_msg.get_text(), msg2.quoted_text().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_quote() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.send_recv_accept(alice, bob, "Hi!").await;
let msg = tcm
.send_recv(
alice,
bob,
"On 2024-08-28, Alice wrote:\n> A quote.\nNot really.",
)
.await;
assert!(msg.quoted_text().is_none());
assert!(msg.quoted_message(bob).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unencrypted_quote_encrypted_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob])
.await;
let sent = alice.send_text(alice_group, "Hi! I created a group").await;
let bob_received_message = bob.recv_msg(&sent).await;
let bob_group = bob_received_message.chat_id;
bob_group.accept(bob).await?;
let sent = bob.send_text(bob_group, "Encrypted message").await;
let alice_received_message = alice.recv_msg(&sent).await;
assert!(alice_received_message.get_showpadlock());
// Alice adds contact without key so chat becomes unencrypted.
let alice_flubby_contact_id =
Contact::create(alice, "Flubby", "flubby@example.org").await?;
add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?;
// Alice quotes encrypted message in unencrypted chat.
let mut msg = Message::new_text("unencrypted".to_string());
msg.set_quote(alice, Some(&alice_received_message)).await?;
chat::send_msg(alice, alice_group, &mut msg).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "...");
assert_eq!(bob_received_message.get_showpadlock(), false);
// Alice replaces a quote of encrypted message with a quote of unencrypted one.
let mut msg1 = Message::new(Viewtype::Text);
msg1.set_quote(alice, Some(&alice_received_message)).await?;
msg1.set_quote(alice, Some(&msg)).await?;
chat::send_msg(alice, alice_group, &mut msg1).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted");
assert_eq!(bob_received_message.get_showpadlock(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_id() {
// Alice receives a message that pops up as a contact request
let alice = TestContext::new_alice().await;
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await
.unwrap();
// check chat-id of this message
let msg = alice.get_last_msg().await;
assert!(!msg.get_chat_id().is_special());
assert_eq!(msg.get_text(), "hello".to_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_override_sender_name() {
// send message with overridden sender name
let alice = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let contact_id = *chat::get_chat_contacts(&alice, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&alice, contact_id).await.unwrap();
let mut msg = Message::new_text("bla blubb".to_string());
msg.set_override_sender_name(Some("over ride".to_string()));
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
let sent_msg = alice.pop_sent_msg().await;
// bob receives that message
let chat = bob.create_chat(&alice).await;
let contact_id = *chat::get_chat_contacts(&bob, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, chat.id);
assert_eq!(msg.text, "bla blubb");
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
// explicitly check that the message does not create a mailing list
// (mailing lists may also use `Sender:`-header)
let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
assert_ne!(chat.typ, Chattype::Mailinglist);
// Alice receives message on another device.
let msg = alice2.recv_msg(&sent_msg).await;
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let mut msg = Message::new_text("this is the text!".to_string());
// alice sends to bob,
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg1 = bob.recv_msg(&sent1).await;
let bob_chat_id = msg1.chat_id;
let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg2 = bob.recv_msg(&sent2).await;
assert_eq!(msg1.chat_id, msg2.chat_id);
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// that has no effect in contact request
markseen_msgs(&bob, vec![msg1.id, msg2.id]).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await?;
assert_eq!(bob_chat.blocked, Blocked::Request);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
bob_chat_id.accept(&bob).await.unwrap();
// bob sends to alice,
// alice knows bob and messages appear in normal chat
let msg1 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let msg2 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, alice_chat.id);
assert_eq!(chats.get_chat_id(0)?, msg1.chat_id);
assert_eq!(chats.get_chat_id(0)?, msg2.chat_id);
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// no message-ids, that should have no effect
markseen_msgs(&alice, vec![]).await?;
// bad message-id, that should have no effect
markseen_msgs(&alice, vec![MsgId::new(123456)]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// mark the most recent as seen
markseen_msgs(&alice, vec![msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 1);
assert_eq!(alice.get_fresh_msgs().await?.len(), 1);
// user scrolled up - mark both as seen
markseen_msgs(&alice, vec![msg1.id, msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 0);
assert_eq!(alice.get_fresh_msgs().await?.len(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
// A not downloaded message can be seen only if it's seen on another device.
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
// Marking the message as seen again is a no op.
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::InProgress)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Failure)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Undecipherable)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Let's assume that Alice and Bob resolved the problem with encryption.
let old_msg = msg;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, old_msg.chat_id);
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
// The message state mustn't be downgraded to `InFresh`.
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
.await?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(msg.state, MessageState::InFresh);
alice.set_config(Config::DownloadLimit, None).await?;
let seen = true;
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
assert_eq!(msg.state, MessageState::InSeen);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let bob_chat = bob.create_chat(&alice).await;
// check both get_state() functions,
// the one requiring a id and the one requiring an object
async fn assert_state(t: &Context, msg_id: MsgId, state: MessageState) {
assert_eq!(msg_id.get_state(t).await.unwrap(), state);
assert_eq!(
Message::load_from_db(t, msg_id).await.unwrap().get_state(),
state
);
}
// check outgoing messages states on sender side
let mut alice_msg = Message::new_text("hi!".to_string());
assert_eq!(alice_msg.get_state(), MessageState::Undefined); // message not yet in db, assert_state() won't work
alice_chat
.id
.set_draft(&alice, Some(&mut alice_msg))
.await?;
let mut alice_msg = alice_chat.id.get_draft(&alice).await?.unwrap();
assert_state(&alice, alice_msg.id, MessageState::OutDraft).await;
let msg_id = chat::send_msg(&alice, alice_chat.id, &mut alice_msg).await?;
assert_eq!(msg_id, alice_msg.id);
assert_state(&alice, alice_msg.id, MessageState::OutPending).await;
let payload = alice.pop_sent_msg().await;
assert_state(&alice, alice_msg.id, MessageState::OutDelivered).await;
set_msg_failed(&alice, &mut alice_msg, "badly failed").await?;
assert_state(&alice, alice_msg.id, MessageState::OutFailed).await;
// check incoming message states on receiver side
let bob_msg = bob.recv_msg(&payload).await;
assert_eq!(bob_chat.id, bob_msg.chat_id);
assert_state(&bob, bob_msg.id, MessageState::InFresh).await;
marknoticed_chat(&bob, bob_msg.chat_id).await?;
assert_state(&bob, bob_msg.id, MessageState::InNoticed).await;
markseen_msgs(&bob, vec![bob_msg.id]).await?;
assert_state(&bob, bob_msg.id, MessageState::InSeen).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_bot() -> Result<()> {
let alice = TestContext::new_alice().await;
// Alice receives an auto-generated non-chat message.
//
// This could be a holiday notice,
// in which case the message should be marked as bot-generated,
// but the contact should not.
receive_imf(
&alice,
b"From: Claire <claire@example.com>\n\
To: alice@example.org\n\
Message-ID: <789@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
// Alice receives a message from Bob the bot.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(contact.is_bot());
// Alice receives a message from Bob who is not the bot anymore.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <456@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello again\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello again".to_string());
assert!(!msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
Ok(())
}
#[test]
fn test_viewtype_derive_display_works_as_expected() {
assert_eq!(format!("{}", Viewtype::Audio), "Audio");
}
#[test]
fn test_viewtype_values() {
// values may be written to disk and must not change
assert_eq!(Viewtype::Unknown, Viewtype::default());
assert_eq!(Viewtype::Unknown, Viewtype::from_i32(0).unwrap());
assert_eq!(Viewtype::Text, Viewtype::from_i32(10).unwrap());
assert_eq!(Viewtype::Image, Viewtype::from_i32(20).unwrap());
assert_eq!(Viewtype::Gif, Viewtype::from_i32(21).unwrap());
assert_eq!(Viewtype::Sticker, Viewtype::from_i32(23).unwrap());
assert_eq!(Viewtype::Audio, Viewtype::from_i32(40).unwrap());
assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
assert_eq!(
Viewtype::VideochatInvitation,
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_quotes() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_text(chat.id, "> First quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> First quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
let sent = alice.send_text(chat.id, "> Second quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> Second quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_message_summary_text() -> Result<()> {
let t = TestContext::new_alice().await;
let chat = t.get_self_chat().await;
let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
// message summary does not change when reactions are applied (in contrast to chatlist summary)
send_reaction(&t, msg_id, "🫵").await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_format_flowed_round_trip() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let text = " Foo bar";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "Foo bar baz";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > A";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let python_program = "\
def hello():
return 'Hello, world!'";
let sent = alice.send_text(chat.id, python_program).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, python_program);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_msgs_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
delete_msgs(&alice, &[msg.id]).await?;
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}
}
mod message_tests;

View File

@@ -0,0 +1,757 @@
use num_traits::FromPrimitive;
use super::*;
use crate::chat::{
self, add_contact_to_chat, forward_msgs, marknoticed_chat, save_msgs, send_text_msg, ChatItem,
ProtectionStatus,
};
use crate::chatlist::Chatlist;
use crate::config::Config;
use crate::reaction::send_reaction;
use crate::receive_imf::receive_imf;
use crate::test_utils as test;
use crate::test_utils::{TestContext, TestContextManager};
#[test]
fn test_guess_msgtype_from_suffix() {
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/bar-sth.mp3")),
Some((Viewtype::Audio, "audio/mpeg"))
);
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/file.html")),
Some((Viewtype::File, "text/html"))
);
assert_eq!(
guess_msgtype_from_path_suffix(Path::new("foo/file.xdc")),
Some((Viewtype::Webxdc, "application/webxdc+zip"))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_parse_webrtc_instance() {
let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "https://foo/bar");
let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
assert_eq!(url, "url");
let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
assert_eq!(webrtc_type, VideochatType::Unknown);
assert_eq!(url, "https://foo/bar?key=val#key=val");
let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
assert_eq!(webrtc_type, VideochatType::Jitsi);
assert_eq!(url, "https://j.si/foo");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
assert_eq!(instance, "https://meet.jit.si/123");
let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
assert_eq!(instance, "https://meet.jit.si/456");
let instance = Message::create_webrtc_instance("meet.jit.si", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance("bla.foo?", "123");
assert_eq!(instance, "https://bla.foo?123");
let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
assert_eq!(instance, "jitsi:https://bla.foo#456");
let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
assert_eq!(instance, "https://bla.foo#room=789");
let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
assert_eq!(instance, "https://bla.foo#room/123");
let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
assert_eq!(instance, "https://bla.foo#room123");
let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
assert_eq!(instance, "https://bla.foo#room=234&after=cont");
let instance = Message::create_webrtc_instance(" meet.jit .si ", "789");
assert_eq!(instance, "https://meet.jit.si/789");
let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_create_webrtc_instance_noroom() {
// webrtc_instance may come from an input field of the ui, be pretty tolerant on input
let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" $NOROOM bla . foo ", "789");
assert_eq!(instance, "https://bla.foo");
let instance = Message::create_webrtc_instance(" bla.foo / $NOROOM ? a = b ", "123");
assert_eq!(instance, "https://bla.foo/?a=b");
// $ROOM has a higher precedence
let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_width_height() {
let t = test::TestContext::new().await;
// test that get_width() and get_height() are returning some dimensions for images;
// (as the device-chat contains a welcome-images, we check that)
t.update_device_chats().await.ok();
let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE)
.await
.unwrap();
let mut has_image = false;
let chatitems = chat::get_chat_msgs(&t, device_chat_id).await.unwrap();
for chatitem in chatitems {
if let ChatItem::Message { msg_id } = chatitem {
if let Ok(msg) = Message::load_from_db(&t, msg_id).await {
if msg.get_viewtype() == Viewtype::Image {
has_image = true;
// just check that width/height are inside some reasonable ranges
assert!(msg.get_width() > 100);
assert!(msg.get_height() > 100);
assert!(msg.get_width() < 4000);
assert!(msg.get_height() < 4000);
}
}
}
}
assert!(has_image);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quote() {
let d = test::TestContext::new().await;
let ctx = &d.ctx;
ctx.set_config(Config::ConfiguredAddr, Some("self@example.com"))
.await
.unwrap();
let chat = d.create_chat_with_contact("", "dest@example.com").await;
let mut msg = Message::new_text("Quoted message".to_string());
// Send message, so it gets a Message-Id.
assert!(msg.rfc724_mid.is_empty());
let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
assert!(!msg.rfc724_mid.is_empty());
let mut msg2 = Message::new(Viewtype::Text);
msg2.set_quote(ctx, Some(&msg))
.await
.expect("can't set quote");
assert_eq!(msg2.quoted_text().unwrap(), msg.get_text());
let quoted_msg = msg2
.quoted_message(ctx)
.await
.expect("error while retrieving quoted message")
.expect("quoted message not found");
assert_eq!(quoted_msg.get_text(), msg2.quoted_text().unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_quote() {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
tcm.send_recv_accept(alice, bob, "Hi!").await;
let msg = tcm
.send_recv(
alice,
bob,
"On 2024-08-28, Alice wrote:\n> A quote.\nNot really.",
)
.await;
assert!(msg.quoted_text().is_none());
assert!(msg.quoted_message(bob).await.unwrap().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unencrypted_quote_encrypted_message() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let alice_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob])
.await;
let sent = alice.send_text(alice_group, "Hi! I created a group").await;
let bob_received_message = bob.recv_msg(&sent).await;
let bob_group = bob_received_message.chat_id;
bob_group.accept(bob).await?;
let sent = bob.send_text(bob_group, "Encrypted message").await;
let alice_received_message = alice.recv_msg(&sent).await;
assert!(alice_received_message.get_showpadlock());
// Alice adds contact without key so chat becomes unencrypted.
let alice_flubby_contact_id = Contact::create(alice, "Flubby", "flubby@example.org").await?;
add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?;
// Alice quotes encrypted message in unencrypted chat.
let mut msg = Message::new_text("unencrypted".to_string());
msg.set_quote(alice, Some(&alice_received_message)).await?;
chat::send_msg(alice, alice_group, &mut msg).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "...");
assert_eq!(bob_received_message.get_showpadlock(), false);
// Alice replaces a quote of encrypted message with a quote of unencrypted one.
let mut msg1 = Message::new(Viewtype::Text);
msg1.set_quote(alice, Some(&alice_received_message)).await?;
msg1.set_quote(alice, Some(&msg)).await?;
chat::send_msg(alice, alice_group, &mut msg1).await?;
let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted");
assert_eq!(bob_received_message.get_showpadlock(), false);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_chat_id() {
// Alice receives a message that pops up as a contact request
let alice = TestContext::new_alice().await;
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await
.unwrap();
// check chat-id of this message
let msg = alice.get_last_msg().await;
assert!(!msg.get_chat_id().is_special());
assert_eq!(msg.get_text(), "hello".to_string());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_override_sender_name() {
// send message with overridden sender name
let alice = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let contact_id = *chat::get_chat_contacts(&alice, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&alice, contact_id).await.unwrap();
let mut msg = Message::new_text("bla blubb".to_string());
msg.set_override_sender_name(Some("over ride".to_string()));
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
let sent_msg = alice.pop_sent_msg().await;
// bob receives that message
let chat = bob.create_chat(&alice).await;
let contact_id = *chat::get_chat_contacts(&bob, chat.id)
.await
.unwrap()
.first()
.unwrap();
let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
let msg = bob.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, chat.id);
assert_eq!(msg.text, "bla blubb");
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
assert_ne!(contact.get_display_name(), "over ride".to_string());
// explicitly check that the message does not create a mailing list
// (mailing lists may also use `Sender:`-header)
let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
assert_ne!(chat.typ, Chattype::Mailinglist);
// Alice receives message on another device.
let msg = alice2.recv_msg(&sent_msg).await;
assert_eq!(
msg.get_override_sender_name(),
Some("over ride".to_string())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_original_msg_id() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
// normal sending of messages does not have an original ID
let one2one_chat = alice.create_chat(&bob).await;
let sent = alice.send_text(one2one_chat.id, "foo").await;
let orig_msg = Message::load_from_db(&alice, sent.sender_msg_id).await?;
assert!(orig_msg.get_original_msg_id(&alice).await?.is_none());
assert!(orig_msg.parent(&alice).await?.is_none());
assert!(orig_msg.quoted_message(&alice).await?.is_none());
// forwarding to "Saved Messages", the message gets the original ID attached
let self_chat = alice.get_self_chat().await;
save_msgs(&alice, &[sent.sender_msg_id]).await?;
let saved_msg = alice.get_last_msg_in(self_chat.get_id()).await;
assert_ne!(saved_msg.get_id(), orig_msg.get_id());
assert_eq!(
saved_msg.get_original_msg_id(&alice).await?.unwrap(),
orig_msg.get_id()
);
assert!(saved_msg.parent(&alice).await?.is_none());
assert!(saved_msg.quoted_message(&alice).await?.is_none());
// forwarding from "Saved Messages" back to another chat, detaches original ID
forward_msgs(&alice, &[saved_msg.get_id()], one2one_chat.get_id()).await?;
let forwarded_msg = alice.get_last_msg_in(one2one_chat.get_id()).await;
assert_ne!(forwarded_msg.get_id(), saved_msg.get_id());
assert_ne!(forwarded_msg.get_id(), orig_msg.get_id());
assert!(forwarded_msg.get_original_msg_id(&alice).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_msgs() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let mut msg = Message::new_text("this is the text!".to_string());
// alice sends to bob,
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg1 = bob.recv_msg(&sent1).await;
let bob_chat_id = msg1.chat_id;
let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
let msg2 = bob.recv_msg(&sent2).await;
assert_eq!(msg1.chat_id, msg2.chat_id);
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
assert_eq!(bob.get_fresh_msgs().await?.len(), 0);
// that has no effect in contact request
markseen_msgs(&bob, vec![msg1.id, msg2.id]).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await?;
assert_eq!(bob_chat.blocked, Blocked::Request);
let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
assert_eq!(msgs.len(), 2);
bob_chat_id.accept(&bob).await.unwrap();
// bob sends to alice,
// alice knows bob and messages appear in normal chat
let msg1 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let msg2 = alice
.recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
.await;
let chats = Chatlist::try_load(&alice, 0, None, None).await?;
assert_eq!(chats.len(), 1);
assert_eq!(chats.get_chat_id(0)?, alice_chat.id);
assert_eq!(chats.get_chat_id(0)?, msg1.chat_id);
assert_eq!(chats.get_chat_id(0)?, msg2.chat_id);
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// no message-ids, that should have no effect
markseen_msgs(&alice, vec![]).await?;
// bad message-id, that should have no effect
markseen_msgs(&alice, vec![MsgId::new(123456)]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
assert_eq!(alice.get_fresh_msgs().await?.len(), 2);
// mark the most recent as seen
markseen_msgs(&alice, vec![msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 1);
assert_eq!(alice.get_fresh_msgs().await?.len(), 1);
// user scrolled up - mark both as seen
markseen_msgs(&alice, vec![msg1.id, msg2.id]).await?;
assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 0);
assert_eq!(alice.get_fresh_msgs().await?.len(), 0);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_markseen_not_downloaded_msg() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert_eq!(msg.state, MessageState::InFresh);
markseen_msgs(alice, vec![msg.id]).await?;
// A not downloaded message can be seen only if it's seen on another device.
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
// Marking the message as seen again is a no op.
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::InProgress)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Failure)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
msg.id
.update_download_state(alice, DownloadState::Undecipherable)
.await?;
markseen_msgs(alice, vec![msg.id]).await?;
assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
assert!(
!alice
.sql
.exists("SELECT COUNT(*) FROM smtp_mdns", ())
.await?
);
alice.set_config(Config::DownloadLimit, None).await?;
// Let's assume that Alice and Bob resolved the problem with encryption.
let old_msg = msg;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.chat_id, old_msg.chat_id);
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
// The message state mustn't be downgraded to `InFresh`.
assert_eq!(msg.state, MessageState::InNoticed);
markseen_msgs(alice, vec![msg.id]).await?;
let msg = Message::load_from_db(alice, msg.id).await?;
assert_eq!(msg.state, MessageState::InSeen);
assert_eq!(
alice
.sql
.count("SELECT COUNT(*) FROM smtp_mdns", ())
.await?,
1
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
alice.set_config(Config::DownloadLimit, Some("1")).await?;
let bob = &tcm.bob().await;
let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;
let file_bytes = include_bytes!("../../test-data/image/screenshot.png");
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
let msg = alice.recv_msg(&sent_msg).await;
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(msg.state, MessageState::InFresh);
alice.set_config(Config::DownloadLimit, None).await?;
let seen = true;
let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
.await
.unwrap()
.unwrap();
assert_eq!(rcvd_msg.chat_id, msg.chat_id);
let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
.await
.unwrap();
assert_eq!(msg.download_state, DownloadState::Done);
assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
assert!(msg.get_showpadlock());
assert_eq!(msg.state, MessageState::InSeen);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_state() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let alice_chat = alice.create_chat(&bob).await;
let bob_chat = bob.create_chat(&alice).await;
// check both get_state() functions,
// the one requiring a id and the one requiring an object
async fn assert_state(t: &Context, msg_id: MsgId, state: MessageState) {
assert_eq!(msg_id.get_state(t).await.unwrap(), state);
assert_eq!(
Message::load_from_db(t, msg_id).await.unwrap().get_state(),
state
);
}
// check outgoing messages states on sender side
let mut alice_msg = Message::new_text("hi!".to_string());
assert_eq!(alice_msg.get_state(), MessageState::Undefined); // message not yet in db, assert_state() won't work
alice_chat
.id
.set_draft(&alice, Some(&mut alice_msg))
.await?;
let mut alice_msg = alice_chat.id.get_draft(&alice).await?.unwrap();
assert_state(&alice, alice_msg.id, MessageState::OutDraft).await;
let msg_id = chat::send_msg(&alice, alice_chat.id, &mut alice_msg).await?;
assert_eq!(msg_id, alice_msg.id);
assert_state(&alice, alice_msg.id, MessageState::OutPending).await;
let payload = alice.pop_sent_msg().await;
assert_state(&alice, alice_msg.id, MessageState::OutDelivered).await;
set_msg_failed(&alice, &mut alice_msg, "badly failed").await?;
assert_state(&alice, alice_msg.id, MessageState::OutFailed).await;
// check incoming message states on receiver side
let bob_msg = bob.recv_msg(&payload).await;
assert_eq!(bob_chat.id, bob_msg.chat_id);
assert_state(&bob, bob_msg.id, MessageState::InFresh).await;
marknoticed_chat(&bob, bob_msg.chat_id).await?;
assert_state(&bob, bob_msg.id, MessageState::InNoticed).await;
markseen_msgs(&bob, vec![bob_msg.id]).await?;
assert_state(&bob, bob_msg.id, MessageState::InSeen).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_is_bot() -> Result<()> {
let alice = TestContext::new_alice().await;
// Alice receives an auto-generated non-chat message.
//
// This could be a holiday notice,
// in which case the message should be marked as bot-generated,
// but the contact should not.
receive_imf(
&alice,
b"From: Claire <claire@example.com>\n\
To: alice@example.org\n\
Message-ID: <789@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
// Alice receives a message from Bob the bot.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <123@example.com>\n\
Auto-Submitted: auto-generated\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello".to_string());
assert!(msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(contact.is_bot());
// Alice receives a message from Bob who is not the bot anymore.
receive_imf(
&alice,
b"From: Bob <bob@example.com>\n\
To: alice@example.org\n\
Chat-Version: 1.0\n\
Message-ID: <456@example.com>\n\
Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
\n\
hello again\n",
false,
)
.await?;
let msg = alice.get_last_msg().await;
assert_eq!(msg.get_text(), "hello again".to_string());
assert!(!msg.is_bot());
let contact = Contact::get_by_id(&alice, msg.from_id).await?;
assert!(!contact.is_bot());
Ok(())
}
#[test]
fn test_viewtype_derive_display_works_as_expected() {
assert_eq!(format!("{}", Viewtype::Audio), "Audio");
}
#[test]
fn test_viewtype_values() {
// values may be written to disk and must not change
assert_eq!(Viewtype::Unknown, Viewtype::default());
assert_eq!(Viewtype::Unknown, Viewtype::from_i32(0).unwrap());
assert_eq!(Viewtype::Text, Viewtype::from_i32(10).unwrap());
assert_eq!(Viewtype::Image, Viewtype::from_i32(20).unwrap());
assert_eq!(Viewtype::Gif, Viewtype::from_i32(21).unwrap());
assert_eq!(Viewtype::Sticker, Viewtype::from_i32(23).unwrap());
assert_eq!(Viewtype::Audio, Viewtype::from_i32(40).unwrap());
assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
assert_eq!(
Viewtype::VideochatInvitation,
Viewtype::from_i32(70).unwrap()
);
assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_quotes() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat = alice.create_chat(&bob).await;
let sent = alice.send_text(chat.id, "> First quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> First quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
let sent = alice.send_text(chat.id, "> Second quote").await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, "> Second quote");
assert!(received.quoted_text().is_none());
assert!(received.quoted_message(&bob).await?.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_get_message_summary_text() -> Result<()> {
let t = TestContext::new_alice().await;
let chat = t.get_self_chat().await;
let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
// message summary does not change when reactions are applied (in contrast to chatlist summary)
send_reaction(&t, msg_id, "🫵").await?;
let msg = Message::load_from_db(&t, msg_id).await?;
let summary = msg.get_summary(&t, None).await?;
assert_eq!(summary.text, "foo");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_format_flowed_round_trip() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let chat = alice.create_chat(&bob).await;
let text = " Foo bar";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "Foo bar baz";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let text = "> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > A";
let sent = alice.send_text(chat.id, text).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, text);
let python_program = "\
def hello():
return 'Hello, world!'";
let sent = alice.send_text(chat.id, python_program).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.text, python_program);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_delete_msgs_offline() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat = alice
.create_chat_with_contact("Bob", "bob@example.org")
.await;
let mut msg = Message::new_text("hi".to_string());
assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
.await
.is_err());
let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
assert!(alice.sql.exists(stmt, (msg.id,)).await?);
delete_msgs(&alice, &[msg.id]).await?;
assert!(!alice.sql.exists(stmt, (msg.id,)).await?);
Ok(())
}

View File

@@ -1,6 +1,7 @@
//! # MIME message production.
use std::collections::HashSet;
use std::path::Path;
use anyhow::{bail, Context as _, Result};
use base64::Engine as _;
@@ -66,8 +67,36 @@ pub struct MimeFactory {
selfstatus: String,
/// Vector of pairs of recipient name and address
recipients: Vec<(String, String)>,
/// Vector of actual recipient addresses.
///
/// This is the list of addresses the message should be sent to.
/// It is not the same as the `To` header,
/// because in case of "member removed" message
/// removed member is in the recipient list,
/// but not in the `To` header.
/// In case of broadcast lists there are multiple recipients,
/// but the `To` header has no members.
///
/// If `bcc_self` configuration is enabled,
/// this list will be extended with own address later,
/// but `MimeFactory` is not responsible for this.
recipients: Vec<String>,
/// Vector of pairs of recipient name and address that goes into the `To` field.
///
/// The list of actual message recipient addresses may be different,
/// e.g. if members are hidden for broadcast lists.
to: Vec<(String, String)>,
/// Vector of pairs of past group member names and addresses.
past_members: Vec<(String, String)>,
/// Timestamps of the members in the same order as in the `recipients`
/// followed by `past_members`.
///
/// If this is not empty, its length
/// should be the sum of `recipients` and `past_members` length.
member_timestamps: Vec<i64>,
timestamp: i64,
loaded: Loaded,
@@ -126,8 +155,10 @@ fn new_address_with_name(name: &str, address: String) -> Address {
impl MimeFactory {
pub async fn from_msg(context: &Context, msg: Message) -> Result<MimeFactory> {
let now = time();
let chat = Chat::load_from_db(context, msg.chat_id).await?;
let attach_profile_data = Self::should_attach_profile_data(&msg);
let undisclosed_recipients = chat.typ == Chattype::Broadcast;
let from_addr = context.get_primary_self_addr().await?;
let config_displayname = context
@@ -145,47 +176,101 @@ impl MimeFactory {
(name, None)
};
let mut recipients = Vec::with_capacity(5);
let mut recipients = Vec::new();
let mut to = Vec::new();
let mut past_members = Vec::new();
let mut member_timestamps = Vec::new();
let mut recipient_ids = HashSet::new();
let mut req_mdn = false;
if chat.is_self_talk() {
if msg.param.get_cmd() == SystemMessage::AutocryptSetupMessage {
recipients.push((from_displayname.to_string(), from_addr.to_string()));
recipients.push(from_addr.to_string());
}
to.push((from_displayname.to_string(), from_addr.to_string()));
} else if chat.is_mailing_list() {
let list_post = chat
.param
.get(Param::ListPost)
.context("Can't write to mailinglist without ListPost param")?;
recipients.push(("".to_string(), list_post.to_string()));
to.push(("".to_string(), list_post.to_string()));
recipients.push(list_post.to_string());
} else {
let email_to_remove = if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
msg.param.get(Param::Arg)
} else {
None
};
context
.sql
.query_map(
"SELECT c.authname, c.addr, c.id \
FROM chats_contacts cc \
LEFT JOIN contacts c ON cc.contact_id=c.id \
WHERE cc.chat_id=? AND cc.contact_id>9;",
(msg.chat_id,),
"SELECT c.authname, c.addr, c.id, cc.add_timestamp, cc.remove_timestamp
FROM chats_contacts cc
LEFT JOIN contacts c ON cc.contact_id=c.id
WHERE cc.chat_id=? AND (cc.contact_id>9 OR (cc.contact_id=1 AND ?))",
(msg.chat_id, chat.typ == Chattype::Group),
|row| {
let authname: String = row.get(0)?;
let addr: String = row.get(1)?;
let id: ContactId = row.get(2)?;
Ok((authname, addr, id))
let add_timestamp: i64 = row.get(3)?;
let remove_timestamp: i64 = row.get(4)?;
Ok((authname, addr, id, add_timestamp, remove_timestamp))
},
|rows| {
let mut past_member_timestamps = Vec::new();
for row in rows {
let (authname, addr, id) = row?;
if !recipients_contain_addr(&recipients, &addr) {
let name = match attach_profile_data {
true => authname,
false => "".to_string(),
};
recipients.push((name, addr));
let (authname, addr, id, add_timestamp, remove_timestamp) = row?;
let addr = if id == ContactId::SELF {
from_addr.to_string()
} else {
addr
};
let name = match attach_profile_data {
true => authname,
false => "".to_string(),
};
if add_timestamp >= remove_timestamp {
if !recipients_contain_addr(&to, &addr) {
recipients.push(addr.clone());
if !undisclosed_recipients {
to.push((name, addr));
member_timestamps.push(add_timestamp);
}
}
recipient_ids.insert(id);
} else if remove_timestamp.saturating_add(60 * 24 * 3600) > now {
// Row is a tombstone,
// member is not actually part of the group.
if !recipients_contain_addr(&past_members, &addr) {
if let Some(email_to_remove) = email_to_remove {
if email_to_remove == addr {
// This is a "member removed" message,
// we need to notify removed member
// that it was removed.
recipients.push(addr.clone());
}
}
if !undisclosed_recipients {
past_members.push((name, addr));
past_member_timestamps.push(remove_timestamp);
}
}
}
recipient_ids.insert(id);
}
debug_assert!(member_timestamps.len() >= to.len());
if to.len() > 1 {
if let Some(position) = to.iter().position(|(_, x)| x == &from_addr) {
to.remove(position);
member_timestamps.remove(position);
}
}
member_timestamps.extend(past_member_timestamps);
Ok(())
},
)
@@ -226,12 +311,19 @@ impl MimeFactory {
};
let attach_selfavatar = Self::should_attach_selfavatar(context, &msg).await;
debug_assert!(
member_timestamps.is_empty()
|| to.len() + past_members.len() == member_timestamps.len()
);
let factory = MimeFactory {
from_addr,
from_displayname,
sender_displayname,
selfstatus,
recipients,
to,
past_members,
member_timestamps,
timestamp: msg.timestamp_sort,
loaded: Loaded::Message { msg, chat },
in_reply_to,
@@ -259,7 +351,10 @@ impl MimeFactory {
from_displayname: "".to_string(),
sender_displayname: None,
selfstatus: "".to_string(),
recipients: vec![("".to_string(), contact.get_addr().to_string())],
recipients: vec![contact.get_addr().to_string()],
to: vec![("".to_string(), contact.get_addr().to_string())],
past_members: vec![],
member_timestamps: vec![],
timestamp,
loaded: Loaded::Mdn {
rfc724_mid,
@@ -283,11 +378,7 @@ impl MimeFactory {
let self_addr = context.get_primary_self_addr().await?;
let mut res = Vec::new();
for (_, addr) in self
.recipients
.iter()
.filter(|(_, addr)| addr != &self_addr)
{
for addr in self.recipients.iter().filter(|&addr| *addr != self_addr) {
res.push((Peerstate::from_addr(context, addr).await?, addr.clone()));
}
@@ -475,10 +566,7 @@ impl MimeFactory {
}
pub fn recipients(&self) -> Vec<String> {
self.recipients
.iter()
.map(|(_, addr)| addr.clone())
.collect()
self.recipients.clone()
}
/// Consumes a `MimeFactory` and renders it into a message which is then stored in
@@ -488,46 +576,33 @@ impl MimeFactory {
let from = new_address_with_name(&self.from_displayname, self.from_addr.clone());
let undisclosed_recipients = match &self.loaded {
Loaded::Message { chat, .. } => chat.typ == Chattype::Broadcast,
Loaded::Mdn { .. } => false,
};
let mut to = Vec::new();
if undisclosed_recipients {
for (name, addr) in &self.to {
if name.is_empty() {
to.push(Address::new_mailbox(addr.clone()));
} else {
to.push(new_address_with_name(name, addr.clone()));
}
}
let mut past_members = Vec::new(); // Contents of `Chat-Group-Past-Members` header.
for (name, addr) in &self.past_members {
if name.is_empty() {
past_members.push(Address::new_mailbox(addr.clone()));
} else {
past_members.push(new_address_with_name(name, addr.clone()));
}
}
debug_assert!(
self.member_timestamps.is_empty()
|| to.len() + past_members.len() == self.member_timestamps.len()
);
if to.is_empty() {
to.push(Address::new_group(
"hidden-recipients".to_string(),
Vec::new(),
));
} else {
let email_to_remove = match &self.loaded {
Loaded::Message { msg, .. } => {
if msg.param.get_cmd() == SystemMessage::MemberRemovedFromGroup {
msg.param.get(Param::Arg)
} else {
None
}
}
Loaded::Mdn { .. } => None,
};
for (name, addr) in &self.recipients {
if let Some(email_to_remove) = email_to_remove {
if email_to_remove == addr {
continue;
}
}
if name.is_empty() {
to.push(Address::new_mailbox(addr.clone()));
} else {
to.push(new_address_with_name(name, addr.clone()));
}
}
if to.is_empty() {
to.push(from.clone());
}
}
// Start with Internet Message Format headers in the order of the standard example
@@ -540,6 +615,31 @@ impl MimeFactory {
headers.push(Header::new_with_value("Sender".into(), vec![sender]).unwrap());
}
headers.push(Header::new_with_value("To".into(), to.clone()).unwrap());
if !past_members.is_empty() {
headers.push(
Header::new_with_value("Chat-Group-Past-Members".into(), past_members.clone())
.unwrap(),
);
}
if let Loaded::Message { chat, .. } = &self.loaded {
if chat.typ == Chattype::Group
&& !self.member_timestamps.is_empty()
&& !chat.member_list_is_stale(context).await?
{
headers.push(
Header::new_with_value(
"Chat-Group-Member-Timestamps".into(),
self.member_timestamps
.iter()
.map(|ts| ts.to_string())
.collect::<Vec<String>>()
.join(" "),
)
.unwrap(),
);
}
}
let subject_str = self.subject_str(context).await?;
let encoded_subject = if subject_str
@@ -652,7 +752,9 @@ impl MimeFactory {
let peerstates = self.peerstates_for_recipients(context).await?;
let is_encrypted = !self.should_force_plaintext()
&& encrypt_helper.should_encrypt(context, e2ee_guaranteed, &peerstates)?;
&& encrypt_helper
.should_encrypt(context, e2ee_guaranteed, &peerstates)
.await?;
let is_securejoin_message = if let Loaded::Message { msg, .. } = &self.loaded {
msg.param.get_cmd() == SystemMessage::SecurejoinMessage
} else {
@@ -1047,7 +1149,6 @@ impl MimeFactory {
part.body(text)
}
#[allow(clippy::cognitive_complexity)]
async fn render_message(
&mut self,
context: &Context,
@@ -1510,12 +1611,17 @@ pub(crate) fn wrapped_base64_encode(buf: &[u8]) -> String {
}
async fn build_body_file(context: &Context, msg: &Message) -> Result<PartBuilder> {
let file_name = msg.get_filename().context("msg has no file")?;
let suffix = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("dat");
let blob = msg
.param
.get_blob(Param::File, context)
.await?
.context("msg has no file")?;
let suffix = blob.suffix().unwrap_or("dat");
// Get file name to use for sending. For privacy purposes, we do
// not transfer the original filenames eg. for images; these names
@@ -1555,18 +1661,14 @@ async fn build_body_file(context: &Context, msg: &Message) -> Result<PartBuilder
),
&suffix
),
_ => msg
.param
.get(Param::Filename)
.unwrap_or_else(|| blob.as_file_name())
.to_string(),
_ => file_name,
};
/* check mimetype */
let mimetype: mime::Mime = match msg.param.get(Param::MimeType) {
Some(mtype) => mtype.parse()?,
None => {
if let Some(res) = message::guess_msgtype_from_suffix(blob.as_rel_path()) {
if let Some(res) = message::guess_msgtype_from_suffix(msg) {
res.1.parse()?
} else {
mime::APPLICATION_OCTET_STREAM
@@ -2459,8 +2561,9 @@ mod tests {
// Alice creates a group with Bob and Claire and then removes Bob.
let alice = TestContext::new_alice().await;
let claire_addr = "claire@foo.de";
let bob_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
let claire_id = Contact::create(&alice, "Claire", "claire@foo.de").await?;
let claire_id = Contact::create(&alice, "Claire", claire_addr).await?;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
add_contact_to_chat(&alice, alice_chat_id, bob_id).await?;
@@ -2476,10 +2579,17 @@ mod tests {
.get_first_header("To")
.context("no To: header parsed")?;
let to = addrparse_header(to)?;
let mailbox = to
.extract_single_info()
.context("to: field does not contain exactly one address")?;
assert_eq!(mailbox.addr, "bob@example.net");
for to_addr in to.iter() {
match to_addr {
mailparse::MailAddr::Single(ref info) => {
// Addresses should be of existing members (Alice and Bob) and not Claire.
assert_ne!(info.addr, claire_addr);
}
mailparse::MailAddr::Group(_) => {
panic!("Group addresses are not expected here");
}
}
}
Ok(())
}
@@ -2521,8 +2631,7 @@ mod tests {
// Long messages are truncated and MimeMessage::decoded_data is set for them. We need
// decoded_data to check presence of the necessary headers.
msg.set_text("a".repeat(constants::DC_DESIRED_TEXT_LEN + 1));
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)
.await?;
msg.set_file_from_bytes(&bob, "foo.bar", "content".as_bytes(), None)?;
let sent = bob.send_msg(chat, &mut msg).await;
assert!(msg.get_showpadlock());
assert!(sent.payload.contains("\r\nSubject: [...]\r\n"));
@@ -2539,4 +2648,40 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_remove_self() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let first_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
alice.send_text(first_group, "Hi! I created a group.").await;
remove_contact_from_chat(alice, first_group, ContactId::SELF).await?;
alice.pop_sent_msg().await;
let second_group = alice
.create_group_with_members(ProtectionStatus::Unprotected, "First group", &[bob])
.await;
let sent = alice
.send_text(second_group, "Hi! I created another group.")
.await;
println!("{}", sent.payload);
let mime_message = MimeMessage::from_bytes(alice, sent.payload.as_bytes(), None)
.await
.unwrap();
assert_eq!(
mime_message.get_header(HeaderDef::ChatGroupPastMembers),
None
);
assert_eq!(
mime_message.chat_group_member_timestamps().unwrap().len(),
1 // There is a timestamp for Bob, not for Alice
);
Ok(())
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ use crate::context::Context;
use crate::net::proxy::ProxyConfig;
use crate::net::session::SessionStream;
use crate::net::tls::wrap_rustls;
use crate::tools::{create_id, time};
use crate::tools::time;
/// HTTP(S) GET response.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -119,12 +119,8 @@ fn http_url_cache_timestamps(url: &str, mimetype: Option<&str>) -> (i64, i64) {
/// Places the binary into HTTP cache.
async fn http_cache_put(context: &Context, url: &str, response: &Response) -> Result<()> {
let blob = BlobObject::create(
context,
&format!("http_cache_{}", create_id()),
response.blob.as_slice(),
)
.await?;
let blob =
BlobObject::create_and_deduplicate_from_bytes(context, response.blob.as_slice(), "")?;
let (expires, stale) = http_url_cache_timestamps(url, response.mimetype.as_deref());
context

View File

@@ -640,6 +640,9 @@ mod tests {
fn test_invalid_proxy_url() {
assert!(ProxyConfig::from_url("foobar://127.0.0.1:9050").is_err());
assert!(ProxyConfig::from_url("abc").is_err());
// This caused panic before shadowsocks 1.22.0.
assert!(ProxyConfig::from_url("ss://foo:bar@127.0.0.1:9999").is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View File

@@ -373,7 +373,6 @@ impl Params {
/// Note that in the [ParamsFile::FsPath] case the blob can be
/// created without copying if the path already refers to a valid
/// blob. If so a [BlobObject] will be returned.
#[allow(clippy::needless_lifetimes)]
pub async fn get_blob<'a>(
&self,
key: Param,

View File

@@ -24,14 +24,12 @@
//! and `joinRealtimeChannel().send()` just like the other peers.
use anyhow::{anyhow, bail, Context as _, Result};
use data_encoding::BASE32_NOPAD;
use email::Header;
use futures_lite::StreamExt;
use iroh::{Endpoint, NodeAddr, NodeId, PublicKey, RelayMap, RelayMode, RelayUrl, SecretKey};
use iroh_gossip::net::{Event, Gossip, GossipEvent, JoinOptions, GOSSIP_ALPN};
use iroh_gossip::proto::TopicId;
use iroh_net::key::{PublicKey, SecretKey};
use iroh_net::relay::{RelayMap, RelayUrl};
use iroh_net::{relay::RelayMode, Endpoint};
use iroh_net::{NodeAddr, NodeId};
use parking_lot::Mutex;
use std::collections::{BTreeSet, HashMap};
use std::env;
@@ -54,8 +52,8 @@ const PUBLIC_KEY_STUB: &[u8] = "static_string".as_bytes();
/// Store iroh peer channels for the context.
#[derive(Debug)]
pub struct Iroh {
/// [Endpoint] needed for iroh peer channels.
pub(crate) endpoint: Endpoint,
/// iroh router needed for iroh peer channels.
pub(crate) router: iroh::protocol::Router,
/// [Gossip] needed for iroh peer channels.
pub(crate) gossip: Gossip,
@@ -75,15 +73,12 @@ pub struct Iroh {
impl Iroh {
/// Notify the endpoint that the network has changed.
pub(crate) async fn network_change(&self) {
self.endpoint.network_change().await
self.router.endpoint().network_change().await
}
/// Closes the QUIC endpoint.
pub(crate) async fn close(self) -> Result<()> {
self.endpoint
.close(0u32.into(), b"")
.await
.context("Closing iroh endpoint failed")
self.router.shutdown().await.context("Closing iroh failed")
}
/// Join a topic and create the subscriber loop for it.
@@ -120,8 +115,8 @@ impl Iroh {
// Inform iroh of potentially new node addresses
for node_addr in &peers {
if !node_addr.info.is_empty() {
self.endpoint.add_node_addr(node_addr.clone())?;
if !node_addr.is_empty() {
self.router.endpoint().add_node_addr(node_addr.clone())?;
}
}
@@ -129,7 +124,7 @@ impl Iroh {
let (gossip_sender, gossip_receiver) = self
.gossip
.join_with_opts(topic, JoinOptions::with_bootstrap(node_ids))
.subscribe_with_opts(topic, JoinOptions::with_bootstrap(node_ids))
.split();
let ctx = ctx.clone();
@@ -148,10 +143,10 @@ impl Iroh {
pub async fn maybe_add_gossip_peers(&self, topic: TopicId, peers: Vec<NodeAddr>) -> Result<()> {
if self.iroh_channels.read().await.get(&topic).is_some() {
for peer in &peers {
self.endpoint.add_node_addr(peer.clone())?;
self.router.endpoint().add_node_addr(peer.clone())?;
}
self.gossip.join_with_opts(
self.gossip.subscribe_with_opts(
topic,
JoinOptions::with_bootstrap(peers.into_iter().map(|peer| peer.node_id)),
);
@@ -198,8 +193,8 @@ impl Iroh {
/// Get the iroh [NodeAddr] without direct IP addresses.
pub(crate) async fn get_node_addr(&self) -> Result<NodeAddr> {
let mut addr = self.endpoint.node_addr().await?;
addr.info.direct_addresses = BTreeSet::new();
let mut addr = self.router.endpoint().node_addr().await?;
addr.direct_addresses = BTreeSet::new();
Ok(addr)
}
@@ -242,7 +237,7 @@ impl Context {
/// Create iroh endpoint and gossip.
async fn init_peer_channels(&self) -> Result<Iroh> {
info!(self, "Initializing peer channels.");
let secret_key = SecretKey::generate();
let secret_key = SecretKey::generate(rand::rngs::OsRng);
let public_key = secret_key.public();
let relay_mode = if let Some(relay_url) = self
@@ -267,24 +262,22 @@ impl Context {
.await?;
// create gossip
let my_addr = endpoint.node_addr().await?;
let gossip_config = iroh_gossip::proto::topic::Config {
// Allow messages up to 128 KB in size.
// We set the limit to 128 KiB to account for internal overhead,
// but only guarantee 128 KB of payload to WebXDC developers.
max_message_size: 128 * 1024,
..Default::default()
};
let gossip = Gossip::from_endpoint(endpoint.clone(), gossip_config, &my_addr.info);
// Allow messages up to 128 KB in size.
// We set the limit to 128 KiB to account for internal overhead,
// but only guarantee 128 KB of payload to WebXDC developers.
// spawn endpoint loop that forwards incoming connections to the gossiper
let context = self.clone();
let gossip = Gossip::builder()
.max_message_size(128 * 1024)
.spawn(endpoint.clone())
.await?;
// Shuts down on deltachat shutdown
tokio::spawn(endpoint_loop(context, endpoint.clone(), gossip.clone()));
let router = iroh::protocol::Router::builder(endpoint)
.accept(GOSSIP_ALPN, gossip.clone())
.spawn()
.await?;
Ok(Iroh {
endpoint,
router,
gossip,
sequence_numbers: Mutex::new(HashMap::new()),
iroh_channels: RwLock::new(HashMap::new()),
@@ -417,7 +410,6 @@ async fn get_iroh_gossip_peers(ctx: &Context, msg_id: MsgId) -> Result<Vec<NodeA
))
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Into::into)
},
)
.await
@@ -507,54 +499,13 @@ fn create_random_topic() -> TopicId {
pub(crate) async fn create_iroh_header(ctx: &Context, msg_id: MsgId) -> Result<Header> {
let topic = create_random_topic();
insert_topic_stub(ctx, msg_id, topic).await?;
let topic_string = BASE32_NOPAD.encode(topic.as_bytes()).to_ascii_lowercase();
Ok(Header::new(
HeaderDef::IrohGossipTopic.get_headername().to_string(),
topic.to_string(),
topic_string,
))
}
async fn endpoint_loop(context: Context, endpoint: Endpoint, gossip: Gossip) {
while let Some(conn) = endpoint.accept().await {
let conn = match conn.accept() {
Ok(conn) => conn,
Err(err) => {
warn!(context, "Failed to accept iroh connection: {err:#}.");
continue;
}
};
info!(context, "IROH_REALTIME: accepting iroh connection");
let gossip = gossip.clone();
let context = context.clone();
tokio::spawn(async move {
if let Err(err) = handle_connection(&context, conn, gossip).await {
warn!(context, "IROH_REALTIME: iroh connection error: {err}");
}
});
}
}
async fn handle_connection(
context: &Context,
mut conn: iroh_net::endpoint::Connecting,
gossip: Gossip,
) -> anyhow::Result<()> {
let alpn = conn.alpn().await?;
let conn = conn.await?;
let peer_id = iroh_net::endpoint::get_remote_node_id(&conn)?;
match alpn.as_slice() {
GOSSIP_ALPN => gossip
.handle_connection(conn)
.await
.context(format!("Gossip connection to {peer_id} failed"))?,
_ => warn!(
context,
"Ignoring connection from {peer_id}: unsupported ALPN protocol"
),
}
Ok(())
}
async fn subscribe_loop(
context: &Context,
mut stream: iroh_gossip::net::GossipReceiver,
@@ -629,7 +580,6 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
@@ -801,7 +751,6 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
@@ -985,7 +934,6 @@ mod tests {
include_bytes!("../test-data/webxdc/minimal.xdc"),
None,
)
.await
.unwrap();
send_msg(alice, alice_chat.id, &mut instance).await.unwrap();
let alice_webxdc = alice.get_last_msg().await;

View File

@@ -711,9 +711,25 @@ impl Peerstate {
Origin::IncomingUnknownFrom,
)
.await?;
chat::remove_from_chat_contacts_table(context, *chat_id, contact_id)
.await?;
chat::add_to_chat_contacts_table(context, *chat_id, &[new_contact_id])
context
.sql
.transaction(|transaction| {
transaction.execute(
"UPDATE chats_contacts
SET remove_timestamp=MAX(add_timestamp+1, ?)
WHERE chat_id=? AND contact_id=?",
(timestamp, chat_id, contact_id),
)?;
transaction.execute(
"INSERT INTO chats_contacts
(chat_id, contact_id, add_timestamp)
VALUES (?1, ?2, ?3)
ON CONFLICT (chat_id, contact_id)
DO UPDATE SET add_timestamp=MAX(remove_timestamp, ?3)",
(chat_id, new_contact_id, timestamp),
)?;
Ok(())
})
.await?;
context.emit_event(EventType::ChatModified(*chat_id));

View File

@@ -21,11 +21,9 @@ use tokio::runtime::Handle;
use crate::constants::KeyGenType;
use crate::key::{DcKey, Fingerprint};
#[allow(missing_docs)]
#[cfg(test)]
pub(crate) const HEADER_AUTOCRYPT: &str = "autocrypt-prefer-encrypt";
#[allow(missing_docs)]
pub const HEADER_SETUPCODE: &str = "passphrase-begin";
/// Preferred symmetric encryption algorithm.
@@ -381,13 +379,6 @@ pub fn pk_validate(
let standalone_signature = StandaloneSignature::from_armor_single(Cursor::new(signature))?.0;
// Remove trailing CRLF before the delimiter.
// According to RFC 3156 it is considered to be part of the MIME delimiter for the purpose of
// OpenPGP signature calculation.
let content = content
.get(..content.len().saturating_sub(2))
.context("index is out of range")?;
for pkey in public_keys_for_validation {
if standalone_signature.verify(pkey, content).is_ok() {
let fp = pkey.dc_fingerprint();

View File

@@ -4,7 +4,7 @@ pub(crate) mod data;
use anyhow::Result;
use deltachat_contact_tools::EmailAddress;
use hickory_resolver::{config, AsyncResolver, TokioAsyncResolver};
use hickory_resolver::{config, Resolver, TokioResolver};
use crate::config::Config;
use crate::context::Context;
@@ -165,11 +165,11 @@ impl ProviderOptions {
/// We first try to read the system's resolver from `/etc/resolv.conf`.
/// This does not work at least on some Androids, therefore we fallback
/// to the default `ResolverConfig` which uses eg. to google's `8.8.8.8` or `8.8.4.4`.
fn get_resolver() -> Result<TokioAsyncResolver> {
if let Ok(resolver) = AsyncResolver::tokio_from_system_conf() {
fn get_resolver() -> Result<TokioResolver> {
if let Ok(resolver) = Resolver::tokio_from_system_conf() {
return Ok(resolver);
}
let resolver = AsyncResolver::tokio(
let resolver = Resolver::tokio(
config::ResolverConfig::default(),
config::ResolverOpts::default(),
);

923
src/qr.rs
View File

@@ -112,7 +112,7 @@ pub enum Qr {
/// Provides a backup that can be retrieved using iroh-net based backup transfer protocol.
Backup2 {
/// Iroh node address.
node_addr: iroh_net::NodeAddr,
node_addr: iroh::NodeAddr,
/// Authentication token.
auth_token: String,
@@ -261,6 +261,13 @@ pub enum Qr {
},
}
// hack around the changed JSON accidentally used by an iroh upgrade, see #6518 for more details and for code snippet.
// this hack is mainly needed to give ppl time to upgrade and can be removed after some months (added 2025-02)
fn fix_add_second_device_qr(qr: &str) -> String {
qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
.replacen(r#""]}}"#, r#""]}"#, 1)
}
fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
string.to_lowercase().starts_with(&pattern.to_lowercase())
}
@@ -290,7 +297,8 @@ pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
} else if qr.starts_with(SHADOWSOCKS_SCHEME) {
decode_shadowsocks_proxy(qr)?
} else if starts_with_ignore_case(qr, DCBACKUP2_SCHEME) {
decode_backup2(qr)?
let qr_fixed = fix_add_second_device_qr(qr);
decode_backup2(&qr_fixed)?
} else if qr.starts_with(MAILTO_SCHEME) {
decode_mailto(context, qr).await?
} else if qr.starts_with(SMTP_SCHEME) {
@@ -644,7 +652,7 @@ fn decode_backup2(qr: &str) -> Result<Qr> {
.split_once('&')
.context("Backup QR code has no separator")?;
let auth_token = auth_token.to_string();
let node_addr = serde_json::from_str::<iroh_net::NodeAddr>(node_addr)
let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
.context("Invalid node addr in backup QR code")?;
Ok(Qr::Backup2 {
@@ -947,911 +955,4 @@ fn normalize_address(addr: &str) -> Result<String> {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aheader::EncryptPreference;
use crate::chat::{create_group_chat, ProtectionStatus};
use crate::config::Config;
use crate::key::DcKey;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{alice_keypair, TestContext};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_http() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "http://www.hello.com:80").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "http://www.hello.com:80".to_string(),
host: "www.hello.com".to_string(),
port: 80
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "http://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/hello".to_string(),
}
);
// Test that QR code whitespace is stripped.
// Users can copy-paste QR code contents and "scan"
// from the clipboard.
let qr = check_qr(&ctx.ctx, " \thttp://www.hello.com/hello \n\t \r\n ").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/hello".to_string(),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_https() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "https://www.hello.com:443").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "https://www.hello.com:443".to_string(),
host: "www.hello.com".to_string(),
port: 443
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "https://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/hello".to_string(),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_text() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "I am so cool").await?;
assert_eq!(
qr,
Qr::Text {
text: "I am so cool".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_vcard() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"BEGIN:VCARD\nVERSION:3.0\nN:Last;First\nEMAIL;TYPE=INTERNET:stress@test.local\nEND:VCARD"
).await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert_eq!(contact.get_name(), "First Last");
assert_eq!(contact.get_authname(), "");
assert_eq!(contact.get_display_name(), "First Last");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_matmsg() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"MATMSG:TO:\n\nstress@test.local ; \n\nSUB:\n\nSubject here\n\nBODY:\n\nhelloworld\n;;",
)
.await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_mailto() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"mailto:stress@test.local?subject=hello&body=beautiful+world",
)
.await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert_eq!(draft.unwrap(), "hello\nbeautiful world");
} else {
bail!("Wrong QR code type");
}
let res = check_qr(&ctx.ctx, "mailto:no-questionmark@example.org").await?;
if let Qr::Addr { contact_id, draft } = res {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "no-questionmark@example.org");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
let res = check_qr(&ctx.ctx, "mailto:no-addr").await;
assert!(res.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_smtp() -> Result<()> {
let ctx = TestContext::new().await;
if let Qr::Addr { contact_id, draft } =
check_qr(&ctx.ctx, "SMTP:stress@test.local:subjecthello:bodyworld").await?
{
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_ideltachat_link() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"https://i.delta.chat/#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
let qr = check_qr(
&ctx.ctx,
"https://i.delta.chat#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
Ok(())
}
// macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too.
// see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7%23a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_group() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
if let Qr::AskVerifyGroup {
contact_id,
grpname,
..
} = qr
{
assert_ne!(contact_id, ContactId::UNDEFINED);
assert_eq!(grpname, "test ? test !");
} else {
bail!("Wrong QR code type");
}
// Test it again with lowercased "openpgp4fpr:" uri scheme
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
if let Qr::AskVerifyGroup {
contact_id,
grpname,
..
} = qr
{
assert_ne!(contact_id, ContactId::UNDEFINED);
assert_eq!(grpname, "test ? test !");
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_invalid_token() -> Result<()> {
let ctx = TestContext::new().await;
// Token cannot contain "/"
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL"
).await?;
assert!(matches!(qr, Qr::FprMismatch { .. }));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_secure_join() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
assert_ne!(contact_id, ContactId::UNDEFINED);
} else {
bail!("Wrong QR code type");
}
// Test it again with lowercased "openpgp4fpr:" uri scheme
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
assert_eq!(contact.get_authname(), "Jörn P. P.");
assert_eq!(contact.get_name(), "");
} else {
bail!("Wrong QR code type");
}
// Regression test
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
assert_eq!(contact.get_authname(), "");
assert_eq!(contact.get_name(), "");
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_fingerprint() -> Result<()> {
let ctx = TestContext::new().await;
let alice_contact_id = Contact::create(&ctx, "Alice", "alice@example.org")
.await
.context("failed to create contact")?;
let pub_key = alice_keypair().public;
let peerstate = Peerstate {
addr: "alice@example.org".to_string(),
last_seen: 1,
last_seen_autocrypt: 1,
prefer_encrypt: EncryptPreference::Mutual,
public_key: Some(pub_key.clone()),
public_key_fingerprint: Some(pub_key.dc_fingerprint()),
gossip_key: None,
gossip_timestamp: 0,
gossip_key_fingerprint: None,
verified_key: None,
verified_key_fingerprint: None,
verifier: None,
secondary_verified_key: None,
secondary_verified_key_fingerprint: None,
secondary_verifier: None,
backward_verified_key_id: None,
fingerprint_changed: false,
};
assert!(
peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(),
"failed to save peerstate"
);
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
)
.await?;
if let Qr::FprMismatch { contact_id, .. } = qr {
assert_eq!(contact_id, Some(alice_contact_id));
} else {
bail!("Wrong QR code type");
}
let qr = check_qr(
&ctx.ctx,
&format!(
"OPENPGP4FPR:{}#a=alice@example.org",
pub_key.dc_fingerprint()
),
)
.await?;
if let Qr::FprOk { contact_id, .. } = qr {
assert_eq!(contact_id, alice_contact_id);
} else {
bail!("Wrong QR code type");
}
assert_eq!(
check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org",
)
.await?,
Qr::FprMismatch { contact_id: None }
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_without_addr() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
);
// Test it again with lowercased "openpgp4fpr:" uri scheme
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
);
let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await;
assert!(res.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifycontact() -> Result<()> {
let alice = TestContext::new_alice().await;
let qr = get_securejoin_qr(&alice, None).await?;
// scanning own verify-contact code offers withdrawing
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::WithdrawVerifyContact { .. }
));
set_config_from_qr(&alice, &qr).await?;
// scanning withdrawn verify-contact code offers reviving
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::ReviveVerifyContact { .. }
));
set_config_from_qr(&alice, &qr).await?;
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::WithdrawVerifyContact { .. }
));
// someone else always scans as ask-verify-contact
let bob = TestContext::new_bob().await;
assert!(matches!(
check_qr(&bob, &qr).await?,
Qr::AskVerifyContact { .. }
));
assert!(set_config_from_qr(&bob, &qr).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifygroup() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let qr = get_securejoin_qr(&alice, Some(chat_id)).await?;
// scanning own verify-group code offers withdrawing
if let Qr::WithdrawVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected WithdrawVerifyGroup");
}
set_config_from_qr(&alice, &qr).await?;
// scanning withdrawn verify-group code offers reviving
if let Qr::ReviveVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected ReviveVerifyGroup");
}
// someone else always scans as ask-verify-group
let bob = TestContext::new_bob().await;
if let Qr::AskVerifyGroup { grpname, .. } = check_qr(&bob, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected AskVerifyGroup");
}
assert!(set_config_from_qr(&bob, &qr).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin() -> Result<()> {
let ctx = TestContext::new().await;
let result = check_qr(&ctx.ctx, "dclogin:usename+extension@host?p=1234&v=1").await?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "usename+extension@host".to_owned());
if let LoginOptions::V1 { mail_pw, .. } = options {
assert_eq!(mail_pw, "1234".to_owned());
} else {
bail!("wrong type")
}
} else {
bail!("wrong type")
}
assert!(ctx.ctx.get_config(Config::Addr).await?.is_none());
assert!(ctx.ctx.get_config(Config::MailPw).await?.is_none());
set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&v=1").await?;
assert_eq!(
ctx.ctx.get_config(Config::Addr).await?,
Some("username+extension@host".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPw).await?,
Some("1234".to_owned())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin_advanced_options() -> Result<()> {
let ctx = TestContext::new().await;
set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&spw=4321&sh=send.host&sp=7273&su=SendUser&ih=host.tld&ip=4343&iu=user&ipw=password&is=ssl&ic=1&sc=3&ss=plain&v=1").await?;
assert_eq!(
ctx.ctx.get_config(Config::Addr).await?,
Some("username+extension@host".to_owned())
);
// `p=1234` is ignored, because `ipw=password` is set
assert_eq!(
ctx.ctx.get_config(Config::MailServer).await?,
Some("host.tld".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPort).await?,
Some("4343".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailUser).await?,
Some("user".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPw).await?,
Some("password".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailSecurity).await?,
Some("1".to_owned()) // ssl
);
assert_eq!(
ctx.ctx.get_config(Config::ImapCertificateChecks).await?,
Some("1".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendPw).await?,
Some("4321".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendServer).await?,
Some("send.host".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendPort).await?,
Some("7273".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendUser).await?,
Some("SendUser".to_owned())
);
// `sc` option is actually ignored and `ic` is used instead
// because `smtp_certificate_checks` is deprecated.
assert_eq!(
ctx.ctx.get_config(Config::SmtpCertificateChecks).await?,
Some("1".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendSecurity).await?,
Some("3".to_owned()) // plain
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_account() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await?;
assert_eq!(
qr,
Qr::Account {
domain: "example.org".to_string()
}
);
// Test it again with lowercased "dcaccount:" uri scheme
let qr = check_qr(
&ctx.ctx,
"dcaccount:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await?;
assert_eq!(
qr,
Qr::Account {
domain: "example.org".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_webrtc_instance() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?;
assert_eq!(
qr,
Qr::WebrtcInstance {
domain: "basicurl.com".to_string(),
instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string()
}
);
// Test it again with mixcased "dcWebRTC:" uri scheme
let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?;
assert_eq!(
qr,
Qr::WebrtcInstance {
domain: "example.org".to_string(),
instance_pattern: "https://example.org/".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_tg_socks_proxy() -> Result<()> {
let t = TestContext::new().await;
let qr = check_qr(&t, "https://t.me/socks?server=84.53.239.95&port=4145").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://84.53.239.95:4145".to_string(),
host: "84.53.239.95".to_string(),
port: 4145,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.bar&port=123").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://foo.bar:123".to_string(),
host: "foo.bar".to_string(),
port: 123,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.baz").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://foo.baz:1080".to_string(),
host: "foo.baz".to_string(),
port: 1080,
}
);
let qr = check_qr(
&t,
"https://t.me/socks?server=foo.baz&port=12345&user=ada&pass=ms%21%2F%24",
)
.await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://ada:ms%21%2F%24@foo.baz:12345".to_string(),
host: "foo.baz".to_string(),
port: 12345,
}
);
// wrong domain results in Qr:Url instead of Qr::Socks5Proxy
let qr = check_qr(&t, "https://not.me/socks?noserver=84.53.239.95&port=4145").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://not.me/socks?noserver=84.53.239.95&port=4145".to_string()
}
);
let qr = check_qr(&t, "https://t.me/socks?noserver=84.53.239.95&port=4145").await;
assert!(qr.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_account_bad_scheme() {
let ctx = TestContext::new().await;
let res = check_qr(
&ctx.ctx,
"DCACCOUNT:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await;
assert!(res.is_err());
// Test it again with lowercased "dcaccount:" uri scheme
let res = check_qr(
&ctx.ctx,
"dcaccount:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await;
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_webrtc_instance_config_from_qr() -> Result<()> {
let ctx = TestContext::new().await;
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await;
assert!(res.is_err());
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await;
assert!(res.is_ok());
assert_eq!(
ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(),
"https://example.org/"
);
let res =
set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await;
assert!(res.is_ok());
assert_eq!(
ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(),
"basicwebrtc:https://foo.bar/?$ROOM&test"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_proxy_config_from_qr() -> Result<()> {
let t = TestContext::new().await;
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, false);
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some("socks5://foo:666".to_string())
);
// Test URL without port.
//
// Also check that whitespace is trimmed.
let res = set_config_from_qr(&t, " https://t.me/socks?server=1.2.3.4\n").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some("socks5://1.2.3.4:1080\nsocks5://foo:666".to_string())
);
// make sure, user&password are set when specified in the URL
// Password is an URL-encoded "x&%$X".
let res =
set_config_from_qr(&t, "https://t.me/socks?server=jau&user=Da&pass=x%26%25%24X").await;
assert!(res.is_ok());
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080\nsocks5://foo:666"
.to_string()
)
);
// Scanning existing proxy brings it to the top in the list.
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080"
.to_string()
)
);
set_config_from_qr(
&t,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080"
.to_string()
)
);
// SOCKS5 config does not have port 1080 explicitly specified,
// but should bring `socks5://1.2.3.4:1080` to the top instead of creating another entry.
set_config_from_qr(&t, "socks5://1.2.3.4").await?;
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://1.2.3.4:1080\nss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080"
.to_string()
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_shadowsocks() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
qr,
Qr::Proxy {
url: "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1".to_string(),
host: "192.168.100.1".to_string(),
port: 8888,
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_socks5() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "socks5://127.0.0.1:9050").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://127.0.0.1:9050".to_string(),
host: "127.0.0.1".to_string(),
port: 9050,
}
);
Ok(())
}
}
mod qr_tests;

921
src/qr/qr_tests.rs Normal file
View File

@@ -0,0 +1,921 @@
use super::*;
use crate::aheader::EncryptPreference;
use crate::chat::{create_group_chat, ProtectionStatus};
use crate::config::Config;
use crate::key::DcKey;
use crate::securejoin::get_securejoin_qr;
use crate::test_utils::{alice_keypair, TestContext};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_http() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "http://www.hello.com:80").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "http://www.hello.com:80".to_string(),
host: "www.hello.com".to_string(),
port: 80
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "http://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "http://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/hello".to_string(),
}
);
// Test that QR code whitespace is stripped.
// Users can copy-paste QR code contents and "scan"
// from the clipboard.
let qr = check_qr(&ctx.ctx, " \thttp://www.hello.com/hello \n\t \r\n ").await?;
assert_eq!(
qr,
Qr::Url {
url: "http://www.hello.com/hello".to_string(),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_https() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "https://www.hello.com:443").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "https://www.hello.com:443".to_string(),
host: "www.hello.com".to_string(),
port: 443
}
);
// If it has no explicit port, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com".to_string(),
}
);
// If it has a path, then it is not a proxy.
let qr = check_qr(&ctx.ctx, "https://www.hello.com/").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/".to_string(),
}
);
let qr = check_qr(&ctx.ctx, "https://www.hello.com/hello").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://www.hello.com/hello".to_string(),
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_text() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "I am so cool").await?;
assert_eq!(
qr,
Qr::Text {
text: "I am so cool".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_vcard() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"BEGIN:VCARD\nVERSION:3.0\nN:Last;First\nEMAIL;TYPE=INTERNET:stress@test.local\nEND:VCARD",
)
.await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert_eq!(contact.get_name(), "First Last");
assert_eq!(contact.get_authname(), "");
assert_eq!(contact.get_display_name(), "First Last");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_matmsg() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"MATMSG:TO:\n\nstress@test.local ; \n\nSUB:\n\nSubject here\n\nBODY:\n\nhelloworld\n;;",
)
.await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_mailto() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"mailto:stress@test.local?subject=hello&body=beautiful+world",
)
.await?;
if let Qr::Addr { contact_id, draft } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert_eq!(draft.unwrap(), "hello\nbeautiful world");
} else {
bail!("Wrong QR code type");
}
let res = check_qr(&ctx.ctx, "mailto:no-questionmark@example.org").await?;
if let Qr::Addr { contact_id, draft } = res {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "no-questionmark@example.org");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
let res = check_qr(&ctx.ctx, "mailto:no-addr").await;
assert!(res.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_smtp() -> Result<()> {
let ctx = TestContext::new().await;
if let Qr::Addr { contact_id, draft } =
check_qr(&ctx.ctx, "SMTP:stress@test.local:subjecthello:bodyworld").await?
{
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "stress@test.local");
assert!(draft.is_none());
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_ideltachat_link() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"https://i.delta.chat/#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
let qr = check_qr(
&ctx.ctx,
"https://i.delta.chat#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
Ok(())
}
// macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too.
// see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7%23a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
assert!(matches!(qr, Qr::AskVerifyGroup { .. }));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_group() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
if let Qr::AskVerifyGroup {
contact_id,
grpname,
..
} = qr
{
assert_ne!(contact_id, ContactId::UNDEFINED);
assert_eq!(grpname, "test ? test !");
} else {
bail!("Wrong QR code type");
}
// Test it again with lowercased "openpgp4fpr:" uri scheme
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL"
).await?;
if let Qr::AskVerifyGroup {
contact_id,
grpname,
..
} = qr
{
assert_ne!(contact_id, ContactId::UNDEFINED);
assert_eq!(grpname, "test ? test !");
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_invalid_token() -> Result<()> {
let ctx = TestContext::new().await;
// Token cannot contain "/"
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL"
).await?;
assert!(matches!(qr, Qr::FprMismatch { .. }));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_secure_join() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
assert_ne!(contact_id, ContactId::UNDEFINED);
} else {
bail!("Wrong QR code type");
}
// Test it again with lowercased "openpgp4fpr:" uri scheme
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
assert_eq!(contact.get_authname(), "Jörn P. P.");
assert_eq!(contact.get_name(), "");
} else {
bail!("Wrong QR code type");
}
// Regression test
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=&i=TbnwJ6lSvD5&s=0ejvbdFSQxB"
).await?;
if let Qr::AskVerifyContact { contact_id, .. } = qr {
let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?;
assert_eq!(contact.get_addr(), "cli@deltachat.de");
assert_eq!(contact.get_authname(), "");
assert_eq!(contact.get_name(), "");
} else {
bail!("Wrong QR code type");
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_fingerprint() -> Result<()> {
let ctx = TestContext::new().await;
let alice_contact_id = Contact::create(&ctx, "Alice", "alice@example.org")
.await
.context("failed to create contact")?;
let pub_key = alice_keypair().public;
let peerstate = Peerstate {
addr: "alice@example.org".to_string(),
last_seen: 1,
last_seen_autocrypt: 1,
prefer_encrypt: EncryptPreference::Mutual,
public_key: Some(pub_key.clone()),
public_key_fingerprint: Some(pub_key.dc_fingerprint()),
gossip_key: None,
gossip_timestamp: 0,
gossip_key_fingerprint: None,
verified_key: None,
verified_key_fingerprint: None,
verifier: None,
secondary_verified_key: None,
secondary_verified_key_fingerprint: None,
secondary_verifier: None,
backward_verified_key_id: None,
fingerprint_changed: false,
};
assert!(
peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(),
"failed to save peerstate"
);
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org",
)
.await?;
if let Qr::FprMismatch { contact_id, .. } = qr {
assert_eq!(contact_id, Some(alice_contact_id));
} else {
bail!("Wrong QR code type");
}
let qr = check_qr(
&ctx.ctx,
&format!(
"OPENPGP4FPR:{}#a=alice@example.org",
pub_key.dc_fingerprint()
),
)
.await?;
if let Qr::FprOk { contact_id, .. } = qr {
assert_eq!(contact_id, alice_contact_id);
} else {
bail!("Wrong QR code type");
}
assert_eq!(
check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org",
)
.await?,
Qr::FprMismatch { contact_id: None }
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_openpgp_without_addr() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"OPENPGP4FPR:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
);
// Test it again with lowercased "openpgp4fpr:" uri scheme
let qr = check_qr(
&ctx.ctx,
"openpgp4fpr:1234567890123456789012345678901234567890",
)
.await?;
assert_eq!(
qr,
Qr::FprWithoutAddr {
fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string()
}
);
let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await;
assert!(res.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifycontact() -> Result<()> {
let alice = TestContext::new_alice().await;
let qr = get_securejoin_qr(&alice, None).await?;
// scanning own verify-contact code offers withdrawing
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::WithdrawVerifyContact { .. }
));
set_config_from_qr(&alice, &qr).await?;
// scanning withdrawn verify-contact code offers reviving
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::ReviveVerifyContact { .. }
));
set_config_from_qr(&alice, &qr).await?;
assert!(matches!(
check_qr(&alice, &qr).await?,
Qr::WithdrawVerifyContact { .. }
));
// someone else always scans as ask-verify-contact
let bob = TestContext::new_bob().await;
assert!(matches!(
check_qr(&bob, &qr).await?,
Qr::AskVerifyContact { .. }
));
assert!(set_config_from_qr(&bob, &qr).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_withdraw_verifygroup() -> Result<()> {
let alice = TestContext::new_alice().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?;
let qr = get_securejoin_qr(&alice, Some(chat_id)).await?;
// scanning own verify-group code offers withdrawing
if let Qr::WithdrawVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected WithdrawVerifyGroup");
}
set_config_from_qr(&alice, &qr).await?;
// scanning withdrawn verify-group code offers reviving
if let Qr::ReviveVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected ReviveVerifyGroup");
}
// someone else always scans as ask-verify-group
let bob = TestContext::new_bob().await;
if let Qr::AskVerifyGroup { grpname, .. } = check_qr(&bob, &qr).await? {
assert_eq!(grpname, "foo");
} else {
bail!("Wrong QR type, expected AskVerifyGroup");
}
assert!(set_config_from_qr(&bob, &qr).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin() -> Result<()> {
let ctx = TestContext::new().await;
let result = check_qr(&ctx.ctx, "dclogin:usename+extension@host?p=1234&v=1").await?;
if let Qr::Login { address, options } = result {
assert_eq!(address, "usename+extension@host".to_owned());
if let LoginOptions::V1 { mail_pw, .. } = options {
assert_eq!(mail_pw, "1234".to_owned());
} else {
bail!("wrong type")
}
} else {
bail!("wrong type")
}
assert!(ctx.ctx.get_config(Config::Addr).await?.is_none());
assert!(ctx.ctx.get_config(Config::MailPw).await?.is_none());
set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&v=1").await?;
assert_eq!(
ctx.ctx.get_config(Config::Addr).await?,
Some("username+extension@host".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPw).await?,
Some("1234".to_owned())
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_and_apply_dclogin_advanced_options() -> Result<()> {
let ctx = TestContext::new().await;
set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&spw=4321&sh=send.host&sp=7273&su=SendUser&ih=host.tld&ip=4343&iu=user&ipw=password&is=ssl&ic=1&sc=3&ss=plain&v=1").await?;
assert_eq!(
ctx.ctx.get_config(Config::Addr).await?,
Some("username+extension@host".to_owned())
);
// `p=1234` is ignored, because `ipw=password` is set
assert_eq!(
ctx.ctx.get_config(Config::MailServer).await?,
Some("host.tld".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPort).await?,
Some("4343".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailUser).await?,
Some("user".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailPw).await?,
Some("password".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::MailSecurity).await?,
Some("1".to_owned()) // ssl
);
assert_eq!(
ctx.ctx.get_config(Config::ImapCertificateChecks).await?,
Some("1".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendPw).await?,
Some("4321".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendServer).await?,
Some("send.host".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendPort).await?,
Some("7273".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendUser).await?,
Some("SendUser".to_owned())
);
// `sc` option is actually ignored and `ic` is used instead
// because `smtp_certificate_checks` is deprecated.
assert_eq!(
ctx.ctx.get_config(Config::SmtpCertificateChecks).await?,
Some("1".to_owned())
);
assert_eq!(
ctx.ctx.get_config(Config::SendSecurity).await?,
Some("3".to_owned()) // plain
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_account() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await?;
assert_eq!(
qr,
Qr::Account {
domain: "example.org".to_string()
}
);
// Test it again with lowercased "dcaccount:" uri scheme
let qr = check_qr(
&ctx.ctx,
"dcaccount:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await?;
assert_eq!(
qr,
Qr::Account {
domain: "example.org".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_webrtc_instance() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?;
assert_eq!(
qr,
Qr::WebrtcInstance {
domain: "basicurl.com".to_string(),
instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string()
}
);
// Test it again with mixcased "dcWebRTC:" uri scheme
let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?;
assert_eq!(
qr,
Qr::WebrtcInstance {
domain: "example.org".to_string(),
instance_pattern: "https://example.org/".to_string()
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_tg_socks_proxy() -> Result<()> {
let t = TestContext::new().await;
let qr = check_qr(&t, "https://t.me/socks?server=84.53.239.95&port=4145").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://84.53.239.95:4145".to_string(),
host: "84.53.239.95".to_string(),
port: 4145,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.bar&port=123").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://foo.bar:123".to_string(),
host: "foo.bar".to_string(),
port: 123,
}
);
let qr = check_qr(&t, "https://t.me/socks?server=foo.baz").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://foo.baz:1080".to_string(),
host: "foo.baz".to_string(),
port: 1080,
}
);
let qr = check_qr(
&t,
"https://t.me/socks?server=foo.baz&port=12345&user=ada&pass=ms%21%2F%24",
)
.await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://ada:ms%21%2F%24@foo.baz:12345".to_string(),
host: "foo.baz".to_string(),
port: 12345,
}
);
// wrong domain results in Qr:Url instead of Qr::Socks5Proxy
let qr = check_qr(&t, "https://not.me/socks?noserver=84.53.239.95&port=4145").await?;
assert_eq!(
qr,
Qr::Url {
url: "https://not.me/socks?noserver=84.53.239.95&port=4145".to_string()
}
);
let qr = check_qr(&t, "https://t.me/socks?noserver=84.53.239.95&port=4145").await;
assert!(qr.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_account_bad_scheme() {
let ctx = TestContext::new().await;
let res = check_qr(
&ctx.ctx,
"DCACCOUNT:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await;
assert!(res.is_err());
// Test it again with lowercased "dcaccount:" uri scheme
let res = check_qr(
&ctx.ctx,
"dcaccount:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3",
)
.await;
assert!(res.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_webrtc_instance_config_from_qr() -> Result<()> {
let ctx = TestContext::new().await;
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await;
assert!(res.is_err());
assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none());
let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await;
assert!(res.is_ok());
assert_eq!(
ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(),
"https://example.org/"
);
let res =
set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await;
assert!(res.is_ok());
assert_eq!(
ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(),
"basicwebrtc:https://foo.bar/?$ROOM&test"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_proxy_config_from_qr() -> Result<()> {
let t = TestContext::new().await;
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, false);
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some("socks5://foo:666".to_string())
);
// Test URL without port.
//
// Also check that whitespace is trimmed.
let res = set_config_from_qr(&t, " https://t.me/socks?server=1.2.3.4\n").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some("socks5://1.2.3.4:1080\nsocks5://foo:666".to_string())
);
// make sure, user&password are set when specified in the URL
// Password is an URL-encoded "x&%$X".
let res =
set_config_from_qr(&t, "https://t.me/socks?server=jau&user=Da&pass=x%26%25%24X").await;
assert!(res.is_ok());
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080\nsocks5://foo:666".to_string()
)
);
// Scanning existing proxy brings it to the top in the list.
let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await;
assert!(res.is_ok());
assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true);
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080".to_string()
)
);
set_config_from_qr(
&t,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080"
.to_string()
)
);
// SOCKS5 config does not have port 1080 explicitly specified,
// but should bring `socks5://1.2.3.4:1080` to the top instead of creating another entry.
set_config_from_qr(&t, "socks5://1.2.3.4").await?;
assert_eq!(
t.get_config(Config::ProxyUrl).await?,
Some(
"socks5://1.2.3.4:1080\nss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080"
.to_string()
)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_shadowsocks() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(
&ctx.ctx,
"ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1",
)
.await?;
assert_eq!(
qr,
Qr::Proxy {
url: "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1".to_string(),
host: "192.168.100.1".to_string(),
port: 8888,
}
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_socks5() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx.ctx, "socks5://127.0.0.1:9050").await?;
assert_eq!(
qr,
Qr::Proxy {
url: "socks5://127.0.0.1:9050".to_string(),
host: "127.0.0.1".to_string(),
port: 9050,
}
);
Ok(())
}
/// Ensure that `DCBACKUP2` QR code does not fail to deserialize
/// because iroh changes the format of `NodeAddr`
/// as happened between iroh 0.29 and iroh 0.30 before.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_decode_backup() -> Result<()> {
let ctx = TestContext::new().await;
let qr = check_qr(&ctx, r#"DCBACKUP2:TWSv6ZjDPa5eoxkocj7xMi8r&{"node_id":"9afc1ea5b4f543e5cdd7b7a21cd26aee7c0b1e1c2af26790896fbd8932a06e1e","relay_url":null,"direct_addresses":["192.168.1.10:12345"]}"#).await?;
assert!(matches!(qr, Qr::Backup2 { .. }));
let qr = check_qr(&ctx, r#"DCBACKUP2:AIvFjRFBt_aMiisSZ8P33JqY&{"node_id":"buzkyd4x76w66qtanjk5fm6ikeuo4quletajowsl3a3p7l6j23pa","info":{"relay_url":null,"direct_addresses":["192.168.1.5:12345"]}}"#).await?;
assert!(matches!(qr, Qr::Backup2 { .. }));
Ok(())
}

View File

@@ -187,7 +187,7 @@ mod tests {
Ok(())
}
#[allow(clippy::assertions_on_constants)]
#[expect(clippy::assertions_on_constants)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_thresholds() -> anyhow::Result<()> {
assert!(QUOTA_ALLCLEAR_PERCENTAGE > 50);

View File

@@ -285,6 +285,7 @@ pub(crate) async fn set_msg_reaction(
&& msg_id.get_state(context).await?.is_outgoing()
{
context.emit_event(EventType::IncomingReaction {
chat_id,
contact_id,
msg_id,
reaction,
@@ -582,6 +583,7 @@ Here's my footer -- bob@example.net"
async fn expect_incoming_reactions_event(
t: &TestContext,
expected_chat_id: ChatId,
expected_msg_id: MsgId,
expected_contact_id: ContactId,
expected_reaction: &str,
@@ -599,10 +601,12 @@ Here's my footer -- bob@example.net"
.await;
match event {
EventType::IncomingReaction {
chat_id,
msg_id,
contact_id,
reaction,
} => {
assert_eq!(chat_id, expected_chat_id);
assert_eq!(msg_id, expected_msg_id);
assert_eq!(contact_id, expected_contact_id);
assert_eq!(reaction, Reaction::from(expected_reaction));
@@ -677,7 +681,14 @@ Here's my footer -- bob@example.net"
assert_eq!(bob_reaction.as_str(), "👍");
expect_reactions_changed_event(&alice, chat_alice.id, alice_msg.sender_msg_id, *bob_id)
.await?;
expect_incoming_reactions_event(&alice, alice_msg.sender_msg_id, *bob_id, "👍").await?;
expect_incoming_reactions_event(
&alice,
chat_alice.id,
alice_msg.sender_msg_id,
*bob_id,
"👍",
)
.await?;
expect_no_unwanted_events(&alice).await;
// Alice reacts to own message.
@@ -720,8 +731,14 @@ Here's my footer -- bob@example.net"
send_reaction(&bob, bob_msg1.id, "👍").await?;
let bob_send_reaction = bob.pop_sent_msg().await;
alice.recv_msg_trash(&bob_send_reaction).await;
expect_incoming_reactions_event(&alice, alice_msg1.sender_msg_id, alice_bob_id, "👍")
.await?;
expect_incoming_reactions_event(
&alice,
alice_chat.id,
alice_msg1.sender_msg_id,
alice_bob_id,
"👍",
)
.await?;
expect_no_unwanted_events(&alice).await;
let chatlist = Chatlist::try_load(&bob, 0, None, None).await?;

File diff suppressed because it is too large Load Diff

View File

@@ -566,6 +566,8 @@ async fn test_escaped_recipients() {
.unwrap()
.0;
// We test with non-chat message here
// because chat messages are not expected to have `Cc` header.
receive_imf(
&t,
b"From: Foobar <foobar@example.com>\n\
@@ -573,8 +575,6 @@ async fn test_escaped_recipients() {
Cc: =?utf-8?q?=3Ch2=3E?= <carl@host.tld>\n\
Subject: foo\n\
Message-ID: <asdklfjjaweofi@example.com>\n\
Chat-Version: 1.0\n\
Chat-Disposition-Notification-To: <foobar@example.com>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
@@ -590,11 +590,12 @@ async fn test_escaped_recipients() {
let msg = Message::load_from_db(&t, chats.get_msg_id(0).unwrap().unwrap())
.await
.unwrap();
assert_eq!(msg.is_dc_message, MessengerMessage::Yes);
assert_eq!(msg.text, "hello");
assert_eq!(msg.param.get_int(Param::WantsMdn).unwrap(), 1);
assert_eq!(msg.is_dc_message, MessengerMessage::No);
assert_eq!(msg.text, "foo hello");
}
/// Tests that `Cc` header updates display name
/// if existing contact has low enough origin.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_cc_to_contact() {
let t = TestContext::new_alice().await;
@@ -612,6 +613,8 @@ async fn test_cc_to_contact() {
.unwrap()
.0;
// We use non-chat message here
// because chat messages are not expected to have `Cc` header.
receive_imf(
&t,
b"Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\
@@ -620,8 +623,6 @@ async fn test_cc_to_contact() {
Cc: Carl <carl@host.tld>\n\
Subject: foo\n\
Message-ID: <asdklfjjaweofi@example.com>\n\
Chat-Version: 1.0\n\
Chat-Disposition-Notification-To: <foobar@example.com>\n\
Date: Sun, 22 Mar 2020 22:37:57 +0000\n\
\n\
hello\n",
@@ -1663,8 +1664,12 @@ async fn test_pdf_filename_simple() {
assert_eq!(msg.viewtype, Viewtype::File);
assert_eq!(msg.text, "mail body");
let file_path = msg.param.get(Param::File).unwrap();
assert!(file_path.starts_with("$BLOBDIR/simple"));
assert!(file_path.ends_with(".pdf"));
assert_eq!(
file_path,
// That's the blake3 hash of the file content:
"$BLOBDIR/24a6af459cec5d733374aeaa19a6133.pdf"
);
assert_eq!(msg.param.get(Param::Filename).unwrap(), "simple.pdf");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -1679,8 +1684,8 @@ async fn test_pdf_filename_continuation() {
assert_eq!(msg.viewtype, Viewtype::File);
assert_eq!(msg.text, "mail body");
let file_path = msg.param.get(Param::File).unwrap();
assert!(file_path.starts_with("$BLOBDIR/test pdf äöüß"));
assert!(file_path.ends_with(".pdf"));
assert!(file_path.starts_with("$BLOBDIR/"));
assert_eq!(msg.get_filename().unwrap(), "test pdf äöüß.pdf");
}
/// HTML-images may come with many embedded images, eg. tiny icons, corners for formatting,
@@ -2200,6 +2205,30 @@ Message content",
assert_ne!(msg.chat_id, t.get_self_chat().await.id);
}
/// Tests that message with hidden recipients is assigned to Saved Messages chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_hidden_recipients_self_chat() {
let t = TestContext::new_alice().await;
receive_imf(
&t,
b"Subject: s
Chat-Version: 1.0
Message-ID: <foobar@localhost>
To: hidden-recipients:;
From: <alice@example.org>
Message content",
false,
)
.await
.unwrap();
let msg = t.get_last_msg().await;
assert_eq!(msg.chat_id, t.get_self_chat().await.id);
assert_eq!(msg.to_id, ContactId::SELF);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_no_unencrypted_name_in_self_chat() -> Result<()> {
let mut tcm = TestContextManager::new();
@@ -3219,11 +3248,11 @@ async fn test_weird_and_duplicated_filenames() -> Result<()> {
"a. tar.tar.gz",
] {
let attachment = alice.blobdir.join(filename_sent);
let content = format!("File content of {filename_sent}");
let content = "File content of tar.gz archive".to_string();
tokio::fs::write(&attachment, content.as_bytes()).await?;
let mut msg_alice = Message::new(Viewtype::File);
msg_alice.set_file(attachment.to_str().unwrap(), None);
msg_alice.set_file_and_deduplicate(&alice, &attachment, None, None)?;
let alice_chat = alice.create_chat(&bob).await;
let sent = alice.send_msg(alice_chat.id, &mut msg_alice).await;
println!("{}", sent.payload());
@@ -3237,9 +3266,10 @@ async fn test_weird_and_duplicated_filenames() -> Result<()> {
let path = msg.get_file(t).unwrap();
let path2 = path.with_file_name("saved.txt");
msg.save_file(t, &path2).await.unwrap();
assert!(
path.to_str().unwrap().ends_with(".tar.gz"),
"path {path:?} doesn't end with .tar.gz"
assert_eq!(
path.file_name().unwrap().to_str().unwrap(),
"79402cb76f44c5761888f9036992a76.gz",
"The hash of the content should always be the same"
);
assert_eq!(fs::read_to_string(&path).await.unwrap(), content);
assert_eq!(fs::read_to_string(&path2).await.unwrap(), content);
@@ -3309,6 +3339,7 @@ async fn test_outgoing_private_reply_multidevice() -> Result<()> {
let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
chat::add_to_chat_contacts_table(
&bob,
time(),
group_id,
&[
bob.add_or_lookup_contact(&alice1).await.id,
@@ -3518,26 +3549,27 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// =============== Bob creates a group ===============
tcm.section("Bob creates a group");
let group_id = chat::create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
chat::add_to_chat_contacts_table(
&bob,
time(),
group_id,
&[bob.add_or_lookup_contact(&alice).await.id],
)
.await?;
// =============== Bob sends the first message to the group ===============
tcm.section("Bob sends the first message to the group");
let sent = bob.send_text(group_id, "Hello all!").await;
alice.recv_msg(&sent).await;
let chats = Chatlist::try_load(&bob, 0, None, None).await?;
assert_eq!(chats.len(), 1);
// =============== Bob blocks Alice ================
tcm.section("Bob blocks Alice");
Contact::block(&bob, bob.add_or_lookup_contact(&alice).await.id).await?;
// =============== Alice replies private to Bob ==============
tcm.section("Alice replies private to Bob");
let received = alice.get_last_msg().await;
assert_eq!(received.text, "Hello all!");
@@ -3551,7 +3583,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let sent2 = alice.send_msg(alice_bob_chat.id, &mut msg_out).await;
bob.recv_msg(&sent2).await;
// ========= check that no contact request was created ============
// check that no contact request was created
let chats = Chatlist::try_load(&bob, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 1);
let chat_id = chats.get_chat_id(0).unwrap();
@@ -3562,7 +3594,7 @@ async fn test_no_private_reply_to_blocked_account() -> Result<()> {
let received = bob.get_last_msg().await;
assert_eq!(received.text, "Hello all!");
// =============== Bob unblocks Alice ================
tcm.section("Bob unblocks Alice");
// test if the blocked chat is restored correctly
Contact::unblock(&bob, bob.add_or_lookup_contact(&alice).await.id).await?;
let chats = Chatlist::try_load(&bob, 0, None, None).await.unwrap();
@@ -4127,11 +4159,15 @@ async fn test_ignore_outdated_membership_changes() -> Result<()> {
SystemTime::shift(Duration::from_secs(3600));
// Bob replies again adding Alice back.
// Bob replies again, even after some time this does not add Alice back.
//
// Bob cannot learn from Alice that Alice has left the group
// because Alice is not going to send more messages to the group.
send_text_msg(bob, bob_chat_id, "i'm bob".to_string()).await?;
let msg = &bob.pop_sent_msg().await;
alice.recv_msg(msg).await;
assert!(is_contact_in_chat(alice, alice_chat_id, ContactId::SELF).await?);
assert!(!is_contact_in_chat(alice, alice_chat_id, ContactId::SELF).await?);
Ok(())
}
@@ -4192,7 +4228,7 @@ async fn test_dont_recreate_contacts_on_add_remove() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
async fn test_delayed_removal_is_ignored() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
@@ -4200,6 +4236,7 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
// create chat with three members
add_to_chat_contacts_table(
&alice,
time(),
chat_id,
&[
Contact::create(&alice, "bob", "bob@example.net").await?,
@@ -4212,12 +4249,12 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
bob_chat_id.accept(&bob).await?;
// bob removes a member
// Bob removes Fiona.
let bob_contact_fiona = Contact::create(&bob, "fiona", "fiona@example.net").await?;
remove_contact_from_chat(&bob, bob_chat_id, bob_contact_fiona).await?;
let remove_msg = bob.pop_sent_msg().await;
// bob adds new members
// Bob adds new members "blue" and "orange", but first addition message is lost.
let bob_blue = Contact::create(&bob, "blue", "blue@example.net").await?;
add_contact_to_chat(&bob, bob_chat_id, bob_blue).await?;
bob.pop_sent_msg().await;
@@ -4225,32 +4262,32 @@ async fn test_recreate_contact_list_on_missing_messages() -> Result<()> {
add_contact_to_chat(&bob, bob_chat_id, bob_orange).await?;
let add_msg = bob.pop_sent_msg().await;
// alice only receives the second member addition
// Alice only receives the second member addition,
// but this results in addition of both members
// and removal of Fiona.
alice.recv_msg(&add_msg).await;
// since we missed messages, a new contact list should be build
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 4);
// re-add fiona
// Alice re-adds Fiona.
add_contact_to_chat(&alice, chat_id, alice_fiona).await?;
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 5);
// delayed removal of fiona shouldn't remove her
alice.recv_msg_trash(&remove_msg).await;
// Delayed removal of Fiona by Bob shouldn't remove her.
alice.recv_msg(&remove_msg).await;
assert_eq!(get_chat_contacts(&alice, chat_id).await?.len(), 5);
alice
.golden_test_chat(
chat_id,
"receive_imf_recreate_contact_list_on_missing_messages",
)
.golden_test_chat(chat_id, "receive_imf_delayed_removal_is_ignored")
.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_readd_with_normal_msg() -> Result<()> {
let alice = TestContext::new_alice().await;
let bob = TestContext::new_bob().await;
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
let alice_chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group").await?;
@@ -4265,6 +4302,7 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
let bob_chat_id = bob.recv_msg(&alice.pop_sent_msg().await).await.chat_id;
bob_chat_id.accept(&bob).await?;
// Bob leaves, but Alice didn't receive Bob's leave message.
remove_contact_from_chat(&bob, bob_chat_id, ContactId::SELF).await?;
bob.pop_sent_msg().await;
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 1);
@@ -4278,12 +4316,11 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
.await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
// Alice didn't receive Bob's leave message although a lot of time has
// passed, so Bob must re-add themselves otherwise other members would think
// Bob is still here while they aren't. Bob should retry to leave if they
// think that Alice didn't re-add them on purpose (which is possible if Alice uses a classical
// MUA).
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
// Bob received a message from Alice, but this should not re-add him to the group.
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
// Bob got an update that fiora is added nevertheless.
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?.len(), 2);
Ok(())
}
@@ -4291,35 +4328,50 @@ async fn test_dont_readd_with_normal_msg() -> Result<()> {
async fn test_mua_cant_remove() -> Result<()> {
let alice = TestContext::new_alice().await;
let now = time();
// Alice creates chat with 3 contacts
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 2000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
&alice,
b"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: Mon, 12 Dec 2022 14:30:39 +0000\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
tst\r\n",
format!(
"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: {date}\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
tst\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
assert_eq!(alice_chat.typ, Chattype::Group);
assert_eq!(alice_chat.member_list_is_stale(&alice).await?, false);
// Bob uses a classical MUA to answer, removing a recipient.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let bob_removes = receive_imf(
&alice,
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: Mon, 12 Dec 2022 14:32:39 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n",
format!(
"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
false,
)
.await?
@@ -4327,22 +4379,29 @@ async fn test_mua_cant_remove() -> Result<()> {
assert_eq!(bob_removes.chat_id, alice_chat.id);
let group_chat = Chat::load_from_db(&alice, bob_removes.chat_id).await?;
assert_eq!(group_chat.typ, Chattype::Group);
assert_eq!(group_chat.member_list_is_stale(&alice).await?, false);
assert_eq!(
chat::get_chat_contacts(&alice, group_chat.id).await?.len(),
4
);
// But if the parent message is missing, the message must goto a new ad-hoc group.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0)
.unwrap()
.to_rfc2822();
let bob_removes = receive_imf(
&alice,
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: Mon, 12 Dec 2022 14:32:40 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients_1@example.net>\r\n\
In-Reply-To: <Mr.missing@example.org>\r\n\
\r\n\
Hi back!\r\n",
format!(
"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients_1@example.net>\r\n\
In-Reply-To: <Mr.missing@example.org>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
false,
)
.await?
@@ -4361,39 +4420,51 @@ async fn test_mua_cant_remove() -> Result<()> {
async fn test_mua_can_add() -> Result<()> {
let alice = TestContext::new_alice().await;
let now = time();
// Alice creates chat with 3 contacts
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 2000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
&alice,
b"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: Mon, 12 Dec 2022 14:30:39 +0000\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n",
format!(
"Subject: =?utf-8?q?Message_from_alice=40example=2Eorg?=\r\n\
From: alice@example.org\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org> \r\n\
Date: {date}\r\n\
Message-ID: <Mr.alices_original_mail@example.org>\r\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let alice_chat = Chat::load_from_db(&alice, msg.chat_id).await?;
assert_eq!(alice_chat.typ, Chattype::Group);
assert_eq!(alice_chat.member_list_is_stale(&alice).await?, false);
// Bob uses a classical MUA to answer, adding a recipient.
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let bob_adds = receive_imf(
&alice,
b"Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>, <fiona@example.org>, <greg@example.host>\r\n\
Date: Mon, 12 Dec 2022 14:32:39 +0000\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n",
false,
)
.await?
.unwrap();
&alice,
format!("Subject: Re: Message from alice\r\n\
From: <bob@example.net>\r\n\
To: <alice@example.org>, <claire@example.org>, <fiona@example.org>, <greg@example.host>\r\n\
Date: {date}\r\n\
Message-ID: <bobs_answer_to_two_recipients@example.net>\r\n\
In-Reply-To: <Mr.alices_original_mail@example.org>\r\n\
\r\n\
Hi back!\r\n").as_bytes(),
false,
)
.await?
.unwrap();
let group_chat = Chat::load_from_db(&alice, bob_adds.chat_id).await?;
assert_eq!(group_chat.typ, Chattype::Group);
@@ -4511,19 +4582,14 @@ async fn test_recreate_member_list_on_missing_add_of_self() -> Result<()> {
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
// But if Bob left a long time ago, they must recreate the member list after missing a message.
// Even if some time passed, Bob must not be re-added back.
SystemTime::shift(Duration::from_secs(3600));
send_text_msg(&alice, alice_chat_id, "5th message".to_string()).await?;
alice.pop_sent_msg().await;
send_text_msg(&alice, alice_chat_id, "6th message".to_string()).await?;
bob.recv_msg(&alice.pop_sent_msg().await).await;
assert!(is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
assert!(!is_contact_in_chat(&bob, bob_chat_id, ContactId::SELF).await?);
bob.golden_test_chat(
bob_chat_id,
"receive_imf_recreate_member_list_on_missing_add_of_self",
)
.await;
Ok(())
}
@@ -4673,8 +4739,7 @@ async fn test_create_group_with_big_msg() -> Result<()> {
let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group").await?;
add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)
.await?;
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_grp_id, &mut msg).await;
assert!(!msg.get_showpadlock());
@@ -4710,8 +4775,7 @@ async fn test_create_group_with_big_msg() -> Result<()> {
let bob_grp_id = create_group_chat(&bob, ProtectionStatus::Unprotected, "Group1").await?;
add_contact_to_chat(&bob, bob_grp_id, ba_contact).await?;
let mut msg = Message::new(Viewtype::Image);
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)
.await?;
msg.set_file_from_bytes(&bob, "a.jpg", file_bytes, None)?;
let sent_msg = bob.send_msg(bob_grp_id, &mut msg).await;
assert!(msg.get_showpadlock());
@@ -4757,13 +4821,6 @@ async fn test_partial_group_consistency() -> Result<()> {
let contacts = get_chat_contacts(&bob, bob_chat_id).await?;
assert_eq!(contacts.len(), 2);
// Get initial timestamp.
let timestamp = bob_chat_id
.get_param(&bob)
.await?
.get_i64(Param::MemberListTimestamp)
.unwrap();
// Bob receives partial message.
let msg_id = receive_imf_from_inbox(
&bob,
@@ -4784,15 +4841,9 @@ Chat-Group-Member-Added: charlie@example.com",
.context("no received message")?;
let msg = Message::load_from_db(&bob, msg_id.msg_ids[0]).await?;
let timestamp2 = bob_chat_id
.get_param(&bob)
.await?
.get_i64(Param::MemberListTimestamp)
.unwrap();
// Partial download does not change the member list.
assert_eq!(msg.download_state, DownloadState::Available);
assert_eq!(timestamp, timestamp2);
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?, contacts);
// Alice sends normal message to bob, adding fiona.
@@ -4805,15 +4856,6 @@ Chat-Group-Member-Added: charlie@example.com",
bob.recv_msg(&alice.pop_sent_msg().await).await;
let timestamp3 = bob_chat_id
.get_param(&bob)
.await?
.get_i64(Param::MemberListTimestamp)
.unwrap();
// Receiving a message after a partial download recreates the member list because we treat
// such messages as if we have not seen them.
assert_ne!(timestamp, timestamp3);
let contacts = get_chat_contacts(&bob, bob_chat_id).await?;
assert_eq!(contacts.len(), 3);
@@ -4837,15 +4879,9 @@ Chat-Group-Member-Added: charlie@example.com",
.context("no received message")?;
let msg = Message::load_from_db(&bob, msg_id.msg_ids[0]).await?;
let timestamp4 = bob_chat_id
.get_param(&bob)
.await?
.get_i64(Param::MemberListTimestamp)
.unwrap();
// After full download, the old message should not change group state.
assert_eq!(msg.download_state, DownloadState::Done);
assert_eq!(timestamp3, timestamp4);
assert_eq!(get_chat_contacts(&bob, bob_chat_id).await?, contacts);
Ok(())
@@ -4868,19 +4904,13 @@ async fn test_leave_protected_group_missing_member_key() -> Result<()> {
("b@b", "bob@example.net"),
)
.await?;
// We fail to send the message.
assert!(remove_contact_from_chat(alice, group_id, ContactId::SELF)
.await
.is_err());
assert!(is_contact_in_chat(alice, group_id, ContactId::SELF).await?);
alice
.sql
.execute(
"UPDATE acpeerstates SET addr=? WHERE addr=?",
("bob@example.net", "b@b"),
)
.await?;
remove_contact_from_chat(alice, group_id, ContactId::SELF).await?;
alice.pop_sent_msg().await;
// The contact is already removed anyway.
assert!(!is_contact_in_chat(alice, group_id, ContactId::SELF).await?);
Ok(())
}
@@ -4902,12 +4932,22 @@ async fn test_protected_group_add_remove_member_missing_key() -> Result<()> {
.await?;
let fiona = &tcm.fiona().await;
let fiona_addr = fiona.get_config(Config::Addr).await?.unwrap();
mark_as_verified(alice, fiona).await;
let alice_fiona_id = alice.add_or_lookup_contact(fiona).await.id;
assert!(add_contact_to_chat(alice, group_id, alice_fiona_id)
.await
.is_err());
assert!(!is_contact_in_chat(alice, group_id, alice_fiona_id).await?);
// Sending the message failed,
// but member is added to the chat locally already.
assert!(is_contact_in_chat(alice, group_id, alice_fiona_id).await?);
let msg = alice.get_last_msg_in(group_id).await;
assert!(msg.is_info());
assert_eq!(
msg.get_text(),
stock_str::msg_add_member_local(alice, &fiona_addr, ContactId::SELF).await
);
// Now the chat has a message "You added member fiona@example.net. [INFO] !!" (with error) that
// may be confusing, but if the error is displayed in UIs, it's more or less ok. This is not a
// normal scenario anyway.
@@ -5176,8 +5216,7 @@ async fn test_prefer_references_to_downloaded_msgs() -> Result<()> {
let file_bytes = include_bytes!("../../test-data/image/screenshot.gif");
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "file", file_bytes, None)
.await?;
msg.set_file_from_bytes(alice, "file", file_bytes, None)?;
let mut sent = alice.send_msg(alice_chat_id, &mut msg).await;
sent.payload = sent
.payload
@@ -5189,8 +5228,7 @@ async fn test_prefer_references_to_downloaded_msgs() -> Result<()> {
assert_eq!(received.chat_id, bob.get_chat(alice).await.id);
let mut msg = Message::new(Viewtype::File);
msg.set_file_from_bytes(alice, "file", file_bytes, None)
.await?;
msg.set_file_from_bytes(alice, "file", file_bytes, None)?;
let sent = alice.send_msg(alice_chat_id, &mut msg).await;
let received = bob.recv_msg(&sent).await;
assert_eq!(received.download_state, DownloadState::Available);
@@ -5249,7 +5287,6 @@ async fn test_receive_vcard() -> Result<()> {
.as_bytes(),
None,
)
.await
.unwrap();
let alice_bob_chat = alice.create_chat(bob).await;
@@ -5448,3 +5485,100 @@ async fn test_prefer_chat_group_id_over_references() -> Result<()> {
assert_ne!(chat1.id, chat2.id);
Ok(())
}
/// Tests that if member timestamps are unknown
/// because of the missing `Chat-Group-Member-Timestamps` header,
/// then timestamps default to zero.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_default_member_timestamps_to_zero() -> Result<()> {
let bob = &TestContext::new_bob().await;
let now = time();
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now - 1000, 0)
.unwrap()
.to_rfc2822();
let msg = receive_imf(
bob,
format!(
"Subject: Some group\r\n\
From: <alice@example.org>\r\n\
To: <bob@example.net>, <claire@example.org>, <fiona@example.org>\r\n\
Date: {date}\r\n\
Message-ID: <first@localhost>\r\n\
Chat-Group-ID: foobarbaz12\n\
Chat-Group-Name: foo\n\
Chat-Version: 1.0\r\n\
\r\n\
Hi!\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 4);
let date = chrono::DateTime::<chrono::Utc>::from_timestamp(now, 0)
.unwrap()
.to_rfc2822();
receive_imf(
bob,
format!(
"Subject: Some group\r\n\
From: <claire@example.org>\r\n\
To: <alice@example.org>, <bob@example.net>\r\n\
Chat-Group-Past-Members: <fiona@example.org>\r\n\
Chat-Group-Member-Timestamps: 1737783000 1737783100 1737783200\r\n\
Chat-Group-ID: foobarbaz12\n\
Chat-Group-Name: foo\n\
Chat-Version: 1.0\r\n\
Date: {date}\r\n\
Message-ID: <second@localhost>\r\n\
\r\n\
Hi back!\r\n"
)
.as_bytes(),
false,
)
.await?
.unwrap();
let chat = Chat::load_from_db(bob, msg.chat_id).await?;
assert_eq!(chat.typ, Chattype::Group);
assert_eq!(chat::get_chat_contacts(bob, chat.id).await?.len(), 3);
Ok(())
}
/// Regression test for the bug
/// that resulted in an info message
/// about Bob addition to the group on Fiona's device.
///
/// There should be no info messages about implicit
/// member changes when we are added to the group.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_two_group_securejoins() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let fiona = &tcm.fiona().await;
let group_id = chat::create_group_chat(alice, ProtectionStatus::Protected, "Group").await?;
let qr = get_securejoin_qr(alice, Some(group_id)).await?;
// Bob joins using QR code.
tcm.exec_securejoin_qr(bob, alice, &qr).await;
// Fiona joins using QR code.
tcm.exec_securejoin_qr(fiona, alice, &qr).await;
let fiona_chat_id = fiona.get_last_msg().await.chat_id;
fiona
.golden_test_chat(fiona_chat_id, "two_group_securejoins")
.await;
Ok(())
}

View File

@@ -671,7 +671,10 @@ async fn fetch_idle(
return Ok(session);
}
info!(ctx, "IMAP session supports IDLE, using it.");
info!(
ctx,
"IMAP session in folder {watch_folder:?} supports IDLE, using it."
);
let session = session
.idle(
ctx,

View File

@@ -744,850 +744,4 @@ fn encrypted_and_signed(
}
#[cfg(test)]
mod tests {
use deltachat_contact_tools::{ContactAddress, EmailAddress};
use super::*;
use crate::chat::{remove_contact_from_chat, CantSendReason};
use crate::chatlist::Chatlist;
use crate::constants::{self, Chattype};
use crate::imex::{imex, ImexMode};
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, chat_protection_enabled};
use crate::test_utils::{get_chat_msg, TimeShiftFalsePositiveNote};
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
use std::collections::HashSet;
use std::time::Duration;
#[derive(PartialEq)]
enum SetupContactCase {
Normal,
CheckProtectionTimestamp,
WrongAliceGossip,
SecurejoinWaitTimeout,
AliceIsBot,
AliceHasName,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact() {
test_setup_contact_ex(SetupContactCase::Normal).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_protection_timestamp() {
test_setup_contact_ex(SetupContactCase::CheckProtectionTimestamp).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_wrong_alice_gossip() {
test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_wait_timeout() {
test_setup_contact_ex(SetupContactCase::SecurejoinWaitTimeout).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_alice_is_bot() {
test_setup_contact_ex(SetupContactCase::AliceIsBot).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_alice_has_name() {
test_setup_contact_ex(SetupContactCase::AliceHasName).await
}
async fn test_setup_contact_ex(case: SetupContactCase) {
let _n = TimeShiftFalsePositiveNote;
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let alice_addr = &alice.get_config(Config::Addr).await.unwrap().unwrap();
if case == SetupContactCase::AliceHasName {
alice
.set_config(Config::Displayname, Some("Alice"))
.await
.unwrap();
}
let bob = tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
.await
.unwrap();
let alice_auto_submitted_hdr;
match case {
SetupContactCase::AliceIsBot => {
alice.set_config_bool(Config::Bot, true).await.unwrap();
alice_auto_submitted_hdr = "Auto-Submitted: auto-generated";
}
_ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied",
};
for t in [&alice, &bob] {
t.set_config_bool(Config::VerifiedOneOnOneChats, true)
.await
.unwrap();
}
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
0
);
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
0
);
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap();
// We want Bob to learn Alice's name from their messages, not from the QR code.
alice
.set_config(Config::Displayname, Some("Alice Exampleorg"))
.await
.unwrap();
// Step 2: Bob scans QR-code, sends vc-request
join_securejoin(&bob.ctx, &qr).await.unwrap();
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
1
);
let contact_alice_id = Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let sent = bob.pop_sent_msg().await;
assert!(!sent.payload.contains("Bob Examplenet"));
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// Step 3: Alice receives vc-request, sends vc-auth-required
alice.recv_msg_trash(&sent).await;
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
1
);
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-auth-required"
);
let bob_chat = bob.create_chat(&alice).await;
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), false);
assert_eq!(
bob_chat.why_cant_send(&bob).await.unwrap(),
Some(CantSendReason::SecurejoinWait)
);
if case == SetupContactCase::SecurejoinWaitTimeout {
SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT));
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true);
}
// Step 4: Bob receives vc-auth-required, sends vc-request-with-auth
bob.recv_msg_trash(&sent).await;
let bob_chat = bob.create_chat(&alice).await;
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true);
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right message.
let sent = bob.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
assert!(!sent.payload.contains("Bob Examplenet"));
let mut msg = alice.parse_msg(&sent).await;
let vc_request_with_auth_ts_sent = msg
.get_header(HeaderDef::Date)
.and_then(|value| mailparse::dateparse(value).ok())
.unwrap();
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx)
.await
.unwrap()
.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
if case == SetupContactCase::WrongAliceGossip {
let wrong_pubkey = load_self_public_key(&bob).await.unwrap();
let alice_pubkey = msg
.gossiped_keys
.insert(alice_addr.to_string(), wrong_pubkey)
.unwrap();
let contact_bob = alice.add_or_lookup_contact(&bob).await;
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false);
msg.gossiped_keys
.insert(alice_addr.to_string(), alice_pubkey)
.unwrap();
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
assert!(contact_bob.is_verified(&alice.ctx).await.unwrap());
return;
}
// Alice should not yet have Bob verified
let contact_bob_id =
Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false);
assert_eq!(contact_bob.get_authname(), "");
if case == SetupContactCase::CheckProtectionTimestamp {
SystemTime::shift(Duration::from_secs(3600));
}
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true);
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.get_authname(), "Bob Examplenet");
assert!(contact_bob.get_name().is_empty());
assert_eq!(contact_bob.is_bot(), false);
// exactly one one-to-one chat should be visible for both now
// (check this before calling alice.create_chat() explicitly below)
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
1
);
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
1
);
// Check Alice got the verified message in her 1:1 chat.
{
let chat = alice.create_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
assert_eq!(msg.get_text(), expected_text);
if case == SetupContactCase::CheckProtectionTimestamp {
assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1);
}
}
// Make sure Alice hasn't yet sent their name to Bob.
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id)
.await
.unwrap();
match case {
SetupContactCase::AliceHasName => assert_eq!(contact_alice.get_authname(), "Alice"),
_ => assert_eq!(contact_alice.get_authname(), ""),
};
// Check Alice sent the right message to Bob.
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-contact-confirm"
);
// Bob should not yet have Alice verified
assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true);
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id)
.await
.unwrap();
assert_eq!(contact_alice.get_authname(), "Alice Exampleorg");
assert!(contact_alice.get_name().is_empty());
assert_eq!(contact_alice.is_bot(), case == SetupContactCase::AliceIsBot);
if case != SetupContactCase::SecurejoinWaitTimeout {
// Later we check that the timeout message isn't added to the already protected chat.
SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT + 1));
assert_eq!(
bob_chat
.check_securejoin_wait(&bob, constants::SECUREJOIN_WAIT_TIMEOUT)
.await
.unwrap(),
0
);
}
// Check Bob got expected info messages in his 1:1 chat.
let msg_cnt: usize = match case {
SetupContactCase::SecurejoinWaitTimeout => 3,
_ => 2,
};
let mut i = 0..msg_cnt;
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
if case == SetupContactCase::SecurejoinWaitTimeout {
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(
msg.get_text(),
stock_str::securejoin_wait_timeout(&bob).await
);
}
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_bad_qr() {
let bob = TestContext::new_bob().await;
let ret = join_securejoin(&bob.ctx, "not a qr code").await;
assert!(ret.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_bob_knows_alice() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// Ensure Bob knows Alice_FP
let alice_pubkey = load_self_public_key(&alice.ctx).await?;
let peerstate = Peerstate {
addr: "alice@example.org".into(),
last_seen: 10,
last_seen_autocrypt: 10,
prefer_encrypt: EncryptPreference::Mutual,
public_key: Some(alice_pubkey.clone()),
public_key_fingerprint: Some(alice_pubkey.dc_fingerprint()),
gossip_key: Some(alice_pubkey.clone()),
gossip_timestamp: 10,
gossip_key_fingerprint: Some(alice_pubkey.dc_fingerprint()),
verified_key: None,
verified_key_fingerprint: None,
verifier: None,
secondary_verified_key: None,
secondary_verified_key_fingerprint: None,
secondary_verifier: None,
backward_verified_key_id: None,
fingerprint_changed: false,
};
peerstate.save_to_db(&bob.ctx.sql).await?;
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
let qr = get_securejoin_qr(&alice.ctx, None).await?;
// Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request
join_securejoin(&bob.ctx, &qr).await.unwrap();
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right handshake message.
let sent = bob.pop_sent_msg().await;
let msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
// Alice should not yet have Bob verified
let (contact_bob_id, _modified) = Contact::add_or_lookup(
&alice.ctx,
"",
&ContactAddress::new("bob@example.net")?,
Origin::ManuallyCreated,
)
.await?;
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-contact-confirm"
);
// Bob should not yet have Alice verified
let contact_alice_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_concurrent_calls() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// do a scan that is not working as claire is never responding
let qr_stale = "OPENPGP4FPR:1234567890123456789012345678901234567890#a=claire%40foo.de&n=&i=12345678901&s=23456789012";
let claire_id = join_securejoin(&bob, qr_stale).await?;
let chat = Chat::load_from_db(&bob, claire_id).await?;
assert!(!claire_id.is_special());
assert_eq!(chat.typ, Chattype::Single);
assert!(bob.pop_sent_msg().await.payload().contains("claire@foo.de"));
// subsequent scans shall abort existing ones or run concurrently -
// but they must not fail as otherwise the whole qr scanning becomes unusable until restart.
let qr = get_securejoin_qr(&alice, None).await?;
let alice_id = join_securejoin(&bob, &qr).await?;
let chat = Chat::load_from_db(&bob, alice_id).await?;
assert!(!alice_id.is_special());
assert_eq!(chat.typ, Chattype::Single);
assert_ne!(claire_id, alice_id);
assert!(bob
.pop_sent_msg()
.await
.payload()
.contains("alice@example.org"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// We start with empty chatlists.
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let alice_chatid =
chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?;
// Step 1: Generate QR-code, secure-join implied by chatid
let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid))
.await
.unwrap();
// Step 2: Bob scans QR-code, sends vg-request
let bob_chatid = join_securejoin(&bob.ctx, &qr).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let sent = bob.pop_sent_msg().await;
assert_eq!(
sent.recipient(),
EmailAddress::new("alice@example.org").unwrap()
);
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`,
// but it was only used by Alice in `vg-request-with-auth`.
// New Delta Chat versions do not use `Secure-Join-Group` header at all
// and it is deprecated.
// Now `Secure-Join-Group` header
// is only sent in `vg-request-with-auth` for compatibility.
assert!(msg.get_header(HeaderDef::SecureJoinGroup).is_none());
// Step 3: Alice receives vg-request, sends vg-auth-required
alice.recv_msg_trash(&sent).await;
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-auth-required"
);
// Step 4: Bob receives vg-auth-required, sends vg-request-with-auth
bob.recv_msg_trash(&sent).await;
let sent = bob.pop_sent_msg().await;
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right handshake message.
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
// Alice should not yet have Bob verified
let contact_bob_id =
Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await?
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-member-added"
);
// Formally this message is auto-submitted, but as the member addition is a result of an
// explicit user action, the Auto-Submitted header shouldn't be present. Otherwise it would
// be strange to have it in "member-added" messages of verified groups only.
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// This is a two-member group, but Alice must Autocrypt-gossip to her other devices.
assert!(msg.get_header(HeaderDef::AutocryptGossip).is_some());
{
// Now Alice's chat with Bob should still be hidden, the verified message should
// appear in the group chat.
let chat = alice.get_chat(&bob).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Alice's 1:1 chat with Bob is not hidden"
);
// There should be 3 messages in the chat:
// - The ChatProtectionEnabled message
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}
// Bob should not yet have Alice verified
let contact_alice_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false);
// Step 7: Bob receives vg-member-added
bob.recv_msg(&sent).await;
{
// Bob has Alice verified, message shows up in the group chat.
assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true);
let chat = bob.get_chat(&alice).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Bob's 1:1 chat with Alice is not hidden"
);
for item in chat::get_chat_msgs(&bob.ctx, bob_chatid).await.unwrap() {
if let chat::ChatItem::Message { msg_id } = item {
let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap();
let text = msg.get_text();
println!("msg {msg_id} text: {text}");
}
}
}
let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?;
assert!(bob_chat.is_protected());
assert!(bob_chat.typ == Chattype::Group);
// On this "happy path", Alice and Bob get only a group-chat where all information are added to.
// The one-to-one chats are used internally for the hidden handshake messages,
// however, should not be visible in the UIs.
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
// If Bob then sends a direct message to alice, however, the one-to-one with Alice should appear.
let bobs_chat_with_alice = bob.create_chat(&alice).await;
let sent = bob.send_text(bobs_chat_with_alice.id, "Hello").await;
alice.recv_msg(&sent).await;
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 2);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_no_qr() -> Result<()> {
let alice = TestContext::new_alice().await;
let mime = br#"Subject: First thread
Message-ID: first@example.org
To: Alice <alice@example.org>, Bob <bob@example.net>
From: Claire <claire@example.org>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
First thread."#;
receive_imf(&alice, mime, false).await?;
let msg = alice.get_last_msg().await;
let chat_id = msg.chat_id;
assert!(get_securejoin_qr(&alice, Some(chat_id)).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unknown_sender() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
tcm.execute_securejoin(&alice, &bob).await;
let alice_chat_id = alice
.create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob])
.await;
let sent = alice.send_text(alice_chat_id, "Hi!").await;
let bob_chat_id = bob.recv_msg(&sent).await.chat_id;
let sent = bob.send_text(bob_chat_id, "Hi hi!").await;
let alice_bob_contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
alice.pop_sent_msg().await;
// The message from Bob is delivered late, Bob is already removed.
let msg = alice.recv_msg(&sent).await;
assert_eq!(msg.text, "Hi hi!");
assert_eq!(msg.error.unwrap(), "Unknown sender for this chat.");
Ok(())
}
/// Tests that Bob gets Alice as verified
/// if `vc-contact-confirm` is lost but Alice then sends
/// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lost_contact_confirm() {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
for t in [&alice, &bob] {
t.set_config_bool(Config::VerifiedOneOnOneChats, true)
.await
.unwrap();
}
let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap();
join_securejoin(&bob.ctx, &qr).await.unwrap();
// vc-request
let sent = bob.pop_sent_msg().await;
alice.recv_msg_trash(&sent).await;
// vc-auth-required
let sent = alice.pop_sent_msg().await;
bob.recv_msg_trash(&sent).await;
// vc-request-with-auth
let sent = bob.pop_sent_msg().await;
alice.recv_msg_trash(&sent).await;
// Alice has Bob verified now.
let contact_bob_id =
Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true);
// Alice sends vc-contact-confirm, but it gets lost.
let _sent_vc_contact_confirm = alice.pop_sent_msg().await;
// Bob should not yet have Alice verified
let contact_alice_id =
Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap();
assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false);
// Alice sends a text message to Bob.
let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await;
let chat_id = received_hello.chat_id;
let chat = Chat::load_from_db(&bob, chat_id).await.unwrap();
assert_eq!(chat.is_protected(), true);
// Received text message in a verified 1:1 chat results in backward verification
// and Bob now marks alice as verified.
let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap();
assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true);
}
/// An unencrypted message with already known Autocrypt key, but sent from another address,
/// means that it's rather a new contact sharing the same key than the existing one changed its
/// address, otherwise it would already have our key to encrypt.
///
/// This is a regression test for a bug where DC wrongly executed AEAP in this case.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_shared_bobs_key() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_addr = &bob.get_config(Config::Addr).await?.unwrap();
tcm.execute_securejoin(bob, alice).await;
let export_dir = tempfile::tempdir().unwrap();
imex(bob, ImexMode::ExportSelfKeys, export_dir.path(), None).await?;
let bob2 = &TestContext::new().await;
let bob2_addr = "bob2@example.net";
bob2.configure_addr(bob2_addr).await;
imex(bob2, ImexMode::ImportSelfKeys, export_dir.path(), None).await?;
tcm.execute_securejoin(bob2, alice).await;
let bob3 = &TestContext::new().await;
let bob3_addr = "bob3@example.net";
bob3.configure_addr(bob3_addr).await;
imex(bob3, ImexMode::ImportSelfKeys, export_dir.path(), None).await?;
tcm.send_recv(bob3, alice, "hi Alice!").await;
let msg = tcm.send_recv(alice, bob3, "hi Bob3!").await;
assert!(msg.get_showpadlock());
let mut bob_ids = HashSet::new();
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob_addr, Origin::Unknown)
.await?
.unwrap(),
);
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob2_addr, Origin::Unknown)
.await?
.unwrap(),
);
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob3_addr, Origin::Unknown)
.await?
.unwrap(),
);
assert_eq!(bob_ids.len(), 3);
Ok(())
}
}
mod securejoin_tests;

View File

@@ -59,8 +59,13 @@ pub(super) async fn start_protocol(context: &Context, invite: QrInvite) -> Resul
// only become usable once the protocol is finished.
let group_chat_id = state.joining_chat_id(context).await?;
if !is_contact_in_chat(context, group_chat_id, invite.contact_id()).await? {
chat::add_to_chat_contacts_table(context, group_chat_id, &[invite.contact_id()])
.await?;
chat::add_to_chat_contacts_table(
context,
time(),
group_chat_id,
&[invite.contact_id()],
)
.await?;
}
let msg = stock_str::secure_join_started(context, invite.contact_id()).await;
chat::add_info_msg(context, group_chat_id, &msg, time()).await?;

View File

@@ -0,0 +1,841 @@
use deltachat_contact_tools::{ContactAddress, EmailAddress};
use super::*;
use crate::chat::{remove_contact_from_chat, CantSendReason};
use crate::chatlist::Chatlist;
use crate::constants::{self, Chattype};
use crate::imex::{imex, ImexMode};
use crate::receive_imf::receive_imf;
use crate::stock_str::{self, chat_protection_enabled};
use crate::test_utils::{get_chat_msg, TimeShiftFalsePositiveNote};
use crate::test_utils::{TestContext, TestContextManager};
use crate::tools::SystemTime;
use std::collections::HashSet;
use std::time::Duration;
#[derive(PartialEq)]
enum SetupContactCase {
Normal,
CheckProtectionTimestamp,
WrongAliceGossip,
SecurejoinWaitTimeout,
AliceIsBot,
AliceHasName,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact() {
test_setup_contact_ex(SetupContactCase::Normal).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_protection_timestamp() {
test_setup_contact_ex(SetupContactCase::CheckProtectionTimestamp).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_wrong_alice_gossip() {
test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_wait_timeout() {
test_setup_contact_ex(SetupContactCase::SecurejoinWaitTimeout).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_alice_is_bot() {
test_setup_contact_ex(SetupContactCase::AliceIsBot).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_alice_has_name() {
test_setup_contact_ex(SetupContactCase::AliceHasName).await
}
async fn test_setup_contact_ex(case: SetupContactCase) {
let _n = TimeShiftFalsePositiveNote;
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let alice_addr = &alice.get_config(Config::Addr).await.unwrap().unwrap();
if case == SetupContactCase::AliceHasName {
alice
.set_config(Config::Displayname, Some("Alice"))
.await
.unwrap();
}
let bob = tcm.bob().await;
bob.set_config(Config::Displayname, Some("Bob Examplenet"))
.await
.unwrap();
let alice_auto_submitted_hdr;
match case {
SetupContactCase::AliceIsBot => {
alice.set_config_bool(Config::Bot, true).await.unwrap();
alice_auto_submitted_hdr = "Auto-Submitted: auto-generated";
}
_ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied",
};
for t in [&alice, &bob] {
t.set_config_bool(Config::VerifiedOneOnOneChats, true)
.await
.unwrap();
}
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
0
);
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
0
);
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap();
// We want Bob to learn Alice's name from their messages, not from the QR code.
alice
.set_config(Config::Displayname, Some("Alice Exampleorg"))
.await
.unwrap();
// Step 2: Bob scans QR-code, sends vc-request
join_securejoin(&bob.ctx, &qr).await.unwrap();
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
1
);
let contact_alice_id = Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let sent = bob.pop_sent_msg().await;
assert!(!sent.payload.contains("Bob Examplenet"));
assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap());
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// Step 3: Alice receives vc-request, sends vc-auth-required
alice.recv_msg_trash(&sent).await;
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
1
);
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-auth-required"
);
let bob_chat = bob.create_chat(&alice).await;
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), false);
assert_eq!(
bob_chat.why_cant_send(&bob).await.unwrap(),
Some(CantSendReason::SecurejoinWait)
);
if case == SetupContactCase::SecurejoinWaitTimeout {
SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT));
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true);
}
// Step 4: Bob receives vc-auth-required, sends vc-request-with-auth
bob.recv_msg_trash(&sent).await;
let bob_chat = bob.create_chat(&alice).await;
assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true);
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right message.
let sent = bob.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
assert!(!sent.payload.contains("Bob Examplenet"));
let mut msg = alice.parse_msg(&sent).await;
let vc_request_with_auth_ts_sent = msg
.get_header(HeaderDef::Date)
.and_then(|value| mailparse::dateparse(value).ok())
.unwrap();
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx)
.await
.unwrap()
.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
if case == SetupContactCase::WrongAliceGossip {
let wrong_pubkey = load_self_public_key(&bob).await.unwrap();
let alice_pubkey = msg
.gossiped_keys
.insert(alice_addr.to_string(), wrong_pubkey)
.unwrap();
let contact_bob = alice.add_or_lookup_contact(&bob).await;
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false);
msg.gossiped_keys
.insert(alice_addr.to_string(), alice_pubkey)
.unwrap();
let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id)
.await
.unwrap();
assert_eq!(handshake_msg, HandshakeMessage::Ignore);
assert!(contact_bob.is_verified(&alice.ctx).await.unwrap());
return;
}
// Alice should not yet have Bob verified
let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false);
assert_eq!(contact_bob.get_authname(), "");
if case == SetupContactCase::CheckProtectionTimestamp {
SystemTime::shift(Duration::from_secs(3600));
}
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true);
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.get_authname(), "Bob Examplenet");
assert!(contact_bob.get_name().is_empty());
assert_eq!(contact_bob.is_bot(), false);
// exactly one one-to-one chat should be visible for both now
// (check this before calling alice.create_chat() explicitly below)
assert_eq!(
Chatlist::try_load(&alice, 0, None, None)
.await
.unwrap()
.len(),
1
);
assert_eq!(
Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(),
1
);
// Check Alice got the verified message in her 1:1 chat.
{
let chat = alice.create_chat(&bob).await;
let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
assert_eq!(msg.get_text(), expected_text);
if case == SetupContactCase::CheckProtectionTimestamp {
assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1);
}
}
// Make sure Alice hasn't yet sent their name to Bob.
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id)
.await
.unwrap();
match case {
SetupContactCase::AliceHasName => assert_eq!(contact_alice.get_authname(), "Alice"),
_ => assert_eq!(contact_alice.get_authname(), ""),
};
// Check Alice sent the right message to Bob.
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains(alice_auto_submitted_hdr));
assert!(!sent.payload.contains("Alice Exampleorg"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-contact-confirm"
);
// Bob should not yet have Alice verified
assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true);
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id)
.await
.unwrap();
assert_eq!(contact_alice.get_authname(), "Alice Exampleorg");
assert!(contact_alice.get_name().is_empty());
assert_eq!(contact_alice.is_bot(), case == SetupContactCase::AliceIsBot);
if case != SetupContactCase::SecurejoinWaitTimeout {
// Later we check that the timeout message isn't added to the already protected chat.
SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT + 1));
assert_eq!(
bob_chat
.check_securejoin_wait(&bob, constants::SECUREJOIN_WAIT_TIMEOUT)
.await
.unwrap(),
0
);
}
// Check Bob got expected info messages in his 1:1 chat.
let msg_cnt: usize = match case {
SetupContactCase::SecurejoinWaitTimeout => 3,
_ => 2,
};
let mut i = 0..msg_cnt;
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await);
if case == SetupContactCase::SecurejoinWaitTimeout {
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(
msg.get_text(),
stock_str::securejoin_wait_timeout(&bob).await
);
}
let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await;
assert!(msg.is_info());
assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_bad_qr() {
let bob = TestContext::new_bob().await;
let ret = join_securejoin(&bob.ctx, "not a qr code").await;
assert!(ret.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_bob_knows_alice() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// Ensure Bob knows Alice_FP
let alice_pubkey = load_self_public_key(&alice.ctx).await?;
let peerstate = Peerstate {
addr: "alice@example.org".into(),
last_seen: 10,
last_seen_autocrypt: 10,
prefer_encrypt: EncryptPreference::Mutual,
public_key: Some(alice_pubkey.clone()),
public_key_fingerprint: Some(alice_pubkey.dc_fingerprint()),
gossip_key: Some(alice_pubkey.clone()),
gossip_timestamp: 10,
gossip_key_fingerprint: Some(alice_pubkey.dc_fingerprint()),
verified_key: None,
verified_key_fingerprint: None,
verifier: None,
secondary_verified_key: None,
secondary_verified_key_fingerprint: None,
secondary_verifier: None,
backward_verified_key_id: None,
fingerprint_changed: false,
};
peerstate.save_to_db(&bob.ctx.sql).await?;
// Step 1: Generate QR-code, ChatId(0) indicates setup-contact
let qr = get_securejoin_qr(&alice.ctx, None).await?;
// Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request
join_securejoin(&bob.ctx, &qr).await.unwrap();
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right handshake message.
let sent = bob.pop_sent_msg().await;
let msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
// Alice should not yet have Bob verified
let (contact_bob_id, _modified) = Contact::add_or_lookup(
&alice.ctx,
"",
&ContactAddress::new("bob@example.net")?,
Origin::ManuallyCreated,
)
.await?;
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vc-contact-confirm"
);
// Bob should not yet have Alice verified
let contact_alice_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false);
// Step 7: Bob receives vc-contact-confirm
bob.recv_msg_trash(&sent).await;
assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_setup_contact_concurrent_calls() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// do a scan that is not working as claire is never responding
let qr_stale = "OPENPGP4FPR:1234567890123456789012345678901234567890#a=claire%40foo.de&n=&i=12345678901&s=23456789012";
let claire_id = join_securejoin(&bob, qr_stale).await?;
let chat = Chat::load_from_db(&bob, claire_id).await?;
assert!(!claire_id.is_special());
assert_eq!(chat.typ, Chattype::Single);
assert!(bob.pop_sent_msg().await.payload().contains("claire@foo.de"));
// subsequent scans shall abort existing ones or run concurrently -
// but they must not fail as otherwise the whole qr scanning becomes unusable until restart.
let qr = get_securejoin_qr(&alice, None).await?;
let alice_id = join_securejoin(&bob, &qr).await?;
let chat = Chat::load_from_db(&bob, alice_id).await?;
assert!(!alice_id.is_special());
assert_eq!(chat.typ, Chattype::Single);
assert_ne!(claire_id, alice_id);
assert!(bob
.pop_sent_msg()
.await
.payload()
.contains("alice@example.org"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_secure_join() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
// We start with empty chatlists.
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
let alice_chatid =
chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?;
// Step 1: Generate QR-code, secure-join implied by chatid
let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid))
.await
.unwrap();
// Step 2: Bob scans QR-code, sends vg-request
let bob_chatid = join_securejoin(&bob.ctx, &qr).await?;
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
let sent = bob.pop_sent_msg().await;
assert_eq!(
sent.recipient(),
EmailAddress::new("alice@example.org").unwrap()
);
let msg = alice.parse_msg(&sent).await;
assert!(!msg.was_encrypted());
assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request");
assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some());
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`,
// but it was only used by Alice in `vg-request-with-auth`.
// New Delta Chat versions do not use `Secure-Join-Group` header at all
// and it is deprecated.
// Now `Secure-Join-Group` header
// is only sent in `vg-request-with-auth` for compatibility.
assert!(msg.get_header(HeaderDef::SecureJoinGroup).is_none());
// Step 3: Alice receives vg-request, sends vg-auth-required
alice.recv_msg_trash(&sent).await;
let sent = alice.pop_sent_msg().await;
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-auth-required"
);
// Step 4: Bob receives vg-auth-required, sends vg-request-with-auth
bob.recv_msg_trash(&sent).await;
let sent = bob.pop_sent_msg().await;
// Check Bob emitted the JoinerProgress event.
let event = bob
.evtracker
.get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. }))
.await;
match event {
EventType::SecurejoinJoinerProgress {
contact_id,
progress,
} => {
let alice_contact_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
assert_eq!(contact_id, alice_contact_id);
assert_eq!(progress, 400);
}
_ => unreachable!(),
}
// Check Bob sent the right handshake message.
assert!(sent.payload.contains("Auto-Submitted: auto-replied"));
let msg = alice.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-request-with-auth"
);
assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some());
let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint();
assert_eq!(
*msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(),
bob_fp.hex()
);
// Alice should not yet have Bob verified
let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await?
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false);
// Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added
alice.recv_msg_trash(&sent).await;
assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true);
let sent = alice.pop_sent_msg().await;
let msg = bob.parse_msg(&sent).await;
assert!(msg.was_encrypted());
assert_eq!(
msg.get_header(HeaderDef::SecureJoin).unwrap(),
"vg-member-added"
);
// Formally this message is auto-submitted, but as the member addition is a result of an
// explicit user action, the Auto-Submitted header shouldn't be present. Otherwise it would
// be strange to have it in "member-added" messages of verified groups only.
assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none());
// This is a two-member group, but Alice must Autocrypt-gossip to her other devices.
assert!(msg.get_header(HeaderDef::AutocryptGossip).is_some());
{
// Now Alice's chat with Bob should still be hidden, the verified message should
// appear in the group chat.
let chat = alice.get_chat(&bob).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Alice's 1:1 chat with Bob is not hidden"
);
// There should be 3 messages in the chat:
// - The ChatProtectionEnabled message
// - You added member bob@example.net
let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await;
assert!(msg.is_info());
let expected_text = chat_protection_enabled(&alice).await;
assert_eq!(msg.get_text(), expected_text);
}
// Bob should not yet have Alice verified
let contact_alice_id =
Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?;
assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false);
// Step 7: Bob receives vg-member-added
bob.recv_msg(&sent).await;
{
// Bob has Alice verified, message shows up in the group chat.
assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true);
let chat = bob.get_chat(&alice).await;
assert_eq!(
chat.blocked,
Blocked::Yes,
"Bob's 1:1 chat with Alice is not hidden"
);
for item in chat::get_chat_msgs(&bob.ctx, bob_chatid).await.unwrap() {
if let chat::ChatItem::Message { msg_id } = item {
let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap();
let text = msg.get_text();
println!("msg {msg_id} text: {text}");
}
}
}
let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?;
assert!(bob_chat.is_protected());
assert!(bob_chat.typ == Chattype::Group);
// On this "happy path", Alice and Bob get only a group-chat where all information are added to.
// The one-to-one chats are used internally for the hidden handshake messages,
// however, should not be visible in the UIs.
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
// If Bob then sends a direct message to alice, however, the one-to-one with Alice should appear.
let bobs_chat_with_alice = bob.create_chat(&alice).await;
let sent = bob.send_text(bobs_chat_with_alice.id, "Hello").await;
alice.recv_msg(&sent).await;
assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 2);
assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_adhoc_group_no_qr() -> Result<()> {
let alice = TestContext::new_alice().await;
let mime = br#"Subject: First thread
Message-ID: first@example.org
To: Alice <alice@example.org>, Bob <bob@example.net>
From: Claire <claire@example.org>
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
First thread."#;
receive_imf(&alice, mime, false).await?;
let msg = alice.get_last_msg().await;
let chat_id = msg.chat_id;
assert!(get_securejoin_qr(&alice, Some(chat_id)).await.is_err());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_unknown_sender() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
tcm.execute_securejoin(&alice, &bob).await;
let alice_chat_id = alice
.create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob])
.await;
let sent = alice.send_text(alice_chat_id, "Hi!").await;
let bob_chat_id = bob.recv_msg(&sent).await.chat_id;
let sent = bob.send_text(bob_chat_id, "Hi hi!").await;
let alice_bob_contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?;
remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?;
alice.pop_sent_msg().await;
// The message from Bob is delivered late, Bob is already removed.
let msg = alice.recv_msg(&sent).await;
assert_eq!(msg.text, "Hi hi!");
assert_eq!(msg.error.unwrap(), "Unknown sender for this chat.");
Ok(())
}
/// Tests that Bob gets Alice as verified
/// if `vc-contact-confirm` is lost but Alice then sends
/// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_lost_contact_confirm() {
let mut tcm = TestContextManager::new();
let alice = tcm.alice().await;
let bob = tcm.bob().await;
for t in [&alice, &bob] {
t.set_config_bool(Config::VerifiedOneOnOneChats, true)
.await
.unwrap();
}
let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap();
join_securejoin(&bob.ctx, &qr).await.unwrap();
// vc-request
let sent = bob.pop_sent_msg().await;
alice.recv_msg_trash(&sent).await;
// vc-auth-required
let sent = alice.pop_sent_msg().await;
bob.recv_msg_trash(&sent).await;
// vc-request-with-auth
let sent = bob.pop_sent_msg().await;
alice.recv_msg_trash(&sent).await;
// Alice has Bob verified now.
let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id)
.await
.unwrap();
assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true);
// Alice sends vc-contact-confirm, but it gets lost.
let _sent_vc_contact_confirm = alice.pop_sent_msg().await;
// Bob should not yet have Alice verified
let contact_alice_id = Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown)
.await
.expect("Error looking up contact")
.expect("Contact not found");
let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap();
assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false);
// Alice sends a text message to Bob.
let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await;
let chat_id = received_hello.chat_id;
let chat = Chat::load_from_db(&bob, chat_id).await.unwrap();
assert_eq!(chat.is_protected(), true);
// Received text message in a verified 1:1 chat results in backward verification
// and Bob now marks alice as verified.
let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap();
assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true);
}
/// An unencrypted message with already known Autocrypt key, but sent from another address,
/// means that it's rather a new contact sharing the same key than the existing one changed its
/// address, otherwise it would already have our key to encrypt.
///
/// This is a regression test for a bug where DC wrongly executed AEAP in this case.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_shared_bobs_key() -> Result<()> {
let mut tcm = TestContextManager::new();
let alice = &tcm.alice().await;
let bob = &tcm.bob().await;
let bob_addr = &bob.get_config(Config::Addr).await?.unwrap();
tcm.execute_securejoin(bob, alice).await;
let export_dir = tempfile::tempdir().unwrap();
imex(bob, ImexMode::ExportSelfKeys, export_dir.path(), None).await?;
let bob2 = &TestContext::new().await;
let bob2_addr = "bob2@example.net";
bob2.configure_addr(bob2_addr).await;
imex(bob2, ImexMode::ImportSelfKeys, export_dir.path(), None).await?;
tcm.execute_securejoin(bob2, alice).await;
let bob3 = &TestContext::new().await;
let bob3_addr = "bob3@example.net";
bob3.configure_addr(bob3_addr).await;
imex(bob3, ImexMode::ImportSelfKeys, export_dir.path(), None).await?;
tcm.send_recv(bob3, alice, "hi Alice!").await;
let msg = tcm.send_recv(alice, bob3, "hi Bob3!").await;
assert!(msg.get_showpadlock());
let mut bob_ids = HashSet::new();
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob_addr, Origin::Unknown)
.await?
.unwrap(),
);
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob2_addr, Origin::Unknown)
.await?
.unwrap(),
);
bob_ids.insert(
Contact::lookup_id_by_addr(alice, bob3_addr, Origin::Unknown)
.await?
.unwrap(),
);
assert_eq!(bob_ids.len(), 3);
Ok(())
}

View File

@@ -103,7 +103,7 @@ impl Smtp {
}
/// Connect using the provided login params.
#[allow(clippy::too_many_arguments)]
#[expect(clippy::too_many_arguments)]
pub async fn connect(
&mut self,
context: &Context,

View File

@@ -45,7 +45,7 @@ async fn new_smtp_transport<S: AsyncBufRead + AsyncWrite + Unpin>(
Ok(transport)
}
#[allow(clippy::too_many_arguments)]
#[expect(clippy::too_many_arguments)]
pub(crate) async fn connect_and_auth(
context: &Context,
proxy_config: &Option<ProxyConfig>,

View File

@@ -44,12 +44,6 @@ macro_rules! params_slice {
};
}
pub(crate) fn params_iter(
iter: &[impl crate::sql::ToSql],
) -> impl Iterator<Item = &dyn crate::sql::ToSql> {
iter.iter().map(|item| item as &dyn crate::sql::ToSql)
}
mod migrations;
mod pool;
@@ -260,9 +254,13 @@ impl Sql {
let mut blob = BlobObject::new_from_path(context, avatar.as_ref()).await?;
match blob.recode_to_avatar_size(context).await {
Ok(()) => {
context
.set_config_internal(Config::Selfavatar, Some(&avatar))
.await?
if let Some(path) = blob.to_abs_path().to_str() {
context
.set_config_internal(Config::Selfavatar, Some(path))
.await?;
} else {
warn!(context, "Setting selfavatar failed: non-UTF-8 filename");
}
}
Err(e) => {
warn!(context, "Migrations can't recode avatar, removing. {:#}", e);
@@ -441,7 +439,7 @@ impl Sql {
.await
}
/// Execute the function inside a transaction assuming that it does write queries.
/// Execute the function inside a transaction assuming that it does writes.
///
/// If the function returns an error, the transaction will be rolled back. If it does not return an
/// error, the transaction will be committed.
@@ -450,7 +448,28 @@ impl Sql {
H: Send + 'static,
G: Send + FnOnce(&mut rusqlite::Transaction<'_>) -> Result<H>,
{
self.call_write(move |conn| {
let query_only = false;
self.transaction_ex(query_only, callback).await
}
/// Execute the function inside a transaction.
///
/// * `query_only` - Whether the function only executes read statements (queries) and can be run
/// in parallel with other transactions. NB: Creating and modifying temporary tables are also
/// allowed with `query_only`, temporary tables aren't visible in other connections, but you
/// need to pass `PRAGMA query_only=0;` to SQLite before that:
/// `pragma_update(None, "query_only", "0")`.
/// Also temporary tables need to be dropped because the connection is returned to the pool
/// then.
///
/// If the function returns an error, the transaction will be rolled back. If it does not return
/// an error, the transaction will be committed.
pub async fn transaction_ex<G, H>(&self, query_only: bool, callback: G) -> Result<H>
where
H: Send + 'static,
G: Send + FnOnce(&mut rusqlite::Transaction<'_>) -> Result<H>,
{
self.call(query_only, move |conn| {
let mut transaction = conn.transaction()?;
let ret = callback(&mut transaction);
@@ -1024,388 +1043,5 @@ async fn prune_tombstones(sql: &Sql) -> Result<()> {
Ok(())
}
/// Helper function to return comma-separated sequence of `?` chars.
///
/// Use this together with [`rusqlite::ParamsFromIter`] to use dynamically generated
/// parameter lists.
pub fn repeat_vars(count: usize) -> String {
let mut s = "?,".repeat(count);
s.pop(); // Remove trailing comma
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{test_utils::TestContext, EventType};
#[test]
fn test_maybe_add_file() {
let mut files = Default::default();
maybe_add_file(&mut files, "$BLOBDIR/hello");
maybe_add_file(&mut files, "$BLOBDIR/world.txt");
maybe_add_file(&mut files, "world2.txt");
maybe_add_file(&mut files, "$BLOBDIR");
assert!(files.contains("hello"));
assert!(files.contains("world.txt"));
assert!(!files.contains("world2.txt"));
assert!(!files.contains("$BLOBDIR"));
}
#[test]
fn test_is_file_in_use() {
let mut files = Default::default();
maybe_add_file(&mut files, "$BLOBDIR/hello");
maybe_add_file(&mut files, "$BLOBDIR/world.txt");
maybe_add_file(&mut files, "world2.txt");
assert!(is_file_in_use(&files, None, "hello"));
assert!(!is_file_in_use(&files, Some(".txt"), "hello"));
assert!(is_file_in_use(&files, Some("-suffix"), "world.txt-suffix"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_table_exists() {
let t = TestContext::new().await;
assert!(t.ctx.sql.table_exists("msgs").await.unwrap());
assert!(!t.ctx.sql.table_exists("foobar").await.unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_col_exists() {
let t = TestContext::new().await;
assert!(t.ctx.sql.col_exists("msgs", "mime_modified").await.unwrap());
assert!(!t.ctx.sql.col_exists("msgs", "foobar").await.unwrap());
assert!(!t.ctx.sql.col_exists("foobar", "foobar").await.unwrap());
}
/// Tests that auto_vacuum is enabled for new databases.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_auto_vacuum() -> Result<()> {
let t = TestContext::new().await;
let query_only = true;
let auto_vacuum = t
.sql
.call(query_only, |conn| {
let auto_vacuum = conn.pragma_query_value(None, "auto_vacuum", |row| {
let auto_vacuum: i32 = row.get(0)?;
Ok(auto_vacuum)
})?;
Ok(auto_vacuum)
})
.await?;
// auto_vacuum=2 is the same as auto_vacuum=INCREMENTAL
assert_eq!(auto_vacuum, 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_db_closed() {
let t = TestContext::new().await;
let avatar_src = t.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../test-data/image/avatar64x64.png");
tokio::fs::write(&avatar_src, avatar_bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
let event_source = t.get_event_emitter();
let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]);
t.sql.close().await;
housekeeping(&t).await.unwrap(); // housekeeping should emit warnings but not fail
t.sql.open(&t, "".to_string()).await.unwrap();
let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]);
while let Ok(event) = event_source.try_recv() {
match event.typ {
EventType::Info(s) => assert!(
!s.contains("Keeping new unreferenced file"),
"File {s} was almost deleted, only reason it was kept is that it was created recently (as the tests don't run for a long time)"
),
EventType::Error(s) => panic!("{}", s),
_ => {}
}
}
}
/// Regression test for a bug where housekeeping deleted drafts since their
/// `hidden` flag is set.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_dont_delete_drafts() {
let t = TestContext::new_alice().await;
let chat = t.create_chat_with_contact("bob", "bob@example.com").await;
let mut new_draft = Message::new_text("This is my draft".to_string());
chat.id.set_draft(&t, Some(&mut new_draft)).await.unwrap();
housekeeping(&t).await.unwrap();
let loaded_draft = chat.id.get_draft(&t).await.unwrap();
assert_eq!(loaded_draft.unwrap().text, "This is my draft");
}
/// Tests that `housekeeping` deletes the blobs backup dir which is created normally by
/// `imex::import_backup`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_delete_blobs_backup_dir() {
let t = TestContext::new_alice().await;
let dir = t.get_blobdir().join(BLOBS_BACKUP_NAME);
tokio::fs::create_dir(&dir).await.unwrap();
tokio::fs::write(dir.join("f"), "").await.unwrap();
housekeeping(&t).await.unwrap();
tokio::fs::create_dir(&dir).await.unwrap();
}
/// Regression test.
///
/// Previously the code checking for existence of `config` table
/// checked it with `PRAGMA table_info("config")` but did not
/// drain `SqlitePool.fetch` result, only using the first row
/// returned. As a result, prepared statement for `PRAGMA` was not
/// finalized early enough, leaving reader connection in a broken
/// state after reopening the database, when `config` table
/// existed and `PRAGMA` returned non-empty result.
///
/// Statements were not finalized due to a bug in sqlx:
/// <https://github.com/launchbadge/sqlx/issues/1147>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_db_reopen() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile);
// Create database with all the tables.
sql.open(&t, "".to_string()).await.unwrap();
sql.close().await;
// Reopen the database
sql.open(&t, "".to_string()).await?;
sql.execute(
"INSERT INTO config (keyname, value) VALUES (?, ?);",
("foo", "bar"),
)
.await?;
let value: Option<String> = sql
.query_get_value("SELECT value FROM config WHERE keyname=?;", ("foo",))
.await?;
assert_eq!(value.unwrap(), "bar");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_migration_flags() -> Result<()> {
let t = TestContext::new().await;
t.evtracker.get_info_contains("Opened database").await;
// as migrations::run() was already executed on context creation,
// another call should not result in any action needed.
// this test catches some bugs where dbversion was forgotten to be persisted.
let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) =
migrations::run(&t, &t.sql).await?;
assert!(!recalc_fingerprints);
assert!(!update_icons);
assert!(!disable_server_delete);
assert!(!recode_avatar);
info!(&t, "test_migration_flags: XXX END MARKER");
loop {
let evt = t
.evtracker
.get_matching(|evt| matches!(evt, EventType::Info(_)))
.await;
match evt {
EventType::Info(msg) => {
assert!(
!msg.contains("[migration]"),
"Migrations were run twice, you probably forgot to update the db version"
);
if msg.contains("test_migration_flags: XXX END MARKER") {
break;
}
}
_ => unreachable!(),
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_check_passphrase() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile.clone());
sql.check_passphrase("foo".to_string()).await?;
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database first time")?;
sql.close().await;
// Reopen the database
let sql = Sql::new(dbfile);
// Test that we can't open encrypted database without a passphrase.
assert!(sql.open(&t, "".to_string()).await.is_err());
// Now open the database with passpharse, it should succeed.
sql.check_passphrase("foo".to_string()).await?;
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database second time")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sql_change_passphrase() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile.clone());
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database first time")?;
sql.close().await;
// Change the passphrase from "foo" to "bar".
let sql = Sql::new(dbfile.clone());
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database second time")?;
sql.change_passphrase("bar".to_string())
.await
.context("failed to change passphrase")?;
// Test that at least two connections are still working.
// This ensures that not only the connection which changed the password is working,
// but other connections as well.
{
let lock = sql.pool.read().await;
let pool = lock.as_ref().unwrap();
let query_only = true;
let conn1 = pool.get(query_only).await?;
let conn2 = pool.get(query_only).await?;
conn1
.query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(()))
.unwrap();
conn2
.query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(()))
.unwrap();
}
sql.close().await;
let sql = Sql::new(dbfile);
// Test that old passphrase is not working.
assert!(sql.open(&t, "foo".to_string()).await.is_err());
// Open the database with the new passphrase.
sql.check_passphrase("bar".to_string()).await?;
sql.open(&t, "bar".to_string())
.await
.context("failed to open the database third time")?;
sql.close().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_query_only() -> Result<()> {
let t = TestContext::new().await;
// `query_row` does not acquire write lock
// and operates on read-only connection.
// Using it to `INSERT` should fail.
let res = t
.sql
.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 1",
("xyz", "ijk"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)
.await;
assert!(res.is_err());
// If you want to `INSERT` and get value via `RETURNING`,
// use `call_write` or `transaction`.
let res: Result<u32> = t
.sql
.call_write(|conn| {
let val = conn.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 2",
("foo", "bar"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)?;
Ok(val)
})
.await;
assert_eq!(res.unwrap(), 2);
let res = t
.sql
.transaction(|t| {
let val = t.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 3",
("abc", "def"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)?;
Ok(val)
})
.await;
assert_eq!(res.unwrap(), 3);
Ok(())
}
/// Tests that incremental_vacuum does not fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_incremental_vacuum() -> Result<()> {
let t = TestContext::new().await;
incremental_vacuum(&t).await?;
Ok(())
}
}
mod sql_tests;

View File

@@ -116,8 +116,6 @@ CREATE INDEX msgs_mdns_index1 ON msgs_mdns (msg_id);"#,
r#"
ALTER TABLE chats ADD COLUMN archived INTEGER DEFAULT 0;
CREATE INDEX chats_index2 ON chats (archived);
-- 'starred' column is not used currently
-- (dropping is not easily doable and stop adding it will make reusing it complicated)
ALTER TABLE msgs ADD COLUMN starred INTEGER DEFAULT 0;
CREATE INDEX msgs_index5 ON msgs (starred);"#,
17,
@@ -1123,11 +1121,7 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
inc_and_check(&mut migration_version, 127)?;
if dbversion < migration_version {
// Existing chatmail configurations having `delete_server_after` disabled should get
// `bcc_self` enabled, they may be multidevice configurations because before,
// `delete_server_after` was set to 0 upon a backup export for them, but together with this
// migration `bcc_self` is enabled instead (whose default is changed to 0 for chatmail). We
// don't check `is_chatmail` for simplicity.
// This is buggy: `delete_server_after` > 1 isn't handled. Migration #129 fixes this.
sql.execute_migration(
"INSERT OR IGNORE INTO config (keyname, value)
SELECT 'bcc_self', '1'
@@ -1138,6 +1132,43 @@ CREATE INDEX msgs_status_updates_index2 ON msgs_status_updates (uid);
.await?;
}
inc_and_check(&mut migration_version, 128)?;
if dbversion < migration_version {
// Add the timestamps of addition and removal.
//
// If `add_timestamp >= remove_timestamp`,
// then the member is currently a member of the chat.
// Otherwise the member is a past member.
sql.execute_migration(
"ALTER TABLE chats_contacts
ADD COLUMN add_timestamp NOT NULL DEFAULT 0;
ALTER TABLE chats_contacts
ADD COLUMN remove_timestamp NOT NULL DEFAULT 0;
",
migration_version,
)
.await?;
}
inc_and_check(&mut migration_version, 129)?;
if dbversion < migration_version {
// Existing chatmail configurations having `delete_server_after` != "delete at once" should
// get `bcc_self` enabled, they may be multidevice configurations:
// - Before migration #127, `delete_server_after` was set to 0 upon a backup export, but
// then `bcc_self` is enabled instead (whose default is changed to 0 for chatmail).
// - The user might set `delete_server_after` to a value other than 0 or 1 when that was
// possible in UIs.
// We don't check `is_chatmail` for simplicity.
sql.execute_migration(
"INSERT OR IGNORE INTO config (keyname, value)
SELECT 'bcc_self', '1'
FROM config WHERE keyname='delete_server_after' AND value!='1'
",
migration_version,
)
.await?;
}
let new_version = sql
.get_raw_config_int(VERSION_CFG)
.await?
@@ -1198,6 +1229,35 @@ impl Sql {
.await
.with_context(|| format!("execute_migration failed for version {version}"))?;
self.set_db_version_in_cache(version).await
self.config_cache.write().await.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_utils::TestContext;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_clear_config_cache() -> anyhow::Result<()> {
// Some migrations change the `config` table in SQL.
// This test checks that the config cache is invalidated in `execute_migration()`.
let t = TestContext::new().await;
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, false);
t.sql
.execute_migration(
"INSERT INTO config (keyname, value) VALUES ('is_chatmail', '1')",
1000,
)
.await?;
assert_eq!(t.get_config_bool(Config::IsChatmail).await?, true);
assert_eq!(t.sql.get_raw_config_int(VERSION_CFG).await?.unwrap(), 1000);
Ok(())
}
}

372
src/sql/sql_tests.rs Normal file
View File

@@ -0,0 +1,372 @@
use super::*;
use crate::{test_utils::TestContext, EventType};
#[test]
fn test_maybe_add_file() {
let mut files = Default::default();
maybe_add_file(&mut files, "$BLOBDIR/hello");
maybe_add_file(&mut files, "$BLOBDIR/world.txt");
maybe_add_file(&mut files, "world2.txt");
maybe_add_file(&mut files, "$BLOBDIR");
assert!(files.contains("hello"));
assert!(files.contains("world.txt"));
assert!(!files.contains("world2.txt"));
assert!(!files.contains("$BLOBDIR"));
}
#[test]
fn test_is_file_in_use() {
let mut files = Default::default();
maybe_add_file(&mut files, "$BLOBDIR/hello");
maybe_add_file(&mut files, "$BLOBDIR/world.txt");
maybe_add_file(&mut files, "world2.txt");
assert!(is_file_in_use(&files, None, "hello"));
assert!(!is_file_in_use(&files, Some(".txt"), "hello"));
assert!(is_file_in_use(&files, Some("-suffix"), "world.txt-suffix"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_table_exists() {
let t = TestContext::new().await;
assert!(t.ctx.sql.table_exists("msgs").await.unwrap());
assert!(!t.ctx.sql.table_exists("foobar").await.unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_col_exists() {
let t = TestContext::new().await;
assert!(t.ctx.sql.col_exists("msgs", "mime_modified").await.unwrap());
assert!(!t.ctx.sql.col_exists("msgs", "foobar").await.unwrap());
assert!(!t.ctx.sql.col_exists("foobar", "foobar").await.unwrap());
}
/// Tests that auto_vacuum is enabled for new databases.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_auto_vacuum() -> Result<()> {
let t = TestContext::new().await;
let query_only = true;
let auto_vacuum = t
.sql
.call(query_only, |conn| {
let auto_vacuum = conn.pragma_query_value(None, "auto_vacuum", |row| {
let auto_vacuum: i32 = row.get(0)?;
Ok(auto_vacuum)
})?;
Ok(auto_vacuum)
})
.await?;
// auto_vacuum=2 is the same as auto_vacuum=INCREMENTAL
assert_eq!(auto_vacuum, 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_db_closed() {
let t = TestContext::new().await;
let avatar_src = t.dir.path().join("avatar.png");
let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png");
tokio::fs::write(&avatar_src, avatar_bytes).await.unwrap();
t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap()))
.await
.unwrap();
let event_source = t.get_event_emitter();
let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]);
t.sql.close().await;
housekeeping(&t).await.unwrap(); // housekeeping should emit warnings but not fail
t.sql.open(&t, "".to_string()).await.unwrap();
let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap();
assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]);
while let Ok(event) = event_source.try_recv() {
match event.typ {
EventType::Info(s) => assert!(
!s.contains("Keeping new unreferenced file"),
"File {s} was almost deleted, only reason it was kept is that it was created recently (as the tests don't run for a long time)"
),
EventType::Error(s) => panic!("{}", s),
_ => {}
}
}
}
/// Regression test for a bug where housekeeping deleted drafts since their
/// `hidden` flag is set.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_dont_delete_drafts() {
let t = TestContext::new_alice().await;
let chat = t.create_chat_with_contact("bob", "bob@example.com").await;
let mut new_draft = Message::new_text("This is my draft".to_string());
chat.id.set_draft(&t, Some(&mut new_draft)).await.unwrap();
housekeeping(&t).await.unwrap();
let loaded_draft = chat.id.get_draft(&t).await.unwrap();
assert_eq!(loaded_draft.unwrap().text, "This is my draft");
}
/// Tests that `housekeeping` deletes the blobs backup dir which is created normally by
/// `imex::import_backup`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_housekeeping_delete_blobs_backup_dir() {
let t = TestContext::new_alice().await;
let dir = t.get_blobdir().join(BLOBS_BACKUP_NAME);
tokio::fs::create_dir(&dir).await.unwrap();
tokio::fs::write(dir.join("f"), "").await.unwrap();
housekeeping(&t).await.unwrap();
tokio::fs::create_dir(&dir).await.unwrap();
}
/// Regression test.
///
/// Previously the code checking for existence of `config` table
/// checked it with `PRAGMA table_info("config")` but did not
/// drain `SqlitePool.fetch` result, only using the first row
/// returned. As a result, prepared statement for `PRAGMA` was not
/// finalized early enough, leaving reader connection in a broken
/// state after reopening the database, when `config` table
/// existed and `PRAGMA` returned non-empty result.
///
/// Statements were not finalized due to a bug in sqlx:
/// <https://github.com/launchbadge/sqlx/issues/1147>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_db_reopen() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile);
// Create database with all the tables.
sql.open(&t, "".to_string()).await.unwrap();
sql.close().await;
// Reopen the database
sql.open(&t, "".to_string()).await?;
sql.execute(
"INSERT INTO config (keyname, value) VALUES (?, ?);",
("foo", "bar"),
)
.await?;
let value: Option<String> = sql
.query_get_value("SELECT value FROM config WHERE keyname=?;", ("foo",))
.await?;
assert_eq!(value.unwrap(), "bar");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_migration_flags() -> Result<()> {
let t = TestContext::new().await;
t.evtracker.get_info_contains("Opened database").await;
// as migrations::run() was already executed on context creation,
// another call should not result in any action needed.
// this test catches some bugs where dbversion was forgotten to be persisted.
let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) =
migrations::run(&t, &t.sql).await?;
assert!(!recalc_fingerprints);
assert!(!update_icons);
assert!(!disable_server_delete);
assert!(!recode_avatar);
info!(&t, "test_migration_flags: XXX END MARKER");
loop {
let evt = t
.evtracker
.get_matching(|evt| matches!(evt, EventType::Info(_)))
.await;
match evt {
EventType::Info(msg) => {
assert!(
!msg.contains("[migration]"),
"Migrations were run twice, you probably forgot to update the db version"
);
if msg.contains("test_migration_flags: XXX END MARKER") {
break;
}
}
_ => unreachable!(),
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_check_passphrase() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile.clone());
sql.check_passphrase("foo".to_string()).await?;
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database first time")?;
sql.close().await;
// Reopen the database
let sql = Sql::new(dbfile);
// Test that we can't open encrypted database without a passphrase.
assert!(sql.open(&t, "".to_string()).await.is_err());
// Now open the database with passpharse, it should succeed.
sql.check_passphrase("foo".to_string()).await?;
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database second time")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sql_change_passphrase() -> Result<()> {
use tempfile::tempdir;
// The context is used only for logging.
let t = TestContext::new().await;
// Create a separate empty database for testing.
let dir = tempdir()?;
let dbfile = dir.path().join("testdb.sqlite");
let sql = Sql::new(dbfile.clone());
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database first time")?;
sql.close().await;
// Change the passphrase from "foo" to "bar".
let sql = Sql::new(dbfile.clone());
sql.open(&t, "foo".to_string())
.await
.context("failed to open the database second time")?;
sql.change_passphrase("bar".to_string())
.await
.context("failed to change passphrase")?;
// Test that at least two connections are still working.
// This ensures that not only the connection which changed the password is working,
// but other connections as well.
{
let lock = sql.pool.read().await;
let pool = lock.as_ref().unwrap();
let query_only = true;
let conn1 = pool.get(query_only).await?;
let conn2 = pool.get(query_only).await?;
conn1
.query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(()))
.unwrap();
conn2
.query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(()))
.unwrap();
}
sql.close().await;
let sql = Sql::new(dbfile);
// Test that old passphrase is not working.
assert!(sql.open(&t, "foo".to_string()).await.is_err());
// Open the database with the new passphrase.
sql.check_passphrase("bar".to_string()).await?;
sql.open(&t, "bar".to_string())
.await
.context("failed to open the database third time")?;
sql.close().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_query_only() -> Result<()> {
let t = TestContext::new().await;
// `query_row` does not acquire write lock
// and operates on read-only connection.
// Using it to `INSERT` should fail.
let res = t
.sql
.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 1",
("xyz", "ijk"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)
.await;
assert!(res.is_err());
// If you want to `INSERT` and get value via `RETURNING`,
// use `call_write` or `transaction`.
let res: Result<u32> = t
.sql
.call_write(|conn| {
let val = conn.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 2",
("foo", "bar"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)?;
Ok(val)
})
.await;
assert_eq!(res.unwrap(), 2);
let res = t
.sql
.transaction(|t| {
let val = t.query_row(
"INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 3",
("abc", "def"),
|row| {
let res: u32 = row.get(0)?;
Ok(res)
},
)?;
Ok(val)
})
.await;
assert_eq!(res.unwrap(), 3);
Ok(())
}
/// Tests that incremental_vacuum does not fail.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_incremental_vacuum() -> Result<()> {
let t = TestContext::new().await;
incremental_vacuum(&t).await?;
Ok(())
}

View File

@@ -1415,9 +1415,10 @@ impl Context {
// add welcome-messages. by the label, this is done only once,
// if the user has deleted the message or the chat, it is not added again.
let image = include_bytes!("../assets/welcome-image.jpg");
let blob = BlobObject::create(self, "welcome-image.jpg", image).await?;
let blob = BlobObject::create_and_deduplicate_from_bytes(self, image, "welcome.jpg")?;
let mut msg = Message::new(Viewtype::Image);
msg.param.set(Param::File, blob.as_name());
msg.param.set(Param::Filename, "welcome-image.jpg");
chat::add_device_msg(self, Some("core-welcome-image"), Some(&mut msg)).await?;
let mut msg = Message::new_text(welcome_message(self).await);
@@ -1438,187 +1439,4 @@ impl Accounts {
}
#[cfg(test)]
mod tests {
use num_traits::ToPrimitive;
use super::*;
use crate::chat::delete_and_reset_all_device_msgs;
use crate::chatlist::Chatlist;
use crate::test_utils::TestContext;
#[test]
fn test_enum_mapping() {
assert_eq!(StockMessage::NoMessages.to_usize().unwrap(), 1);
assert_eq!(StockMessage::SelfMsg.to_usize().unwrap(), 2);
}
#[test]
fn test_fallback() {
assert_eq!(StockMessage::NoMessages.fallback(), "No messages.");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_stock_translation() {
let t = TestContext::new().await;
t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string())
.await
.unwrap();
assert_eq!(no_messages(&t).await, "xyz")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_stock_translation_wrong_replacements() {
let t = TestContext::new().await;
assert!(t
.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string())
.await
.is_err());
assert!(t
.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string())
.await
.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_str() {
let t = TestContext::new().await;
assert_eq!(no_messages(&t).await, "No messages.");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_string_repl_str() {
let t = TestContext::new().await;
let contact_id = Contact::create(&t.ctx, "Someone", "someone@example.org")
.await
.unwrap();
let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap();
// uses %1$s substitution
assert_eq!(
contact_verified(&t, &contact).await,
"Someone (someone@example.org) verified."
);
// We have no string using %1$d to test...
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_simple() {
let t = TestContext::new().await;
assert_eq!(
msg_location_enabled(&t).await,
"Location streaming enabled."
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_me() {
let t = TestContext::new().await;
assert_eq!(
msg_add_member_remote(&t, "alice@example.org").await,
"I added member alice@example.org."
);
assert_eq!(
msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await,
"You added member alice@example.org."
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_me_with_displayname() {
let t = TestContext::new().await;
Contact::create(&t, "Alice", "alice@example.org")
.await
.expect("failed to create contact");
assert_eq!(
msg_add_member_remote(&t, "alice@example.org").await,
"I added member alice@example.org."
);
assert_eq!(
msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await,
"You added member Alice (alice@example.org)."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_other_with_displayname() {
let t = TestContext::new().await;
let contact_id = {
Contact::create(&t, "Alice", "alice@example.org")
.await
.expect("Failed to create contact Alice");
Contact::create(&t, "Bob", "bob@example.com")
.await
.expect("failed to create bob")
};
assert_eq!(
msg_add_member_local(&t, "alice@example.org", contact_id,).await,
"Member Alice (alice@example.org) added by Bob (bob@example.com)."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_exceeding_stock_str() -> Result<()> {
let t = TestContext::new().await;
let str = quota_exceeding(&t, 81).await;
assert!(str.contains("81% "));
assert!(str.contains("100% "));
assert!(!str.contains("%%"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_msg_body() -> Result<()> {
let t = TestContext::new().await;
let str = partial_download_msg_body(&t, 1024 * 1024).await;
assert_eq!(str, "1 MiB message");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_device_chats() {
let t = TestContext::new().await;
t.update_device_chats().await.ok();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 2);
let chat0 = Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
.await
.unwrap();
let (self_talk_id, device_chat_id) = if chat0.is_self_talk() {
(chats.get_chat_id(0).unwrap(), chats.get_chat_id(1).unwrap())
} else {
(chats.get_chat_id(1).unwrap(), chats.get_chat_id(0).unwrap())
};
// delete self-talk first; this adds a message to device-chat about how self-talk can be restored
let device_chat_msgs_before = chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len();
self_talk_id.delete(&t).await.ok();
assert_eq!(
chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(),
device_chat_msgs_before + 1
);
// delete device chat
device_chat_id.delete(&t).await.ok();
// check, that the chatlist is empty
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
// a subsequent call to update_device_chats() must not re-add manually deleted messages or chats
t.update_device_chats().await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
// Reset all device messages. This normally happens due to account export and import.
// Check that update_device_chats() does not add welcome message for imported account.
delete_and_reset_all_device_msgs(&t).await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
t.update_device_chats().await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
}
}
mod stock_str_tests;

View File

@@ -0,0 +1,182 @@
use num_traits::ToPrimitive;
use super::*;
use crate::chat::delete_and_reset_all_device_msgs;
use crate::chatlist::Chatlist;
use crate::test_utils::TestContext;
#[test]
fn test_enum_mapping() {
assert_eq!(StockMessage::NoMessages.to_usize().unwrap(), 1);
assert_eq!(StockMessage::SelfMsg.to_usize().unwrap(), 2);
}
#[test]
fn test_fallback() {
assert_eq!(StockMessage::NoMessages.fallback(), "No messages.");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_stock_translation() {
let t = TestContext::new().await;
t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string())
.await
.unwrap();
assert_eq!(no_messages(&t).await, "xyz")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_set_stock_translation_wrong_replacements() {
let t = TestContext::new().await;
assert!(t
.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string())
.await
.is_err());
assert!(t
.ctx
.set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string())
.await
.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_str() {
let t = TestContext::new().await;
assert_eq!(no_messages(&t).await, "No messages.");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_string_repl_str() {
let t = TestContext::new().await;
let contact_id = Contact::create(&t.ctx, "Someone", "someone@example.org")
.await
.unwrap();
let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap();
// uses %1$s substitution
assert_eq!(
contact_verified(&t, &contact).await,
"Someone (someone@example.org) verified."
);
// We have no string using %1$d to test...
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_simple() {
let t = TestContext::new().await;
assert_eq!(
msg_location_enabled(&t).await,
"Location streaming enabled."
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_me() {
let t = TestContext::new().await;
assert_eq!(
msg_add_member_remote(&t, "alice@example.org").await,
"I added member alice@example.org."
);
assert_eq!(
msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await,
"You added member alice@example.org."
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_me_with_displayname() {
let t = TestContext::new().await;
Contact::create(&t, "Alice", "alice@example.org")
.await
.expect("failed to create contact");
assert_eq!(
msg_add_member_remote(&t, "alice@example.org").await,
"I added member alice@example.org."
);
assert_eq!(
msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await,
"You added member Alice (alice@example.org)."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_stock_system_msg_add_member_by_other_with_displayname() {
let t = TestContext::new().await;
let contact_id = {
Contact::create(&t, "Alice", "alice@example.org")
.await
.expect("Failed to create contact Alice");
Contact::create(&t, "Bob", "bob@example.com")
.await
.expect("failed to create bob")
};
assert_eq!(
msg_add_member_local(&t, "alice@example.org", contact_id,).await,
"Member Alice (alice@example.org) added by Bob (bob@example.com)."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_quota_exceeding_stock_str() -> Result<()> {
let t = TestContext::new().await;
let str = quota_exceeding(&t, 81).await;
assert!(str.contains("81% "));
assert!(str.contains("100% "));
assert!(!str.contains("%%"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_partial_download_msg_body() -> Result<()> {
let t = TestContext::new().await;
let str = partial_download_msg_body(&t, 1024 * 1024).await;
assert_eq!(str, "1 MiB message");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_update_device_chats() {
let t = TestContext::new().await;
t.update_device_chats().await.ok();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 2);
let chat0 = Chat::load_from_db(&t, chats.get_chat_id(0).unwrap())
.await
.unwrap();
let (self_talk_id, device_chat_id) = if chat0.is_self_talk() {
(chats.get_chat_id(0).unwrap(), chats.get_chat_id(1).unwrap())
} else {
(chats.get_chat_id(1).unwrap(), chats.get_chat_id(0).unwrap())
};
// delete self-talk first; this adds a message to device-chat about how self-talk can be restored
let device_chat_msgs_before = chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len();
self_talk_id.delete(&t).await.ok();
assert_eq!(
chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(),
device_chat_msgs_before + 1
);
// delete device chat
device_chat_id.delete(&t).await.ok();
// check, that the chatlist is empty
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
// a subsequent call to update_device_chats() must not re-add manually deleted messages or chats
t.update_device_chats().await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
// Reset all device messages. This normally happens due to account export and import.
// Check that update_device_chats() does not add welcome message for imported account.
delete_and_reset_all_device_msgs(&t).await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
t.update_device_chats().await.unwrap();
let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap();
assert_eq!(chats.len(), 0);
}

View File

@@ -286,6 +286,8 @@ impl Message {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::chat::ChatId;
use crate::param::Param;
@@ -305,62 +307,90 @@ mod tests {
.unwrap();
let some_text = " bla \t\n\tbla\n\t".to_string();
async fn write_file_to_blobdir(d: &TestContext) -> PathBuf {
let bytes = &[38, 209, 39, 29]; // Just some random bytes
let file = d.get_blobdir().join("random_filename_392438");
tokio::fs::write(&file, bytes).await.unwrap();
file
}
let msg = Message::new_text(some_text.to_string());
assert_summary_texts(&msg, ctx, "bla bla").await; // for simple text, the type is not added to the summary
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Image);
msg.set_file("foo.jpg", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "📷 Image").await; // file names are not added for images
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Image);
msg.set_text(some_text.to_string());
msg.set_file("foo.jpg", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.jpg"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "📷 bla bla").await; // type is visible by emoji if text is set
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Video);
msg.set_file("foo.mp4", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎥 Video").await; // file names are not added for videos
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Video);
msg.set_text(some_text.to_string());
msg.set_file("foo.mp4", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp4"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎥 bla bla").await; // type is visible by emoji if text is set
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Gif);
msg.set_file("foo.gif", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "GIF").await; // file names are not added for GIFs
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Gif);
msg.set_text(some_text.to_string());
msg.set_file("foo.gif", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.gif"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "GIF \u{2013} bla bla").await; // file names are not added for GIFs
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Sticker);
msg.set_file("foo.png", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.png"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "Sticker").await; // file names are not added for stickers
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Voice);
msg.set_file("foo.mp3", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎤 Voice message").await; // file names are not added for voice messages
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Voice);
msg.set_text(some_text.clone());
msg.set_file("foo.mp3", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎤 bla bla").await;
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Audio);
msg.set_file("foo.mp3", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎵 foo.mp3").await; // file name is added for audio
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::Audio);
msg.set_text(some_text.clone());
msg.set_file("foo.mp3", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.mp3"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "🎵 foo.mp3 \u{2013} bla bla").await; // file name and text added for audio
let mut msg = Message::new(Viewtype::File);
let bytes = include_bytes!("../test-data/webxdc/with-minimal-manifest.xdc");
msg.set_file_from_bytes(ctx, "foo.xdc", bytes, None)
.await
.unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_eq!(msg.viewtype, Viewtype::Webxdc);
@@ -369,24 +399,28 @@ mod tests {
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_summary_texts(&msg, ctx, "nice app! \u{2013} bla bla").await;
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_file("foo.bar", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "📎 foo.bar").await; // file name is added for files
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.set_file("foo.bar", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "📎 foo.bar \u{2013} bla bla").await; // file name is added for files
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::VideochatInvitation);
msg.set_text(some_text.clone());
msg.set_file("foo.bar", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
assert_summary_texts(&msg, ctx, "Video chat invitation").await; // text is not added for videochat invitations
let mut msg = Message::new(Viewtype::Vcard);
msg.set_file_from_bytes(ctx, "foo.vcf", b"", None)
.await
.unwrap();
msg.set_file_from_bytes(ctx, "foo.vcf", b"", None).unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
// If a vCard can't be parsed, the message becomes `Viewtype::File`.
assert_eq!(msg.viewtype, Viewtype::File);
@@ -406,7 +440,6 @@ mod tests {
END:VCARD",
None,
)
.await
.unwrap();
chat_id.set_draft(ctx, Some(&mut msg)).await.unwrap();
assert_eq!(msg.viewtype, Viewtype::Vcard);
@@ -419,9 +452,11 @@ mod tests {
assert_eq!(msg.get_summary_text(ctx).await, "Forwarded: bla bla"); // for simple text, the type is not added to the summary
assert_eq!(msg.get_summary_text_without_prefix(ctx).await, "bla bla"); // skipping prefix used for reactions summaries
let file = write_file_to_blobdir(&d).await;
let mut msg = Message::new(Viewtype::File);
msg.set_text(some_text.clone());
msg.set_file("foo.bar", None);
msg.set_file_and_deduplicate(&d, &file, Some("foo.bar"), None)
.unwrap();
msg.param.set_int(Param::Forwarded, 1);
assert_eq!(
msg.get_summary_text(ctx).await,

View File

@@ -16,7 +16,7 @@ use crate::param::Param;
use crate::sync::SyncData::{AddQrToken, AlterChat, DeleteQrToken};
use crate::token::Namespace;
use crate::tools::time;
use crate::{stock_str, token};
use crate::{message, stock_str, token};
/// Whether to send device sync messages. Aimed for usage in the internal API.
#[derive(Debug, PartialEq)]
@@ -62,6 +62,10 @@ pub(crate) enum SyncData {
key: Config,
val: String,
},
SaveMessage {
src: String, // RFC724 id (i.e. "Message-Id" header)
dest: String, // RFC724 id (i.e. "Message-Id" header)
},
}
#[derive(Debug, Serialize, Deserialize)]
@@ -259,6 +263,7 @@ impl Context {
DeleteQrToken(token) => self.delete_qr_token(token).await,
AlterChat { id, action } => self.sync_alter_chat(id, action).await,
SyncData::Config { key, val } => self.sync_config(key, val).await,
SyncData::SaveMessage { src, dest } => self.save_message(src, dest).await,
},
SyncDataOrUnknown::Unknown(data) => {
warn!(self, "Ignored unknown sync item: {data}.");
@@ -268,6 +273,15 @@ impl Context {
.log_err(self)
.ok();
}
// Since there was a sync message, we know that there is a second device.
// Set BccSelf to true if it isn't already.
if !items.items.is_empty() && !self.get_config_bool(Config::BccSelf).await.unwrap_or(true) {
self.set_config_ex(Sync::Nosync, Config::BccSelf, Some("1"))
.await
.log_err(self)
.ok();
}
}
async fn add_qr_token(&self, token: &QrTokenData) -> Result<()> {
@@ -282,6 +296,13 @@ impl Context {
token::delete(self, Namespace::Auth, &token.auth).await?;
Ok(())
}
async fn save_message(&self, src_rfc724_mid: &str, dest_rfc724_mid: &String) -> Result<()> {
if let Some((src_msg_id, _)) = message::rfc724_mid_exists(self, src_rfc724_mid).await? {
chat::save_copy_in_self_talk(self, &src_msg_id, dest_rfc724_mid).await?;
}
Ok(())
}
}
#[cfg(test)]
@@ -578,6 +599,62 @@ mod tests {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_send_sync_msg_enables_bccself() -> Result<()> {
for (chatmail, sync_message_sent) in
[(false, false), (false, true), (true, false), (true, true)]
{
let alice1 = TestContext::new_alice().await;
let alice2 = TestContext::new_alice().await;
// SyncMsgs defaults to true on real devices, but in tests it defaults to false,
// so we need to enable it
alice1.set_config_bool(Config::SyncMsgs, true).await?;
alice2.set_config_bool(Config::SyncMsgs, true).await?;
if chatmail {
alice1.set_config_bool(Config::IsChatmail, true).await?;
alice2.set_config_bool(Config::IsChatmail, true).await?;
} else {
alice2.set_config_bool(Config::BccSelf, false).await?;
}
alice1.set_config_bool(Config::BccSelf, true).await?;
let sent_msg = if sync_message_sent {
alice1
.add_sync_item(SyncData::AddQrToken(QrTokenData {
invitenumber: "in".to_string(),
auth: "testtoken".to_string(),
grpid: None,
}))
.await?;
alice1.send_sync_msg().await?.unwrap();
alice1.pop_sent_sync_msg().await
} else {
let chat = alice1.get_self_chat().await;
alice1.send_text(chat.id, "Hi").await
};
// On chatmail accounts, BccSelf defaults to false.
// When receiving a sync message from another device,
// there obviously is a multi-device-setup, and BccSelf
// should be enabled.
assert_eq!(alice2.get_config_bool(Config::BccSelf).await?, false);
alice2.recv_msg_opt(&sent_msg).await;
assert_eq!(
alice2.get_config_bool(Config::BccSelf).await?,
// BccSelf should be enabled when receiving a sync message,
// but not when receiving another outgoing message
// because we might have forgotten it and it then it might have been forwarded to us again
// (though of course this is very unlikely).
sync_message_sent
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_bot_no_sync_msgs() -> Result<()> {
let mut tcm = TestContextManager::new();

View File

@@ -41,6 +41,7 @@ use crate::pgp::KeyPair;
use crate::receive_imf::receive_imf;
use crate::securejoin::{get_securejoin_qr, join_securejoin};
use crate::stock_str::StockStrings;
use crate::tools::time;
#[allow(non_upper_case_globals)]
pub const AVATAR_900x900_BYTES: &[u8] = include_bytes!("../test-data/image/avatar900x900.png");
@@ -880,7 +881,7 @@ impl TestContext {
let contact = self.add_or_lookup_contact(member).await;
to_add.push(contact.id);
}
add_to_chat_contacts_table(self, chat_id, &to_add)
add_to_chat_contacts_table(self, time(), chat_id, &to_add)
.await
.unwrap();

View File

@@ -686,7 +686,7 @@ async fn test_break_protection_then_verify_again() -> Result<()> {
alice.create_chat(&bob).await;
assert_verified(&alice, &bob, ProtectionStatus::Protected).await;
let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?;
assert!(chats.len() == 1);
assert_eq!(chats.len(), 1);
tcm.section("Bob reinstalls DC");
drop(bob);
@@ -709,7 +709,7 @@ async fn test_break_protection_then_verify_again() -> Result<()> {
assert_eq!(chat.is_protected(), false);
assert_eq!(chat.is_protection_broken(), true);
let chats = Chatlist::try_load(&alice, DC_GCL_FOR_FORWARDING, None, None).await?;
assert!(chats.len() == 1);
assert_eq!(chats.len(), 1);
{
let alice_bob_chat = alice.get_chat(&bob_new).await;

View File

@@ -26,7 +26,7 @@ use chrono::{Local, NaiveDateTime, NaiveTime, TimeZone};
use deltachat_contact_tools::EmailAddress;
#[cfg(test)]
pub use deltachat_time::SystemTimeTools as SystemTime;
use futures::{StreamExt, TryStreamExt};
use futures::TryStreamExt;
use mailparse::dateparse;
use mailparse::headers::Headers;
use mailparse::MailHeaderMap;
@@ -366,22 +366,6 @@ pub(crate) async fn delete_file(context: &Context, path: impl AsRef<Path>) -> Re
Ok(())
}
pub async fn delete_files_in_dir(context: &Context, path: impl AsRef<Path>) -> Result<()> {
let read_dir = tokio::fs::read_dir(path)
.await
.context("could not read dir to delete")?;
let mut read_dir = tokio_stream::wrappers::ReadDirStream::new(read_dir);
while let Some(entry) = read_dir.next().await {
match entry {
Ok(file) => {
delete_file(context, file.file_name()).await?;
}
Err(e) => warn!(context, "Could not read file to delete: {}", e),
}
}
Ok(())
}
/// A guard which will remove the path when dropped.
///
/// It implements [`Deref`] so it can be used as a `&Path`.

Some files were not shown because too many files have changed in this diff Show More