Compare commits

..

52 Commits

Author SHA1 Message Date
charles a4f668929c (backend): add command admin
we want to run the indexing from the admin.
in `dmin/core/runindexing/`is a form to do so.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles 209622c523 📝(backend): add docs
i am documenting the index command

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles 0076df5782 ♻️(backend): renaming
to me tasks/search.py should be tasks/indexing.py

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:18 +01:00
charles e2344dcc17 (backend): add time-bound filtering options on index command
we need to be able to specify time bounds on the bulk index
to allow recovering from checkpoint after crash

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-26 17:41:17 +01:00
charles e183ee9ed7 (backend): add crash-safe mode to index command
crash-save mode consist in indexing documents in ascending
updated_at order and save the last document.update_at.
This allows resuming indexing from the last successful batch
in case of a crash.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-25 17:05:40 +01:00
charles 5ce3ab71d3 📝(backend): improve docstring in tasks.search
i think some docstrings, about a counter, were outdated.
i add more details to help understand the logic.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-24 15:42:33 +01:00
ZouicheOmar 0d09f761dc 💄(frontend) improve comments highlights
Updated comments styles to respect design proposal,
adding distinguishable highlighting, click and hover
style interactions.
2026-03-24 09:38:31 +01:00
Manuel Raynaud ce5f9a1417 🔖(patch) release 4.8.3
Changed

- 💫(frontend) fix the help button to the bottom in tree #2073
- ️(frontend) improve version history list accessibility #2033
- ️(frontend) fix more options menu feedback for screen readers #2071
- (frontend) focus skip link on headings and skip grid dropzone #1983
- ️(frontend) fix search modal accessibility issues #2054
- ️(frontend) add sr-only format to export download button #2088
- ️(frontend) announce formatting shortcuts for screen readers #2070
- (frontend) add markdown copy icon for Copy as Markdown option #2096
- ♻️(backend) skip saving in database a document when payload is empty #2062

Fixed

- ️(frontend) fix aria-labels for table of contents #2065
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
2026-03-23 17:32:50 +01:00
Anthony LC 83a24c3796 ️(frontend) add debounce WebSocket reconnect
We add a debounce mechanism to the WebSocket
reconnect logic in the `useProviderStore` to
prevent rapid reconnection attempts that can
lead to performance issues and potential server
overload.
2026-03-23 17:01:02 +01:00
Anthony LC 4a269e6b0e 🐛(y-provider) fix loop when no cookies
We observed a huge amount of logs sometimes in
the y-provider server logs, all related to the
same error: "No cookies".
When this happens, the client keeps trying to
reconnect, and the server keeps logging the error,
creating a loop.
We stop the loop by checking if the error is a
"No cookies" error, and if so, we don't
try to reconnect.
2026-03-23 11:53:55 +01:00
Anthony LC d9d7b70b71 ♻️(frontend) refacto Version modal to fit with the design system
We refactored the version modal to fit
the design system. We removed some dead code and
fixed some state issues.
2026-03-23 10:58:50 +01:00
Anthony LC a4326366c2 🐛(frontend) fix leftpanel button in doc version
The left panel button was shown in the doc version page.
This commit removes the button from the doc version
page by moving it to the DocLayout.
By moving it to the DocLayout, we do not have the
flickering when we switch between subpages.
2026-03-23 10:33:05 +01:00
Anthony LC 1d7b57e03d 🐛(frontend) fix close panel when click on subdoc
Recent refacto of left panel components caused
the close panel function to stop working when
clicking on a subdoc.
This commit fixes that issue by ensuring that the
close panel function is properly called when
a subdoc is clicked.
2026-03-23 10:11:19 +01:00
Manuel Raynaud c4c6c22e42 ♻️(backend) skip saving in database a document when payload is empty
The frontend application is making PATCH request with an empty body.
This PATCH request is not making any change but an UPDATE sql query is
made, the `updated_at` field is the only one updated. When can skip this
save in the databse by returning the Document instance in the serializer
update method
2026-03-21 10:33:02 +01:00
Manuel Raynaud 10a8eccc71 (backend) add missing update api test using the PATCH method
No tests were made using the PATCH method to update a Document using the
API. The frontend appllication mostly use the patch method instead of
the PUT method.
2026-03-21 10:15:50 +01:00
Manuel Raynaud 728332f8f7 (backend) assert document path can not change during API update
We want to assert on every succesful update test that the document path
has not change.
2026-03-21 10:15:49 +01:00
Manuel Raynaud 487b95c207 🐛(backend) allow using search endpoint without refresh token enabled
The search endpoint was using the refresh_roken method decorator. This
decorator force having a valid refresh token stored in the session for
the entire viewset. The search endpoint still allow having the legacy
search behavior and for this we don't need to configure at all the OIDC
refrsh mechanism.
2026-03-21 08:22:45 +00:00
Cyril d23b38e478 (frontend) add markdown copy icon for Copy as Markdown option
Replace generic copy icon with dedicated markdown_copy SVG in DocToolBox.
2026-03-20 15:41:03 +01:00
Cyril d6333c9b81 ️(frontend) fix aria-labels for table of contents nav vs buttons
Screen readers announce nav as "Sommaire, navigation" and button as toggle.
2026-03-20 15:04:29 +01:00
renovate[bot] 03b6c6a206 ⬆️(dependencies) update next to v16.1.7 [SECURITY] 2026-03-20 13:12:08 +00:00
Cyril aadabf8d3c ️(frontend) announce formatting shortcuts for screen readers
Announce formatting shortcuts (headings, lists, paragraph, code block).
2026-03-20 12:56:38 +01:00
Cyril 2a708d6e46 ️(frontend) add format to export download btn aria-label
Add format to export button aria-label for a11y. DRY format options.
2026-03-20 11:27:02 +01:00
Cyril b47c730e19 ️(frontend) announce search results through a live region
Announce result count updates while focus stays in input (#2043)
2026-03-20 10:47:18 +01:00
Cyril cef83067e6 ️(frontend) restore focus to input after search filter reset
Move filters out of listbox and refocus the search input (#2044)
2026-03-20 10:47:03 +01:00
Cyril 4cabfcc921 ️(frontend) update aria-expanded dynamically on search combobox
Override cmdk aria-expanded via ref to reflect list state (#2039)
2026-03-20 10:47:02 +01:00
Cyril b8d4b0a044 ️(frontend) add label text to search input field
Pass label prop to QuickSearch to render non-empty label (#2041)
2026-03-20 10:47:01 +01:00
Cyril 71c4d2921b ️(frontend) add explicit aria-label to search reset button
Add aria-label to clarify the reset button resets filters (#2042)
2026-03-20 10:47:01 +01:00
Cyril d1636dee13 ️(frontend) set empty alt on decorative search image
Set alt="" on decorative empty state image in search modal (#2038)
2026-03-20 10:46:49 +01:00
Cyril bf93640af8 ️(frontend) skip link as anchor instead of button
Replace button with anchor link
2026-03-20 10:05:42 +01:00
Cyril da79c310ae ️(frontend) focus skip link on headings and skip grid dropzone
We land keyboard users on page headings and keep the grid dropzone untabbable.
2026-03-20 10:05:33 +01:00
Cyril 99c486571d ️(frontend) fix more options menu feedback for screen readers
Pin/unpin: vocal announce only. Duplicate, copy: toast only.
2026-03-19 18:34:24 +01:00
Cyril cdf3161869 ️(frontend) use aria-label trad for version history modal #2023
Replace hardcoded aria-label with aria-lab trad.
2026-03-19 15:52:28 +01:00
Cyril ef108227b3 ️(frontend) improve version history list accessibility
Dynamic aria-label per version, aria-pressed + live region
2026-03-19 14:04:59 +01:00
Anthony LC 9991820cb1 🔊(CHANGELOG) fix entries changelog
The changelog was not updated correctly.
By not updating correctly, the changelog was not
showing the correct entries for the release,
leading to a patch release instead of a minor
release.
2026-03-19 13:36:48 +01:00
Anthony LC 2801ece358 ️(frontend) change aria-label for help menu button
The help menu button's aria-label was
previously "Open onboarding menu", which was not
accurate and could be confusing for screen reader
users. This commit updates the aria-label to
"Open help menu" to better reflect the button's
purpose and improve accessibility.
2026-03-19 13:31:03 +01:00
Anthony LC 0b37996899 💫(frontend) fix the help button to the bottom in tree
The tree take a bit of time to load, during this
time the help button was not at the bottom of
the left panel. To fix this issue, we addded a
skeleton for the tree in wait for the tree to
load, by doing this, the help button
is always at the bottom.
2026-03-19 13:28:22 +01:00
Manuel Raynaud 0867ccef1a 🔖(patch) release 4.8.2
Changed

- ️(frontend) ensure doc title is h1 for accessibility #2006
- ️(frontend) add nb accesses in share button aria-label #2017

Fixed

- 🐛(frontend) fix image resizing when caption #2045
- 🙈(docker) add \*\*/.next to .dockerignore #2034
- ️(frontend) fix share modal heading hierarchy #2007
- ️(frontend) fix Copy link toast accessibility for screen readers #2029
- ️(frontend) fix modal aria-label and name #2014
- ️(frontend) fix language dropdown ARIA for screen readers #2020
- ️(frontend) fix waffle aria-label spacing for new-window links #2030
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
- 🐛(backend) duplicate a document as last-sibling #2084
2026-03-19 10:24:25 +01:00
Manuel Raynaud b3ae6e1a30 🐛(backend) duplicate a document as last-sibling
When a document is duplicated, it is duplicated at the direct right of
the duplicated document. Doing this force to move all the other
documents at the right, if it is duplicated at the root this can impact
a lot of documents, create lot of locks in the database. If the process
is stop for any reason then the paths can be in an inconsistent paths in
the Document table
2026-03-19 10:14:56 +01:00
Manuel Raynaud 1df6242927 🐛(backend) stop using add_sibling method to create sandbox document
In a past release we added a feature to create a sandbox document to a
newly created used. To create this sandbox document, we duplicate an
existing document and this duplicate is using the add_sibling method
with the "right" agument on this original document. Adding a sibling at
the right to a document involve moving right every root document created
after the original document, so the path of all this documents are
recalculated and changed. This can lead to the lost of some leaf in a
tree because to do this operation, multiple locks are created on the
database, creating lot of connection to the database and if the max
number connection to the database is reached or if the memory allocated
by the database is too hight, the database can close all connections
leading to inconsistent paths in the Document table.
2026-03-19 10:14:54 +01:00
Cyril 35fba02085 ️(i18n) fix waffle aria-label spacing for new-window links
Include space and parentheses in translation value for proper aria-label.
2026-03-19 09:14:45 +01:00
Cyril 0e5c9ed834 ️(frontend) fix language dropdown ARIA for screen readers
Add aria-haspopup, aria-expanded and menuitemradio pattern for SR.
2026-03-18 17:06:53 +01:00
Sylvain Boissel 4e54a53072 (backend) add resource server api
Adds a resource server API similar to the one that already
exists for Drive.
2026-03-18 16:06:29 +01:00
Charles Englebert 4f8aea7b80 Search feature flags (#1897)
## Purpose

For beta testing purposes we need to be able to activate Find hybrid
search to some users, Find full-text search to some others and leave
remaining users on basic DRF title search.

## Proposal

The solution proposed is based on [django-waffle
](https://waffle.readthedocs.io/en/stable/types/flag.html).

- [x] install waffle and activate the default app in settings.
- [x] implement `_get_search_type` in `DocumentViewset` to determine
which search type (title, hybrid or full-text) to use.
- [x] send the `search_type` in the search query. 

## External contributions

Thank you for your contribution! 🎉  

Please ensure the following items are checked before submitting your
pull request:
- [x] I have read and followed the [contributing
guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [x] I have read and agreed to the [Code of
Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [x] I have signed off my commits with `git commit --signoff` (DCO
compliance)
- [x] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [x] My commit messages follow the required format: `<gitmoji>(type)
title description`
- [x] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [x] I have added corresponding tests for new features or bug fixes (if
applicable)

---------

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-18 15:04:55 +00:00
Cyril 1172fbe0b5 ️(frontend) add nb accesses in share button aria-label
Expose nb_accesses_direct to screen readers when share button shows count.
2026-03-18 14:21:04 +01:00
Cyril 7cf144e0de ️(frontend) fix modal aria-label object Object
Add aria-label on modals with JSX title to avoid returning object Object
2026-03-18 13:39:50 +01:00
Anthony LC 54c15c541e 🐛(frontend) fix image resizing when caption
When the caption was present, the image resizing
handles were not working.
This was because we were adding a Figure element
around the resizing div instead of the image itself.
2026-03-18 12:17:06 +01:00
Cyril 8472e661f5 ️(frontend) fix Copy link toast accessibility for screen readers
Add aria-live announcements so screen readers announce the toast feedback.
2026-03-18 11:51:15 +01:00
Cyril 1d819d8fa2 ️(frontend) fix share modal heading hierarchy
Improve h struct in docShareModal use h2 for group names and link settings
2026-03-18 10:47:39 +01:00
Cyril 5020bc1c1a ️(frontend) fix share modal heading hierarchy
Render QuickSearchGroup names and link settings as h2 headings.
2026-03-18 10:02:24 +01:00
Cyril 4cd72ffa4f ️(frontend) ensure doc title is h1 for accessibility
Fix heading hierarchy when withTitle is false in production
2026-03-18 10:02:08 +01:00
Anthony LC c1998a9b24 🙈(docker) add **/.next to .dockerignore
All the ".next" files are generated by the build
process and should not be included in the Docker
context.
2026-03-18 09:04:46 +01:00
Charles Englebert 0fca6db79c Integrate Find (#1834)
## Purpose

integrate Find to Docs

## Proposal

- [x]  add a `useSeachDocs` hook in charged of calling the search
endpoint.
- [x]  add a optional `path` param to the `search` route. This param
represents the parent document path in case of a sub-documents
(descendants) search.
- [x] ️return Indexer results directly without DB calls to retrieve the
Document objects. All informations necessary for display are indexed in
Find. We can skip the DB calls and improve performance.
- [x] ♻️ refactor react `DocSearchContent` components.
`DocSearchContent` and `DocSearchSubContent` are now merged a unique
component handling all search scenarios and relying on the unique
`search` route.
- [x] 🔥remove pagination logic in the Indexer. Removing the DB calls
also removes the DRF queryset object which handles the pagination. Also
we consider pagination not to be necessary for search v1.
- [x] 🔥remove the `document/<document_id>/descendants` route. This route
is not used anymore. The logic of finding the descendants are moved to
the internal `_list_descendants` method. This method is based on the
parent `path` instead of the parent `id` which has some consequence
about the user access management. Relying on the path prevents the use
of the `self.get_object()` method which used to handle the user access
logic.
- [x] handle fallback logic on DRF based title search in case of
non-configured, badly configured or failing at run time indexer.
- [x] handle language extension in `title` field. Find returns titles
with a language extension (ex: `{ title.fr: "rapport d'activité" }`
instead of `{ "title": "rapport d'activité" }`.
- [x] 🔧 add a `common.test` file to allow running the tests without
docker
- [x] ♻️ rename `SearchIndexer` -> `FindDocumentIndexer`. This class has
to do with Find in particular and the convention is more coherent with
`BaseDocumentIndexer`
- [x] ♻️ rename `SEARCH_INDEXER_URL` -> `INDEXING_URL` and
`SEARCH_INDEXER_QUERY_URL` -> `SEARCH_URL`. I found the original names
very confusing.
- [x] 🔧 update the environment variables to activate the
FindDocumentIndexer.
- [x] automate the generation of encryption key during bootstrap.
OIDC_STORE_REFRESH_TOKEN_KEY is a mandatory secret key. We can not push
it on Github and we want any contributor to be able to run the app by
only running the `make bootstrap`. We chose to generate and wright it
into the `common.local` during bootstrap.

## External contributions

Thank you for your contribution! 🎉  

Please ensure the following items are checked before submitting your
pull request:
- [x] I have read and followed the [contributing
guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [x] I have read and agreed to the [Code of
Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [x] I have signed off my commits with `git commit --signoff` (DCO
compliance)
- [x] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [x] My commit messages follow the required format: `<gitmoji>(type)
title description`
- [x] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [x] I have added corresponding tests for new features or bug fixes (if
applicable)

---------

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-17 17:32:03 +01:00
137 changed files with 6329 additions and 942 deletions
+1 -1
View File
@@ -34,4 +34,4 @@ db.sqlite3
# Frontend
node_modules
.next
**/.next
+64 -5
View File
@@ -6,6 +6,67 @@ and this project adheres to
## [Unreleased]
### Changed
- 💄(frontend) improve comments highlights #1961
## [v4.8.3] - 2026-03-23
### Changed
- ✨(backend) improve indexing command with checkpoint recovery and asynchronicity
- ♿️(frontend) improve version history list accessibility #2033
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
- ♿️(frontend) add sr-only format to export download button #2088
- ♿️(frontend) announce formatting shortcuts for screen readers #2070
- ✨(frontend) add markdown copy icon for Copy as Markdown option #2096
- ♻️(backend) skip saving in database a document when payload is empty #2062
- ♻️(frontend) refacto Version modal to fit with the design system #2091
- ⚡️(frontend) add debounce WebSocket reconnect #2104
### Fixed
- ♿️(frontend) fix more options menu feedback for screen readers #2071
- ♿️(frontend) fix more options menu feedback for screen readers #2071
- 💫(frontend) fix the help button to the bottom in tree #2073
- ♿️(frontend) fix aria-labels for table of contents #2065
- 🐛(backend) allow using search endpoint without refresh token enabled #2097
- 🐛(frontend) fix close panel when click on subdoc #2094
- 🐛(frontend) fix leftpanel button in doc version #9238
- 🐛(y-provider) fix loop when no cookies #2101
## [v4.8.2] - 2026-03-19
### Added
- ✨(backend) add resource server api #1923
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
### Changed
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
- ♿️(frontend) add nb accesses in share button aria-label #2017
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
- 🐛(frontend) fix image resizing when caption #2045
- 🙈(docker) add \*\*/.next to .dockerignore #2034
- ♿️(frontend) fix share modal heading hierarchy #2007
- ♿️(frontend) fix Copy link toast accessibility for screen readers #2029
- ♿️(frontend) fix modal aria-label and name #2014
- ♿️(frontend) fix language dropdown ARIA for screen readers #2020
- ♿️(frontend) fix waffle aria-label spacing for new-window links #2030
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
- 🐛(backend) duplicate a document as last-sibling #2084
### Removed
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
## [v4.8.1] - 2026-03-17
### Added
@@ -107,16 +168,12 @@ and this project adheres to
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
@@ -1121,7 +1178,9 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.1...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.3...main
[v4.8.3]: https://github.com/suitenumerique/docs/releases/v4.8.3
[v4.8.2]: https://github.com/suitenumerique/docs/releases/v4.8.2
[v4.8.1]: https://github.com/suitenumerique/docs/releases/v4.8.1
[v4.8.0]: https://github.com/suitenumerique/docs/releases/v4.8.0
[v4.7.0]: https://github.com/suitenumerique/docs/releases/v4.7.0
+5
View File
@@ -162,6 +162,10 @@ endif
@echo ""
.PHONY: post-beautiful-bootstrap
create-docker-network: ## create the docker network if it doesn't exist
@docker network create lasuite-network || true
.PHONY: create-docker-network
bootstrap: ## Prepare the project for local development
bootstrap: \
pre-beautiful-bootstrap \
@@ -219,6 +223,7 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@@ -47,6 +47,10 @@ server {
try_files $uri @proxy_to_docs_backend;
}
location /external_api {
try_files $uri @proxy_to_docs_backend;
}
location /static {
try_files $uri @proxy_to_docs_backend;
}
+52
View File
@@ -0,0 +1,52 @@
# Index Command
The `index` management command is used to index documents to the remote search indexer.
It sends an asynchronous task to the Celery worker.
## Usage
### Command Line
```bash
python manage.py index \
--lower-time-bound "2024-01-01T00:00:00" \
--upper-time-bound "2024-01-31T23:59:59" \
--batch-size 200 \
--crash-safe-mode false
```
### Django Admin
The command is available in the Django admin interface:
1. Go to `/admin/`
2. Click on **"Run Indexing"** in the CORE section
3. Fill in the form with the desired parameters
4. Click **"Run Indexing Command"**
## Parameters
### `--batch-size`
- **type:** Integer
- **default:** `50`
- **description:** Number of documents to process per batch. Higher values may improve performance but use more memory.
### `--lower-time-bound`
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated after this date will be indexed.
### `--upper-time-bound` (optional)
- **optional**: true
- **type:** ISO 8601 datetime string
- **default:** `None`
- **description:** Only documents updated before this date will be indexed.
### `--crash-safe-mode`
- **type:** Boolean flag
- **default:** `True`
- **description:** When enabled, the command orders documents by `updated_at` and stores the last indexed document's timestamp in cache. This allows resuming indexing from the last successful batch in case of a crash. However, it is more computationally expensive due to sorting.b
+106
View File
@@ -0,0 +1,106 @@
# Use Docs as a Resource Server
Docs implements resource server, so it means it can be used from an external app to perform some operation using the dedicated API.
> **Note:** This feature might be subject to future evolutions. The API endpoints, configuration options, and behavior may change in future versions.
## Prerequisites
In order to activate the resource server on Docs you need to setup the following environment variables
```python
OIDC_RESOURCE_SERVER_ENABLED=True
OIDC_OP_URL=
OIDC_OP_INTROSPECTION_ENDPOINT=
OIDC_RS_CLIENT_ID=
OIDC_RS_CLIENT_SECRET=
OIDC_RS_AUDIENCE_CLAIM=
OIDC_RS_ALLOWED_AUDIENCES=
```
It implements the resource server using `django-lasuite`, see the [documentation](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-resource-server-backend.md)
## Customise allowed routes
Configure the `EXTERNAL_API` setting to control which routes and actions are available in the external API. Set it via the `EXTERNAL_API` environment variable (as JSON) or in Django settings.
Default configuration:
```python
EXTERNAL_API = {
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "create", "children"],
},
"document_access": {
"enabled": False,
"actions": [],
},
"document_invitation": {
"enabled": False,
"actions": [],
},
"users": {
"enabled": True,
"actions": ["get_me"],
},
}
```
**Endpoints:**
- `documents`: Controls `/external_api/v1.0/documents/`. Available actions: `list`, `retrieve`, `create`, `update`, `destroy`, `trashbin`, `children`, `restore`, `move`,`versions_list`, `versions_detail`, `favorite_detail`,`link_configuration`, `attachment_upload`, `media_auth`, `ai_transform`, `ai_translate`, `ai_proxy`. Always allowed actions: `favorite_list`, `duplicate`.
- `document_access`: `/external_api/v1.0/documents/{id}/accesses/`. Available actions: `list`, `retrieve`, `create`, `update`, `partial_update`, `destroy`
- `document_invitation`: Controls `/external_api/v1.0/documents/{id}/invitations/`. Available actions: `list`, `retrieve`, `create`, `partial_update`, `destroy`
- `users`: Controls `/external_api/v1.0/documents/`. Available actions: `get_me`.
Each endpoint has `enabled` (boolean) and `actions` (list of allowed actions). Only actions explicitly listed are accessible.
## Request Docs
In order to request Docs from an external resource provider, you need to implement the basic setup of `django-lasuite` [Using the OIDC Authentication Backend to request a resource server](https://github.com/suitenumerique/django-lasuite/blob/main/documentation/how-to-use-oidc-call-to-resource-server.md)
Then you can requests some routes that are available at `/external_api/v1.0/*`, here are some examples of what you can do.
### Create a document
Here is an example of a view that creates a document from a markdown file at the root level in Docs.
```python
@method_decorator(refresh_oidc_access_token)
def create_document_from_markdown(self, request):
"""
Create a new document from a Markdown file at root level.
"""
# Get the access token from the session
access_token = request.session.get('oidc_access_token')
# Create a new document from a file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = requests.post(
f"{settings.DOCS_API}/documents/",
{
"file": file,
},
format="multipart",
)
response.raise_for_status()
data = response.json()
return {"id": data["id"]}
```
### Get user information
The same way, you can use the /me endpoint to get user information.
```python
response = requests.get(
"{settings.DOCS_API}/users/me/",
headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
)
```
+11 -2
View File
@@ -51,9 +51,18 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS="localhost:8083,localhost:3000"
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Resource Server Backend
OIDC_OP_URL=http://localhost:8083/realms/docs
OIDC_OP_INTROSPECTION_ENDPOINT = http://nginx:8083/realms/docs/protocol/openid-connect/token/introspect
OIDC_RESOURCE_SERVER_ENABLED=False
OIDC_RS_CLIENT_ID=docs
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RS_AUDIENCE_CLAIM="client_id" # The claim used to identify the audience
OIDC_RS_ALLOWED_AUDIENCES=""
# Store OIDC tokens in the session. Needed by search/ endpoint.
OIDC_STORE_ACCESS_TOKEN=True
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
# OIDC_STORE_ACCESS_TOKEN=True
# OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session.
# Must be a valid Fernet key (32 url-safe base64-encoded bytes)
# To create one, use the bin/fernetkey command.
+36 -1
View File
@@ -1,8 +1,10 @@
"""Admin classes and registrations for core app."""
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.shortcuts import redirect
from django.core.management import call_command
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
@@ -227,3 +229,36 @@ class InvitationAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
obj.issuer = request.user
obj.save()
@admin.register(models.RunIndexing)
class RunIndexingAdmin(admin.ModelAdmin):
"""Admin for running indexing commands."""
def changelist_view(self, request, extra_context=None):
"""Override to avoid querying the database and handle form submission."""
if request.method == "POST":
try:
call_command(
"index",
batch_size=int(request.POST.get("batch_size")),
lower_time_bound=request.POST.get("lower_time_bound"),
upper_time_bound=request.POST.get("upper_time_bound"),
crash_safe_mode=request.POST.get("crash_safe_mode"),
)
messages.success(request, _("Indexing triggered!"))
except Exception as e:
messages.error(
request,
_("Error running indexing command: %(error)s") % {"error": str(e)},
)
return render(
request=request,
template_name="runindexing.html",
context={
**self.admin_site.each_context(request),
"title": "Run Indexing Command",
"default_batch_size": settings.SEARCH_INDEXER_BATCH_SIZE,
},
)
+9
View File
@@ -300,6 +300,15 @@ class DocumentSerializer(ListDocumentSerializer):
return file
def update(self, instance, validated_data):
"""
When no data is sent on the update, skip making the update in the database and return
directly the instance unchanged.
"""
if not validated_data:
return instance # No data provided, skip the update
return super().update(instance, validated_data)
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
+15
View File
@@ -6,8 +6,10 @@ from abc import ABC, abstractmethod
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.utils.decorators import method_decorator
import botocore
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework.throttling import BaseThrottle
@@ -91,6 +93,19 @@ def generate_s3_authorization_headers(key):
return request
def conditional_refresh_oidc_token(func):
"""
Conditionally apply refresh_oidc_access_token decorator.
The decorator is only applied if OIDC_STORE_REFRESH_TOKEN is True, meaning
we can actually refresh something. Broader settings checks are done in settings.py.
"""
if settings.OIDC_STORE_REFRESH_TOKEN:
return method_decorator(refresh_oidc_access_token)(func)
return func
class AIBaseRateThrottle(BaseThrottle, ABC):
"""Base throttle class for AI-related rate limiting with backoff."""
+24 -19
View File
@@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.http import content_disposition_header
from django.utils.text import capfirst, slugify
@@ -38,7 +37,6 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from pydantic import ValidationError as PydanticValidationError
from rest_framework import filters, status, viewsets
@@ -457,36 +455,45 @@ class DocumentViewSet(
### Additional Actions:
1. **Trashbin**: List soft deleted documents for a document owner
Example: GET /documents/{id}/trashbin/
Example: GET /documents/trashbin/
2. **Children**: List or create child documents.
2. **Restore**: Restore a soft deleted document.
Example: POST /documents/{id}/restore/
3. **Move**: Move a document to another parent document.
Example: POST /documents/{id}/move/
4. **Duplicate**: Duplicate a document.
Example: POST /documents/{id}/duplicate/
5. **Children**: List or create child documents.
Example: GET, POST /documents/{id}/children/
3. **Versions List**: Retrieve version history of a document.
6. **Versions List**: Retrieve version history of a document.
Example: GET /documents/{id}/versions/
4. **Version Detail**: Get or delete a specific document version.
7. **Version Detail**: Get or delete a specific document version.
Example: GET, DELETE /documents/{id}/versions/{version_id}/
5. **Favorite**: Get list of favorite documents for a user. Mark or unmark
8. **Favorite**: Get list of favorite documents for a user. Mark or unmark
a document as favorite.
Examples:
- GET /documents/favorite/
- GET /documents/favorite_list/
- POST, DELETE /documents/{id}/favorite/
6. **Create for Owner**: Create a document via server-to-server on behalf of a user.
9. **Create for Owner**: Create a document via server-to-server on behalf of a user.
Example: POST /documents/create-for-owner/
7. **Link Configuration**: Update document link configuration.
10. **Link Configuration**: Update document link configuration.
Example: PUT /documents/{id}/link-configuration/
8. **Attachment Upload**: Upload a file attachment for the document.
11. **Attachment Upload**: Upload a file attachment for the document.
Example: POST /documents/{id}/attachment-upload/
9. **Media Auth**: Authorize access to document media.
12. **Media Auth**: Authorize access to document media.
Example: GET /documents/media-auth/
10. **AI Transform**: Apply a transformation action on a piece of text with AI.
13. **AI Transform**: Apply a transformation action on a piece of text with AI.
Example: POST /documents/{id}/ai-transform/
Expected data:
- text (str): The input text.
@@ -494,7 +501,7 @@ class DocumentViewSet(
Returns: JSON response with the processed text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
11. **AI Translate**: Translate a piece of text with AI.
14. **AI Translate**: Translate a piece of text with AI.
Example: POST /documents/{id}/ai-translate/
Expected data:
- text (str): The input text.
@@ -502,7 +509,7 @@ class DocumentViewSet(
Returns: JSON response with the translated text.
Throttled by: AIDocumentRateThrottle, AIUserRateThrottle.
12. **AI Proxy**: Proxy an AI request to an external AI service.
15. **AI Proxy**: Proxy an AI request to an external AI service.
Example: POST /api/v1.0/documents/<resource_id>/ai-proxy
### Ordering: created_at, updated_at, is_favorite, title
@@ -1352,7 +1359,7 @@ class DocumentViewSet(
)
else:
duplicated_document = document_to_duplicate.add_sibling(
"right",
"last-sibling",
title=title,
content=base64_yjs_content,
attachments=attachments,
@@ -1406,7 +1413,7 @@ class DocumentViewSet(
return duplicated_document
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
@utils.conditional_refresh_oidc_token
def search(self, request, *args, **kwargs):
"""
Returns an ordered list of documents best matching the search query parameter 'q'.
@@ -1417,7 +1424,6 @@ class DocumentViewSet(
params = serializers.SearchDocumentSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
search_type = self._get_search_type()
if search_type == SearchType.TITLE:
return self._title_search(request, params.validated_data, *args, **kwargs)
@@ -1435,7 +1441,6 @@ class DocumentViewSet(
# fallback on title search if the indexer is not reached
return self._title_search(request, params.validated_data, *args, **kwargs)
def _get_search_type(self) -> SearchType:
"""
Returns the search type to use for the search endpoint based on feature flags.
@@ -0,0 +1,41 @@
"""Resource Server Permissions for the Docs app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import permissions
class ResourceServerClientPermission(permissions.BasePermission):
"""
Permission class for resource server views.
This provides a way to open the resource server views to a limited set of
Service Providers.
Note: we might add a more complex permission system in the future, based on
the Service Provider ID and the requested scopes.
"""
def has_permission(self, request, view):
"""
Check if the user is authenticated and the token introspection
provides an authorized Service Provider.
"""
if not isinstance(
request.successful_authenticator, ResourceServerAuthentication
):
# Not a resource server request
return False
# Check if the user is authenticated
if not request.user.is_authenticated:
return False
if (
hasattr(view, "resource_server_actions")
and view.action not in view.resource_server_actions
):
return False
# When used as a resource server, the request has a token audience
return (
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
)
+91
View File
@@ -0,0 +1,91 @@
"""Resource Server Viewsets for the Docs app."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from core.api.permissions import (
CanCreateInvitationPermission,
DocumentPermission,
IsSelf,
ResourceAccessPermission,
)
from core.api.viewsets import (
DocumentAccessViewSet,
DocumentViewSet,
InvitationViewset,
UserViewSet,
)
from core.external_api.permissions import ResourceServerClientPermission
# pylint: disable=too-many-ancestors
class ResourceServerRestrictionMixin:
"""
Mixin for Resource Server Viewsets to provide shortcut to get
configured actions for a given resource.
"""
def _get_resource_server_actions(self, resource_name):
"""Get resource_server_actions from settings."""
external_api_config = settings.EXTERNAL_API.get(resource_name, {})
return list(external_api_config.get("actions", []))
class ResourceServerDocumentViewSet(ResourceServerRestrictionMixin, DocumentViewSet):
"""Resource Server Viewset for Documents."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & DocumentPermission] # type: ignore
@property
def resource_server_actions(self):
"""Build resource_server_actions from settings."""
return self._get_resource_server_actions("documents")
class ResourceServerDocumentAccessViewSet(
ResourceServerRestrictionMixin, DocumentAccessViewSet
):
"""Resource Server Viewset for DocumentAccess."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & ResourceAccessPermission] # type: ignore
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("document_access")
class ResourceServerInvitationViewSet(
ResourceServerRestrictionMixin, InvitationViewset
):
"""Resource Server Viewset for Invitations."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [
ResourceServerClientPermission & CanCreateInvitationPermission
]
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("document_invitation")
class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet):
"""Resource Server Viewset for User."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [ResourceServerClientPermission & IsSelf] # type: ignore
@property
def resource_server_actions(self):
"""Get resource_server_actions from settings."""
return self._get_resource_server_actions("users")
+37 -11
View File
@@ -3,11 +3,12 @@ Handle search setup that needs to be done at bootstrap time.
"""
import logging
import time
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from core.services.search_indexers import get_document_indexer
from core.tasks.indexing import batch_document_indexer_task
logger = logging.getLogger("docs.search.bootstrap_search")
@@ -27,6 +28,31 @@ class Command(BaseCommand):
default=50,
help="Indexation query batch size",
)
parser.add_argument(
"--lower-time-bound",
action="store",
dest="lower_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated after this date will be indexed",
)
parser.add_argument(
"--upper-time-bound",
action="store",
dest="upper_time_bound",
type=datetime.fromisoformat,
default=None,
help="DateTime in ISO format. Only documents updated before this date will be indexed",
)
parser.add_argument(
"--crash-safe-mode",
action="store_true",
dest="crash_safe_mode",
default=True,
help="If True, order documents by updated_at and store the last indexed document.updated_at in cache. "
"This allows resuming indexing from the last successful batch in case of a crash "
"but is more computationally expensive due to sorting.",
)
def handle(self, *args, **options):
"""Launch and log search index generation."""
@@ -35,18 +61,18 @@ class Command(BaseCommand):
if not indexer:
raise CommandError("The indexer is not enabled or properly configured.")
logger.info("Starting to regenerate Find index...")
start = time.perf_counter()
batch_size = options["batch_size"]
try:
count = indexer.index(batch_size=batch_size)
batch_document_indexer_task.apply_async(
kwargs={
"lower_time_bound": options["lower_time_bound"],
"upper_time_bound": options["upper_time_bound"],
"batch_size": options["batch_size"],
"crash_safe_mode": options["crash_safe_mode"],
},
)
except Exception as err:
raise CommandError("Unable to regenerate index") from err
raise CommandError("Unable send indexing task to worker") from err
duration = time.perf_counter() - start
logger.info(
"Search index regenerated from %d document(s) in %.2f seconds.",
count,
duration,
"Document indexing task sent to worker",
)
@@ -0,0 +1,23 @@
# Generated by Django 5.2.12 on 2026-03-26 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0031_clean_onboarding_accesses'),
]
operations = [
migrations.CreateModel(
name='RunIndexing',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
options={
'verbose_name': 'Run Indexing',
'managed': False,
},
),
]
+11 -2
View File
@@ -285,8 +285,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
return
sandbox_document = template_document.add_sibling(
"right",
sandbox_document = Document.add_root(
title=template_document.title,
content=template_document.content,
attachments=template_document.attachments,
@@ -2030,3 +2029,13 @@ class Invitation(BaseModel):
"partial_update": is_admin_or_owner,
"retrieve": is_admin_or_owner,
}
class RunIndexing(models.Model):
"""Proxy model for indexing management in admin."""
class Meta:
"""Meta options."""
managed = False
verbose_name = _("Run Indexing")
+23 -16
View File
@@ -1,5 +1,6 @@
"""Document search index management utilities and indexers"""
import itertools
import logging
from abc import ABC, abstractmethod
from collections import defaultdict
@@ -7,6 +8,7 @@ from functools import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
@@ -18,6 +20,9 @@ from core.enums import SearchType
logger = logging.getLogger(__name__)
BULK_INDEXER_CHECKPOINT = "bulk-indexer-checkpoint"
@cache
def get_document_indexer():
"""Returns an instance of indexer service if enabled and properly configured."""
@@ -125,7 +130,7 @@ class BaseDocumentIndexer(ABC):
if not self.search_url:
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
def index(self, queryset=None, batch_size=None):
def index(self, queryset=None, batch_size=None, crash_safe_mode=False):
"""
Fetch documents in batches, serialize them, and push to the search backend.
@@ -134,35 +139,37 @@ class BaseDocumentIndexer(ABC):
Defaults to all documents without filter.
batch_size (int, optional): Number of documents per batch.
Defaults to settings.SEARCH_INDEXER_BATCH_SIZE.
crash_safe_mode (bool, optional): If True, order documents by updated_at
and store the last indexed document.updated_at in cache.
This allows resuming indexing from the last successful batch in case of a crash
but is more computationally expensive due to sorting.
"""
last_id = 0
count = 0
queryset = queryset or models.Document.objects.all()
batch_size = batch_size or self.batch_size
while True:
documents_batch = list(
queryset.filter(
id__gt=last_id,
).order_by("id")[:batch_size]
)
if not documents_batch:
break
if crash_safe_mode:
queryset = queryset.order_by("updated_at")
for documents_batch in itertools.batched(queryset.iterator(), batch_size):
doc_paths = [doc.path for doc in documents_batch]
last_id = documents_batch[-1].id
accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths)
serialized_batch = [
self.serialize_document(document, accesses_by_document_path)
for document in documents_batch
if document.content or document.title
]
if serialized_batch:
self.push(serialized_batch)
count += len(serialized_batch)
if not serialized_batch:
continue
self.push(serialized_batch)
count += len(serialized_batch)
if crash_safe_mode:
django_cache.set(
BULK_INDEXER_CHECKPOINT, serialized_batch[-1]["updated_at"]
)
return count
+1 -1
View File
@@ -10,7 +10,7 @@ from django.db.models import signals
from django.dispatch import receiver
from core import models
from core.tasks.search import trigger_batch_document_indexer
from core.tasks.indexing import trigger_batch_document_indexer
from core.utils import get_users_sharing_documents_with_cache_key
+130
View File
@@ -0,0 +1,130 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from core.utils import build_indexable_documents_queryset
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""
Celery Task: Indexes a single document by its ID.
Args:
document_id: Primary key of the document to index.
"""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Acquire a throttle lock to prevent multiple batch indexation tasks during countdown.
implements a debouncing pattern: only the first call during the timeout period
will succeed, subsequent calls are skipped until the timeout expires.
Args:
timeout (int): Lock duration in seconds (countdown period).
atomic (bool): Use Redis locks for atomic operations if available.
Returns:
bool: True if lock acquired (first call), False if already held (subsequent calls).
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# cache.add() is atomic test-and-set operation:
# - If key doesn't exist: creates it with timeout and returns True
# - If key already exists: does nothing and returns False
# The key expires after timeout seconds, releasing the lock.
# The value 1 is irrelevant, only the key presence/absence matters.
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(lower_time_bound=None, upper_time_bound=None, **kwargs):
"""
Celery Task: Batch indexes all documents modified since timestamp.
Args:
lower_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted after this timestamp.
upper_time_bound (datetime|isoformat, optional):
indexes documents updated or deleted before this timestamp.
"""
indexer = get_document_indexer()
if not indexer:
logger.warning("Indexing task triggered but no indexer configured: skipping")
return
count = indexer.index(
queryset=build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
),
**kwargs,
)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger document indexation with optional debounce mechanism.
behavior depends on SEARCH_INDEXER_COUNTDOWN setting:
- if countdown > 0 sec (async batch mode):
* schedules a batch indexation task after countdown in seconds
* uses throttle mechanism to ensure only ONE batch task runs per countdown period
* all documents modified since first trigger are indexed together
- if countdown == 0 sec (sync mode):
* executes indexation synchronously in the current thread
* no batching, no throttling, no Celery task queuing
Args:
document (Document): the document instance that triggered the indexation.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation is disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# use throttle to ensure only one task is scheduled per countdown period.
# if throttle acquired, schedule batch task; otherwise skip.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
kwargs={"lower_time_bound": document.updated_at}, countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
-95
View File
@@ -1,95 +0,0 @@
"""Trigger document indexation using celery task."""
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.db.models import Q
from django_redis.cache import RedisCache
from core import models
from core.services.search_indexers import (
get_document_indexer,
)
from impress.celery_app import app
logger = getLogger(__file__)
@app.task
def document_indexer_task(document_id):
"""Celery Task : Sends indexation query for a document."""
indexer = get_document_indexer()
if indexer:
logger.info("Start document %s indexation", document_id)
indexer.index(models.Document.objects.filter(pk=document_id))
def batch_indexer_throttle_acquire(timeout: int = 0, atomic: bool = True):
"""
Enable the task throttle flag for a delay.
Uses redis locks if available to ensure atomic changes
"""
key = "document-batch-indexer-throttle"
# Redis is used as cache database (not in tests). Use the lock feature here
# to ensure atomicity of changes to the throttle flag.
if isinstance(cache, RedisCache) and atomic:
with cache.locks(key):
return batch_indexer_throttle_acquire(timeout, atomic=False)
# Use add() here :
# - set the flag and returns true if not exist
# - do nothing and return false if exist
return cache.add(key, 1, timeout=timeout)
@app.task
def batch_document_indexer_task(timestamp):
"""Celery Task : Sends indexation query for a batch of documents."""
indexer = get_document_indexer()
if indexer:
queryset = models.Document.objects.filter(
Q(updated_at__gte=timestamp)
| Q(deleted_at__gte=timestamp)
| Q(ancestors_deleted_at__gte=timestamp)
)
count = indexer.index(queryset)
logger.info("Indexed %d documents", count)
def trigger_batch_document_indexer(document):
"""
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
Args:
document (Document): The document instance.
"""
countdown = int(settings.SEARCH_INDEXER_COUNTDOWN)
# DO NOT create a task if indexation if disabled
if not settings.SEARCH_INDEXER_CLASS:
return
if countdown > 0:
# Each time this method is called during a countdown, we increment the
# counter and each task decrease it, so the index be run only once.
if batch_indexer_throttle_acquire(timeout=countdown):
logger.info(
"Add task for batch document indexation from updated_at=%s in %d seconds",
document.updated_at.isoformat(),
countdown,
)
batch_document_indexer_task.apply_async(
args=[document.updated_at], countdown=countdown
)
else:
logger.info("Skip task for batch document %s indexation", document.pk)
else:
document_indexer_task.apply(args=[document.pk])
@@ -0,0 +1,43 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form method="POST" >
{% csrf_token %}
<hr style="margin-bottom: 10px;">
<p>
This command triggers the indexing of all documents within the specified time bound.
</p>
<p>
See <b>docs/commands/index.md</b> for more details.
</p>
<hr style="margin-bottom: 20px;">
<div>
<label for="batch_size" style="margin-right: 10px">Batch size</label>
<input id="batch_size" type="number" name="batch_size" value={{ default_batch_size }} style="width: 80px;" >
</div>
<div>
<label for="lower_time_bound" style="margin-right: 10px">Lower time bound (optional)</label>
<input id="lower_time_bound" type="datetime-local" name="lower_time_bound" >
</div>
<div>
<label for="upper_time_bound" style="margin-right: 10px">Upper time bound (optional)</label>
<input id="upper_time_bound" type="datetime-local" name="upper_time_bound" >
</div>
<div>
<label for="crash_safe_mode" style="margin-right: 10px">Crash save mode</label>
<input id="crash_safe_mode" type="checkbox" name="crash_safe_mode" value="true" checked>
</div>
<input type="submit" value="{% translate 'Run Indexing' %}" style="margin-top: 20px;">
</form>
{% endblock %}
+147 -12
View File
@@ -2,21 +2,24 @@
Unit test for `index` command.
"""
from datetime import datetime, timedelta, timezone
from operator import itemgetter
from unittest import mock
from django.core.cache import cache
from django.core.management import CommandError, call_command
from django.db import transaction
import pytest
from core import factories
from core.services.search_indexers import FindDocumentIndexer
from core.services.search_indexers import BULK_INDEXER_CHECKPOINT, FindDocumentIndexer
from core.tests.conftest import create_document_with_updated_at
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index():
def test_index_without_bound_success():
"""Test the command `index` that run the Find app indexer for all the available documents."""
user = factories.UserFactory()
indexer = FindDocumentIndexer()
@@ -37,20 +40,152 @@ def test_index():
}
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command("index")
call_command("index", crash_safe_mode=False)
push_call_args = [call.args[0] for call in mock_push.call_args_list]
push_call_args = [call.args[0] for call in mock_push.call_args_list]
# called once but with a batch of docs
mock_push.assert_called_once()
# called once but with a batch of docs
mock_push.assert_called_once()
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
assert sorted(push_call_args[0], key=itemgetter("id")) == sorted(
[
indexer.serialize_document(doc, accesses),
indexer.serialize_document(no_title_doc, accesses),
],
key=itemgetter("id"),
)
# crash_safe_mode deactivated -> no checkpoint stored
assert cache.get(BULK_INDEXER_CHECKPOINT) is None
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_both_bounds_success():
"""Test the command `index` for all documents within time bound."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
lower_time_bound - timedelta(days=10)
)
document_in_window_1 = create_document_with_updated_at(
lower_time_bound + timedelta(days=5)
)
document_in_window_2 = create_document_with_updated_at(
lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
upper_time_bound + timedelta(days=10)
)
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# Only documents in window should be indexed
assert str(document_too_early.id) not in all_push_call_args
assert str(document_in_window_1.id) in all_push_call_args
assert str(document_in_window_2.id) in all_push_call_args
assert str(document_too_late.id) not in all_push_call_args
# Checkpoint should be set to last indexed document's updated_at
assert (
cache.get(BULK_INDEXER_CHECKPOINT)
== document_in_window_2.updated_at.isoformat()
)
@pytest.mark.django_db
@pytest.mark.usefixtures("indexer_settings")
def test_index_with_crash_recovery():
"""Test resuming indexing from checkpoint after a crash."""
cache.clear()
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=60)
batch_size = 2
documents = [
# batch 0
create_document_with_updated_at(lower_time_bound + timedelta(days=5)),
create_document_with_updated_at(lower_time_bound + timedelta(days=10)),
# batch 1
create_document_with_updated_at(lower_time_bound + timedelta(days=20)),
create_document_with_updated_at(lower_time_bound + timedelta(days=25)),
# batch 2 - will crash here
create_document_with_updated_at(lower_time_bound + timedelta(days=30)),
create_document_with_updated_at(lower_time_bound + timedelta(days=35)),
# batch 3
create_document_with_updated_at(lower_time_bound + timedelta(days=40)),
create_document_with_updated_at(lower_time_bound + timedelta(days=45)),
]
# First run: simulate crash on batch 3
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
def push_with_failure(data):
# Crash when we encounter document at index 4 (batch 2 with batch_size=2)
if str(documents[4].id) in [document["id"] for document in data]:
raise ConnectionError("Simulated indexing error")
mock_push.side_effect = push_with_failure
call_command(
"index",
batch_size=batch_size,
lower_time_bound=lower_time_bound.isoformat(),
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# First 2 batches should have been indexed and checkpoint saved
checkpoint = cache.get(BULK_INDEXER_CHECKPOINT)
assert checkpoint == documents[3].updated_at.isoformat()
# first 2 batches should be indexed successfully
for i in range(0, 4):
assert str(documents[i].id) in all_push_call_args
# next batch should have been attempted but failed
for i in range(4, 6):
assert str(documents[i].id) in all_push_call_args
# last batches indexing should not have been attempted
for i in range(6, 8):
assert str(documents[i].id) not in all_push_call_args
# Second run: resume from checkpoint
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
call_command(
"index",
batch_size=batch_size,
lower_time_bound=checkpoint,
upper_time_bound=upper_time_bound.isoformat(),
)
all_push_call_args = [
document["id"]
for call_arg_list in mock_push.call_args_list
for document in call_arg_list.args[0]
]
# first 2 batches should NOT be re-indexed
# except the last document of the last batch which is on the checkpoint boundary
for i in range(0, 3):
assert str(documents[i].id) not in all_push_call_args
# next batches should be indexed including the document at the checkpoint boundary
# which has already been indexed and is re-indexed
for i in range(4, 8):
assert str(documents[i].id) in all_push_call_args
@pytest.mark.django_db
+101
View File
@@ -1,10 +1,15 @@
"""Fixtures for tests in the impress core application"""
import base64
from unittest import mock
from django.core.cache import cache
import pytest
import responses
from core import factories, models
from core.tests.utils.urls import reload_urls
USER = "user"
TEAM = "team"
@@ -49,3 +54,99 @@ def indexer_settings_fixture(settings):
# clear cache to prevent issues with other tests
get_document_indexer.cache_clear()
def resource_server_backend_setup(settings):
"""
A fixture to create a user token for testing.
"""
assert (
settings.OIDC_RS_BACKEND_CLASS
== "lasuite.oidc_resource_server.backend.ResourceServerBackend"
)
settings.OIDC_RESOURCE_SERVER_ENABLED = True
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_RS_SCOPES = ["openid", "groups"]
settings.OIDC_RS_ALLOWED_AUDIENCES = ["some_service_provider"]
@pytest.fixture
def resource_server_backend_conf(settings):
"""
A fixture to create a user token for testing.
"""
resource_server_backend_setup(settings)
reload_urls()
@pytest.fixture
def resource_server_backend(settings):
"""
A fixture to create a user token for testing.
Including a mocked introspection endpoint.
"""
resource_server_backend_setup(settings)
reload_urls()
with responses.RequestsMock() as rsps:
rsps.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid groups",
"active": True,
},
)
yield rsps
@pytest.fixture
def user_specific_sub():
"""
A fixture to create a user token for testing.
"""
user = factories.UserFactory(sub="very-specific-sub", full_name="External User")
yield user
def build_authorization_bearer(token):
"""
Build an Authorization Bearer header value from a token.
This can be used like this:
client.post(
...
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer('some_token')}",
)
"""
return base64.b64encode(token.encode("utf-8")).decode("utf-8")
@pytest.fixture
def user_token():
"""
A fixture to create a user token for testing.
"""
return build_authorization_bearer("some_token")
def create_document_with_updated_at(updated_at, **kwargs):
"""Helper to create a document with a specific updated_at, bypassing auto_now=True."""
document = factories.DocumentFactory(**kwargs)
models.Document.objects.filter(pk=document.pk).update(updated_at=updated_at)
return models.Document.objects.get(pk=document.pk)
@@ -123,7 +123,7 @@ def test_api_documents_duplicate_success(index):
image_refs[0][0]
] # Only the first image key
assert duplicated_document.get_parent() == document.get_parent()
assert duplicated_document.path == document.get_next_sibling().path
assert duplicated_document.path == document.get_last_sibling().path
# Check that accesses were not duplicated.
# The user who did the duplicate is forced as owner
@@ -180,6 +180,7 @@ def test_api_documents_duplicate_with_accesses_admin(role):
client = APIClient()
client.force_login(user)
documents_before = factories.DocumentFactory.create_batch(20)
document = factories.DocumentFactory(
users=[(user, role)],
title="document with accesses",
@@ -187,6 +188,12 @@ def test_api_documents_duplicate_with_accesses_admin(role):
user_access = factories.UserDocumentAccessFactory(document=document)
team_access = factories.TeamDocumentAccessFactory(document=document)
documents_after = factories.DocumentFactory.create_batch(20)
all_documents = documents_before + [document] + documents_after
paths = {document.pk: document.path for document in all_documents}
# Duplicate the document via the API endpoint requesting to duplicate accesses
response = client.post(
f"/api/v1.0/documents/{document.id!s}/duplicate/",
@@ -212,6 +219,10 @@ def test_api_documents_duplicate_with_accesses_admin(role):
assert duplicated_accesses.get(user=user_access.user).role == user_access.role
assert duplicated_accesses.get(team=team_access.team).role == team_access.role
for document in all_documents:
document.refresh_from_db()
assert document.path == paths[document.id]
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_duplicate_with_accesses_non_admin(role):
@@ -70,17 +70,20 @@ def test_api_documents_search_anonymous(search_query, indexer_settings):
@mock.patch("core.api.viewsets.DocumentViewSet.list")
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
def test_api_documents_search_fall_back_on_search_list(mock_list, settings):
"""
When indexer is not configured and no path is provided,
should fall back on list method
"""
indexer_settings.SEARCH_URL = None
assert get_document_indexer() is None
assert settings.OIDC_STORE_REFRESH_TOKEN is False
assert settings.OIDC_STORE_ACCESS_TOKEN is False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
mocked_response = {
"count": 0,
@@ -93,6 +96,8 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
q = "alpha"
response = client.get("/api/v1.0/documents/search/", data={"q": q})
assert response.status_code == 200
assert mock_list.call_count == 1
assert mock_list.call_args[0][0].GET.get("q") == q
assert response.json() == mocked_response
@@ -100,18 +105,21 @@ def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settin
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
def test_api_documents_search_fallback_on_search_list_sub_docs(
mock_list_descendants, indexer_settings
mock_list_descendants, settings
):
"""
When indexer is not configured and path parameter is provided,
should call _list_descendants() method
"""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
assert get_document_indexer() is None
assert settings.OIDC_STORE_REFRESH_TOKEN is False
assert settings.OIDC_STORE_ACCESS_TOKEN is False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
parent = factories.DocumentFactory(title="parent", users=[user])
@@ -128,9 +136,9 @@ def test_api_documents_search_fallback_on_search_list_sub_docs(
"/api/v1.0/documents/search/", data={"q": q, "path": parent.path}
)
assert mock_list_descendants.call_count == 1
assert mock_list_descendants.call_args[0][0].GET.get("q") == q
assert mock_list_descendants.call_args[0][0].GET.get("path") == parent.path
mock_list_descendants.assert_called_with(
mock.ANY, {"q": "alpha", "path": parent.path}
)
assert response.json() == mocked_response
@@ -152,7 +160,9 @@ def test_api_documents_search_indexer_crashes(mock_title_search, indexer_setting
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
mocked_response = {
"count": 0,
@@ -185,7 +195,9 @@ def test_api_documents_search_invalid_params(indexer_settings):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
client.force_login(
user, backend="core.authentication.backends.OIDCAuthenticationBackend"
)
response = client.get("/api/v1.0/documents/search/")
@@ -48,6 +48,7 @@ def test_api_documents_search_success( # noqa : PLR0913
expected_search_type,
expected_search_with_indexer_called,
expected_title_search_called,
indexer_settings,
):
"""
Test that the API endpoint for searching documents returns a successful response
@@ -1,8 +1,10 @@
"""
Tests for Documents API endpoint in impress's core app: update
"""
# pylint: disable=too-many-lines
import random
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
@@ -17,6 +19,25 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
# A valid Yjs document derived from YDOC_HELLO_WORLD_BASE64 with "Hello" replaced by "World",
# used in PATCH tests to guarantee a real content change distinct from what DocumentFactory
# produces.
YDOC_UPDATED_CONTENT_BASE64 = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVX"
"b3JsZIb17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
@@ -330,6 +351,7 @@ def test_api_documents_update_authenticated_no_websocket(settings):
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
@@ -338,6 +360,8 @@ def test_api_documents_update_authenticated_no_websocket(settings):
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@@ -446,6 +470,7 @@ def test_api_documents_update_user_connected_to_websocket(settings):
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
@@ -453,6 +478,9 @@ def test_api_documents_update_user_connected_to_websocket(settings):
format="json",
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@@ -486,6 +514,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
@@ -494,6 +523,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@@ -605,6 +636,7 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
@@ -613,6 +645,8 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@@ -643,6 +677,7 @@ def test_api_documents_update_feature_flag_disabled(settings):
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
@@ -651,6 +686,8 @@ def test_api_documents_update_feature_flag_disabled(settings):
)
assert response.status_code == 200
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@@ -716,3 +753,724 @@ def test_api_documents_update_invalid_content():
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}
# =============================================================================
# PATCH tests
# =============================================================================
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach, role",
[
("restricted", "reader"),
("restricted", "editor"),
("authenticated", "reader"),
("authenticated", "editor"),
("public", "reader"),
],
)
def test_api_documents_patch_anonymous_forbidden(reach, role, via_parent):
"""
Anonymous users should not be allowed to patch a document when link
configuration does not allow it.
"""
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = APIClient().patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"reach,role",
[
("public", "reader"),
("authenticated", "reader"),
("restricted", "reader"),
("restricted", "editor"),
],
)
def test_api_documents_patch_authenticated_unrelated_forbidden(reach, role, via_parent):
"""
Authenticated users should not be allowed to patch a document to which
they are not related if the link configuration does not allow it.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
"is_authenticated,reach,role",
[
(False, "public", "editor"),
(True, "public", "editor"),
(True, "authenticated", "editor"),
],
)
def test_api_documents_patch_anonymous_or_authenticated_unrelated(
is_authenticated, reach, role, via_parent
):
"""
Anonymous and authenticated users should be able to patch a document to which
they are not related if the link configuration allows it.
"""
client = APIClient()
if is_authenticated:
user = factories.UserFactory(with_owned_document=True)
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach=reach, link_role=role)
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
else:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
"depth",
"numchild",
"path",
]:
assert document_values[key] == old_document_values[key]
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_authenticated_reader(via, via_parent, mock_user_teams):
"""Users who are reader of a document should not be allowed to patch it."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role="reader"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role="reader"
)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
document.refresh_from_db()
assert serializers.DocumentSerializer(instance=document).data == old_document_values
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_authenticated_editor_administrator_or_owner(
via, role, via_parent, mock_user_teams
):
"""A user who is editor, administrator or owner of a document should be allowed to patch it."""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
if via_parent:
grand_parent = factories.DocumentFactory(link_reach="restricted")
parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted")
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
access_document = grand_parent
else:
document = factories.DocumentFactory(link_reach="restricted")
access_document = document
if via == USER:
factories.UserDocumentAccessFactory(
document=access_document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=access_document, team="lasuite", role=role
)
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
"depth",
"numchild",
"path",
"nb_accesses_ancestors",
"nb_accesses_direct",
]:
assert document_values[key] == old_document_values[key]
@responses.activate
def test_api_documents_patch_authenticated_no_websocket(settings):
"""
When a user patches the document, not connected to the websocket and is the first to update,
the document should be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_authenticated_no_websocket_user_already_editing(settings):
"""
When a user patches the document, not connected to the websocket and is not the first to
update, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user patches the document, not connected to the websocket and another user is connected
to the websocket, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_user_connected_to_websocket(settings):
"""
When a user patches the document while connected to the websocket, the document should be
updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not wirk because the content is in cache.
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
"""
When the websocket server is unreachable, the patch should be applied like if the user was
not connected to the websocket.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
"""
When the websocket server is unreachable, the behavior falls back to no-websocket.
If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
"""
When the WebSocket server does not have the room created, the logic should fallback to
no-WebSocket. If another user is already editing, the patch must be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_patch_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 200
# Using document.refresh_from_db does not work because the content is cached.
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_teams):
"""
Being administrator or owner of a document should not grant authorization to patch
another document.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role=random.choice(["administrator", "owner"])
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document,
team="lasuite",
role=random.choice(["administrator", "owner"]),
)
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{other_document.id!s}/",
{"content": new_content},
format="json",
)
assert response.status_code == 403
other_document.refresh_from_db()
assert (
serializers.DocumentSerializer(instance=other_document).data
== old_document_values
)
def test_api_documents_patch_invalid_content():
"""
Patching a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}
@responses.activate
def test_api_documents_patch_empty_body(settings):
"""
Test when data is empty the document should not be updated.
The `updated_at` property should not change asserting that no update in the database is made.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
document_updated_at = document.updated_at
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_document_values = serializers.DocumentSerializer(instance=document).data
with patch("core.models.Document.save") as mock_document_save:
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
content_type="application/json",
)
mock_document_save.assert_not_called()
assert response.status_code == 200
document = models.Document.objects.get(id=document.id)
new_document_values = serializers.DocumentSerializer(instance=document).data
assert new_document_values == old_document_values
assert document_updated_at == document.updated_at
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@@ -0,0 +1,772 @@
"""
Tests for the Resource Server API for documents.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from datetime import timedelta
from io import BytesIO
from unittest.mock import patch
from django.test import override_settings
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.services import mime_types
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_retrieve_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to retrieve a document from external
API if resource server is not enabled.
"""
document = factories.DocumentFactory(link_reach="public")
response = APIClient().get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 404
def test_external_api_documents_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list documents if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 404
def test_external_api_documents_list_connected_resource_server(
user_token, resource_server_backend, user_specific_sub
):
"""Connected users should be allowed to list documents from a resource server."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role="reader"
)
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 200
def test_external_api_documents_list_connected_resource_server_with_invalid_token(
user_token, resource_server_backend
):
"""A user with an invalid sub SHOULD NOT be allowed to retrieve documents
from a resource server."""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 401
def test_external_api_documents_retrieve_connected_resource_server_with_wrong_abilities(
user_token, user_specific_sub, resource_server_backend
):
"""
A user with wrong abilities SHOULD NOT be allowed to retrieve a document from
a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
def test_external_api_documents_retrieve_connected_resource_server_using_access_token(
user_token, resource_server_backend, user_specific_sub
):
"""
A user with an access token SHOULD be allowed to retrieve a document from
a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.LinkRoleChoices.READER
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_external_api_documents_create_root_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token should be able to create a root document through the resource
server and should automatically be declared as the owner of the newly created document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
"/external_api/v1.0/documents/",
{
"title": "Test Root Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Root Document"
assert document.creator == user_specific_sub
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
def test_external_api_documents_create_subdocument_owner_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD BE able to create a sub-document through the resource
server when they have OWNER permissions on the parent document.
The creator is set to the authenticated user, and permissions are inherited
from the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Sub Document"
assert document.creator == user_specific_sub
assert document.get_parent() == parent_document
# Child documents inherit permissions from parent, no direct access needed
assert not document.accesses.exists()
def test_external_api_documents_create_subdocument_editor_success(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD BE able to create a sub-document through the resource
server when they have EDITOR permissions on the parent document.
Permissions are inherited from the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.EDITOR,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "Test Sub Document"
assert document.creator == user_specific_sub
assert document.get_parent() == parent_document
# Child documents inherit permissions from parent, no direct access needed
assert not document.accesses.exists()
def test_external_api_documents_create_subdocument_reader_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token SHOULD NOT be able to create a sub-document through the resource
server when they have READER permissions on the parent document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create a parent document first
parent_document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=parent_document,
user=user_specific_sub,
role=models.RoleChoices.READER,
)
response = client.post(
f"/external_api/v1.0/documents/{parent_document.id}/children/",
{
"title": "Test Sub Document",
},
)
assert response.status_code == 403
@patch("core.services.converter_services.Converter.convert")
def test_external_api_documents_create_with_markdown_file_success(
mock_convert, user_token, resource_server_backend, user_specific_sub
):
"""
Users with an access token should be able to create documents through the resource
server by uploading a Markdown file and should automatically be declared as the owner
of the newly created document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Mock the conversion
converted_yjs = "base64encodedyjscontent"
mock_convert.return_value = converted_yjs
# Create a fake Markdown file
file_content = b"# Test Document\n\nThis is a test."
file = BytesIO(file_content)
file.name = "readme.md"
response = client.post(
"/external_api/v1.0/documents/",
{
"file": file,
},
format="multipart",
)
assert response.status_code == 201
data = response.json()
document = models.Document.objects.get(id=data["id"])
assert document.title == "readme.md"
assert document.content == converted_yjs
assert document.accesses.filter(role="owner", user=user_specific_sub).exists()
# Verify the converter was called correctly
mock_convert.assert_called_once_with(
file_content,
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
def test_external_api_documents_list_with_multiple_roles(
user_token, resource_server_backend, user_specific_sub
):
"""
List all documents accessible to a user with different roles and verify
that associated permissions are correctly returned in the response.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
# Create documents with different roles for the user
owner_document = factories.DocumentFactory(
title="Owner Document",
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=owner_document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
editor_document = factories.DocumentFactory(
title="Editor Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=editor_document,
user=user_specific_sub,
role=models.RoleChoices.EDITOR,
)
reader_document = factories.DocumentFactory(
title="Reader Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=reader_document,
user=user_specific_sub,
role=models.RoleChoices.READER,
)
# Create a document the user should NOT have access to
other_document = factories.DocumentFactory(
title="Other Document",
link_reach=models.LinkReachChoices.RESTRICTED,
)
other_user = factories.UserFactory()
factories.UserDocumentAccessFactory(
document=other_document,
user=other_user,
role=models.RoleChoices.OWNER,
)
response = client.get("/external_api/v1.0/documents/")
assert response.status_code == 200
data = response.json()
# Verify the response contains results
assert "results" in data
results = data["results"]
# Verify user can see exactly 3 documents (owner, editor, reader)
result_ids = {result["id"] for result in results}
assert len(results) == 3
assert str(owner_document.id) in result_ids
assert str(editor_document.id) in result_ids
assert str(reader_document.id) in result_ids
assert str(other_document.id) not in result_ids
# Verify each document has correct user_role field indicating permission level
for result in results:
if result["id"] == str(owner_document.id):
assert result["title"] == "Owner Document"
assert result["user_role"] == models.RoleChoices.OWNER
elif result["id"] == str(editor_document.id):
assert result["title"] == "Editor Document"
assert result["user_role"] == models.RoleChoices.EDITOR
elif result["id"] == str(reader_document.id):
assert result["title"] == "Reader Document"
assert result["user_role"] == models.RoleChoices.READER
def test_external_api_documents_duplicate_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users CAN DUPLICATE a document from a resource server
when they have the required permissions on the document,
as this action bypasses the permission checks.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/duplicate/",
)
assert response.status_code == 201
# NOT allowed actions on resource server.
def test_external_api_documents_put_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to PUT a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
)
assert response.status_code == 403
def test_external_api_document_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 403
def test_external_api_documents_move_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to MOVE a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
new_parent = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=new_parent,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/move/",
{"target_document_id": new_parent.id},
)
assert response.status_code == 403
def test_external_api_documents_restore_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to restore a document from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 403
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
def test_external_api_documents_trashbin_not_allowed(
role, reach, user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list documents from the trashbin,
regardless of the document link reach and user role, from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=reach,
creator=user_specific_sub,
deleted_at=timezone.now(),
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=role,
)
response = client.get("/external_api/v1.0/documents/trashbin/")
assert response.status_code == 403
def test_external_api_documents_create_for_owner_not_allowed():
"""
Authenticated users SHOULD NOT be allowed to call create documents
on behalf of other users.
This API endpoint is reserved for server-to-server calls.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
data = {
"title": "My Document",
"content": "Document content",
"sub": "123",
"email": "john.doe@example.com",
}
response = client.post(
"/external_api/v1.0/documents/create-for-owner/",
data,
format="json",
)
assert response.status_code == 401
assert not models.Document.objects.exists()
# Test overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "trashbin"],
},
}
)
def test_external_api_documents_trashbin_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list soft deleted documents from a resource server
when the trashbin action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
document.soft_delete()
response = client.get("/external_api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "destroy"],
},
}
)
def test_external_api_documents_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete a document from a resource server
when the delete action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/")
assert response.status_code == 204
# Verify the document is soft deleted
document.refresh_from_db()
assert document.deleted_at is not None
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"update",
],
},
}
)
def test_external_api_documents_update_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to update a document from a resource server
when the update action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
original_title = document.title
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/", {"title": "new title"}
)
assert response.status_code == 200
# Verify the document is updated
document.refresh_from_db()
assert document.title == "new title"
assert document.title != original_title
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "move"],
},
}
)
def test_external_api_documents_move_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to move a document from a resource server
when the move action is enabled in EXTERNAL_API settings and they
have the required permissions on the document and the target location.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
parent = factories.DocumentFactory(
users=[(user_specific_sub, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(
parent=parent, users=[(user_specific_sub, "reader")]
)
target = factories.DocumentFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": "first-sibling"},
)
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "restore"],
},
}
)
def test_external_api_documents_restore_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to restore a recently soft-deleted document
from a resource server when the restore action is enabled in EXTERNAL_API
settings and they have the required permissions on the document.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
now = timezone.now() - timedelta(days=15)
document = factories.DocumentFactory(deleted_at=now)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role="owner"
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/restore/")
assert response.status_code == 200
assert response.json() == {"detail": "Document has been successfully restored."}
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at is None
@@ -0,0 +1,681 @@
"""
Tests for the Resource Server API for documents accesses.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
from core.api import serializers
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_document_accesses_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to list document accesses
from external API if resource server is not enabled.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
)
response = APIClient().get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
)
assert response.status_code == 404
def test_external_api_document_accesses_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list document accesses
if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
response = APIClient().get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/"
)
assert response.status_code == 404
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list the accesses of
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific access of
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
access = factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_documents_accesses_create_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to create an access for a document
from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
{"user_id": other_user.id, "role": models.RoleChoices.READER},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update an access for a
document from a resource server through PUT.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{"role": models.RoleChoices.EDITOR},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_document_accesses_partial_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update an access
for a document from a resource server through PATCH.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{"role": models.RoleChoices.EDITOR},
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": [],
},
}
)
def test_external_api_documents_accesses_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete an access for
a document from a resource server.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
access = factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "retrieve"],
},
}
)
def test_external_api_document_accesses_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list the accesses of a document from a resource server
when the list action is enabled in EXTERNAL_API document_access settings.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
)
user_access = factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# Create additional accesses
other_user = factories.UserFactory()
other_access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/accesses/")
assert response.status_code == 200
data = response.json()
access_ids = [entry["id"] for entry in data]
assert str(user_access.id) in access_ids
assert str(other_access.id) in access_ids
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "retrieve"],
},
}
)
def test_external_api_document_accesses_retrieve_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
):
"""
A user who is related to a document SHOULD be allowed to retrieve the
associated document user accesses.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
)
data = response.json()
assert response.status_code == 200
assert data["id"] == str(access.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "create"],
},
}
)
def test_external_api_document_accesses_create_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
):
"""
A user who is related to a document SHOULD be allowed to create
a user access for the document.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/accesses/",
data={"user_id": other_user.id, "role": models.RoleChoices.READER},
)
data = response.json()
assert response.status_code == 201
assert data["role"] == models.RoleChoices.READER
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "update"],
},
}
)
def test_external_api_document_accesses_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
a user access for the document through PUT.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
# Update only the role field
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
{**old_values, "role": models.RoleChoices.EDITOR}, #  type: ignore
format="json",
)
assert response.status_code == 200
data = response.json()
assert data["role"] == models.RoleChoices.EDITOR
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "partial_update"],
},
}
)
def test_external_api_document_accesses_partial_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
a user access for the document through PATCH.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
other_user = factories.UserFactory()
access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={"role": models.RoleChoices.EDITOR},
)
data = response.json()
assert response.status_code == 200
assert data["role"] == models.RoleChoices.EDITOR
assert str(data["user"]["id"]) == str(other_user.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_access": {
"enabled": True,
"actions": ["list", "destroy"],
},
}
)
def test_external_api_documents_accesses_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub, settings
):
"""
Connected users SHOULD be allowed to delete an access for
a document from a resource server when the destroy action is
enabled in settings.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
other_user = factories.UserFactory()
other_access = factories.UserDocumentAccessFactory(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{other_access.id!s}/",
)
assert response.status_code == 204
@@ -0,0 +1,273 @@
"""
Tests for the Resource Server API for document AI features.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from unittest.mock import MagicMock, patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.documents.test_api_documents_ai_proxy import ( # pylint: disable=unused-import
ai_settings,
)
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_ai_transform_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI transform endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-transform/",
{"text": "hello", "action": "prompt"},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_external_api_documents_ai_translate_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI translate endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-translate/",
{"text": "hello", "language": "es"},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_external_api_documents_ai_proxy_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access AI proxy endpoints
from a resource server by default.
"""
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/",
b"{}",
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_transform",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_external_api_documents_ai_transform_can_be_allowed(
mock_create, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to transform a document using AI when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/external_api/v1.0/documents/{document.id!s}/ai-transform/"
response = client.post(url, {"text": "Hello", "action": "prompt"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
# pylint: disable=line-too-long
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Answer the prompt using markdown formatting for structure and emphasis. "
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
"Preserve the language and markdown formatting. "
"Do not provide any other information. "
"Preserve the language."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_translate",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("openai.resources.chat.completions.Completions.create")
def test_external_api_documents_ai_translate_can_be_allowed(
mock_create, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to translate a document using AI when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_create.return_value = MagicMock(
choices=[MagicMock(message=MagicMock(content="Salut"))]
)
url = f"/external_api/v1.0/documents/{document.id!s}/ai-translate/"
response = client.post(url, {"text": "Hello", "language": "es-co"})
assert response.status_code == 200
assert response.json() == {"answer": "Salut"}
mock_create.assert_called_once_with(
model="llama",
messages=[
{
"role": "system",
"content": (
"Keep the same html structure and formatting. "
"Translate the content in the html to the "
"specified language Colombian Spanish. "
"Check the translation for accuracy and make any necessary corrections. "
"Do not provide any other information."
),
},
{"role": "user", "content": "Hello"},
],
)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"ai_proxy",
],
},
}
)
@pytest.mark.usefixtures("ai_settings")
@patch("core.services.ai_services.AIService.stream")
def test_external_api_documents_ai_proxy_can_be_allowed(
mock_stream, user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to use the AI proxy endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, creator=user_specific_sub
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
mock_stream.return_value = iter(["data: response\n"])
url = f"/external_api/v1.0/documents/{document.id!s}/ai-proxy/"
response = client.post(
url,
b"{}",
content_type="application/json",
)
assert response.status_code == 200
assert response["Content-Type"] == "text/event-stream" # type: ignore
mock_stream.assert_called_once()
@@ -0,0 +1,121 @@
"""
Tests for the Resource Server API for document attachments.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import re
import uuid
from urllib.parse import parse_qs, urlparse
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_attachment_upload_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to upload attachments to a document
from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
pixel = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
{"file": file},
format="multipart",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"attachment_upload",
],
},
}
)
def test_external_api_documents_attachment_upload_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to upload attachments to a document
from a resource server when the attachment-upload action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
pixel = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
b"\xa7V\xbd\xfa\x00\x00\x00\x00IEND\xaeB`\x82"
)
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
file = SimpleUploadedFile(name="test.png", content=pixel, content_type="image/png")
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/attachment-upload/",
{"file": file},
format="multipart",
)
assert response.status_code == 201
pattern = re.compile(rf"^{document.id!s}/attachments/(.*)\.png")
url_parsed = urlparse(response.json()["file"])
assert url_parsed.path == f"/api/v1.0/documents/{document.id!s}/media-check/"
query = parse_qs(url_parsed.query)
assert query["key"][0] is not None
file_path = query["key"][0]
match = pattern.search(file_path)
file_id = match.group(1) # type: ignore
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
@@ -0,0 +1,157 @@
"""
Tests for the Resource Server API for document favorites.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_favorites_list_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list their favorites
from a resource server, as favorite_list() bypasses permissions.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.UserDocumentAccessFactory(
user=user_specific_sub,
role=models.RoleChoices.READER,
document__favorited_by=[user_specific_sub],
).document
response = client.get("/external_api/v1.0/documents/favorite_list/")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["results"][0]["id"] == str(document.id)
def test_external_api_documents_favorite_add_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
By default the "favorite" action is not permitted on the external API.
POST to the endpoint must return 403.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
def test_external_api_documents_favorite_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
By default the "favorite" action is not permitted on the external API.
DELETE to the endpoint must return 403.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"favorite",
],
},
}
)
def test_external_api_documents_favorite_add_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to POST to the favorite endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 201
assert models.DocumentFavorite.objects.filter(
document=document, user=user_specific_sub
).exists()
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"favorite",
],
},
}
)
def test_external_api_documents_favorite_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Users SHOULD be allowed to DELETE from the favorite endpoint when the
corresponding action is enabled via EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED, favorited_by=[user_specific_sub]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.delete(f"/external_api/v1.0/documents/{document.id!s}/favorite/")
assert response.status_code == 204
assert not models.DocumentFavorite.objects.filter(
document=document, user=user_specific_sub
).exists()
@@ -0,0 +1,474 @@
"""
Tests for the Resource Server API for invitations.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_document_invitations_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to list invitations from external
API if resource server is not enabled.
"""
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 404
def test_external_api_document_invitations_list_connected_not_resource_server():
"""
Connected users SHOULD NOT be allowed to list document invitations
if resource server is not enabled.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
invitation = factories.InvitationFactory()
response = APIClient().get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 404
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list document invitations
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
invitation = factories.InvitationFactory()
response = client.get(
f"/external_api/v1.0/documents/{invitation.document.id!s}/invitations/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
invitation = factories.InvitationFactory()
document = invitation.document
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": [],
},
},
)
def test_external_api_document_invitations_create_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to create a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
{"email": "invited@example.com", "role": models.RoleChoices.READER},
format="json",
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_partial_update_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to partially update a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(
document=document, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
{"role": models.RoleChoices.EDITOR},
format="json",
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a document invitation
by default.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list document invitations
when the action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/invitations/")
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["results"][0]["id"] == str(invitation.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve"],
},
},
)
def test_external_api_document_invitations_retrieve_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve a document invitation
when the action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/"
)
assert response.status_code == 200
data = response.json()
assert data["id"] == str(invitation.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "create"],
},
},
)
def test_external_api_document_invitations_create_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to create a document invitation
when the create action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.post(
f"/external_api/v1.0/documents/{document.id!s}/invitations/",
{"email": "invited@example.com", "role": models.RoleChoices.READER},
format="json",
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "invited@example.com"
assert data["role"] == models.RoleChoices.READER
assert str(data["document"]) == str(document.id)
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "partial_update"],
},
},
)
def test_external_api_document_invitations_partial_update_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to partially update a document invitation
when the partial_update action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(
document=document, role=models.RoleChoices.READER
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
{"role": models.RoleChoices.EDITOR},
format="json",
)
assert response.status_code == 200
data = response.json()
assert data["role"] == models.RoleChoices.EDITOR
assert data["email"] == invitation.email
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
],
},
"document_invitation": {
"enabled": True,
"actions": ["list", "retrieve", "destroy"],
},
},
)
def test_external_api_document_invitations_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete a document invitation
when the destroy action is explicitly enabled.
"""
reload_urls()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
invitation = factories.InvitationFactory(document=document)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/invitations/{invitation.id!s}/",
)
assert response.status_code == 204
@@ -0,0 +1,105 @@
"""
Tests for the Resource Server API for document link configurations.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from unittest.mock import patch
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_link_configuration_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update the link configuration of a document
from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/"
)
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"link_configuration",
],
},
},
COLLABORATION_API_URL="http://example.com/",
COLLABORATION_SERVER_SECRET="secret-token",
)
@patch("core.services.collaboration_services.CollaborationService.reset_connections")
def test_external_api_documents_link_configuration_can_be_allowed(
mock_reset, user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to update the link configuration of a document
from a resource server when the corresponding action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
# attempt to change reach/role to a valid combination
new_data = {
"link_reach": models.LinkReachChoices.PUBLIC,
"link_role": models.LinkRoleChoices.EDITOR,
}
response = client.put(
f"/external_api/v1.0/documents/{document.id!s}/link-configuration/",
new_data,
format="json",
)
assert response.status_code == 200
# verify the document was updated in the database
document.refresh_from_db()
assert document.link_reach == models.LinkReachChoices.PUBLIC
assert document.link_role == models.LinkRoleChoices.EDITOR
@@ -0,0 +1,94 @@
"""
Tests for the Resource Server API for document media authentication.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
from io import BytesIO
from uuid import uuid4
from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
from core.enums import DocumentAttachmentStatus
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_media_auth_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to access media auth endpoints
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/documents/media-auth/")
assert response.status_code == 403
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"media_auth",
],
},
}
)
def test_external_api_documents_media_auth_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to access media auth endpoints
from a resource server when the media-auth action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document_id = uuid4()
filename = f"{uuid4()!s}.jpg"
key = f"{document_id!s}/attachments/{filename:s}"
media_url = f"http://localhost/media/{key:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
Metadata={"status": DocumentAttachmentStatus.READY},
)
document = factories.DocumentFactory(
id=document_id, link_reach=models.LinkReachChoices.RESTRICTED, attachments=[key]
)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.READER
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/external_api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -0,0 +1,163 @@
"""
Tests for the Resource Server API for document versions.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import time
from django.test import override_settings
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_documents_versions_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list the versions of a document
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(
link_reach=models.LinkReachChoices.RESTRICTED,
creator=user_specific_sub,
)
factories.UserDocumentAccessFactory(
document=document,
user=user_specific_sub,
role=models.RoleChoices.OWNER,
)
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 403
def test_external_api_documents_versions_detail_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific version of a document
from a resource server by default.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/versions/1234/"
)
assert response.status_code == 403
# Overrides
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": ["list", "retrieve", "children", "versions_list"],
},
}
)
def test_external_api_documents_versions_list_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to list version of a document from a resource server
when the versions action is enabled in EXTERNAL_API settings.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# Add new versions to the document
for i in range(3):
document.content = f"new content {i:d}"
document.save()
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 2
@override_settings(
EXTERNAL_API={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"children",
"versions_list",
"versions_detail",
],
},
}
)
def test_external_api_documents_versions_detail_can_be_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve a specific version of a document
from a resource server when the versions_detail action is enabled.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.OWNER
)
# ensure access datetime is earlier than versions (minio precision is one second)
time.sleep(1)
# create several versions, spacing them out to get distinct LastModified values
for i in range(3):
document.content = f"new content {i:d}"
document.save()
time.sleep(1)
# call the list endpoint and verify basic structure
response = client.get(f"/external_api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
content = response.json()
# count should reflect two saved versions beyond the original
assert content.get("count") == 2
# pick the first version returned by the list (should be accessible)
version_id = content.get("versions")[0]["version_id"]
detailed_response = client.get(
f"/external_api/v1.0/documents/{document.id!s}/versions/{version_id}/"
)
assert detailed_response.status_code == 200
assert detailed_response.json()["content"] == "new content 1"
@@ -0,0 +1,158 @@
"""
Tests for the Resource Server API for users.
Not testing external API endpoints that are already tested in the /api
because the resource server viewsets inherit from the api viewsets.
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
from core.tests.utils.urls import reload_urls
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_external_api_users_me_anonymous_public_standalone():
"""
Anonymous users SHOULD NOT be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
response = APIClient().get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_external_api_users_me_connected_not_allowed():
"""
Connected users SHOULD NOT be allowed to retrieve their own user information from external
API if resource server is not enabled.
"""
reload_urls()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 404
def test_external_api_users_me_connected_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to retrieve their own user information from external API
if resource server is enabled.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(user_specific_sub.id)
assert data["email"] == user_specific_sub.email
def test_external_api_users_me_connected_with_invalid_token_not_allowed(
user_token, resource_server_backend
):
"""
Connected users SHOULD NOT be allowed to retrieve their own user information from external API
if resource server is enabled with an invalid token.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/me/")
assert response.status_code == 401
# Non allowed actions on resource server.
def test_external_api_users_list_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to list users from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get("/external_api/v1.0/users/")
assert response.status_code == 403
def test_external_api_users_retrieve_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to retrieve a specific user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.get(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403
def test_external_api_users_put_patch_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to update or patch a user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
new_user_values = {
k: v
for k, v in serializers.UserSerializer(
instance=factories.UserFactory()
).data.items()
if v is not None
}
response = client.put(
f"/external_api/v1.0/users/{other_user.id!s}/", new_user_values
)
assert response.status_code == 403
response = client.patch(
f"/external_api/v1.0/users/{other_user.id!s}/",
{"email": "new_email@example.com"},
)
assert response.status_code == 403
def test_external_api_users_delete_not_allowed(
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD NOT be allowed to delete a user from a resource server.
"""
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
other_user = factories.UserFactory()
response = client.delete(f"/external_api/v1.0/users/{other_user.id!s}/")
assert response.status_code == 403
+5 -5
View File
@@ -48,7 +48,7 @@ def test_api_users_list_query_email():
Only results with a Levenstein distance less than 3 with the query should be returned.
We want to match by Levenstein distance because we want to prevent typing errors.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient()
client.force_login(user)
@@ -83,7 +83,7 @@ def test_api_users_list_query_email_with_internationalized_domain_names():
Authenticated users should be able to list users and filter by email.
It should work even if the email address contains an internationalized domain name.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient()
client.force_login(user)
@@ -123,7 +123,7 @@ def test_api_users_list_query_full_name():
Authenticated users should be able to list users and filter by full name.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient()
client.force_login(user)
@@ -168,7 +168,7 @@ def test_api_users_list_query_accented_full_name():
Authenticated users should be able to list users and filter by full name with accents.
Only results with a Trigram similarity greater than 0.2 with the query should be returned.
"""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient()
client.force_login(user)
@@ -416,7 +416,7 @@ def test_api_users_list_query_long_queries():
def test_api_users_list_query_inactive():
"""Inactive users should not be listed."""
user = factories.UserFactory(email="user@example.com")
user = factories.UserFactory(email="user@example.com", full_name="Example User")
client = APIClient()
client.force_login(user)
@@ -0,0 +1,32 @@
"""module testing the conditional_refresh_oidc_token utils."""
from unittest import mock
from core.api import utils
def test_refresh_oidc_access_token_storing_refresh_token_disabled(settings):
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
settings.OIDC_STORE_REFRESH_TOKEN = False
callback = mock.MagicMock()
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
result = utils.conditional_refresh_oidc_token(callback)
mock_method_decorator.assert_not_called()
assert result == callback
def test_refresh_oidc_access_token_storing_refresh_token_enabled(settings):
"""The method_decorator must not be called when OIDC_STORE_REFRESH_TOKEN is False."""
settings.OIDC_STORE_REFRESH_TOKEN = True
callback = mock.MagicMock()
with mock.patch.object(utils, "method_decorator") as mock_method_decorator:
utils.conditional_refresh_oidc_token(callback)
mock_method_decorator.assert_called_with(utils.refresh_oidc_access_token)
@@ -216,7 +216,13 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
When USER_ONBOARDING_SANDBOX_DOCUMENT is set with a valid template document,
a new sandbox document should be created for the user with OWNER access.
"""
documents_before = factories.DocumentFactory.create_batch(20)
template_document = factories.DocumentFactory(title="Getting started with Docs")
documents_after = factories.DocumentFactory.create_batch(20)
all_documents = documents_before + [template_document] + documents_after
paths = {document.pk: document.path for document in all_documents}
with override_settings(USER_ONBOARDING_SANDBOX_DOCUMENT=str(template_document.id)):
user = factories.UserFactory()
@@ -233,6 +239,10 @@ def test_models_users_duplicate_onboarding_sandbox_document_creates_sandbox():
access = models.DocumentAccess.objects.get(user=user, document=sandbox_doc)
assert access.role == models.RoleChoices.OWNER
for document in all_documents:
document.refresh_from_db()
assert document.path == paths[document.id]
def test_models_users_duplicate_onboarding_sandbox_document_with_invalid_template_id():
"""
+126
View File
@@ -2,6 +2,7 @@
import base64
import uuid
from datetime import datetime, timedelta, timezone
from django.core.cache import cache
@@ -9,6 +10,7 @@ import pycrdt
import pytest
from core import factories, utils
from core.tests.conftest import create_document_with_updated_at
pytestmark = pytest.mark.django_db
@@ -240,3 +242,127 @@ def test_utils_get_value_by_pattern_no_match():
result = utils.get_value_by_pattern(data, r"^title\.")
assert result == []
def test_utils_build_indexable_documents_queryset_no_filters():
"""Test build_indexable_documents_queryset with no filters returns all documents."""
factories.DocumentFactory.create_batch(3)
queryset = utils.build_indexable_documents_queryset()
assert queryset.count() == 3
def test_utils_build_indexable_documents_queryset_oldest_updated_at():
"""Test filtering by oldest_updated_at includes documents updated after timestamp."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_too_old = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(lower_time_bound)
document_recent = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 2
assert document_too_old not in queryset
assert document_at_boundary in queryset
assert document_recent in queryset
def test_utils_build_indexable_documents_queryset_newest_updated_at():
"""Test filtering by newest_updated_at includes documents updated before timestamp."""
upper_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_old = create_document_with_updated_at(
updated_at=upper_time_bound - timedelta(days=30)
)
document_at_boundary = create_document_with_updated_at(upper_time_bound)
document_too_recent = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=15)
)
queryset = utils.build_indexable_documents_queryset(
upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_old in queryset
assert document_at_boundary in queryset
assert document_too_recent not in queryset
def test_utils_build_indexable_documents_queryset_both_bounds():
"""Test filtering with both oldest and newest bounds."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
upper_time_bound = lower_time_bound + timedelta(days=30)
document_too_early = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=10)
)
document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=5)
)
other_document_in_window = create_document_with_updated_at(
updated_at=lower_time_bound + timedelta(days=15)
)
document_too_late = create_document_with_updated_at(
updated_at=upper_time_bound + timedelta(days=10)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound, upper_time_bound=upper_time_bound
)
assert queryset.count() == 2
assert document_too_early not in queryset
assert document_in_window in queryset
assert other_document_in_window in queryset
assert document_too_late not in queryset
def test_utils_build_indexable_documents_queryset_deleted_at():
"""Test filtering includes documents with deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_deleted_in_range in queryset
assert document_updated_before_range not in queryset
def test_utils_build_indexable_documents_queryset_ancestors_deleted_at():
"""Test filtering includes documents with ancestors_deleted_at in range."""
lower_time_bound = datetime(2024, 2, 1, tzinfo=timezone.utc)
document_ancestor_deleted_in_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30),
ancestors_deleted_at=lower_time_bound + timedelta(days=15),
)
document_updated_before_range = create_document_with_updated_at(
updated_at=lower_time_bound - timedelta(days=30)
)
queryset = utils.build_indexable_documents_queryset(
lower_time_bound=lower_time_bound
)
assert queryset.count() == 1
assert document_ancestor_deleted_in_range in queryset
assert document_updated_before_range not in queryset
+20
View File
@@ -0,0 +1,20 @@
"""Utils for testing URLs."""
import importlib
from django.urls import clear_url_caches
def reload_urls():
"""
Reload the URLs. Since the URLs are loaded based on a
settings value, we need to reload them to make the
URL settings based condition effective.
"""
import core.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
import impress.urls # pylint:disable=import-outside-toplevel # noqa: PLC0415
importlib.reload(core.urls)
importlib.reload(impress.urls)
clear_url_caches()
+49
View File
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.external_api import viewsets as external_api_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -43,6 +44,19 @@ thread_related_router.register(
basename="comments",
)
# - Resource server routes
external_api_router = DefaultRouter()
external_api_router.register(
"documents",
external_api_viewsets.ResourceServerDocumentViewSet,
basename="resource_server_documents",
)
external_api_router.register(
"users",
external_api_viewsets.ResourceServerUserViewSet,
basename="resource_server_users",
)
urlpatterns = [
path(
@@ -68,3 +82,38 @@ urlpatterns = [
),
path(f"api/{settings.API_VERSION}/config/", viewsets.ConfigView.as_view()),
]
if settings.OIDC_RESOURCE_SERVER_ENABLED:
# - Routes nested under a document in external API
external_api_document_related_router = DefaultRouter()
document_access_config = settings.EXTERNAL_API.get("document_access", {})
if document_access_config.get("enabled", False):
external_api_document_related_router.register(
"accesses",
external_api_viewsets.ResourceServerDocumentAccessViewSet,
basename="resource_server_document_accesses",
)
document_invitation_config = settings.EXTERNAL_API.get("document_invitation", {})
if document_invitation_config.get("enabled", False):
external_api_document_related_router.register(
"invitations",
external_api_viewsets.ResourceServerInvitationViewSet,
basename="resource_server_document_invitations",
)
urlpatterns.append(
path(
f"external_api/{settings.API_VERSION}/",
include(
[
*external_api_router.urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(external_api_document_related_router.urls),
),
]
),
)
)
+45 -1
View File
@@ -8,7 +8,7 @@ from collections import defaultdict
from django.core.cache import cache
from django.db import models as db
from django.db.models import Subquery
from django.db.models import Q, Subquery
import pycrdt
from bs4 import BeautifulSoup
@@ -168,3 +168,47 @@ def users_sharing_documents_with(user):
elapsed,
)
return result
def build_indexable_documents_queryset(lower_time_bound=None, upper_time_bound=None):
"""
Build a queryset of documents to index filtered by timestamp range.
filters out documents that have been updated, or deleted outside the time range.
Args:
lower_time_bound (datetime, optional):
keeps documents updated or deleted after this timestamp.
upper_time_bound (datetime, optional):
keeps documents updated or deleted before this timestamp.
Returns:
QuerySet: Filtered Document queryset ready for indexation.
"""
queryset = models.Document.objects.all()
conditions = Q()
if lower_time_bound and upper_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound, updated_at__lte=upper_time_bound)
| Q(deleted_at__gte=lower_time_bound, deleted_at__lte=upper_time_bound)
| Q(
ancestors_deleted_at__gte=lower_time_bound,
ancestors_deleted_at__lte=upper_time_bound,
)
)
elif lower_time_bound:
conditions = (
Q(updated_at__gte=lower_time_bound)
| Q(deleted_at__gte=lower_time_bound)
| Q(ancestors_deleted_at__gte=lower_time_bound)
)
elif upper_time_bound:
conditions = (
Q(updated_at__lte=upper_time_bound)
| Q(deleted_at__lte=upper_time_bound)
| Q(ancestors_deleted_at__lte=upper_time_bound)
)
return queryset.filter(conditions)
+103
View File
@@ -687,6 +687,109 @@ class Base(Configuration):
environ_prefix=None,
)
# OIDC Resource Server
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
)
OIDC_RS_BACKEND_CLASS = values.Value(
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
environ_name="OIDC_RS_BACKEND_CLASS",
environ_prefix=None,
)
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
)
OIDC_TIMEOUT = values.PositiveIntegerValue(
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
)
OIDC_RS_AUDIENCE_CLAIM = values.Value(
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
)
OIDC_RS_SIGNING_ALGO = values.Value(
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
)
OIDC_RS_SCOPES = values.ListValue(
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
default=[],
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
environ_prefix=None,
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
environ_name="OIDC_RS_PRIVATE_KEY_STR",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA",
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
environ_prefix=None,
)
# External API Configuration
# Configure available routes and actions for external_api endpoints
EXTERNAL_API = values.DictValue(
default={
"documents": {
"enabled": True,
"actions": [
"list",
"retrieve",
"create",
"children",
],
},
"document_access": {
"enabled": False,
"actions": [],
},
"document_invitation": {
"enabled": False,
"actions": [],
},
"users": {
"enabled": True,
"actions": ["get_me"],
},
},
environ_name="EXTERNAL_API",
environ_prefix=None,
)
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.8.1"
version = "4.8.3"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
mockedDocument,
overrideConfig,
verifyDocName,
@@ -178,32 +179,18 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Rephrase' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Summarize' }),
).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Correct' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
await expect(getMenuItem(page, 'Rephrase')).toBeVisible();
await expect(getMenuItem(page, 'Summarize')).toBeVisible();
await expect(getMenuItem(page, 'Correct')).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
await page.getByRole('menuitem', { name: 'Language' }).hover();
await expect(
page.getByRole('menuitem', { name: 'English', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'French', exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'German', exact: true }),
).toBeVisible();
await getMenuItem(page, 'Language').hover();
await expect(getMenuItem(page, 'English', { exact: true })).toBeVisible();
await expect(getMenuItem(page, 'French', { exact: true })).toBeVisible();
await expect(getMenuItem(page, 'German', { exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'German', exact: true }).click();
await getMenuItem(page, 'German', { exact: true }).click();
await expect(editor.getByText('Hallo Welt')).toBeVisible();
});
@@ -269,23 +256,15 @@ test.describe('Doc AI feature', () => {
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeVisible();
await expect(getMenuItem(page, 'Use as prompt')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
).toBeHidden();
await expect(getMenuItem(page, 'Use as prompt')).toBeHidden();
}
if (ai_translate) {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeVisible();
await expect(getMenuItem(page, 'Language')).toBeVisible();
} else {
await expect(
page.getByRole('menuitem', { name: 'Language' }),
).toBeHidden();
await expect(getMenuItem(page, 'Language')).toBeHidden();
}
});
});
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
closeHeaderMenu,
createDoc,
getMenuItem,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
@@ -130,12 +131,13 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(thread.getByText('This is a comment').first()).toBeHidden();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
// Check background color changed
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
@@ -150,7 +152,7 @@ test.describe('Doc Comments', () => {
// Edit Comment
await thread.getByText('This is a comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Edit comment' }).click();
await getMenuItem(thread, 'Edit comment').click();
const commentEditor = thread.getByText('This is a comment').first();
await commentEditor.fill('This is an edited comment');
const saveBtn = thread.locator('button[data-test="save"]').first();
@@ -175,7 +177,7 @@ test.describe('Doc Comments', () => {
// Delete second comment
await thread.getByText('This is a second comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await getMenuItem(thread, 'Delete comment').click();
await expect(
thread.getByText('This is a second comment').first(),
).toBeHidden();
@@ -184,6 +186,7 @@ test.describe('Doc Comments', () => {
await thread.getByText('This is an edited comment').first().hover();
await thread.locator('[data-test="resolve"]').click();
await expect(thread).toBeHidden();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
@@ -195,18 +198,21 @@ test.describe('Doc Comments', () => {
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor.first().click();
await editor.getByText('Hello').click();
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await getMenuItem(thread, 'Delete comment').click();
await expect(editor.getByText('Hello')).not.toHaveClass('bn-thread-mark');
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
@@ -262,7 +268,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
// We change the role of the second user to reader
@@ -297,7 +303,7 @@ test.describe('Doc Comments', () => {
await expect(otherEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await otherEditor.getByText('Hello').click();
await expect(
@@ -343,7 +349,7 @@ test.describe('Doc Comments', () => {
await expect(editor1.getByText('Document One')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
'color(srgb 0.882353 0.831373 0.717647 / 0.4)',
);
await editor1.getByText('Document One').click();
@@ -5,6 +5,7 @@ import cs from 'convert-stream';
import {
createDoc,
getMenuItem,
goToGridDoc,
overrideConfig,
verifyDocName,
@@ -147,7 +148,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -608,7 +609,7 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Reading' }).click();
await getMenuItem(page, 'Reading').click();
// Close the modal
await page.getByRole('button', { name: 'close' }).first().click();
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
getOtherBrowserName,
mockedListDocs,
toggleHeaderMenu,
@@ -206,7 +207,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -294,7 +295,7 @@ test.describe('Doc grid move', () => {
const row = await getGridRow(page, titleDoc1);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -341,7 +342,7 @@ test.describe('Doc grid move', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await otherPage.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(otherPage, 'Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(otherPage.getByText('Access Requests')).toBeHidden();
@@ -352,7 +353,7 @@ test.describe('Doc grid move', () => {
await page.reload();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Move into a doc' }).click();
await getMenuItem(page, 'Move into a doc').click();
await expect(
page.getByRole('dialog').getByRole('heading', { name: 'Move' }),
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { createDoc, getGridRow, verifyDocName } from './utils-common';
import {
createDoc,
getGridRow,
getMenuItem,
verifyDocName,
} from './utils-common';
import { addNewMember, connectOtherUserToDoc } from './utils-share';
type SmallDoc = {
@@ -99,7 +104,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await getMenuItem(page, 'Share').click();
await expect(
page.getByRole('dialog').getByText('Share the document'),
@@ -115,7 +120,7 @@ test.describe('Document grid item options', () => {
// Pin
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
// Check is pinned
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
@@ -142,7 +147,7 @@ test.describe('Document grid item options', () => {
const row = await getGridRow(page, docTitle);
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await getMenuItem(page, 'Delete').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getGridRow,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -78,11 +79,7 @@ test.describe('Doc Header', () => {
await page.getByTestId('doc-visibility').click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await getMenuItem(page, 'Public').click();
await page.getByRole('button', { name: 'close' }).first().click();
@@ -156,10 +153,8 @@ test.describe('Doc Header', () => {
const emojiPicker = page.locator('.--docs--doc-title').getByRole('button');
const optionMenu = page.getByLabel('Open the document options');
const addEmojiMenuItem = page.getByRole('menuitem', { name: 'Add emoji' });
const removeEmojiMenuItem = page.getByRole('menuitem', {
name: 'Remove emoji',
});
const addEmojiMenuItem = getMenuItem(page, 'Add emoji');
const removeEmojiMenuItem = getMenuItem(page, 'Remove emoji');
// Top parent should not have emoji picker
await expect(emojiPicker).toBeHidden();
@@ -213,7 +208,7 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Delete document' }).click();
await getMenuItem(page, 'Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
@@ -275,9 +270,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -285,7 +278,7 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
name: 'Share the document',
});
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
@@ -300,7 +293,7 @@ test.describe('Doc Header', () => {
await invitationRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(invitationCard).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
@@ -312,9 +305,7 @@ test.describe('Doc Header', () => {
await expect(roles).toBeVisible();
await roles.click();
await expect(
page.getByRole('menuitem', { name: 'Remove access' }),
).toBeEnabled();
await expect(getMenuItem(page, 'Remove access')).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -354,9 +345,7 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -364,7 +353,7 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
name: 'Share the document',
});
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
@@ -426,16 +415,16 @@ test.describe('Doc Header', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
const shareModal = page.getByRole('dialog', {
name: 'Share the document',
});
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
@@ -484,8 +473,10 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
await getMenuItem(page, 'Copy as Markdown').click();
await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
// Test that clipboard is in Markdown format
const handle = await page.evaluateHandle(() =>
@@ -546,7 +537,7 @@ test.describe('Doc Header', () => {
.click();
// Pin
await page.getByRole('menuitem', { name: 'Pin' }).click();
await getMenuItem(page, 'Pin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
@@ -567,11 +558,11 @@ test.describe('Doc Header', () => {
.click();
// Unpin
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await getMenuItem(page, 'Unpin').click();
await page
.getByRole('button', { name: 'Open the document options' })
.click();
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await expect(getMenuItem(page, 'Pin')).toBeVisible();
await page.goto('/');
@@ -589,7 +580,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
await expect(
page.getByText('Document duplicated successfully!'),
).toBeVisible();
@@ -604,7 +595,7 @@ test.describe('Doc Header', () => {
await expect(row.getByText(duplicateTitle)).toBeVisible();
await row.getByText(`more_horiz`).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
const duplicateDuplicateTitle = 'Copy of ' + duplicateTitle;
await page.getByText(duplicateDuplicateTitle).click();
await expect(page.getByText('Hello Duplicated World')).toBeVisible();
@@ -637,7 +628,7 @@ test.describe('Doc Header', () => {
const currentUrl = page.url();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await getMenuItem(page, 'Duplicate').click();
await expect(page).not.toHaveURL(new RegExp(currentUrl));
@@ -676,10 +667,8 @@ test.describe('Documents Header mobile', () => {
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Copy link' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'Share' }).click();
await expect(getMenuItem(page, 'Copy link')).toBeVisible();
await getMenuItem(page, 'Share').click();
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
});
@@ -702,13 +691,15 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Share' }).click();
await getMenuItem(page, 'Share').click();
const shareModal = page.getByRole('dialog', {
name: 'Share modal content',
name: 'Share the document',
});
await expect(shareModal).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await expect(page.getByLabel('Share modal')).toBeHidden();
await expect(
page.getByRole('dialog', { name: 'Share the document' }),
).toBeHidden();
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { updateShareLink } from './utils-share';
import { createRootSubPage } from './utils-sub-pages';
@@ -53,19 +53,17 @@ test.describe('Inherited share accesses', () => {
await expect(docVisibilityCard.getByText('Reading')).toBeVisible();
await docVisibilityCard.getByText('Reading').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(docVisibilityCard.getByText('Reading')).toBeHidden();
await expect(docVisibilityCard.getByText('Editing')).toBeVisible();
// Verify inherited link
await docVisibilityCard.getByText('Connected').click();
await expect(
page.getByRole('menuitem', { name: 'Private' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Private')).toBeDisabled();
// Update child link
await page.getByRole('menuitem', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(docVisibilityCard.getByText('Connected')).toBeHidden();
await expect(
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
BROWSERS,
createDoc,
getMenuItem,
keyCloakSignIn,
randomName,
verifyDocName,
@@ -75,15 +76,13 @@ test.describe('Document create member', () => {
// Check roles are displayed
await list.getByTestId('doc-role-dropdown').click();
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeVisible();
await expect(getMenuItem(page, 'Reader')).toBeVisible();
await expect(getMenuItem(page, 'Editor')).toBeVisible();
await expect(getMenuItem(page, 'Owner')).toBeVisible();
await expect(getMenuItem(page, 'Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await page.getByTestId('doc-share-invite-button').click();
// Check invitation added
@@ -129,7 +128,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -147,7 +146,7 @@ test.describe('Document create member', () => {
// Choose a role
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Owner' }).click();
await getMenuItem(page, 'Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -184,7 +183,7 @@ test.describe('Document create member', () => {
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -211,13 +210,13 @@ test.describe('Document create member', () => {
);
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
await userInvitation.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(userInvitation).toBeHidden();
});
@@ -269,7 +268,7 @@ test.describe('Document create member', () => {
`doc-share-access-request-row-${emailRequest}`,
);
await container.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await container.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByText('Access Requests')).toBeHidden();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -160,9 +160,7 @@ test.describe('Document list members', () => {
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Administrator' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Administrator')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
@@ -185,18 +183,18 @@ test.describe('Document list members', () => {
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await newUserRoles.click();
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeDisabled();
await expect(getMenuItem(page, 'Owner')).toBeDisabled();
await list.click({
force: true, // Force click to close the dropdown
});
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await getMenuItem(page, 'Reader').click();
await list.click({
force: true, // Force click to close the dropdown
});
@@ -236,11 +234,11 @@ test.describe('Document list members', () => {
await expect(userReader).toBeVisible();
await userReaderRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(userReader).toBeHidden();
await mySelfRole.click();
await page.getByRole('menuitem', { name: 'Remove access' }).click();
await getMenuItem(page, 'Remove access').click();
await expect(
page.getByText('Insufficient access rights to view the document.'),
).toBeVisible();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './utils-common';
import { createDoc, getMenuItem, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.beforeEach(async ({ page }) => {
@@ -29,7 +29,7 @@ test.describe('Document search', () => {
await page.getByTestId('search-docs-button').click();
await expect(
page.getByRole('img', { name: 'No active search' }),
page.getByLabel('Search modal').locator('img[alt=""]'),
).toBeVisible();
await expect(
@@ -107,7 +107,7 @@ test.describe('Document search', () => {
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible();
await expect(filters).toBeHidden();
@@ -120,7 +120,7 @@ test.describe('Document search', () => {
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible();
await expect(filters).toBeHidden();
@@ -136,13 +136,9 @@ test.describe('Document search', () => {
await filters.click();
await filters.getByRole('button', { name: 'Current doc' }).click();
await expect(
page.getByRole('menuitem', { name: 'All docs' }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Current doc' }),
).toBeVisible();
await page.getByRole('menuitem', { name: 'All docs' }).click();
await expect(getMenuItem(page, 'All docs')).toBeVisible();
await expect(getMenuItem(page, 'Current doc')).toBeVisible();
await getMenuItem(page, 'All docs').click();
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
});
@@ -168,9 +164,9 @@ test.describe('Document search', () => {
const searchButton = page.getByTestId('search-docs-button');
await searchButton.click();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
await page.getByRole('combobox', { name: 'Search documents' }).click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.getByRole('combobox', { name: 'Search documents' })
.fill('sub page search');
// Expect to find the first and second docs in the results list
@@ -192,7 +188,7 @@ test.describe('Document search', () => {
);
await searchButton.click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.getByRole('combobox', { name: 'Search documents' })
.fill('second');
// Now there is a sub page - expect to have the focus on the current doc
@@ -19,7 +19,9 @@ test.describe('Doc Table Content', () => {
await page.locator('.ProseMirror').click();
await expect(page.getByRole('button', { name: 'Summary' })).toBeHidden();
await expect(
page.getByRole('button', { name: 'Show the table of contents' }),
).toBeHidden();
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
updateDocTitle,
verifyDocName,
@@ -162,7 +163,7 @@ test.describe('Doc Tree', () => {
);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await getMenuItem(page, 'Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
@@ -192,9 +193,10 @@ test.describe('Doc Tree', () => {
const menu = child.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
await expect(getMenuItem(page, 'Move to my docs')).toHaveAttribute(
'aria-disabled',
'true',
);
});
test('keyboard navigation with Enter key opens documents', async ({
@@ -297,7 +299,7 @@ test.describe('Doc Tree', () => {
await page.keyboard.press('Tab');
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Tab');
@@ -307,7 +309,7 @@ test.describe('Doc Tree', () => {
await page.keyboard.press('Shift+Tab');
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Shift+Tab');
@@ -338,9 +340,7 @@ test.describe('Doc Tree', () => {
await row.hover();
const menu = row.getByText(`more_horiz`);
await menu.click();
await expect(
page.getByRole('menuitem', { name: 'Remove emoji' }),
).toBeHidden();
await expect(getMenuItem(page, 'Remove emoji')).toBeHidden();
// Close the menu
await page.keyboard.press('Escape');
@@ -360,7 +360,7 @@ test.describe('Doc Tree', () => {
// Now remove the emoji using the new action
await row.hover();
await menu.click();
await page.getByRole('menuitem', { name: 'Remove emoji' }).click();
await getMenuItem(page, 'Remove emoji').click();
await expect(row.getByText('😀')).toBeHidden();
await expect(titleEmojiPicker).toBeHidden();
@@ -390,11 +390,7 @@ test.describe('Doc Tree: Inheritance', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
createDoc,
getMenuItem,
goToGridDoc,
mockedDocument,
verifyDocName,
@@ -20,11 +21,11 @@ test.describe('Doc Version', () => {
// Initially, there is no version
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
@@ -74,7 +75,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -124,9 +125,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(getMenuItem(page, 'Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -153,23 +152,28 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Version history' }).click();
await getMenuItem(page, 'Version history').click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await panel.getByRole('button', { name: 'version item' }).click();
await panel.locator('.version-item').first().click();
await expect(modal.getByText('World')).toBeHidden();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await expect(
page.getByText(
"The current document will be replaced, but you'll still find it in the version history.",
),
).toBeVisible();
await page.getByLabel('Restore', { exact: true }).click();
await page.waitForTimeout(500);
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
@@ -4,6 +4,7 @@ import {
BROWSERS,
createDoc,
expectLoginPage,
getMenuItem,
keyCloakSignIn,
verifyDocName,
} from './utils-common';
@@ -46,21 +47,17 @@ test.describe('Doc Visibility', () => {
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(
page.getByRole('menuitem', { name: 'Read only' }),
).toBeHidden();
await expect(
page.getByRole('menuitem', { name: 'Can read and edit' }),
).toBeHidden();
await expect(getMenuItem(page, 'Read only')).toBeHidden();
await expect(getMenuItem(page, 'Can read and edit')).toBeHidden();
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Connected' }).click();
await getMenuItem(page, 'Connected').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await selectVisibility.click();
await page.getByRole('menuitem', { name: 'Public' }).click();
await getMenuItem(page, 'Public').click();
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
});
@@ -205,11 +202,7 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -217,11 +210,7 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page
.getByRole('menuitem', {
name: 'Reading',
})
.click();
await getMenuItem(page, 'Reading').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -307,18 +296,14 @@ test.describe('Doc Visibility: Public', () => {
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await getMenuItem(page, 'Public').click();
await expect(
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -402,11 +387,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -454,11 +435,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -556,11 +533,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await getMenuItem(page, 'Connected').click();
await expect(
page.getByText('The document visibility has been updated.'),
@@ -568,7 +541,7 @@ test.describe('Doc Visibility: Authenticated', () => {
const urlDoc = page.url();
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: 'Editing' }).click();
await getMenuItem(page, 'Editing').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { overrideConfig } from './utils-common';
import { getMenuItem, overrideConfig } from './utils-common';
test.describe('Footer', () => {
test.use({ storageState: { cookies: [], origins: [] } });
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page.locator('footer').getByText('Mentions légales'),
@@ -131,7 +131,7 @@ test.describe('Footer', () => {
// Check the translation
const header = page.locator('header').first();
await header.getByRole('button').getByText('English').click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await getMenuItem(page, 'Français').click();
await expect(
page
@@ -55,7 +55,8 @@ test.describe('Header', () => {
'src',
'/assets/icon-docs-v2.svg',
);
await expect(header.locator('h1')).toBeHidden();
// With withTitle: false, the h1 is kept for accessibility but visually hidden via sr-only
await expect(header.locator('h1').getByText('Docs')).toHaveClass(/sr-only/);
});
test('checks a custom waffle', async ({ page }) => {
@@ -190,25 +191,27 @@ test.describe('Header: Override configuration', () => {
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
test('it displays skip link on first TAB and focuses page heading on click', async ({
page,
}) => {
await page.goto('/');
// Wait for skip button to be mounted (client-side only component)
const skipButton = page.getByRole('button', { name: 'Go to content' });
await skipButton.waitFor({ state: 'attached' });
// Wait for skip link to be mounted (client-side only component)
const skipLink = page.getByRole('link', { name: 'Go to content' });
await skipLink.waitFor({ state: 'attached' });
// First TAB shows the skip button
// First TAB shows the skip link
await page.keyboard.press('Tab');
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
// The skip link should be visible and focused
await expect(skipLink).toBeFocused();
await expect(skipLink).toBeVisible();
// Clicking moves focus to the page heading
await skipLink.click();
const pageHeading = page.getByRole('heading', {
name: 'All docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
});
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import {
TestLanguage,
getMenuItem,
overrideConfig,
waitForLanguageSwitch,
} from './utils-common';
@@ -26,7 +27,7 @@ test.describe('Help feature', () => {
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open onboarding menu' }),
page.getByRole('button', { name: 'Open help menu' }),
).toBeHidden();
});
@@ -42,9 +43,9 @@ test.describe('Help feature', () => {
},
});
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
await page.getByRole('button', { name: 'Open help menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -86,8 +87,8 @@ test.describe('Help feature', () => {
});
test('closes modal with Skip button', async ({ page }) => {
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal');
await expect(modal).toBeVisible();
@@ -106,11 +107,9 @@ test.describe('Help feature', () => {
// switch to french
await waitForLanguageSwitch(page, TestLanguage.French);
await page
.getByRole('button', { name: "Ouvrir le menu d'embarquement" })
.click();
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await page.getByRole('menuitem', { name: 'Premiers pas' }).click();
await getMenuItem(page, 'Premiers pas').click();
const modal = page.getByLabel('Apprenez les principes fondamentaux');
@@ -75,7 +75,7 @@ test.describe('Language', () => {
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.getByRole('menuitem');
const menuItems = page.locator('[role="menuitem"], [role="menuitemradio"]');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
@@ -18,7 +19,7 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('focuses main content after switching the docs filter', async ({
test('focuses page heading after switching the docs filter', async ({
page,
}) => {
await page.goto('/');
@@ -28,8 +29,11 @@ test.describe('Left panel desktop', () => {
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/target=my_docs/);
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
const pageHeading = page.getByRole('heading', {
name: 'My docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
test('checks resize handle is present and functional on document page', async ({
@@ -118,6 +122,47 @@ test.describe('Left panel mobile', () => {
await expect(logoutButton).toBeInViewport();
});
test('checks panel closes when clicking on a subdoc', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'mobile-doc-test',
browserName,
1,
true,
);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'mobile-doc-test-child',
true,
);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'mobile-doc-test-child-2',
true,
);
const header = page.locator('header').first();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docTitle)).toBeVisible();
await expect(docTree.getByText(docChild)).toBeVisible();
await expect(docTree.getByText(docChild2)).toBeVisible();
await docTree.getByText(docChild).click();
await verifyDocName(page, docChild);
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
});
test('checks resize handle is not present on mobile', async ({ page }) => {
await page.goto('/');
@@ -3,6 +3,16 @@ import path from 'path';
import { Locator, Page, TestInfo, expect } from '@playwright/test';
/** Returns a locator for a menu item (handles both menuitem and menuitemradio roles) */
export const getMenuItem = (
context: Page | Locator,
name: string,
options?: { exact?: boolean },
): Locator =>
context
.getByRole('menuitem', { name, exact: options?.exact })
.or(context.getByRole('menuitemradio', { name, exact: options?.exact }));
import theme_customization from '../../../../../backend/impress/configuration/theme/default.json';
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
@@ -382,12 +392,12 @@ export async function waitForLanguageSwitch(
await languagePicker.click();
await page.getByRole('menuitem', { name: lang.label }).click();
await getMenuItem(page, lang.label).click();
}
export const clickInEditorMenu = async (page: Page, textButton: string) => {
await page.getByRole('button', { name: 'Open the document options' }).click();
await page.getByRole('menuitem', { name: textButton }).click();
await getMenuItem(page, textButton).click();
};
export const clickInGridMenu = async (
@@ -398,7 +408,7 @@ export const clickInGridMenu = async (
await row
.getByRole('button', { name: /Open the menu of actions for the document/ })
.click();
await page.getByRole('menuitem', { name: textButton }).click();
await getMenuItem(page, textButton).click();
};
export const writeReport = async (
@@ -2,6 +2,7 @@ import { Page, chromium, expect } from '@playwright/test';
import {
BrowserName,
getMenuItem,
getOtherBrowserName,
keyCloakSignIn,
verifyDocName,
@@ -39,7 +40,7 @@ export const addNewMember = async (
// Choose a role
await page.getByTestId('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: role }).click();
await getMenuItem(page, role).click();
await page.getByTestId('doc-share-invite-button').click();
return users[index].email;
@@ -51,7 +52,7 @@ export const updateShareLink = async (
linkRole?: LinkRole | null,
) => {
await page.getByTestId('doc-visibility').click();
await page.getByRole('menuitem', { name: linkReach }).click();
await getMenuItem(page, linkReach).click();
const visibilityUpdatedText = page
.getByText('The document visibility has been updated')
@@ -61,7 +62,7 @@ export const updateShareLink = async (
if (linkRole) {
await page.getByTestId('doc-access-mode').click();
await page.getByRole('menuitem', { name: linkRole }).click();
await getMenuItem(page, linkRole).click();
await expect(visibilityUpdatedText).toBeVisible();
}
};
@@ -76,7 +77,7 @@ export const updateRoleUser = async (
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
const currentUserRole = currentUser.getByTestId('doc-role-dropdown');
await currentUserRole.click();
await page.getByRole('menuitem', { name: role }).click();
await getMenuItem(page, role).click();
await list.click();
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.8.1",
"version": "4.8.3",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.8.1",
"version": "4.8.3",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -46,6 +46,7 @@
"@hocuspocus/provider": "3.4.4",
"@mantine/core": "8.3.14",
"@mantine/hooks": "8.3.14",
"@react-aria/live-announcer": "3.4.4",
"@react-pdf/renderer": "4.3.1",
"@sentry/nextjs": "10.38.0",
"@tanstack/react-query": "5.90.21",
@@ -63,7 +64,7 @@
"idb": "8.0.3",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "16.1.6",
"next": "16.1.7",
"posthog-js": "1.347.2",
"react": "*",
"react-aria-components": "1.15.1",
@@ -0,0 +1,9 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path
d="M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm210-360h60v-180h40v120h60v-120h40v180h60v-200q0-17-11.5-28.5T630-680H450q-17 0-28.5 11.5T410-640v200Zm-50 120v-480 480Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

@@ -93,6 +93,8 @@ export const DropButton = ({
onOpenChangeHandler(true);
}}
aria-label={label}
aria-haspopup="true"
aria-expanded={isLocalOpen}
data-testid={testId}
$css={css`
font-family: ${font};
@@ -1,63 +1,33 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/utils';
export const SkipToContent = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens } = useCunninghamTheme();
const [isVisible, setIsVisible] = useState(false);
// Reset focus after route change so first TAB goes to skip link
useEffect(() => {
const handleRouteChange = () => {
(document.activeElement as HTMLElement)?.blur();
document.body.setAttribute('tabindex', '-1');
document.body.focus({ preventScroll: true });
setTimeout(() => {
document.body.removeAttribute('tabindex');
}, 100);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
mainContent.focus();
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
const focusTarget = focusMainContentStart();
if (focusTarget instanceof HTMLElement) {
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Box
$css={css`
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
box-shadow:
0 0 0 1px var(--c--globals--colors--white-000),
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Box>
<Button
onClick={handleClick}
type="button"
href={`#${MAIN_LAYOUT_ID}`}
color="brand"
className="--docs--skip-to-content"
onClick={handleClick}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
style={{
@@ -65,7 +35,6 @@ export const SkipToContent = () => {
pointerEvents: isVisible ? 'auto' : 'none',
position: 'fixed',
top: spacingsTokens['2xs'],
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
zIndex: 9999,
whiteSpace: 'nowrap',
@@ -110,6 +110,10 @@ export const DropdownMenu = ({
[onOpenChange],
);
const hasSelectable =
selectedValues !== undefined ||
options.some((option) => option.isSelected !== undefined);
if (disabled) {
return children;
}
@@ -172,6 +176,11 @@ export const DropdownMenu = ({
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
const ariaChecked = hasSelectable
? option.isSelected ||
selectedValues?.includes(option.value ?? '') ||
false
: undefined;
return (
<Fragment key={option.label}>
@@ -179,7 +188,8 @@ export const DropdownMenu = ({
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role="menuitem"
role={hasSelectable ? 'menuitemradio' : 'menuitem'}
aria-checked={ariaChecked}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -0,0 +1,119 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, test, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
const mockAddLastFocus = vi.fn();
vi.mock('@/stores', () => ({
useFocusStore: (selector: any) =>
selector({ addLastFocus: mockAddLastFocus }),
useResponsiveStore: () => ({ isDesktop: true }),
}));
const baseOptions: DropdownMenuOption[] = [
{ label: 'English', callback: vi.fn() },
{ label: 'Français', callback: vi.fn() },
{ label: 'Deutsch', callback: vi.fn() },
];
const selectableOptions: DropdownMenuOption[] = [
{ label: 'English', isSelected: false, callback: vi.fn() },
{ label: 'Français', isSelected: true, callback: vi.fn() },
{ label: 'Deutsch', isSelected: false, callback: vi.fn() },
];
describe('<DropdownMenu />', () => {
test('renders menuitem role when options have no selection', async () => {
render(
<DropdownMenu options={baseOptions} label="Options" opened>
Open menu
</DropdownMenu>,
{ wrapper: AppWrapper },
);
const items = screen.getAllByRole('menuitem');
expect(items).toHaveLength(3);
items.forEach((item) => {
expect(item).not.toHaveAttribute('aria-checked');
});
});
test('renders menuitemradio role with aria-checked when options have isSelected', async () => {
render(
<DropdownMenu options={selectableOptions} label="Select language" opened>
Français
</DropdownMenu>,
{ wrapper: AppWrapper },
);
const radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('renders menuitemradio role with aria-checked when selectedValues is provided', async () => {
const optionsWithValues: DropdownMenuOption[] = [
{ label: 'English', value: 'en', callback: vi.fn() },
{ label: 'Français', value: 'fr', callback: vi.fn() },
{ label: 'Deutsch', value: 'de', callback: vi.fn() },
];
render(
<DropdownMenu
options={optionsWithValues}
selectedValues={['fr']}
label="Select language"
opened
>
Français
</DropdownMenu>,
{ wrapper: AppWrapper },
);
const radios = screen.getAllByRole('menuitemradio');
expect(radios).toHaveLength(3);
expect(radios[0]).toHaveAttribute('aria-checked', 'false');
expect(radios[1]).toHaveAttribute('aria-checked', 'true');
expect(radios[2]).toHaveAttribute('aria-checked', 'false');
});
test('trigger button has aria-haspopup and aria-expanded', async () => {
render(
<DropdownMenu options={baseOptions} label="Select language">
Français
</DropdownMenu>,
{ wrapper: AppWrapper },
);
const trigger = screen.getByRole('button', { name: 'Select language' });
expect(trigger).toHaveAttribute('aria-haspopup', 'true');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(trigger);
await waitFor(() => {
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
});
test('displays check icon for selected item', async () => {
render(
<DropdownMenu options={selectableOptions} label="Select language" opened>
Français
</DropdownMenu>,
{ wrapper: AppWrapper },
);
const menu = screen.getByRole('menu');
const checkedItem = within(menu).getAllByRole('menuitemradio')[1];
expect(checkedItem).toHaveAttribute('aria-checked', 'true');
expect(within(checkedItem).getByText('Français')).toBeInTheDocument();
});
});
@@ -56,7 +56,7 @@ export const AlertModal = ({
isOpen={isOpen}
size={ModalSize.MEDIUM}
onClose={onClose}
aria-describedby="alert-modal-title"
aria-label={title}
title={
<Text
$size="h6"
@@ -32,6 +32,7 @@ export type QuickSearchProps = {
label?: string;
placeholder?: string;
groupKey?: string;
beforeList?: ReactNode;
};
export const QuickSearch = ({
@@ -41,6 +42,7 @@ export const QuickSearch = ({
showInput = true,
label,
placeholder,
beforeList,
children,
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
@@ -76,6 +78,7 @@ export const QuickSearch = ({
{inputContent}
</QuickSearchInput>
)}
{beforeList}
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
@@ -19,9 +19,11 @@ export const QuickSearchGroup = <T,>({
}: Props<T>) => {
return (
<Box>
<Text as="h2" $weight="700" $size="sm" $margin="none">
{group.groupName}
</Text>
<Command.Group
key={group.groupName}
heading={group.groupName}
forceMount={false}
contentEditable={false}
>
@@ -1,9 +1,10 @@
import { Command } from 'cmdk';
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useFocusStore } from '@/stores';
import { Box } from '../Box';
import { Icon } from '../Icon';
@@ -14,7 +15,6 @@ type QuickSearchInputProps = {
placeholder?: string;
withSeparator?: boolean;
listId?: string;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
inputValue,
@@ -26,6 +26,12 @@ export const QuickSearchInput = ({
}: PropsWithChildren<QuickSearchInputProps>) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const inputRef = useRef<HTMLInputElement>(null);
const addLastFocus = useFocusStore((state) => state.addLastFocus);
useEffect(() => {
addLastFocus(inputRef.current);
}, [addLastFocus]);
if (children) {
return (
@@ -47,6 +53,7 @@ export const QuickSearchInput = ({
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
<Command.Input
ref={inputRef}
autoFocus={true}
aria-label={t('Quick search input')}
aria-controls={listId}
@@ -19,7 +19,7 @@ export const ModalConfirmDownloadUnsafe = ({
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-describedby="modal-confirm-download-unsafe-title"
aria-label={t('Warning')}
rightActions={
<>
<Button
@@ -48,7 +48,7 @@ export const ModalConfirmDownloadUnsafe = ({
size={ModalSize.SMALL}
title={
<Text
as="h1"
as="h2"
id="modal-confirm-download-unsafe-title"
$gap="0.7rem"
$size="h6"
@@ -61,10 +61,7 @@ export const ModalConfirmDownloadUnsafe = ({
</Text>
}
>
<Box
aria-label={t('Modal confirmation to download the attachment')}
className="--docs--modal-confirm-download-unsafe"
>
<Box className="--docs--modal-confirm-download-unsafe">
<Box>
<Box $direction="column" $gap="0.35rem" $margin={{ top: 'sm' }}>
<Text $variation="secondary">
@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Box, Loading } from '@/components';
import { DocHeader, FloatingBar } from '@/docs/doc-header/';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
@@ -35,7 +35,6 @@ export const DocEditorContainer = ({
return (
<>
{isDesktop && <FloatingBar />}
<Box
$maxWidth="868px"
$width="100%"
@@ -8,12 +8,37 @@ export const cssComments = (
& .--docs--main-editor .ProseMirror {
// Comments marks in the editor
.bn-editor {
.bn-thread-mark:not([data-orphan='true']),
.bn-thread-mark-selected:not([data-orphan='true']) {
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--globals--colors--gray-700);
// Resets blocknote comments styles
.bn-thread-mark,
.bn-thread-mark-selected {
background-color: transparent;
}
${canSeeComment &&
css`
.bn-thread-mark:not([data-orphan='true']) {
background-color: color-mix(
in srgb,
var(--c--contextuals--background--palette--yellow--tertiary) 40%,
transparent
);
border-bottom: 2px solid
var(--c--contextuals--background--palette--yellow--secondary);
mix-blend-mode: multiply;
transition:
background-color var(--c--globals--transitions--duration),
border-bottom-color var(--c--globals--transitions--duration);
&:has(.bn-thread-mark-selected) {
background-color: var(
--c--contextuals--background--palette--yellow--tertiary
);
}
}
`}
[data-show-selection] {
color: HighlightText;
}
@@ -57,30 +57,22 @@ export const accessibleImageRender =
imgSelector?.removeAttribute('aria-hidden');
imgSelector?.setAttribute('tabindex', '0');
const figureElement = document.createElement('figure');
const originalCaption = dom.querySelector('.bn-file-caption');
// Copy all attributes from the original div
figureElement.className = dom.className;
const styleAttr = dom.getAttribute('style');
if (styleAttr) {
figureElement.setAttribute('style', styleAttr);
}
figureElement.style.setProperty('margin', '0');
if (imgSelector?.parentNode && originalCaption) {
const figureElement = document.createElement('figure');
figureElement.style.setProperty('margin', '0');
Array.from(dom.children).forEach((child) => {
figureElement.appendChild(child.cloneNode(true));
});
// Wrap only the img inside figure, preserving the rest of the dom tree
imgSelector.parentNode.insertBefore(figureElement, imgSelector);
figureElement.appendChild(imgSelector);
// Replace the <p> caption with <figcaption>
const figcaptionElement = document.createElement('figcaption');
const originalCaption = figureElement.querySelector('.bn-file-caption');
if (originalCaption) {
// Replace the <p> caption with <figcaption> inside the figure
const figcaptionElement = document.createElement('figcaption');
figcaptionElement.className = originalCaption.className;
figcaptionElement.textContent = originalCaption.textContent;
originalCaption.parentNode?.replaceChild(
figcaptionElement,
originalCaption,
);
figureElement.appendChild(figcaptionElement);
originalCaption.parentNode?.removeChild(originalCaption);
// Add explicit role and aria-label for better screen reader support
figureElement.setAttribute('role', 'img');
@@ -90,10 +82,9 @@ export const accessibleImageRender =
);
}
// Return the figure element as the new dom
return {
...imageRenderComputed,
dom: figureElement,
dom,
};
};
@@ -1,11 +1,83 @@
import { useEffect } from 'react';
import { announce } from '@react-aria/live-announcer';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockNoteEditor } from '../types';
const getFormattingShortcutLabel = (
event: KeyboardEvent,
t: (key: string) => string,
): string | null => {
const isMod = event.ctrlKey || event.metaKey;
if (!isMod) {
return null;
}
if (event.altKey) {
switch (event.code) {
case 'Digit1':
return t('Heading 1 applied');
case 'Digit2':
return t('Heading 2 applied');
case 'Digit3':
return t('Heading 3 applied');
default:
return null;
}
}
if (event.shiftKey) {
switch (event.code) {
case 'Digit0':
return t('Paragraph applied');
case 'Digit6':
return t('Toggle list applied');
case 'Digit7':
return t('Numbered list applied');
case 'Digit8':
return t('Bulleted list applied');
case 'Digit9':
return t('Checklist applied');
case 'KeyC':
return t('Code block applied');
default:
return null;
}
}
return null;
};
export const useShortcuts = (
editor: DocsBlockNoteEditor,
el: HTMLDivElement | null,
) => {
const { t } = useTranslation();
const handleFormattingShortcut = useCallback(
(event: KeyboardEvent) => {
if (!editor?.isFocused()) {
return;
}
const label = getFormattingShortcutLabel(event, t);
if (label) {
setTimeout(() => {
announce(label, 'assertive');
}, 150);
}
},
[editor, t],
);
useEffect(() => {
el?.addEventListener('keydown', handleFormattingShortcut, true);
return () => {
el?.removeEventListener('keydown', handleFormattingShortcut, true);
};
}, [el, handleFormattingShortcut]);
useEffect(() => {
// Check if editor and its view are mounted
if (!editor || !el) {
@@ -60,6 +60,23 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const formatOptions = [
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
{ label: t('Print'), value: DocDownloadFormat.PRINT },
];
const formatLabels = Object.fromEntries(
formatOptions.map((opt) => [opt.value, opt.label]),
);
const downloadButtonAriaLabel =
format === DocDownloadFormat.PRINT
? t('Print')
: t('Download {{format}}', { format: formatLabels[format] });
async function onSubmit() {
if (!editor) {
toast(t('The export failed'), VariantType.ERROR);
@@ -196,6 +213,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
closeOnClickOutside
onClose={() => onClose()}
hideCloseButton
aria-label={t('Export')}
aria-describedby="modal-export-title"
rightActions={
<>
@@ -210,9 +228,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
</Button>
<Button
data-testid="doc-export-download-button"
aria-label={
format === DocDownloadFormat.PRINT ? t('Print') : t('Download')
}
aria-label={downloadButtonAriaLabel}
variant="primary"
fullWidth
onClick={() => void onSubmit()}
@@ -247,7 +263,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
>
<Box
$margin={{ bottom: 'xl' }}
aria-label={t('Content modal to export the document')}
$gap="1rem"
className="--docs--modal-export-content"
>
@@ -260,13 +275,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
clearable={false}
fullWidth
label={t('Format')}
options={[
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
{ label: t('Print'), value: DocDownloadFormat.PRINT },
]}
options={formatOptions}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
@@ -73,6 +73,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-label={t("Why you can't edit the document?")}
rightActions={
<>
<Button
@@ -92,11 +93,7 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
</Text>
}
>
<Box
aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network"
$margin={{ top: 'md' }}
>
<Box className="--docs--modal-alert-network" $margin={{ top: 'md' }}>
<Text $size="sm" $variation="secondary">
{t(
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
@@ -55,7 +55,9 @@ export const BoutonShare = ({
`}
>
<Button
aria-label={t('Share button')}
aria-label={t('Share button, shared with {{count}} users', {
count: doc.nb_accesses_direct,
})}
variant="secondary"
icon={
<Icon
@@ -17,6 +17,7 @@ import GroupSVG from '@/assets/icons/ui-kit/group.svg';
import HistorySVG from '@/assets/icons/ui-kit/history.svg';
import KeepSVG from '@/assets/icons/ui-kit/keep.svg';
import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg';
import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg';
import {
Box,
DropdownMenu,
@@ -193,7 +194,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: <ContentCopySVG width={24} height={24} />,
icon: <MarkdownCopySVG width={24} height={24} />,
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
@@ -13,7 +13,8 @@ export const useCopyCurrentEditorToClipboard = () => {
return async (asFormat: 'html' | 'markdown') => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
const message = t('Editor unavailable');
toast(message, VariantType.ERROR, { duration: 3000 });
return;
}
@@ -23,10 +24,20 @@ export const useCopyCurrentEditorToClipboard = () => {
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
const successMessage =
asFormat === 'markdown'
? t('Copied as Markdown to clipboard')
: t('Copied to clipboard');
toast(successMessage, VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
const errorMessage =
asFormat === 'markdown'
? t('Failed to copy as Markdown to clipboard')
: t('Failed to copy to clipboard');
toast(errorMessage, VariantType.ERROR, {
duration: 3000,
});
}
@@ -1,4 +1,6 @@
import { announce } from '@react-aria/live-announcer';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -29,6 +31,8 @@ export function useCreateFavoriteDoc({
listInvalidQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
@@ -37,7 +41,15 @@ export function useCreateFavoriteDoc({
queryKey: [queryKey],
});
});
const message = t('Document pinned successfully!');
announce(message, 'polite');
onSuccess?.();
},
onError: () => {
const message = t('Failed to pin the document.');
announce(message, 'assertive');
},
});
}
@@ -1,4 +1,6 @@
import { announce } from '@react-aria/live-announcer';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -29,6 +31,8 @@ export function useDeleteFavoriteDoc({
listInvalidQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
@@ -37,7 +41,15 @@ export function useDeleteFavoriteDoc({
queryKey: [queryKey],
});
});
const message = t('Document unpinned successfully!');
announce(message, 'polite');
onSuccess?.();
},
onError: () => {
const message = t('Failed to unpin the document.');
announce(message, 'assertive');
},
});
}
@@ -88,14 +88,16 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
queryKey: [KEY_LIST_DOC],
});
toast(t('Document duplicated successfully!'), VariantType.SUCCESS, {
const message = t('Document duplicated successfully!');
toast(message, VariantType.SUCCESS, {
duration: 3000,
});
void options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
const message = t('Failed to duplicate the document...');
toast(message, VariantType.ERROR, {
duration: 3000,
});
@@ -90,7 +90,7 @@ export const ModalRemoveDoc = ({
closeOnClickOutside
hideCloseButton
onClose={handleClose}
aria-describedby="modal-remove-doc-title"
aria-label={t('Delete a doc')}
rightActions={
<>
<Button
@@ -30,6 +30,8 @@ const defaultValues = {
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
...defaultValues,
createProvider: (wsUrl, storeId, initialDoc) => {
@@ -48,7 +50,20 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
onDisconnect(data) {
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
if ((data.event as ExtendedCloseEvent).wasClean) {
void provider.connect();
if (data.event.reason === 'No cookies' && data.event.code === 4001) {
console.error(
'Disconnection due to missing cookies. Not attempting to reconnect.',
);
void provider.disconnect();
set({
isReady: true,
isConnected: false,
});
return;
}
clearTimeout(reconnectTimeout);
reconnectTimeout = setTimeout(() => void provider.connect(), 1000);
}
},
onAuthenticationFailed() {
@@ -107,6 +122,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
return provider;
},
destroyProvider: () => {
clearTimeout(reconnectTimeout);
const provider = get().provider;
if (provider) {
provider.destroy();
@@ -1,3 +1,4 @@
import { announce } from '@react-aria/live-announcer';
import { t } from 'i18next';
import { useEffect, useState } from 'react';
import { InView } from 'react-intersection-observer';
@@ -73,10 +74,12 @@ export const DocSearchContent = ({
docs = docs.filter(filterResults);
}
const elements = search || isSearchNotMandatory ? docs : [];
setDocsData({
groupName: docs.length > 0 ? groupName : '',
groupKey: 'docs',
elements: search || isSearchNotMandatory ? docs : [],
elements,
emptyString: t('No document found'),
endActions: hasNextPage
? [
@@ -90,6 +93,13 @@ export const DocSearchContent = ({
]
: [],
});
if (search) {
announce(
t('{{count}} result(s) available', { count: elements.length }),
'polite',
);
}
}, [
search,
data?.pages,
@@ -58,7 +58,13 @@ export const DocSearchFilters = ({
/>
</Box>
{hasFilters && (
<Button color="brand" variant="tertiary" size="small" onClick={onReset}>
<Button
color="brand"
variant="tertiary"
size="small"
onClick={onReset}
aria-label={t('Reset search filters')}
>
{t('Reset')}
</Button>
)}
@@ -14,7 +14,7 @@ import {
DocSearchFiltersValues,
DocSearchTarget,
} from '@/docs/doc-search';
import { useResponsiveStore } from '@/stores';
import { useFocusStore, useResponsiveStore } from '@/stores';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
@@ -36,6 +36,7 @@ const DocSearchModalGlobal = ({
}: DocSearchModalGlobalProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const restoreFocus = useFocusStore((state) => state.restoreFocus);
const router = useRouter();
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<DocSearchFiltersValues>(
@@ -51,6 +52,7 @@ const DocSearchModalGlobal = ({
const handleResetFilters = () => {
setFilters({});
restoreFocus();
};
return (
@@ -86,21 +88,26 @@ const DocSearchModalGlobal = ({
/>
</Box>
<QuickSearch
label={t('Search documents')}
placeholder={t('Type the name of a document')}
loading={loading}
onFilter={handleInputSearch}
beforeList={
showFilters ? (
<Box $padding={{ horizontal: '10px' }}>
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
</Box>
) : undefined
}
>
<Box
$padding={{ horizontal: '10px', vertical: 'base' }}
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
>
{showFilters && (
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
)}
{search.length === 0 && (
<Box
$direction="column"
@@ -108,11 +115,7 @@ const DocSearchModalGlobal = ({
$align="center"
$justify="center"
>
<Image
width={320}
src={EmptySearchIcon}
alt={t('No active search')}
/>
<Image width={320} src={EmptySearchIcon} alt="" />
</Box>
)}
{search && (
@@ -1,4 +1,5 @@
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { announce } from '@react-aria/live-announcer';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -80,7 +81,6 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState('');
const [liveAnnouncement, setLiveAnnouncement] = useState('');
const [listHeight, setListHeight] = useState<string>('400px');
const canShare = doc.abilities.accesses_manage && isRootDoc;
@@ -93,18 +93,16 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
setUserQuery('');
setInputValue('');
// Announce to screen readers
const userName = user.full_name || user.email;
setLiveAnnouncement(
announce(
t(
'{{name}} added to invite list. Add more members or press Tab to select role and invite.',
{
name: userName,
},
),
'polite',
);
// Clear announcement after it's been read
setTimeout(() => setLiveAnnouncement(''), 100);
};
const { data: membersQuery } = useDocAccesses({
@@ -132,14 +130,13 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
const newArray = [...prevState];
newArray.splice(index, 1);
// Announce to screen readers
const userName = row.full_name || row.email;
setLiveAnnouncement(
announce(
t('{{name}} removed from invite list', {
name: userName,
}),
'polite',
);
setTimeout(() => setLiveAnnouncement(''), 100);
return newArray;
});
@@ -183,7 +180,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
isOpen
closeOnClickOutside
data-testid="doc-share-modal"
aria-labelledby="doc-share-modal-title"
aria-label={t('Share the document')}
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
aria-modal="true"
onClose={onClose}
@@ -208,23 +205,12 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
hideCloseButton
>
<ShareModalStyle />
{/* Screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement}
</div>
<Box
$height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
$overflow="hidden"
className="--docs--doc-share-modal noPadding "
$justify="space-between"
role="dialog"
aria-label={t('Share modal content')}
>
<Box
$flex={1}
@@ -137,7 +137,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$gap={spacingsTokens['base']}
className="--docs--doc-visibility"
>
<Text $weight="700" $size="sm">
<Text as="h2" $weight="700" $size="sm" $margin="none">
{t('Link settings')}
</Text>
{isDesynchronized && <DocDesynchronized doc={doc} />}
@@ -51,11 +51,13 @@ export const Heading = ({
editor.setTextCursorPosition(headingId, 'end');
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
document
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
}}
$radius="var(--c--globals--spacings--st)"
$background={

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