mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-07 15:43:01 +02:00
Compare commits
19 Commits
helm-psyco
...
v4.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0867ccef1a | ||
|
|
b3ae6e1a30 | ||
|
|
1df6242927 | ||
|
|
35fba02085 | ||
|
|
0e5c9ed834 | ||
|
|
4e54a53072 | ||
|
|
4f8aea7b80 | ||
|
|
1172fbe0b5 | ||
|
|
7cf144e0de | ||
|
|
54c15c541e | ||
|
|
8472e661f5 | ||
|
|
1d819d8fa2 | ||
|
|
5020bc1c1a | ||
|
|
4cd72ffa4f | ||
|
|
c1998a9b24 | ||
|
|
0fca6db79c | ||
|
|
ad36210e45 | ||
|
|
73a7c250b5 | ||
|
|
0c17d76f60 |
@@ -34,4 +34,4 @@ db.sqlite3
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
.next
|
||||
**/.next
|
||||
|
||||
1
.github/workflows/impress.yml
vendored
1
.github/workflows/impress.yml
vendored
@@ -143,7 +143,6 @@ jobs:
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: impress
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
DB_PSYCOPG_POOL_MAX_SIZE: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -6,12 +6,42 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v4.8.2] - 2026-03-19
|
||||
|
||||
### 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
|
||||
|
||||
## [v4.8.1] - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
- 🔧(backend) add DB_PSYCOPG_POOL_ENABLED settings #2035
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬇️(backend) downgrade django-treebeard to version < 5.0.0 #2036
|
||||
|
||||
## [v4.8.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add a is_first_connection flag to the User model #1938
|
||||
- ✨(frontend) add onboarding modal with help menu button #1868
|
||||
- ✨(backend) add resource server api #1923
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -97,12 +127,16 @@ 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
|
||||
|
||||
@@ -116,6 +150,8 @@ and this project adheres to
|
||||
### Removed
|
||||
|
||||
- 🔥(project) remove all code related to template #1780
|
||||
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
|
||||
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
|
||||
|
||||
### Security
|
||||
|
||||
@@ -1105,7 +1141,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.0...main
|
||||
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.8.2...main
|
||||
[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
|
||||
[v4.6.0]: https://github.com/suitenumerique/docs/releases/v4.6.0
|
||||
|
||||
13
Makefile
13
Makefile
@@ -79,10 +79,16 @@ create-env-local-files:
|
||||
@touch env.d/development/kc_postgresql.local
|
||||
.PHONY: create-env-local-files
|
||||
|
||||
generate-secret-keys:
|
||||
generate-secret-keys: ## generate secret keys to be stored in common.local
|
||||
@bin/generate-oidc-store-refresh-token-key.sh
|
||||
.PHONY: generate-secret-keys
|
||||
|
||||
pre-bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-local-files
|
||||
create-env-local-files \
|
||||
generate-secret-keys
|
||||
.PHONY: pre-bootstrap
|
||||
|
||||
post-bootstrap: \
|
||||
@@ -156,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 \
|
||||
@@ -213,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
|
||||
|
||||
@@ -173,6 +173,11 @@ make frontend-test
|
||||
make frontend-lint
|
||||
```
|
||||
|
||||
Backend tests can be run without docker. This is useful to configure PyCharm or VSCode to do it.
|
||||
Removing docker for testing requires to overwrite some URL and port values that are different in and out of
|
||||
Docker. `env.d/development/common` contains all variables, some of them having to be overwritten by those in
|
||||
`env.d/development/common.test`.
|
||||
|
||||
### Demo content
|
||||
|
||||
Create a basic demo site:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run app-dev python -c 'from cryptography.fernet import Fernet;import sys; sys.stdout.write("\n" + Fernet.generate_key().decode() + "\n");'
|
||||
13
bin/generate-oidc-store-refresh-token-key.sh
Executable file
13
bin/generate-oidc-store-refresh-token-key.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Generate the secret OIDC_STORE_REFRESH_TOKEN_KEY and store it to common.local
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
COMMON_LOCAL="env.d/development/common.local"
|
||||
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY=$(openssl rand -base64 32)
|
||||
|
||||
echo "" >> "${COMMON_LOCAL}"
|
||||
echo "OIDC_STORE_REFRESH_TOKEN_KEY=${OIDC_STORE_REFRESH_TOKEN_KEY}" >> "${COMMON_LOCAL}"
|
||||
echo "✓ OIDC_STORE_REFRESH_TOKEN_KEY generated and stored in ${COMMON_LOCAL}"
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| DB_NAME | Name of the database | impress |
|
||||
| DB_PASSWORD | Password to authenticate with | pass |
|
||||
| DB_PORT | Port of the database | 5432 |
|
||||
| DB_PSYCOPG_POOL_ENABLED | Enable or not the psycopg pool configuration in the default database options | False |
|
||||
| DB_PSYCOPG_POOL_MIN_SIZE | The psycopg min pool size | 4 |
|
||||
| DB_PSYCOPG_POOL_MAX_SIZE | The psycopg max pool size | None |
|
||||
| DB_PSYCOPG_POOL_TIMEOUT | The default maximum time in seconds that a client can wait to receive a connection from the pool | 3 |
|
||||
@@ -107,6 +108,9 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||
| OIDC_STORE_ACCESS_TOKEN | If True stores OIDC access token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN | If True stores OIDC refresh token in session. | false |
|
||||
| OIDC_STORE_REFRESH_TOKEN_KEY | Key to encrypt refresh token stored in session, must be a valid Fernet key | |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
@@ -116,8 +120,9 @@ These are the environment variables you can set for the `impress-backend` contai
|
||||
| SEARCH_INDEXER_CLASS | Class of the backend for document indexation & search | |
|
||||
| SEARCH_INDEXER_COUNTDOWN | Minimum debounce delay of indexation jobs (in seconds) | 1 |
|
||||
| SEARCH_INDEXER_QUERY_LIMIT | Maximum number of results expected from search endpoint | 50 |
|
||||
| SEARCH_INDEXER_SECRET | Token for indexation queries | |
|
||||
| SEARCH_INDEXER_URL | Find application endpoint for indexation | |
|
||||
| SEARCH_URL | Find application endpoint for search queries | |
|
||||
| SEARCH_INDEXER_SECRET | Token required for indexation queries | |
|
||||
| INDEXING_URL | Find application endpoint for indexation | |
|
||||
| SENTRY_DSN | Sentry host | |
|
||||
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Register new user to the marketing onboarding. If True, see env LASUITE_MARKETING_* system | False |
|
||||
|
||||
106
docs/resource_server.md
Normal file
106
docs/resource_server.md
Normal 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"},
|
||||
)
|
||||
```
|
||||
@@ -1,8 +1,8 @@
|
||||
# Setup the Find search for Impress
|
||||
# Setup Find search for Docs
|
||||
|
||||
This configuration will enable the fulltext search feature for Docs :
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexer
|
||||
- The `api/v1.0/documents/search/` will work as a proxy with the Find API for fulltext search.
|
||||
This configuration will enable Find searches:
|
||||
- Each save on **core.Document** or **core.DocumentAccess** will trigger the indexing of the document into Find.
|
||||
- The `api/v1.0/documents/search/` will be used as proxy for searching documents from Find indexes.
|
||||
|
||||
## Create an index service for Docs
|
||||
|
||||
@@ -15,27 +15,38 @@ See [how-to-use-indexer.md](how-to-use-indexer.md) for details.
|
||||
|
||||
## Configure settings of Docs
|
||||
|
||||
Add those Django settings the Docs application to enable the feature.
|
||||
Find uses a service provider authentication for indexing and a OIDC authentication for searching.
|
||||
|
||||
Add those Django settings to the Docs application to enable the feature.
|
||||
|
||||
```shell
|
||||
SEARCH_INDEXER_CLASS="core.services.search_indexers.FindDocumentIndexer"
|
||||
|
||||
SEARCH_INDEXER_COUNTDOWN=10 # Debounce delay in seconds for the indexer calls.
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50 # Maximum number of results expected from the search endpoint
|
||||
|
||||
# The token from service "docs" of Find application (development).
|
||||
INDEXING_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
|
||||
# Service provider authentication
|
||||
SEARCH_INDEXER_SECRET="find-api-key-for-docs-with-exactly-50-chars-length"
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
|
||||
# Search endpoint. Uses the OIDC token for authentication
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
# Maximum number of results expected from the search endpoint
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
# OIDC authentication
|
||||
OIDC_STORE_ACCESS_TOKEN=True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN=True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY="<your-32-byte-encryption-key==>"
|
||||
```
|
||||
|
||||
We also need to enable the **OIDC Token** refresh or the authentication will fail quickly.
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY` must be a valid Fernet key (32 url-safe base64-encoded bytes).
|
||||
To create one, use the `bin/generate-oidc-store-refresh-token-key.sh` command.
|
||||
|
||||
```shell
|
||||
# Store OIDC tokens in the session
|
||||
OIDC_STORE_ACCESS_TOKEN = True # Store the access token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN = True # Store the encrypted refresh token in the session
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = "your-32-byte-encryption-key==" # Must be a valid Fernet key (32 url-safe base64-encoded bytes)
|
||||
```
|
||||
## Feature flags
|
||||
|
||||
The Find search integration is controlled by two feature flags:
|
||||
- `flag_find_hybrid_search`
|
||||
- `flag_find_full_text_search`
|
||||
|
||||
If a user has both flags activated the most advanced search is used (hybrid > full text > title).
|
||||
A user with no flag will default to the basic title search.
|
||||
|
||||
Feature flags can be activated through the admin interface.
|
||||
|
||||
@@ -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.
|
||||
@@ -87,8 +96,9 @@ DOCSPEC_API_URL=http://docspec:4000/conversion
|
||||
# Theme customization
|
||||
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||
|
||||
# Indexer (disabled)
|
||||
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
|
||||
# Indexer (disabled by default)
|
||||
# SEARCH_INDEXER_CLASS=core.services.search_indexers.FindDocumentIndexer
|
||||
SEARCH_INDEXER_SECRET=find-api-key-for-docs-with-exactly-50-chars-length # Key generated by create_demo in Find app.
|
||||
SEARCH_INDEXER_URL="http://find:8000/api/v1.0/documents/index/"
|
||||
SEARCH_INDEXER_QUERY_URL="http://find:8000/api/v1.0/documents/search/"
|
||||
INDEXING_URL=http://find:8000/api/v1.0/documents/index/
|
||||
SEARCH_URL=http://find:8000/api/v1.0/documents/search/
|
||||
SEARCH_INDEXER_QUERY_LIMIT=50
|
||||
|
||||
7
env.d/development/common.test
Normal file
7
env.d/development/common.test
Normal file
@@ -0,0 +1,7 @@
|
||||
# Test environment configuration for running tests without docker
|
||||
# Base configuration is loaded from 'common' file
|
||||
|
||||
DJANGO_SETTINGS_MODULE=impress.settings
|
||||
DJANGO_CONFIGURATION=Test
|
||||
DB_PORT=15432
|
||||
AWS_S3_ENDPOINT_URL=http://localhost:9000
|
||||
@@ -9,4 +9,3 @@ DB_NAME=impress
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
DB_PSYCOPG_POOL_MAX_SIZE=15
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
"matchPackageNames": ["langfuse"],
|
||||
"allowedVersions": "<3.12.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed django-treebeard versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["django-treebeard"],
|
||||
"allowedVersions": "<5.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -47,10 +47,13 @@ class DocumentFilter(django_filters.FilterSet):
|
||||
title = AccentInsensitiveCharFilter(
|
||||
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
|
||||
)
|
||||
q = AccentInsensitiveCharFilter(
|
||||
field_name="title", lookup_expr="unaccent__icontains", label=_("Search")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["title"]
|
||||
fields = ["title", "q"]
|
||||
|
||||
|
||||
class ListDocumentFilter(DocumentFilter):
|
||||
@@ -70,7 +73,7 @@ class ListDocumentFilter(DocumentFilter):
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = ["is_creator_me", "is_favorite", "title"]
|
||||
fields = ["is_creator_me", "is_favorite", "title", "q"]
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
|
||||
@@ -1004,8 +1004,5 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
class SearchDocumentSerializer(serializers.Serializer):
|
||||
"""Serializer for fulltext search requests through Find application"""
|
||||
|
||||
q = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
|
||||
page_size = serializers.IntegerField(
|
||||
required=False, min_value=1, max_value=50, default=20
|
||||
)
|
||||
page = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
q = serializers.CharField(required=True, allow_blank=True, trim_whitespace=True)
|
||||
path = serializers.CharField(required=False, allow_blank=False)
|
||||
|
||||
@@ -33,6 +33,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
import rest_framework as drf
|
||||
import waffle
|
||||
from botocore.exceptions import ClientError
|
||||
from csp.constants import NONE
|
||||
from csp.decorators import csp_update
|
||||
@@ -71,8 +72,13 @@ from core.utils import (
|
||||
users_sharing_documents_with,
|
||||
)
|
||||
|
||||
from ..enums import FeatureFlag, SearchType
|
||||
from . import permissions, serializers, utils
|
||||
from .filters import DocumentFilter, ListDocumentFilter, UserSearchFilter
|
||||
from .filters import (
|
||||
DocumentFilter,
|
||||
ListDocumentFilter,
|
||||
UserSearchFilter,
|
||||
)
|
||||
from .throttling import (
|
||||
DocumentThrottle,
|
||||
UserListThrottleBurst,
|
||||
@@ -451,36 +457,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.
|
||||
@@ -488,7 +503,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.
|
||||
@@ -496,7 +511,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
|
||||
@@ -604,20 +619,18 @@ class DocumentViewSet(
|
||||
It performs early filtering on model fields, annotates user roles, and removes
|
||||
descendant documents to keep only the highest ancestors readable by the current user.
|
||||
"""
|
||||
user = self.request.user
|
||||
user = request.user
|
||||
|
||||
# Not calling filter_queryset. We do our own cooking.
|
||||
queryset = self.get_queryset()
|
||||
|
||||
filterset = ListDocumentFilter(
|
||||
self.request.GET, queryset=queryset, request=self.request
|
||||
)
|
||||
filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
filter_data = filterset.form.cleaned_data
|
||||
|
||||
# Filter as early as possible on fields that are available on the model
|
||||
for field in ["is_creator_me", "title"]:
|
||||
for field in ["is_creator_me", "title", "q"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
@@ -1084,7 +1097,7 @@ class DocumentViewSet(
|
||||
filter_data = filterset.form.cleaned_data
|
||||
|
||||
# Filter as early as possible on fields that are available on the model
|
||||
for field in ["is_creator_me", "title"]:
|
||||
for field in ["is_creator_me", "title", "q"]:
|
||||
queryset = filterset.filters[field].filter(queryset, filter_data[field])
|
||||
|
||||
queryset = queryset.annotate_user_roles(user)
|
||||
@@ -1107,7 +1120,11 @@ class DocumentViewSet(
|
||||
ordering=["path"],
|
||||
)
|
||||
def descendants(self, request, *args, **kwargs):
|
||||
"""Handle listing descendants of a document"""
|
||||
"""Deprecated endpoint to list descendants of a document."""
|
||||
logger.warning(
|
||||
"The 'descendants' endpoint is deprecated and will be removed in a future release. "
|
||||
"The search endpoint should be used for all document retrieval use cases."
|
||||
)
|
||||
document = self.get_object()
|
||||
|
||||
queryset = document.get_descendants().filter(ancestors_deleted_at__isnull=True)
|
||||
@@ -1344,7 +1361,7 @@ class DocumentViewSet(
|
||||
)
|
||||
else:
|
||||
duplicated_document = document_to_duplicate.add_sibling(
|
||||
"right",
|
||||
"last-sibling",
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
@@ -1397,82 +1414,123 @@ class DocumentViewSet(
|
||||
|
||||
return duplicated_document
|
||||
|
||||
def _search_simple(self, request, text):
|
||||
"""
|
||||
Returns a queryset filtered by the content of the document title
|
||||
"""
|
||||
# As the 'list' view we get a prefiltered queryset (deleted docs are excluded)
|
||||
queryset = self.get_queryset()
|
||||
filterset = DocumentFilter({"title": text}, queryset=queryset)
|
||||
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.filter_queryset(queryset)
|
||||
|
||||
return self.get_response_for_queryset(
|
||||
queryset.order_by("-updated_at"),
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
def _search_fulltext(self, indexer, request, params):
|
||||
"""
|
||||
Returns a queryset from the results the fulltext search of Find
|
||||
"""
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
user = request.user
|
||||
text = params.validated_data["q"]
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
# Retrieve the documents ids from Find.
|
||||
results = indexer.search(
|
||||
text=text,
|
||||
token=access_token,
|
||||
visited=get_visited_document_ids_of(queryset, user),
|
||||
)
|
||||
|
||||
docs_by_uuid = {str(d.pk): d for d in queryset.filter(pk__in=results)}
|
||||
ordered_docs = [docs_by_uuid[id] for id in results]
|
||||
|
||||
page = self.paginate_queryset(ordered_docs)
|
||||
|
||||
serializer = self.get_serializer(
|
||||
page if page else ordered_docs,
|
||||
many=True,
|
||||
context={
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
|
||||
@method_decorator(refresh_oidc_access_token)
|
||||
def search(self, request, *args, **kwargs):
|
||||
"""
|
||||
Returns a DRF response containing the filtered, annotated and ordered document list.
|
||||
Returns an ordered list of documents best matching the search query parameter 'q'.
|
||||
|
||||
Applies filtering based on request parameter 'q' from `SearchDocumentSerializer`.
|
||||
Depending of the configuration it can be:
|
||||
- A fulltext search through the opensearch indexation app "find" if the backend is
|
||||
enabled (see SEARCH_INDEXER_CLASS)
|
||||
- A filtering by the model field 'title'.
|
||||
|
||||
The ordering is always by the most recent first.
|
||||
It depends on a search configurable Search Indexer. If no Search Indexer is configured
|
||||
or if it is not reachable, the function falls back to a basic title search.
|
||||
"""
|
||||
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)
|
||||
|
||||
indexer = get_document_indexer()
|
||||
if indexer is None:
|
||||
# fallback on title search if the indexer is not configured
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
if indexer:
|
||||
return self._search_fulltext(indexer, request, params=params)
|
||||
try:
|
||||
return self._search_with_indexer(
|
||||
indexer, request, params=params, search_type=search_type
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error while searching documents with indexer: %s", e)
|
||||
# fallback on title search if the indexer is not reached
|
||||
return self._title_search(request, params.validated_data, *args, **kwargs)
|
||||
|
||||
# The indexer is not configured, we fallback on a simple icontains filter by the
|
||||
# model field 'title'.
|
||||
return self._search_simple(request, text=params.validated_data["q"])
|
||||
def _get_search_type(self) -> SearchType:
|
||||
"""
|
||||
Returns the search type to use for the search endpoint based on feature flags.
|
||||
If a user has both flags activated the most advanced search is used
|
||||
(HYBRID > FULL_TEXT > TITLE).
|
||||
A user with no flag will default to the basic title search.
|
||||
"""
|
||||
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_HYBRID_SEARCH):
|
||||
return SearchType.HYBRID
|
||||
if waffle.flag_is_active(self.request, FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH):
|
||||
return SearchType.FULL_TEXT
|
||||
return SearchType.TITLE
|
||||
|
||||
@staticmethod
|
||||
def _search_with_indexer(indexer, request, params, search_type):
|
||||
"""
|
||||
Returns a list of documents matching the query (q) according to the configured indexer.
|
||||
"""
|
||||
queryset = models.Document.objects.all()
|
||||
|
||||
results = indexer.search(
|
||||
q=params.validated_data["q"],
|
||||
search_type=search_type,
|
||||
token=request.session.get("oidc_access_token"),
|
||||
path=(
|
||||
params.validated_data["path"]
|
||||
if "path" in params.validated_data
|
||||
else None
|
||||
),
|
||||
visited=get_visited_document_ids_of(queryset, request.user),
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"count": len(results),
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": results,
|
||||
}
|
||||
)
|
||||
|
||||
def _title_search(self, request, validated_data, *args, **kwargs):
|
||||
"""
|
||||
Fallback search method when no indexer is configured.
|
||||
Only searches in the title field of documents.
|
||||
"""
|
||||
if not validated_data.get("path"):
|
||||
return self.list(request, *args, **kwargs)
|
||||
|
||||
return self._list_descendants(request, validated_data)
|
||||
|
||||
def _list_descendants(self, request, validated_data):
|
||||
"""
|
||||
List all documents whose path starts with the provided path parameter.
|
||||
Includes the parent document itself.
|
||||
Used internally by the search endpoint when path filtering is requested.
|
||||
"""
|
||||
# Get parent document without access filtering
|
||||
parent_path = validated_data["path"]
|
||||
try:
|
||||
parent = models.Document.objects.annotate_user_roles(request.user).get(
|
||||
path=parent_path
|
||||
)
|
||||
except models.Document.DoesNotExist as exc:
|
||||
raise drf.exceptions.NotFound("Document not found from path.") from exc
|
||||
|
||||
abilities = parent.get_abilities(request.user)
|
||||
if not abilities.get("search"):
|
||||
raise drf.exceptions.PermissionDenied(
|
||||
"You do not have permission to search within this document."
|
||||
)
|
||||
|
||||
# Get descendants and include the parent, ordered by path
|
||||
queryset = (
|
||||
parent.get_descendants(include_self=True)
|
||||
.filter(ancestors_deleted_at__isnull=True)
|
||||
.order_by("path")
|
||||
)
|
||||
queryset = self.filter_queryset(queryset)
|
||||
|
||||
# filter by title
|
||||
filterset = DocumentFilter(request.GET, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise drf.exceptions.ValidationError(filterset.errors)
|
||||
|
||||
queryset = filterset.qs
|
||||
return self.get_response_for_queryset(queryset)
|
||||
|
||||
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
|
||||
def versions_list(self, request, *args, **kwargs):
|
||||
|
||||
@@ -3,7 +3,7 @@ Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.db import models
|
||||
@@ -46,3 +46,24 @@ class DocumentAttachmentStatus(StrEnum):
|
||||
|
||||
PROCESSING = "processing"
|
||||
READY = "ready"
|
||||
|
||||
|
||||
class SearchType(str, Enum):
|
||||
"""
|
||||
Defines the possible search types for a document search query.
|
||||
- TITLE: DRF based search in the title of the documents only.
|
||||
- HYBRID and FULL_TEXT: more advanced search based on Find indexer.
|
||||
"""
|
||||
|
||||
TITLE = "title"
|
||||
HYBRID = "hybrid"
|
||||
FULL_TEXT = "full-text"
|
||||
|
||||
|
||||
class FeatureFlag(str, Enum):
|
||||
"""
|
||||
Defines the possible feature flags for the application.
|
||||
"""
|
||||
|
||||
FLAG_FIND_HYBRID_SEARCH = "flag_find_hybrid_search"
|
||||
FLAG_FIND_FULL_TEXT_SEARCH = "flag_find_full_text_search"
|
||||
|
||||
41
src/backend/core/external_api/permissions.py
Normal file
41
src/backend/core/external_api/permissions.py
Normal file
@@ -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
src/backend/core/external_api/viewsets.py
Normal file
91
src/backend/core/external_api/viewsets.py
Normal 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")
|
||||
@@ -22,7 +22,7 @@ def set_path_on_existing_documents(apps, schema_editor):
|
||||
|
||||
# Iterate over all existing documents and make them root nodes
|
||||
documents = Document.objects.order_by("created_at").values_list("id", flat=True)
|
||||
numconv = NumConv(ALPHABET)
|
||||
numconv = NumConv(len(ALPHABET), ALPHABET)
|
||||
|
||||
updates = []
|
||||
for i, pk in enumerate(documents):
|
||||
|
||||
@@ -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,
|
||||
@@ -1330,6 +1329,7 @@ class Document(MP_Node, BaseModel):
|
||||
"versions_destroy": is_owner_or_admin,
|
||||
"versions_list": has_access_role,
|
||||
"versions_retrieve": has_access_role,
|
||||
"search": can_get,
|
||||
}
|
||||
|
||||
def send_email(self, subject, emails, context=None, language=None):
|
||||
|
||||
@@ -8,12 +8,12 @@ from functools import cache
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import Subquery
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.enums import SearchType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_batch_accesses_by_users_and_teams(paths):
|
||||
return dict(access_by_document_path)
|
||||
|
||||
|
||||
def get_visited_document_ids_of(queryset, user):
|
||||
def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]:
|
||||
"""
|
||||
Returns the ids of the documents that have a linktrace to the user and NOT owned.
|
||||
It will be use to limit the opensearch responses to the public documents already
|
||||
@@ -78,7 +78,9 @@ def get_visited_document_ids_of(queryset, user):
|
||||
if isinstance(user, AnonymousUser):
|
||||
return []
|
||||
|
||||
qs = models.LinkTrace.objects.filter(user=user)
|
||||
visited_ids = models.LinkTrace.objects.filter(user=user).values_list(
|
||||
"document_id", flat=True
|
||||
)
|
||||
|
||||
docs = (
|
||||
queryset.exclude(accesses__user=user)
|
||||
@@ -86,12 +88,12 @@ def get_visited_document_ids_of(queryset, user):
|
||||
deleted_at__isnull=True,
|
||||
ancestors_deleted_at__isnull=True,
|
||||
)
|
||||
.filter(pk__in=Subquery(qs.values("document_id")))
|
||||
.filter(pk__in=visited_ids)
|
||||
.order_by("pk")
|
||||
.distinct("pk")
|
||||
)
|
||||
|
||||
return [str(id) for id in docs.values_list("pk", flat=True)]
|
||||
return tuple(str(id) for id in docs.values_list("pk", flat=True))
|
||||
|
||||
|
||||
class BaseDocumentIndexer(ABC):
|
||||
@@ -107,15 +109,13 @@ class BaseDocumentIndexer(ABC):
|
||||
Initialize the indexer.
|
||||
"""
|
||||
self.batch_size = settings.SEARCH_INDEXER_BATCH_SIZE
|
||||
self.indexer_url = settings.SEARCH_INDEXER_URL
|
||||
self.indexer_url = settings.INDEXING_URL
|
||||
self.indexer_secret = settings.SEARCH_INDEXER_SECRET
|
||||
self.search_url = settings.SEARCH_INDEXER_QUERY_URL
|
||||
self.search_url = settings.SEARCH_URL
|
||||
self.search_limit = settings.SEARCH_INDEXER_QUERY_LIMIT
|
||||
|
||||
if not self.indexer_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_URL must be set in Django settings."
|
||||
)
|
||||
raise ImproperlyConfigured("INDEXING_URL must be set in Django settings.")
|
||||
|
||||
if not self.indexer_secret:
|
||||
raise ImproperlyConfigured(
|
||||
@@ -123,9 +123,7 @@ class BaseDocumentIndexer(ABC):
|
||||
)
|
||||
|
||||
if not self.search_url:
|
||||
raise ImproperlyConfigured(
|
||||
"SEARCH_INDEXER_QUERY_URL must be set in Django settings."
|
||||
)
|
||||
raise ImproperlyConfigured("SEARCH_URL must be set in Django settings.")
|
||||
|
||||
def index(self, queryset=None, batch_size=None):
|
||||
"""
|
||||
@@ -184,8 +182,16 @@ class BaseDocumentIndexer(ABC):
|
||||
Must be implemented by subclasses.
|
||||
"""
|
||||
|
||||
# pylint: disable-next=too-many-arguments,too-many-positional-arguments
|
||||
def search(self, text, token, visited=(), nb_results=None):
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[str, ...] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
"""
|
||||
Search for documents in Find app.
|
||||
Ensure the same default ordering as "Docs" list : -updated_at
|
||||
@@ -193,7 +199,7 @@ class BaseDocumentIndexer(ABC):
|
||||
Returns ids of the documents
|
||||
|
||||
Args:
|
||||
text (str): Text search content.
|
||||
q (str): user query.
|
||||
token (str): OIDC Authentication token.
|
||||
visited (list, optional):
|
||||
List of ids of active public documents with LinkTrace
|
||||
@@ -201,21 +207,28 @@ class BaseDocumentIndexer(ABC):
|
||||
nb_results (int, optional):
|
||||
The number of results to return.
|
||||
Defaults to 50 if not specified.
|
||||
path (str, optional):
|
||||
The parent path to search descendants of.
|
||||
search_type (SearchType, optional):
|
||||
Type of search to perform. Can be SearchType.HYBRID or SearchType.FULL_TEXT.
|
||||
If None, the backend search service will use its default search behavior.
|
||||
"""
|
||||
nb_results = nb_results or self.search_limit
|
||||
response = self.search_query(
|
||||
results = self.search_query(
|
||||
data={
|
||||
"q": text,
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
return [d["_id"] for d in response]
|
||||
return results
|
||||
|
||||
@abstractmethod
|
||||
def search_query(self, data, token) -> dict:
|
||||
@@ -226,11 +239,72 @@ class BaseDocumentIndexer(ABC):
|
||||
"""
|
||||
|
||||
|
||||
class SearchIndexer(BaseDocumentIndexer):
|
||||
class FindDocumentIndexer(BaseDocumentIndexer):
|
||||
"""
|
||||
Document indexer that pushes documents to La Suite Find app.
|
||||
Document indexer that indexes and searches documents with La Suite Find app.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def search( # noqa : PLR0913
|
||||
self,
|
||||
q: str,
|
||||
token: str,
|
||||
visited: tuple[()] = (),
|
||||
nb_results: int = None,
|
||||
path: str = None,
|
||||
search_type: SearchType = None,
|
||||
):
|
||||
"""format Find search results"""
|
||||
search_results = super().search(
|
||||
q=q,
|
||||
token=token,
|
||||
visited=visited,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
search_type=search_type,
|
||||
)
|
||||
return [
|
||||
{
|
||||
**hit["_source"],
|
||||
"id": hit["_id"],
|
||||
"title": self.get_title(hit["_source"]),
|
||||
}
|
||||
for hit in search_results
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_title(source):
|
||||
"""
|
||||
Find returns the titles with an extension depending on the language.
|
||||
This function extracts the title in a generic way.
|
||||
|
||||
Handles multiple cases:
|
||||
- Localized title fields like "title.<some_extension>"
|
||||
- Fallback to plain "title" field if localized version not found
|
||||
- Returns empty string if no title field exists
|
||||
|
||||
Args:
|
||||
source (dict): The _source dictionary from a search hit
|
||||
|
||||
Returns:
|
||||
str: The extracted title or empty string if not found
|
||||
|
||||
Example:
|
||||
>>> get_title({"title.fr": "Bonjour", "id": 1})
|
||||
"Bonjour"
|
||||
>>> get_title({"title": "Hello", "id": 1})
|
||||
"Hello"
|
||||
>>> get_title({"id": 1})
|
||||
""
|
||||
"""
|
||||
titles = utils.get_value_by_pattern(source, r"^title\.")
|
||||
for title in titles:
|
||||
if title:
|
||||
return title
|
||||
if "title" in source:
|
||||
return source["title"]
|
||||
return ""
|
||||
|
||||
def serialize_document(self, document, accesses):
|
||||
"""
|
||||
Convert a Document to the JSON format expected by La Suite Find.
|
||||
|
||||
@@ -63,7 +63,7 @@ def batch_document_indexer_task(timestamp):
|
||||
logger.info("Indexed %d documents", count)
|
||||
|
||||
|
||||
def trigger_batch_document_indexer(item):
|
||||
def trigger_batch_document_indexer(document):
|
||||
"""
|
||||
Trigger indexation task with debounce a delay set by the SEARCH_INDEXER_COUNTDOWN setting.
|
||||
|
||||
@@ -82,14 +82,14 @@ def trigger_batch_document_indexer(item):
|
||||
if batch_indexer_throttle_acquire(timeout=countdown):
|
||||
logger.info(
|
||||
"Add task for batch document indexation from updated_at=%s in %d seconds",
|
||||
item.updated_at.isoformat(),
|
||||
document.updated_at.isoformat(),
|
||||
countdown,
|
||||
)
|
||||
|
||||
batch_document_indexer_task.apply_async(
|
||||
args=[item.updated_at], countdown=countdown
|
||||
args=[document.updated_at], countdown=countdown
|
||||
)
|
||||
else:
|
||||
logger.info("Skip task for batch document %s indexation", item.pk)
|
||||
logger.info("Skip task for batch document %s indexation", document.pk)
|
||||
else:
|
||||
document_indexer_task.apply(args=[item.pk])
|
||||
document_indexer_task.apply(args=[document.pk])
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -19,7 +19,7 @@ from core.services.search_indexers import SearchIndexer
|
||||
def test_index():
|
||||
"""Test the command `index` that run the Find app indexer for all the available documents."""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory()
|
||||
@@ -36,7 +36,7 @@ def test_index():
|
||||
str(no_title_doc.path): {"users": [user.sub]},
|
||||
}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
call_command("index")
|
||||
|
||||
push_call_args = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
@@ -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
|
||||
from core.tests.utils.urls import reload_urls
|
||||
|
||||
USER = "user"
|
||||
TEAM = "team"
|
||||
@@ -39,15 +44,102 @@ def indexer_settings_fixture(settings):
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.SearchIndexer"
|
||||
settings.SEARCH_INDEXER_CLASS = "core.services.search_indexers.FindDocumentIndexer"
|
||||
settings.SEARCH_INDEXER_SECRET = "ThisIsAKeyForTest"
|
||||
settings.SEARCH_INDEXER_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://localhost:8081/api/v1.0/documents/search/"
|
||||
)
|
||||
settings.INDEXING_URL = "http://localhost:8081/api/v1.0/documents/index/"
|
||||
settings.SEARCH_URL = "http://localhost:8081/api/v1.0/documents/search/"
|
||||
settings.SEARCH_INDEXER_COUNTDOWN = 1
|
||||
|
||||
yield 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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -16,7 +16,16 @@ fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_list_filter_and_access_rights():
|
||||
@pytest.mark.parametrize(
|
||||
"title_search_field",
|
||||
# for integration with indexer search we must have
|
||||
# the same filtering behaviour with "q" and "title" parameters
|
||||
[
|
||||
("title"),
|
||||
("q"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_list_filter_and_access_rights(title_search_field):
|
||||
"""Filtering on querystring parameters should respect access rights."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
@@ -76,7 +85,7 @@ def test_api_documents_list_filter_and_access_rights():
|
||||
|
||||
filters = {
|
||||
"link_reach": random.choice([None, *models.LinkReachChoices.values]),
|
||||
"title": random.choice([None, *word_list]),
|
||||
title_search_field: random.choice([None, *word_list]),
|
||||
"favorite": random.choice([None, True, False]),
|
||||
"creator": random.choice([None, user, other_user]),
|
||||
"ordering": random.choice(
|
||||
|
||||
@@ -59,6 +59,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -136,6 +137,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -246,6 +248,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
|
||||
"partial_update": document.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": document.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -330,6 +333,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
|
||||
"partial_update": grand_parent.link_role == "editor",
|
||||
"restore": False,
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": grand_parent.link_role == "editor",
|
||||
"versions_destroy": False,
|
||||
@@ -529,6 +533,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
|
||||
"partial_update": access.role not in ["reader", "commenter"],
|
||||
"restore": access.role == "owner",
|
||||
"retrieve": True,
|
||||
"search": True,
|
||||
"tree": True,
|
||||
"update": access.role not in ["reader", "commenter"],
|
||||
"versions_destroy": access.role in ["administrator", "owner"],
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
"""
|
||||
Tests for Documents API endpoint in impress's core app: list
|
||||
Tests for Documents API endpoint in impress's core app: search
|
||||
"""
|
||||
|
||||
import random
|
||||
from json import loads as json_loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from faker import Faker
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.test import APIClient
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from core import factories, models
|
||||
from core import factories
|
||||
from core.enums import FeatureFlag, SearchType
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def build_search_url(**kwargs):
|
||||
"""Build absolute uri for search endpoint with ORDERED query arguments"""
|
||||
return (
|
||||
RequestFactory()
|
||||
.get("/api/v1.0/documents/search/", dict(sorted(kwargs.items())))
|
||||
.build_absolute_uri()
|
||||
)
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_flag_find_hybrid_search():
|
||||
"""Enable flag_find_hybrid_search for all tests in this module."""
|
||||
with override_flag(FeatureFlag.FLAG_FIND_HYBRID_SEARCH, active=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
|
||||
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
|
||||
@responses.activate
|
||||
def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
def test_api_documents_search_anonymous(search_query, indexer_settings):
|
||||
"""
|
||||
Anonymous users should not be allowed to search documents whatever the
|
||||
link reach and link role
|
||||
Anonymous users should be allowed to search documents with Find.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
|
||||
factories.DocumentFactory(link_reach=reach, link_role=role)
|
||||
|
||||
# Find response
|
||||
# mock Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
@@ -48,7 +42,23 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
q = "alpha"
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert search_query.call_count == 1
|
||||
assert search_query.call_args[1] == {
|
||||
"data": {
|
||||
"q": q,
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": None,
|
||||
"search_type": SearchType.HYBRID,
|
||||
},
|
||||
"token": None,
|
||||
}
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
@@ -59,64 +69,121 @@ def test_api_documents_search_anonymous(reach, role, indexer_settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_endpoint_is_none(indexer_settings):
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet.list")
|
||||
def test_api_documents_search_fall_back_on_search_list(mock_list, indexer_settings):
|
||||
"""
|
||||
Missing SEARCH_INDEXER_QUERY_URL, so the indexer is not properly configured.
|
||||
Should fallback on title filter
|
||||
When indexer is not configured and no path is provided,
|
||||
should fall back on list method
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(title="alpha")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked list result"}],
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
mock_list.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": q})
|
||||
|
||||
assert mock_list.call_count == 1
|
||||
assert mock_list.call_args[0][0].GET.get("q") == q
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._list_descendants")
|
||||
def test_api_documents_search_fallback_on_search_list_sub_docs(
|
||||
mock_list_descendants, indexer_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
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked _list_descendants result"}],
|
||||
}
|
||||
mock_list_descendants.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/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
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
|
||||
def test_api_documents_search_indexer_crashes(mock_title_search, indexer_settings):
|
||||
"""
|
||||
When indexer is configured but crashes -> falls back on title_search
|
||||
"""
|
||||
# indexer is properly configured
|
||||
indexer_settings.SEARCH_URL = None
|
||||
assert get_document_indexer() is None
|
||||
# but returns an error when the query is sent
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[{"error": "Some indexer error"}],
|
||||
status=404,
|
||||
)
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mocked_response = {
|
||||
"count": 0,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [{"title": "mocked title_search result"}],
|
||||
}
|
||||
mock_title_search.return_value = drf_response.Response(mocked_response)
|
||||
|
||||
parent = factories.DocumentFactory(title="parent", users=[user])
|
||||
q = "alpha"
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha", "path": parent.path}
|
||||
)
|
||||
|
||||
# the search endpoint did not crash
|
||||
assert response.status_code == 200
|
||||
# fallback on title_search
|
||||
assert mock_title_search.call_count == 1
|
||||
assert mock_title_search.call_args[0][0].GET.get("q") == q
|
||||
assert mock_title_search.call_args[0][0].GET.get("path") == parent.path
|
||||
assert response.json() == mocked_response
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_invalid_params(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -125,49 +192,28 @@ def test_api_documents_search_invalid_params(indexer_settings):
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field is required."]}
|
||||
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": " "})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"q": ["This field may not be blank."]}
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "any", "page": "NaN"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"page": ["A valid integer is required."]}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_documents_search_format(indexer_settings):
|
||||
def test_api_documents_search_success(indexer_settings):
|
||||
"""Validate the format of documents as returned by the search view."""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
user_a, user_b, user_c = factories.UserFactory.create_batch(3)
|
||||
document = factories.DocumentFactory(
|
||||
title="alpha",
|
||||
users=(user_a, user_c),
|
||||
link_traces=(user, user_b),
|
||||
)
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
document = {"id": "doc-123", "title": "alpha", "path": "path/to/alpha.pdf"}
|
||||
|
||||
# Find response
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=[
|
||||
{"_id": str(document.pk)},
|
||||
{
|
||||
"_id": str(document["id"]),
|
||||
"_source": {"title": document["title"], "path": document["path"]},
|
||||
},
|
||||
],
|
||||
status=200,
|
||||
)
|
||||
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
@@ -177,249 +223,6 @@ def test_api_documents_search_format(indexer_settings):
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results[0] == {
|
||||
"id": str(document.id),
|
||||
"abilities": document.get_abilities(user),
|
||||
"ancestors_link_reach": None,
|
||||
"ancestors_link_role": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 3,
|
||||
"numchild": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"deleted_at": None,
|
||||
"user_role": access.role,
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": None,
|
||||
"range": (0, None),
|
||||
"api_page_size": 21, # default page_size is 20
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "score" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = "http://find/api/v1.0/search"
|
||||
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
docs = factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
docs_by_uuid = {str(doc.pk): doc for doc in docs}
|
||||
api_results = [{"_id": id} for id in docs_by_uuid.keys()]
|
||||
|
||||
# reorder randomly to simulate score ordering
|
||||
random.shuffle(api_results)
|
||||
|
||||
# Find response
|
||||
# pylint: disable-next=assignment-from-none
|
||||
api_search = responses.add(
|
||||
responses.POST,
|
||||
"http://find/api/v1.0/search",
|
||||
json=api_results,
|
||||
status=200,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
start, end = expected["range"]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
# The find api results ordering by score is kept
|
||||
assert [r["id"] for r in results] == [r["_id"] for r in api_results[start:end]]
|
||||
|
||||
# Check the query parameters.
|
||||
assert api_search.call_count == 1
|
||||
assert api_search.calls[0].response.status_code == 200
|
||||
assert json_loads(api_search.calls[0].request.body) == {
|
||||
"q": "alpha",
|
||||
"visited": [],
|
||||
"services": ["docs"],
|
||||
"nb_results": 50,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@pytest.mark.parametrize(
|
||||
"pagination, status, expected",
|
||||
(
|
||||
(
|
||||
{"page": 1, "page_size": 10},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{},
|
||||
200,
|
||||
{"count": 10, "previous": None, "next": None, "range": (0, None)},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 10},
|
||||
404,
|
||||
{},
|
||||
),
|
||||
(
|
||||
{"page": 1, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": None,
|
||||
"next": {"page": 2, "page_size": 5},
|
||||
"range": (0, 5),
|
||||
},
|
||||
),
|
||||
(
|
||||
{"page": 2, "page_size": 5},
|
||||
200,
|
||||
{
|
||||
"count": 10,
|
||||
"previous": {"page_size": 5},
|
||||
"next": None,
|
||||
"range": (5, None),
|
||||
},
|
||||
),
|
||||
({"page": 3, "page_size": 5}, 404, {}),
|
||||
),
|
||||
)
|
||||
def test_api_documents_search_pagination_endpoint_is_none(
|
||||
indexer_settings, pagination, status, expected
|
||||
):
|
||||
"""Documents should be ordered by descending "-updated_at" by default"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
|
||||
assert get_document_indexer() is None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.DocumentFactory.create_batch(10, title="alpha", users=[user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/",
|
||||
data={
|
||||
"q": "alpha",
|
||||
**pagination,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status
|
||||
|
||||
if response.status_code < 300:
|
||||
previous_url = (
|
||||
build_search_url(q="alpha", **expected["previous"])
|
||||
if expected["previous"]
|
||||
else None
|
||||
)
|
||||
next_url = (
|
||||
build_search_url(q="alpha", **expected["next"])
|
||||
if expected["next"]
|
||||
else None
|
||||
)
|
||||
queryset = models.Document.objects.order_by("-updated_at")
|
||||
start, end = expected["range"]
|
||||
expected_results = [str(d.pk) for d in queryset[start:end]]
|
||||
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == expected["count"]
|
||||
assert content["previous"] == previous_url
|
||||
assert content["next"] == next_url
|
||||
|
||||
results = content.pop("results")
|
||||
|
||||
assert [r["id"] for r in results] == expected_results
|
||||
assert results == [
|
||||
{"id": document["id"], "title": document["title"], "path": document["path"]}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,956 @@
|
||||
"""
|
||||
Tests for search API endpoint in impress's core app when indexer is not
|
||||
available and a path param is given.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.filters import remove_accents
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_indexer(indexer_settings):
|
||||
"""Disable search indexer for all tests in this file."""
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = None
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_anonymous_public_standalone():
|
||||
"""Anonymous users should be allowed to retrieve the descendants of a public document."""
|
||||
document = factories.DocumentFactory(link_reach="public", title="doc parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="doc child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="doc grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_role": None,
|
||||
"ancestors_link_reach": None,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 1,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role
|
||||
if (child1.link_reach == "public" and child1.link_role == "editor")
|
||||
else document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": document.link_reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_anonymous_public_parent():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve the descendants of a document who
|
||||
has a public ancestor.
|
||||
"""
|
||||
grand_parent = factories.DocumentFactory(
|
||||
link_reach="public", title="grand parent doc"
|
||||
)
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent,
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
title="parent doc",
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=random.choice(["authenticated", "restricted"]),
|
||||
parent=parent,
|
||||
title="document",
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child doc"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child doc")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 4,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
# the search should include the parent document itself
|
||||
"abilities": document.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": document.computed_link_reach,
|
||||
"computed_link_role": document.computed_link_role,
|
||||
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(document.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": document.excerpt,
|
||||
"id": str(document.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": document.link_reach,
|
||||
"link_role": document.link_role,
|
||||
"numchild": 2,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": document.path,
|
||||
"title": document.title,
|
||||
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child1.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(AnonymousUser()),
|
||||
"ancestors_link_reach": "public",
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": "public",
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_anonymous_restricted_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Anonymous users should not be able to retrieve descendants of a document that is not public.
|
||||
"""
|
||||
document = factories.DocumentFactory(title="parent", link_reach=reach)
|
||||
child = factories.DocumentFactory(title="child", parent=document)
|
||||
_grand_child = factories.DocumentFactory(title="grand child", parent=child)
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_authenticated_unrelated_public_or_authenticated(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be able to retrieve the descendants of a public/authenticated
|
||||
document to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach=reach, title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": document.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reach", ["public", "authenticated"])
|
||||
def test_api_documents_search_descendants_list_authenticated_public_or_authenticated_parent(
|
||||
reach,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document who
|
||||
has a public or authenticated ancestor.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach=reach, title="grand parent")
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
link_reach="restricted", parent=parent, title="document"
|
||||
)
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, link_reach="restricted", title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": reach,
|
||||
"ancestors_link_role": grand_parent.link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 0,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_unrelated_restricted():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve the descendants of a document that is
|
||||
restricted and to which they are not related.
|
||||
"""
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
_grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_direct():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document
|
||||
to which they are directly related whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(title="parent")
|
||||
access = factories.UserDocumentAccessFactory(document=document, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 3,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_parent():
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document if they
|
||||
are related to one of its ancestors whatever the role.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
grand_parent = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
grand_parent_access = factories.UserDocumentAccessFactory(
|
||||
document=grand_parent, user=user
|
||||
)
|
||||
|
||||
parent = factories.DocumentFactory(
|
||||
parent=grand_parent, link_reach="restricted", title="parent"
|
||||
)
|
||||
document = factories.DocumentFactory(
|
||||
parent=parent, link_reach="restricted", title="document"
|
||||
)
|
||||
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
factories.UserDocumentAccessFactory(document=child1)
|
||||
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 1,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 5,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 2,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 4,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": grand_parent_access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_child():
|
||||
"""
|
||||
Authenticated users should not be allowed to retrieve all the descendants of a document
|
||||
as a result of being related to one of its children.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted")
|
||||
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
|
||||
_grand_child = factories.DocumentFactory(parent=child1)
|
||||
|
||||
factories.UserDocumentAccessFactory(document=child1, user=user)
|
||||
factories.UserDocumentAccessFactory(document=document)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_team_none(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should not be able to retrieve the descendants of a restricted document
|
||||
related to teams in which the user is not.
|
||||
"""
|
||||
mock_user_teams.return_value = []
|
||||
|
||||
user = factories.UserFactory(with_owned_document=True)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="document")
|
||||
factories.DocumentFactory.create_batch(2, parent=document, title="child")
|
||||
|
||||
factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "doc", "path": document.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to search within this document."
|
||||
}
|
||||
|
||||
|
||||
def test_api_documents_search_descendants_list_authenticated_related_team_members(
|
||||
mock_user_teams,
|
||||
):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve the descendants of a document to which they
|
||||
are related via a team whatever the role.
|
||||
"""
|
||||
mock_user_teams.return_value = ["myteam"]
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(link_reach="restricted", title="parent")
|
||||
child1, child2 = factories.DocumentFactory.create_batch(
|
||||
2, parent=document, title="child"
|
||||
)
|
||||
grand_child = factories.DocumentFactory(parent=child1, title="grand child")
|
||||
|
||||
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": "child", "path": document.path}
|
||||
)
|
||||
|
||||
# pylint: disable=R0801
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"count": 3,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
"results": [
|
||||
{
|
||||
"abilities": child1.get_abilities(user),
|
||||
"ancestors_link_reach": child1.ancestors_link_reach,
|
||||
"ancestors_link_role": child1.ancestors_link_role,
|
||||
"computed_link_reach": child1.computed_link_reach,
|
||||
"computed_link_role": child1.computed_link_role,
|
||||
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child1.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child1.excerpt,
|
||||
"id": str(child1.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child1.link_reach,
|
||||
"link_role": child1.link_role,
|
||||
"numchild": 1,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child1.path,
|
||||
"title": child1.title,
|
||||
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": grand_child.get_abilities(user),
|
||||
"ancestors_link_reach": grand_child.ancestors_link_reach,
|
||||
"ancestors_link_role": grand_child.ancestors_link_role,
|
||||
"computed_link_reach": grand_child.computed_link_reach,
|
||||
"computed_link_role": grand_child.computed_link_role,
|
||||
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(grand_child.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 3,
|
||||
"excerpt": grand_child.excerpt,
|
||||
"id": str(grand_child.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": grand_child.link_reach,
|
||||
"link_role": grand_child.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": grand_child.path,
|
||||
"title": grand_child.title,
|
||||
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
{
|
||||
"abilities": child2.get_abilities(user),
|
||||
"ancestors_link_reach": child2.ancestors_link_reach,
|
||||
"ancestors_link_role": child2.ancestors_link_role,
|
||||
"computed_link_reach": child2.computed_link_reach,
|
||||
"computed_link_role": child2.computed_link_role,
|
||||
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": str(child2.creator.id),
|
||||
"deleted_at": None,
|
||||
"depth": 2,
|
||||
"excerpt": child2.excerpt,
|
||||
"id": str(child2.id),
|
||||
"is_favorite": False,
|
||||
"link_reach": child2.link_reach,
|
||||
"link_role": child2.link_role,
|
||||
"numchild": 0,
|
||||
"nb_accesses_ancestors": 1,
|
||||
"nb_accesses_direct": 0,
|
||||
"path": child2.path,
|
||||
"title": child2.title,
|
||||
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"user_role": access.role,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,nb_results",
|
||||
[
|
||||
("", 7), # Empty string
|
||||
("Project Alpha", 1), # Exact match
|
||||
("project", 2), # Partial match (case-insensitive)
|
||||
("Guide", 2), # Word match within a title
|
||||
("Special", 0), # No match (nonexistent keyword)
|
||||
("2024", 2), # Match by numeric keyword
|
||||
("velo", 1), # Accent-insensitive match (velo vs vélo)
|
||||
("bêta", 1), # Accent-insensitive match (bêta vs beta)
|
||||
],
|
||||
)
|
||||
def test_api_documents_search_descendants_search_on_title(query, nb_results):
|
||||
"""Authenticated users should be able to search documents by their unaccented title."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent = factories.DocumentFactory(users=[user])
|
||||
|
||||
# Create documents with predefined titles
|
||||
titles = [
|
||||
"Project Alpha Documentation",
|
||||
"Project Beta Overview",
|
||||
"User Guide",
|
||||
"Financial Report 2024",
|
||||
"Annual Review 2024",
|
||||
"Guide du vélo urbain", # <-- Title with accent for accent-insensitive test
|
||||
]
|
||||
for title in titles:
|
||||
factories.DocumentFactory(title=title, parent=parent)
|
||||
|
||||
# Perform the search query
|
||||
response = client.get(
|
||||
"/api/v1.0/documents/search/", data={"q": query, "path": parent.path}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == nb_results
|
||||
|
||||
# Ensure all results contain the query in their title
|
||||
for result in results:
|
||||
assert (
|
||||
remove_accents(query).lower().strip()
|
||||
in remove_accents(result["title"]).lower()
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tests for Find search feature flags
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
from waffle.testutils import override_flag
|
||||
|
||||
from core.enums import FeatureFlag, SearchType
|
||||
from core.services.search_indexers import get_document_indexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
|
||||
@mock.patch("core.api.viewsets.DocumentViewSet._search_with_indexer")
|
||||
@pytest.mark.parametrize(
|
||||
"activated_flags,"
|
||||
"expected_search_type,"
|
||||
"expected_search_with_indexer_called,"
|
||||
"expected_title_search_called",
|
||||
[
|
||||
([], SearchType.TITLE, False, True),
|
||||
([FeatureFlag.FLAG_FIND_HYBRID_SEARCH], SearchType.HYBRID, True, False),
|
||||
(
|
||||
[
|
||||
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
|
||||
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
|
||||
],
|
||||
SearchType.HYBRID,
|
||||
True,
|
||||
False,
|
||||
),
|
||||
([FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH], SearchType.FULL_TEXT, True, False),
|
||||
],
|
||||
)
|
||||
# pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def test_api_documents_search_success( # noqa : PLR0913
|
||||
mock_search_with_indexer,
|
||||
mock_title_search,
|
||||
activated_flags,
|
||||
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
|
||||
with the expected search type according to the activated feature flags,
|
||||
and that the appropriate search method is called.
|
||||
"""
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
mock_search_with_indexer.return_value = HttpResponse()
|
||||
mock_title_search.return_value = HttpResponse()
|
||||
|
||||
with override_flag(
|
||||
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
|
||||
active=FeatureFlag.FLAG_FIND_HYBRID_SEARCH in activated_flags,
|
||||
):
|
||||
with override_flag(
|
||||
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
|
||||
active=FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH in activated_flags,
|
||||
):
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/documents/search/", data={"q": "alpha"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
if expected_search_with_indexer_called:
|
||||
mock_search_with_indexer.assert_called_once()
|
||||
assert (
|
||||
mock_search_with_indexer.call_args.kwargs["search_type"]
|
||||
== expected_search_type
|
||||
)
|
||||
else:
|
||||
assert not mock_search_with_indexer.called
|
||||
|
||||
if expected_title_search_called:
|
||||
assert SearchType.TITLE == expected_search_type
|
||||
mock_title_search.assert_called_once()
|
||||
else:
|
||||
assert not mock_title_search.called
|
||||
@@ -101,6 +101,7 @@ def test_api_documents_trashbin_format():
|
||||
"partial_update": False,
|
||||
"restore": True,
|
||||
"retrieve": True,
|
||||
"search": False,
|
||||
"tree": True,
|
||||
"update": False,
|
||||
"versions_destroy": False,
|
||||
|
||||
0
src/backend/core/tests/external_api/__init__.py
Normal file
0
src/backend/core/tests/external_api/__init__.py
Normal file
@@ -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"
|
||||
158
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
158
src/backend/core/tests/external_api/test_external_api_users.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ def test_models_documents_get_abilities_forbidden(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": False,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -255,6 +256,7 @@ def test_models_documents_get_abilities_reader(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -326,6 +328,7 @@ def test_models_documents_get_abilities_commenter(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -394,6 +397,7 @@ def test_models_documents_get_abilities_editor(
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": True,
|
||||
}
|
||||
nb_queries = 1 if is_authenticated else 0
|
||||
with django_assert_num_queries(nb_queries):
|
||||
@@ -451,6 +455,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -494,6 +499,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": False,
|
||||
"versions_retrieve": False,
|
||||
"search": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +547,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
|
||||
"versions_destroy": True,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -598,6 +605,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
with django_assert_num_queries(1):
|
||||
assert document.get_abilities(user) == expected_abilities
|
||||
@@ -663,6 +671,7 @@ def test_models_documents_get_abilities_reader_user(
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||
@@ -729,6 +738,7 @@ def test_models_documents_get_abilities_commenter_user(
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting):
|
||||
@@ -791,6 +801,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
|
||||
"versions_destroy": False,
|
||||
"versions_list": True,
|
||||
"versions_retrieve": True,
|
||||
"search": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Unit tests for the Document model
|
||||
Unit tests for FindDocumentIndexer
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
@@ -12,7 +12,8 @@ from django.db import transaction
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.services.search_indexers import SearchIndexer
|
||||
from core.enums import SearchType
|
||||
from core.services.search_indexers import FindDocumentIndexer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -30,7 +31,7 @@ def reset_throttle():
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer(mock_push):
|
||||
@@ -41,7 +42,7 @@ def test_models_documents_post_save_indexer(mock_push):
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
|
||||
@@ -64,14 +65,14 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
"""Test indexation task on doculment creation, no throttle"""
|
||||
indexer_settings.SEARCH_INDEXER_COUNTDOWN = 0
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
doc1, doc2, doc3 = factories.DocumentFactory.create_batch(3)
|
||||
|
||||
accesses = {}
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# 3 calls
|
||||
assert len(data) == 3
|
||||
@@ -91,7 +92,7 @@ def test_models_documents_post_save_indexer_no_batches(indexer_settings):
|
||||
assert cache.get("file-batch-indexer-throttle") is None
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_settings):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
@@ -106,13 +107,13 @@ def test_models_documents_post_save_indexer_not_configured(mock_push, indexer_se
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
"""Task should not start an indexation when disabled"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -123,7 +124,7 @@ def test_models_documents_post_save_indexer_wrongly_configured(
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
@@ -145,7 +146,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 1
|
||||
assert sorted(data[0], key=itemgetter("id")) == sorted(
|
||||
@@ -158,7 +159,7 @@ def test_models_documents_post_save_indexer_with_accesses(mock_push):
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
@@ -207,7 +208,7 @@ def test_models_documents_post_save_indexer_deleted(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
assert len(data) == 2
|
||||
|
||||
@@ -244,14 +245,14 @@ def test_models_documents_indexer_hard_deleted():
|
||||
factories.UserDocumentAccessFactory(document=doc, user=user)
|
||||
|
||||
# Call task on deleted document.
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
doc.delete()
|
||||
|
||||
# Hard delete document are not re-indexed.
|
||||
assert mock_push.assert_not_called
|
||||
|
||||
|
||||
@mock.patch.object(SearchIndexer, "push")
|
||||
@mock.patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@@ -308,7 +309,7 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
|
||||
data = [call.args[0] for call in mock_push.call_args_list]
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
# All docs are re-indexed
|
||||
assert len(data) == 2
|
||||
@@ -337,16 +338,16 @@ def test_models_documents_post_save_indexer_restored(mock_push):
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_models_documents_post_save_indexer_throttle():
|
||||
"""Test indexation task skipping on document update"""
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
user = factories.UserFactory()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
docs = factories.DocumentFactory.create_batch(5, users=(user,))
|
||||
|
||||
accesses = {str(item.path): {"users": [user.sub]} for item in docs}
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# Simulate 1 running task
|
||||
cache.set("document-batch-indexer-throttle", 1)
|
||||
|
||||
@@ -359,7 +360,7 @@ def test_models_documents_post_save_indexer_throttle():
|
||||
|
||||
assert [call.args[0] for call in mock_push.call_args_list] == []
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
# No waiting task
|
||||
cache.delete("document-batch-indexer-throttle")
|
||||
|
||||
@@ -389,7 +390,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
"""Test indexation task on DocumentAccess update"""
|
||||
users = factories.UserFactory.create_batch(3)
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push"):
|
||||
with mock.patch.object(FindDocumentIndexer, "push"):
|
||||
with transaction.atomic():
|
||||
doc = factories.DocumentFactory(users=users)
|
||||
doc_accesses = models.DocumentAccess.objects.filter(document=doc).order_by(
|
||||
@@ -398,7 +399,7 @@ def test_models_documents_access_post_save_indexer():
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -426,7 +427,7 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
|
||||
reset_batch_indexer_throttle()
|
||||
|
||||
with mock.patch.object(SearchIndexer, "push") as mock_push:
|
||||
with mock.patch.object(FindDocumentIndexer, "push") as mock_push:
|
||||
with transaction.atomic():
|
||||
for doc_access in doc_accesses:
|
||||
doc_access.save()
|
||||
@@ -439,3 +440,77 @@ def test_models_items_access_post_save_indexer_no_throttle(indexer_settings):
|
||||
assert [len(d) for d in data] == [1] * 3
|
||||
# the same document is indexed 3 times
|
||||
assert [d[0]["id"] for d in data] == [str(doc.pk)] * 3
|
||||
|
||||
|
||||
@mock.patch.object(FindDocumentIndexer, "search_query")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_find_document_indexer_search(mock_search_query):
|
||||
"""Test search function of FindDocumentIndexer returns formatted results"""
|
||||
|
||||
# Mock API response from Find
|
||||
hits = [
|
||||
{
|
||||
"_id": "doc-123",
|
||||
"_source": {
|
||||
"title": "Test Document",
|
||||
"content": "This is test content",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
"path": "/some/path/doc-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"_id": "doc-456",
|
||||
"_source": {
|
||||
"title.fr": "Document de test",
|
||||
"content": "Contenu de test",
|
||||
"updated_at": "2024-01-02T00:00:00Z",
|
||||
},
|
||||
},
|
||||
]
|
||||
mock_search_query.return_value = hits
|
||||
|
||||
q = "test"
|
||||
token = "fake-token"
|
||||
nb_results = 10
|
||||
path = "/some/path/"
|
||||
visited = ["doc-123"]
|
||||
search_type = SearchType.HYBRID
|
||||
results = FindDocumentIndexer().search(
|
||||
q=q,
|
||||
token=token,
|
||||
nb_results=nb_results,
|
||||
path=path,
|
||||
visited=visited,
|
||||
search_type=search_type,
|
||||
)
|
||||
|
||||
mock_search_query.assert_called_once()
|
||||
call_args = mock_search_query.call_args
|
||||
assert call_args[1]["data"] == {
|
||||
"q": q,
|
||||
"visited": visited,
|
||||
"services": ["docs"],
|
||||
"nb_results": nb_results,
|
||||
"order_by": "updated_at",
|
||||
"order_direction": "desc",
|
||||
"path": path,
|
||||
"search_type": search_type,
|
||||
}
|
||||
|
||||
assert len(results) == 2
|
||||
assert results == [
|
||||
{
|
||||
"id": hits[0]["_id"],
|
||||
"title": hits[0]["_source"]["title"],
|
||||
"content": hits[0]["_source"]["content"],
|
||||
"updated_at": hits[0]["_source"]["updated_at"],
|
||||
"path": hits[0]["_source"]["path"],
|
||||
},
|
||||
{
|
||||
"id": hits[1]["_id"],
|
||||
"title": hits[1]["_source"]["title.fr"],
|
||||
"title.fr": hits[1]["_source"]["title.fr"], # <- Find response artefact
|
||||
"content": hits[1]["_source"]["content"],
|
||||
"updated_at": hits[1]["_source"]["updated_at"],
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,7 @@ from requests import HTTPError
|
||||
from core import factories, models, utils
|
||||
from core.services.search_indexers import (
|
||||
BaseDocumentIndexer,
|
||||
SearchIndexer,
|
||||
FindDocumentIndexer,
|
||||
get_document_indexer,
|
||||
get_visited_document_ids_of,
|
||||
)
|
||||
@@ -78,41 +78,41 @@ def test_services_search_indexer_is_configured(indexer_settings):
|
||||
|
||||
# Valid class
|
||||
indexer_settings.SEARCH_INDEXER_CLASS = (
|
||||
"core.services.search_indexers.SearchIndexer"
|
||||
"core.services.search_indexers.FindDocumentIndexer"
|
||||
)
|
||||
|
||||
get_document_indexer.cache_clear()
|
||||
assert get_document_indexer() is not None
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
# Invalid url
|
||||
get_document_indexer.cache_clear()
|
||||
assert not get_document_indexer()
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_none(indexer_settings):
|
||||
def test_services_indexing_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is None or empty.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is None or empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = None
|
||||
indexer_settings.INDEXING_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_url_is_empty(indexer_settings):
|
||||
def test_services_indexing_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_URL is empty string.
|
||||
Indexer should raise RuntimeError if INDEXING_URL is empty string.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = ""
|
||||
indexer_settings.INDEXING_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_URL must be set in Django settings." in str(exc_info.value)
|
||||
assert "INDEXING_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
@@ -122,7 +122,7 @@ def test_services_search_indexer_secret_is_none(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
@@ -136,39 +136,35 @@ def test_services_search_indexer_secret_is_empty(indexer_settings):
|
||||
indexer_settings.SEARCH_INDEXER_SECRET = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_SECRET must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_none(indexer_settings):
|
||||
def test_services_search_url_is_none(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is None.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is None.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = None
|
||||
indexer_settings.SEARCH_URL = None
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
def test_services_search_endpoint_is_empty(indexer_settings):
|
||||
def test_services_search_url_is_empty(indexer_settings):
|
||||
"""
|
||||
Indexer should raise RuntimeError if SEARCH_INDEXER_QUERY_URL is empty.
|
||||
Indexer should raise RuntimeError if SEARCH_URL is empty.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = ""
|
||||
indexer_settings.SEARCH_URL = ""
|
||||
|
||||
with pytest.raises(ImproperlyConfigured) as exc_info:
|
||||
SearchIndexer()
|
||||
FindDocumentIndexer()
|
||||
|
||||
assert "SEARCH_INDEXER_QUERY_URL must be set in Django settings." in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "SEARCH_URL must be set in Django settings." in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -192,7 +188,7 @@ def test_services_search_indexers_serialize_document_returns_expected_json():
|
||||
}
|
||||
}
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, accesses)
|
||||
|
||||
assert set(result.pop("users")) == {str(user_a.sub), str(user_b.sub)}
|
||||
@@ -221,7 +217,7 @@ def test_services_search_indexers_serialize_document_deleted():
|
||||
parent.soft_delete()
|
||||
document.refresh_from_db()
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["is_active"] is False
|
||||
@@ -232,7 +228,7 @@ def test_services_search_indexers_serialize_document_empty():
|
||||
"""Empty documents returns empty content in the serialized json."""
|
||||
document = factories.DocumentFactory(content="", title=None)
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
result = indexer.serialize_document(document, {})
|
||||
|
||||
assert result["content"] == ""
|
||||
@@ -246,7 +242,7 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
indexer_settings.INDEXING_URL = "http://app-find/api/v1.0/documents/index/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -256,10 +252,10 @@ def test_services_search_indexers_index_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().index()
|
||||
FindDocumentIndexer().index()
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
mock_push, indexer_settings
|
||||
):
|
||||
@@ -276,7 +272,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index() == 5
|
||||
assert FindDocumentIndexer().index() == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -299,7 +295,7 @@ def test_services_search_indexers_batches_pass_only_batch_accesses(
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
"""
|
||||
@@ -314,7 +310,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
access = factories.UserDocumentAccessFactory(document=document)
|
||||
expected_user_subs[str(document.id)] = str(access.user.sub)
|
||||
|
||||
assert SearchIndexer().index(batch_size=2) == 5
|
||||
assert FindDocumentIndexer().index(batch_size=2) == 5
|
||||
|
||||
# Should be 3 batches: 2 + 2 + 1
|
||||
assert mock_push.call_count == 3
|
||||
@@ -337,7 +333,7 @@ def test_services_search_indexers_batch_size_argument(mock_push):
|
||||
assert seen_doc_ids == {str(d.id) for d in documents}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
"""
|
||||
@@ -349,7 +345,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
empty_title = factories.DocumentFactory(title="")
|
||||
empty_content = factories.DocumentFactory(content="")
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
@@ -365,7 +361,7 @@ def test_services_search_indexers_ignore_empty_documents(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings):
|
||||
"""
|
||||
Documents indexing batch can be empty if all the docs are empty.
|
||||
@@ -377,14 +373,14 @@ def test_services_search_indexers_skip_empty_batches(mock_push, indexer_settings
|
||||
# Only empty docs
|
||||
factories.DocumentFactory.create_batch(5, content="", title="")
|
||||
|
||||
assert SearchIndexer().index() == 1
|
||||
assert FindDocumentIndexer().index() == 1
|
||||
assert mock_push.call_count == 1
|
||||
|
||||
results = [doc["id"] for doc in mock_push.call_args[0][0]]
|
||||
assert results == [str(document.id)]
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
"""Document accesses and reach should take into account ancestors link reaches."""
|
||||
@@ -395,7 +391,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, link_reach="public")
|
||||
document = factories.DocumentFactory(parent=parent, link_reach="restricted")
|
||||
|
||||
assert SearchIndexer().index() == 4
|
||||
assert FindDocumentIndexer().index() == 4
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 4
|
||||
@@ -405,7 +401,7 @@ def test_services_search_indexers_ancestors_link_reach(mock_push):
|
||||
assert results[str(document.id)]["reach"] == "public"
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_users(mock_push):
|
||||
"""Document accesses and reach should include users from ancestors."""
|
||||
@@ -415,7 +411,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, users=[user_p])
|
||||
document = factories.DocumentFactory(parent=parent, users=[user_d])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -428,7 +424,7 @@ def test_services_search_indexers_ancestors_users(mock_push):
|
||||
}
|
||||
|
||||
|
||||
@patch.object(SearchIndexer, "push")
|
||||
@patch.object(FindDocumentIndexer, "push")
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
"""Document accesses and reach should include teams from ancestors."""
|
||||
@@ -436,7 +432,7 @@ def test_services_search_indexers_ancestors_teams(mock_push):
|
||||
parent = factories.DocumentFactory(parent=grand_parent, teams=["team_p"])
|
||||
document = factories.DocumentFactory(parent=parent, teams=["team_d"])
|
||||
|
||||
assert SearchIndexer().index() == 3
|
||||
assert FindDocumentIndexer().index() == 3
|
||||
|
||||
results = {doc["id"]: doc for doc in mock_push.call_args[0][0]}
|
||||
assert len(results) == 3
|
||||
@@ -451,9 +447,9 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
push() should call requests.post with the correct URL from settings
|
||||
the timeout set to 10 seconds and the data as JSON.
|
||||
"""
|
||||
indexer_settings.SEARCH_INDEXER_URL = "http://example.com/index"
|
||||
indexer_settings.INDEXING_URL = "http://example.com/index"
|
||||
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
sample_data = [{"id": "123", "title": "Test"}]
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
@@ -464,7 +460,7 @@ def test_push_uses_correct_url_and_data(mock_post, indexer_settings):
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_URL
|
||||
assert args[0] == indexer_settings.INDEXING_URL
|
||||
assert kwargs.get("json") == sample_data
|
||||
assert kwargs.get("timeout") == 10
|
||||
|
||||
@@ -498,7 +494,7 @@ def test_get_visited_document_ids_of():
|
||||
factories.UserDocumentAccessFactory(user=user, document=doc2)
|
||||
|
||||
# The second document have an access for the user
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc1.pk)]
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc1.pk),)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("indexer_settings")
|
||||
@@ -532,7 +528,7 @@ def test_get_visited_document_ids_of_deleted():
|
||||
doc_deleted.soft_delete()
|
||||
|
||||
# Only the first document is not deleted
|
||||
assert get_visited_document_ids_of(queryset, user) == [str(doc.pk)]
|
||||
assert get_visited_document_ids_of(queryset, user) == (str(doc.pk),)
|
||||
|
||||
|
||||
@responses.activate
|
||||
@@ -542,9 +538,7 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
"""
|
||||
factories.DocumentFactory()
|
||||
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_URL = (
|
||||
"http://app-find/api/v1.0/documents/search/"
|
||||
)
|
||||
indexer_settings.SEARCH_URL = "http://app-find/api/v1.0/documents/search/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
@@ -554,17 +548,17 @@ def test_services_search_indexers_search_errors(indexer_settings):
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPError):
|
||||
SearchIndexer().search("alpha", token="mytoken")
|
||||
FindDocumentIndexer().search(q="alpha", token="mytoken")
|
||||
|
||||
|
||||
@patch("requests.post")
|
||||
def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
"""
|
||||
search() should call requests.post to SEARCH_INDEXER_QUERY_URL with the
|
||||
search() should call requests.post to SEARCH_URL with the
|
||||
document ids from linktraces.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -578,11 +572,11 @@ def test_services_search_indexers_search(mock_post, indexer_settings):
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
|
||||
query_data = kwargs.get("json")
|
||||
assert query_data["q"] == "alpha"
|
||||
@@ -605,7 +599,7 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
indexer_settings.SEARCH_INDEXER_QUERY_LIMIT = 25
|
||||
|
||||
user = factories.UserFactory()
|
||||
indexer = SearchIndexer()
|
||||
indexer = FindDocumentIndexer()
|
||||
|
||||
mock_response = mock_post.return_value
|
||||
mock_response.raise_for_status.return_value = None # No error
|
||||
@@ -619,17 +613,65 @@ def test_services_search_indexers_search_nb_results(mock_post, indexer_settings)
|
||||
|
||||
visited = get_visited_document_ids_of(models.Document.objects.all(), user)
|
||||
|
||||
indexer.search("alpha", visited=visited, token="mytoken")
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken")
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 25
|
||||
|
||||
# The argument overrides the setting value
|
||||
indexer.search("alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
indexer.search(q="alpha", visited=visited, token="mytoken", nb_results=109)
|
||||
|
||||
args, kwargs = mock_post.call_args
|
||||
|
||||
assert args[0] == indexer_settings.SEARCH_INDEXER_QUERY_URL
|
||||
assert args[0] == indexer_settings.SEARCH_URL
|
||||
assert kwargs.get("json")["nb_results"] == 109
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_localized_field():
|
||||
"""Test extracting title from localized title field."""
|
||||
source = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_localized_fields():
|
||||
"""Test that first matching localized title is returned."""
|
||||
source = {"title.extension": "Bonjour", "title.en": "Hello", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result in ["Bonjour", "Hello"]
|
||||
|
||||
|
||||
def test_search_indexer_get_title_fallback_to_plain_title():
|
||||
"""Test fallback to plain 'title' field when no localized field exists."""
|
||||
source = {"title": "Hello World", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Hello World"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_no_title_field():
|
||||
"""Test that empty string is returned when no title field exists."""
|
||||
source = {"id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_empty_localized_title():
|
||||
"""Test that fallback works when localized title is empty."""
|
||||
source = {"title.extension": "", "title": "Fallback Title", "id": 1}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Fallback Title"
|
||||
|
||||
|
||||
def test_search_indexer_get_title_with_multiple_extension():
|
||||
"""Test extracting title from title field with multiple extensions."""
|
||||
source = {"title.extension_1.extension_2": "Bonjour", "id": 1, "content": "test"}
|
||||
result = FindDocumentIndexer.get_title(source)
|
||||
|
||||
assert result == "Bonjour"
|
||||
|
||||
@@ -28,3 +28,39 @@ def test_invalid_settings_oidc_email_configuration():
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
|
||||
def test_settings_psycopg_pool_not_enabled():
|
||||
"""
|
||||
Test that not changing DB_PSYCOPG_POOL_ENABLED should not configure psycopg in the DATABASES
|
||||
settings.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings without enabling psycopg"""
|
||||
|
||||
TestSettings.post_setup()
|
||||
|
||||
assert TestSettings.DATABASES["default"].get("OPTIONS") == {}
|
||||
|
||||
|
||||
def test_settings_psycopg_pool_enabled(monkeypatch):
|
||||
"""
|
||||
Test when DB_PSYCOPG_POOL_ENABLED is set to True, the psycopg pool options should be present
|
||||
in the DATABASES OPTIONS.
|
||||
"""
|
||||
|
||||
monkeypatch.setenv("DB_PSYCOPG_POOL_ENABLED", "True")
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings without enabling psycopg"""
|
||||
|
||||
TestSettings.post_setup()
|
||||
|
||||
assert TestSettings.DATABASES["default"].get("OPTIONS") == {
|
||||
"pool": {
|
||||
"min_size": 4,
|
||||
"max_size": None,
|
||||
"timeout": 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,3 +205,38 @@ def test_utils_users_sharing_documents_with_empty_result():
|
||||
|
||||
cached_data = cache.get(cache_key)
|
||||
assert cached_data == {}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_matching_key():
|
||||
"""Test extracting value from a dictionary with a matching key pattern."""
|
||||
data = {"title.extension": "Bonjour", "id": 1, "content": "test"}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_matches():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1": "Bonjour", "title.extension_2": "Hello", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {
|
||||
"Bonjour",
|
||||
"Hello",
|
||||
}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_multiple_extensions():
|
||||
"""Test that all matching keys are returned."""
|
||||
data = {"title.extension_1.extension_2": "Bonjour", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert set(result) == {"Bonjour"}
|
||||
|
||||
|
||||
def test_utils_get_value_by_pattern_no_match():
|
||||
"""Test that empty list is returned when no key matches the pattern."""
|
||||
data = {"name": "Test", "id": 1}
|
||||
result = utils.get_value_by_pattern(data, r"^title\.")
|
||||
|
||||
assert result == []
|
||||
|
||||
20
src/backend/core/tests/utils/urls.py
Normal file
20
src/backend/core/tests/utils/urls.py
Normal 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()
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,6 +18,27 @@ from core import enums, models
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_value_by_pattern(data, pattern):
|
||||
"""
|
||||
Get all values from keys matching a regex pattern in a dictionary.
|
||||
|
||||
Args:
|
||||
data (dict): Source dictionary to search
|
||||
pattern (str): Regex pattern to match against keys
|
||||
|
||||
Returns:
|
||||
list: List of values for all matching keys, empty list if no matches
|
||||
|
||||
Example:
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "id": 1}, r"^title\\.")
|
||||
["Bonjour"]
|
||||
>>> get_value_by_pattern({"title.fr": "Bonjour", "title.en": "Hello"}, r"^title\\.")
|
||||
["Bonjour", "Hello"]
|
||||
"""
|
||||
regex = re.compile(pattern)
|
||||
return [value for key, value in data.items() if regex.match(key)]
|
||||
|
||||
|
||||
def get_ancestor_to_descendants_map(paths, steplen):
|
||||
"""
|
||||
Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths.
|
||||
|
||||
@@ -99,24 +99,7 @@ class Base(Configuration):
|
||||
"localhost", environ_name="DB_HOST", environ_prefix=None
|
||||
),
|
||||
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
||||
"OPTIONS": {
|
||||
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
|
||||
"pool": {
|
||||
"min_size": values.IntegerValue(
|
||||
4, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
|
||||
),
|
||||
"max_size": values.IntegerValue(
|
||||
None,
|
||||
environ_name="DB_PSYCOPG_POOL_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"timeout": values.IntegerValue(
|
||||
3,
|
||||
environ_name="DB_PSYCOPG_POOL_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
},
|
||||
# Psycopg pool can be configured in the post_setup method
|
||||
}
|
||||
}
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
@@ -130,8 +113,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
||||
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_URL", environ_prefix=None
|
||||
INDEXING_URL = values.Value(
|
||||
default=None, environ_name="INDEXING_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
||||
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
||||
@@ -139,8 +122,8 @@ class Base(Configuration):
|
||||
SEARCH_INDEXER_SECRET = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_INDEXER_QUERY_URL", environ_prefix=None
|
||||
SEARCH_URL = values.Value(
|
||||
default=None, environ_name="SEARCH_URL", environ_prefix=None
|
||||
)
|
||||
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
||||
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
||||
@@ -348,6 +331,7 @@ class Base(Configuration):
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"csp.middleware.CSPMiddleware",
|
||||
"waffle.middleware.WaffleMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
@@ -369,6 +353,7 @@ class Base(Configuration):
|
||||
"parler",
|
||||
"treebeard",
|
||||
"easy_thumbnails",
|
||||
"waffle",
|
||||
# Django
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@@ -702,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
|
||||
)
|
||||
@@ -1017,6 +1105,36 @@ class Base(Configuration):
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
psycopg_pool_enabled = values.BooleanValue(
|
||||
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=""
|
||||
)
|
||||
|
||||
if psycopg_pool_enabled:
|
||||
cls.DATABASES["default"].update(
|
||||
{
|
||||
"OPTIONS": {
|
||||
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
|
||||
"pool": {
|
||||
"min_size": values.IntegerValue(
|
||||
4,
|
||||
environ_name="DB_PSYCOPG_POOL_MIN_SIZE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"max_size": values.IntegerValue(
|
||||
None,
|
||||
environ_name="DB_PSYCOPG_POOL_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"timeout": values.IntegerValue(
|
||||
3,
|
||||
environ_name="DB_PSYCOPG_POOL_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "4.8.0"
|
||||
version = "4.8.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -40,8 +40,9 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django<6.0.0",
|
||||
"django-treebeard==5.0.5",
|
||||
"django-treebeard<5.0.0",
|
||||
"djangorestframework==3.16.1",
|
||||
"django-waffle==5.0.0",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.1.26",
|
||||
"easy_thumbnails==2.10.1",
|
||||
|
||||
@@ -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';
|
||||
@@ -150,7 +151,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 +176,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();
|
||||
@@ -205,7 +206,7 @@ test.describe('Doc Comments', () => {
|
||||
|
||||
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')).toHaveCSS(
|
||||
'background-color',
|
||||
|
||||
@@ -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,7 +473,7 @@ 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 getMenuItem(page, 'Copy as Markdown').click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -546,7 +535,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 +556,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 +578,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 +593,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 +626,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 +665,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 +689,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 }) => {
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 ({
|
||||
@@ -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,10 +21,10 @@ 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 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,9 +152,9 @@ 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 modal = page.getByRole('dialog', { name: 'Version history' });
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
TestLanguage,
|
||||
getMenuItem,
|
||||
overrideConfig,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
@@ -44,7 +45,7 @@ test.describe('Help feature', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Onboarding' }).click();
|
||||
await getMenuItem(page, 'Onboarding').click();
|
||||
|
||||
const modal = page.getByTestId('onboarding-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
@@ -87,7 +88,7 @@ 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 getMenuItem(page, 'Onboarding').click();
|
||||
|
||||
const modal = page.getByTestId('onboarding-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
@@ -110,7 +111,7 @@ test.describe('Help feature', () => {
|
||||
.getByRole('button', { name: "Ouvrir le menu d'embarquement" })
|
||||
.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();
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.2",
|
||||
"repository": "https://github.com/suitenumerique/docs",
|
||||
"author": "DINUM",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "4.8.0",
|
||||
"version": "4.8.2",
|
||||
"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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
@@ -87,7 +89,7 @@ export const SearchPage = ({
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
* the inline content, so we need to set the focus on the input
|
||||
@@ -226,9 +228,11 @@ export const SearchPage = ({
|
||||
`}
|
||||
$margin={{ top: '0.5rem' }}
|
||||
>
|
||||
<DocSearchSubPageContent
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
target={DocSearchTarget.CURRENT}
|
||||
parentPath={treeContext?.root?.path}
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
@@ -256,7 +260,7 @@ export const SearchPage = ({
|
||||
|
||||
editor.focus();
|
||||
}}
|
||||
renderElement={(doc) => {
|
||||
renderSearchElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
@@ -196,6 +196,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
closeOnClickOutside
|
||||
onClose={() => onClose()}
|
||||
hideCloseButton
|
||||
aria-label={t('Export')}
|
||||
aria-describedby="modal-export-title"
|
||||
rightActions={
|
||||
<>
|
||||
@@ -247,7 +248,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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,5 +9,5 @@ export * from './useDocsFavorite';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useMoveDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useSearchDocs';
|
||||
|
||||
@@ -15,7 +15,6 @@ export type DocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,9 +30,6 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
if (params.is_creator_me !== undefined) {
|
||||
searchParams.set('is_creator_me', params.is_creator_me.toString());
|
||||
}
|
||||
if (params.title && params.title.length > 0) {
|
||||
searchParams.set('title', params.title);
|
||||
}
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
APIList,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
export type SearchDocsParams = {
|
||||
page: number;
|
||||
q: string;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const constructParams = ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): URLSearchParams => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('q', q);
|
||||
|
||||
if (target === DocSearchTarget.CURRENT && parentPath) {
|
||||
searchParams.set('path', parentPath);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
const searchDocs = async ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): Promise<APIList<Doc>> => {
|
||||
const searchParams = constructParams({ q, page, target, parentPath });
|
||||
const response = await fetchAPI(
|
||||
`documents/search/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<APIList<Doc>>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SEARCH_DOC = 'search-docs';
|
||||
|
||||
export const useSearchDocs = (
|
||||
{ q, page, target, parentPath }: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useQuery<APIList<Doc>, APIError, APIList<Doc>>({
|
||||
queryKey: [KEY_LIST_SEARCH_DOC, 'search', { q, page, target, parentPath }],
|
||||
queryFn: () => searchDocs({ q, page, target, parentPath }),
|
||||
...queryConfig,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInfiniteSearchDocs = (
|
||||
params: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useAPIInfiniteQuery(
|
||||
KEY_LIST_SEARCH_DOC,
|
||||
searchDocs,
|
||||
params,
|
||||
queryConfig,
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocsOrdering } from '../types';
|
||||
|
||||
import { DocsResponse, constructParams } from './useDocs';
|
||||
|
||||
export type SubDocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
export const getSubDocs = async (
|
||||
params: SubDocsParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
searchParams.set('parent_id', params.parent_id);
|
||||
|
||||
const response: Response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the sub docs',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SUB_DOC = 'sub-docs';
|
||||
|
||||
export function useSubDocs(
|
||||
params: SubDocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_SUB_DOC, params],
|
||||
queryFn: () => getSubDocs(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteSubDocs = (
|
||||
params: SubDocsParams,
|
||||
queryConfig?: InfiniteQueryConfig<DocsResponse>,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,10 @@ import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { Box } from '@/components/';
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
|
||||
import { useInfiniteSearchDocs } from '@/docs/doc-management/api/useSearchDocs';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
@@ -15,6 +18,8 @@ type DocSearchContentProps = {
|
||||
isSearchNotMandatory?: boolean;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
renderSearchElement?: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -25,6 +30,8 @@ export const DocSearchContent = ({
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderSearchElement,
|
||||
target,
|
||||
parentPath,
|
||||
isSearchNotMandatory,
|
||||
}: DocSearchContentProps) => {
|
||||
const {
|
||||
@@ -34,10 +41,17 @@ export const DocSearchContent = ({
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
...(search ? { title: search } : {}),
|
||||
});
|
||||
} = useInfiniteSearchDocs(
|
||||
{
|
||||
q: search,
|
||||
page: 1,
|
||||
target,
|
||||
parentPath,
|
||||
},
|
||||
{
|
||||
enabled: target !== DocSearchTarget.CURRENT || !!parentPath,
|
||||
},
|
||||
);
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
|
||||
@@ -79,12 +93,12 @@ export const DocSearchContent = ({
|
||||
}, [
|
||||
search,
|
||||
data?.pages,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
filterResults,
|
||||
groupName,
|
||||
isSearchNotMandatory,
|
||||
loading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { TreeContextType, useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -8,45 +9,39 @@ import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Box, ButtonCloseModal, Text } from '@/components';
|
||||
import { QuickSearch } from '@/components/quick-search';
|
||||
import { Doc, useDocUtils } from '@/docs/doc-management';
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchContent } from './DocSearchContent';
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
import { DocSearchSubPageContent } from './DocSearchSubPageContent';
|
||||
|
||||
type DocSearchModalGlobalProps = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
treeContext?: TreeContextType<Doc> | null;
|
||||
};
|
||||
|
||||
const DocSearchModalGlobal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
treeContext,
|
||||
...modalProps
|
||||
}: DocSearchModalGlobalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const isDocPage = router.pathname === '/docs/[id]';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
|
||||
const target = filters.target ?? DocSearchTarget.ALL;
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
@@ -67,7 +62,6 @@ const DocSearchModalGlobal = ({
|
||||
aria-describedby="doc-search-modal-title"
|
||||
>
|
||||
<Box
|
||||
aria-label={t('Search modal')}
|
||||
$direction="column"
|
||||
$justify="space-between"
|
||||
className="--docs--doc-search-modal"
|
||||
@@ -121,25 +115,22 @@ const DocSearchModalGlobal = ({
|
||||
</Box>
|
||||
)}
|
||||
{search && (
|
||||
<>
|
||||
{target === DocSearchTarget.ALL && (
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
/>
|
||||
)}
|
||||
{isDocPage && target === DocSearchTarget.CURRENT && (
|
||||
<DocSearchSubPageContent
|
||||
search={search}
|
||||
filters={filters}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
target={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? DocSearchTarget.CURRENT
|
||||
: DocSearchTarget.ALL
|
||||
}
|
||||
parentPath={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? treeContext?.root?.path
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</QuickSearch>
|
||||
@@ -158,6 +149,7 @@ const DocSearchModalDetail = ({
|
||||
}: DocSearchModalDetailProps) => {
|
||||
const { hasChildren, isChild } = useDocUtils(doc);
|
||||
const isWithChildren = isChild || hasChildren;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
let defaultFilters = DocSearchTarget.ALL;
|
||||
let showFilters = false;
|
||||
@@ -171,6 +163,7 @@ const DocSearchModalDetail = ({
|
||||
{...modalProps}
|
||||
showFilters={showFilters}
|
||||
defaultFilters={{ target: defaultFilters }}
|
||||
treeContext={treeContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { t } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Doc, useInfiniteSubDocs } from '@/docs/doc-management';
|
||||
|
||||
import { DocSearchFiltersValues } from './DocSearchFilters';
|
||||
|
||||
type DocSearchSubPageContentProps = {
|
||||
search: string;
|
||||
filters: DocSearchFiltersValues;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
renderElement: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocSearchSubPageContent = ({
|
||||
search,
|
||||
filters,
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderElement,
|
||||
}: DocSearchSubPageContentProps) => {
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const {
|
||||
data: subDocsData,
|
||||
isFetching,
|
||||
isRefetching,
|
||||
isLoading,
|
||||
fetchNextPage: subDocsFetchNextPage,
|
||||
hasNextPage: subDocsHasNextPage,
|
||||
} = useInfiniteSubDocs(
|
||||
{
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: treeContext?.root?.id ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!treeContext?.root?.id,
|
||||
},
|
||||
);
|
||||
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
|
||||
groupName: '',
|
||||
elements: [],
|
||||
emptyString: '',
|
||||
});
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subDocs = subDocsData?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
if (treeContext?.root) {
|
||||
const isRootTitleIncludeSearch = treeContext.root?.title
|
||||
?.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
|
||||
if (isRootTitleIncludeSearch) {
|
||||
subDocs.unshift(treeContext.root);
|
||||
}
|
||||
}
|
||||
|
||||
setDocsData({
|
||||
groupName: subDocs.length > 0 ? t('Select a doc') : '',
|
||||
elements: search ? subDocs : [],
|
||||
emptyString: search ? t('No document found') : t('Search by title'),
|
||||
endActions: subDocsHasNextPage
|
||||
? [
|
||||
{
|
||||
content: <InView onChange={() => void subDocsFetchNextPage()} />,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}, [
|
||||
loading,
|
||||
search,
|
||||
subDocsData?.pages,
|
||||
subDocsFetchNextPage,
|
||||
subDocsHasNextPage,
|
||||
treeContext?.root,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
onSelect={onSelect}
|
||||
group={docsData}
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './DocSearchContent';
|
||||
export * from './DocSearchModal';
|
||||
export * from './DocSearchFilters';
|
||||
export * from './DocSearchSubPageContent';
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user