From 6eef5aa79dac8673fb3ddce6b4d976b1badc6d65 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Fri, 16 Oct 2026 21:39:13 +0900 Subject: [PATCH 1/3] feat: add the general, plugin and storage commands Adds three admin API wrappers, available on 14.19.2, 15.6.1, 15.7.0, 15.8.0 and 15.9.0 (storage upload is not possible on 15.6 to 15.8, see below). - general get / update: update reads the current settings, sends the four required fields plus only the keys given with --set KEY=VALUE, and relies on the server merging them into the stored settings, so other settings keep their values. Unknown keys are rejected locally because the server ignores them silently. - plugin installed / available / install / uninstall: the uninstall request carries a JSON body on DELETE, so send_request now sends json_data on DELETE through httpx.request. Install and uninstall are carried out by Fess in a background thread; the command says the request was accepted. - storage list / download / upload / delete: objects are addressed by the URL-safe Base64 of their path, computed by the client. Download writes to --file (refusing to overwrite without --force) or to stdout, and treats the 200 with Content-Length 0 that Fess sends for an unreadable object as an error. Upload is a multipart POST (14.x, 15.9+); Fess 15.6 to 15.8 only declare PUT and do not parse it, so the command reports that instead. All commands exit non-zero on an API failure in every output format. Includes unit tests, integration tests and skills reference pages. --- skills/fessctl/SKILL.md | 5 +- skills/fessctl/references/features/general.md | 53 ++++ skills/fessctl/references/features/plugin.md | 45 +++ skills/fessctl/references/features/storage.md | 52 ++++ skills/fessctl/references/workflows.md | 2 +- src/fessctl/api/client.py | 125 ++++++++- src/fessctl/cli.py | 12 +- src/fessctl/commands/general.py | 129 +++++++++ src/fessctl/commands/plugin.py | 131 +++++++++ src/fessctl/commands/storage.py | 172 ++++++++++++ tests/commands/test_general.py | 73 +++++ tests/commands/test_plugin.py | 81 ++++++ tests/commands/test_storage.py | 72 +++++ tests/unit/test_client.py | 192 +++++++++++++ tests/unit/test_general.py | 181 ++++++++++++ tests/unit/test_plugin.py | 133 +++++++++ tests/unit/test_storage.py | 264 ++++++++++++++++++ 17 files changed, 1711 insertions(+), 11 deletions(-) create mode 100644 skills/fessctl/references/features/general.md create mode 100644 skills/fessctl/references/features/plugin.md create mode 100644 skills/fessctl/references/features/storage.md create mode 100644 src/fessctl/commands/general.py create mode 100644 src/fessctl/commands/plugin.py create mode 100644 src/fessctl/commands/storage.py create mode 100644 tests/commands/test_general.py create mode 100644 tests/commands/test_plugin.py create mode 100644 tests/commands/test_storage.py create mode 100644 tests/unit/test_general.py create mode 100644 tests/unit/test_plugin.py create mode 100644 tests/unit/test_storage.py diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index a24fc0b..b0be6b6 100644 --- a/skills/fessctl/SKILL.md +++ b/skills/fessctl/SKILL.md @@ -1,6 +1,6 @@ --- name: fessctl -description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 25 resource types and cross-feature workflows like initial crawl setup. +description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 28 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -61,6 +61,9 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Label types | references/features/labeltype.md | | User tags | references/features/tagtype.md | | Themes | references/features/theme.md | +| General settings | references/features/general.md | +| Plugins | references/features/plugin.md | +| Object storage | references/features/storage.md | | Key match | references/features/keymatch.md | | Boost document | references/features/boostdoc.md | | Elevate word | references/features/elevateword.md | diff --git a/skills/fessctl/references/features/general.md b/skills/fessctl/references/features/general.md new file mode 100644 index 0000000..8133c3b --- /dev/null +++ b/skills/fessctl/references/features/general.md @@ -0,0 +1,53 @@ +# General settings (fessctl `general`) + +## What it is +The General page of the Admin UI (**System → General**): crawl and purge defaults, search-log and user-info switches, LDAP, notification text, log levels, object-storage connection, RAG chat and the SSO provider settings (OpenID Connect, SAML, SPNEGO, Entra ID). The admin API exposes the whole page as one flat object of snake_case keys. + +Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). The set of keys grows with the version (`get` returned 37 keys on 14.19.2, 100 on 15.8.0 and 102 on 15.9.0; six LDAP keys are left out while unset); `get` shows exactly what the server under test has. Update is `POST` on 14.x and `PUT` on 15.x; fessctl picks the method. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `get` | Show every setting. | none | +| `update` | Change settings; every setting not given keeps its value. | one or more `--set KEY=VALUE` | + +Always reconfirm with `fessctl general --help`. + +## How update works +`update` first reads the current settings, then sends the four fields the server requires on every request (`day_for_cleanup`, `crawling_thread_count`, `failure_count_threshold`, `csv_file_encoding`, with their current values) plus only the keys given with `--set`. The server merges the request into the stored settings (a key that is absent or null keeps its value), so a one-key change leaves all other settings as they were; read back with `get` they are identical. + +- `KEY` must be a name `get` shows (the LDAP keys `ldap_provider_url`, `ldap_security_principal`, `ldap_admin_security_principal`, `ldap_admin_security_credentials`, `ldap_base_dn`, `ldap_account_filter` are left out of `get` while unset but can be set). An unknown key is rejected locally, because the server would accept it and ignore it silently. +- A setting that is an integer in `get` takes an integer; checkbox-like settings are the strings `true` / `false`. +- The value after the first `=` is taken literally, so it may contain `=`. + +## Resource JSON shape +```json +{ + "response": { + "setting": { + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "login_required": "false", + "purge_job_log_day": -1, + "storage_secret_key": "**********" + }, + "version": "15.9", + "status": 0 + } +} +``` + +## Gotchas +- Secrets (`storage_access_key`, `storage_secret_key`, `oic_client_id`, `oic_client_secret`, `entraid_client_id`, `entraid_client_secret`, `spnego_preauth_password`) are masked as `**********` by `get` (`ldap_admin_security_credentials` is never returned at all) and are never sent back by `update`. A new value replaces the secret; a value made only of `*` is ignored by the server. A secret cannot be cleared through the API (an empty value is ignored); the exception is `spnego_preauth_password`, where an empty value removes it. +- There is no way to remove a key: an empty value (`--set key=`) stores an empty string. For the unset LDAP keys that turns "absent" into "empty". This differs from the Admin UI, where a blank field removes the system property. +- Bad values are rejected by the server with a message and a non-zero exit (for example `day_for_cleanup` above 1000, or from 15.8 on `spnego_prompt_ntlm=true` together with `spnego_allow_basic=false` when `sso_type=spnego`); nothing is changed in that case. + +## Examples +```bash +fessctl general get +fessctl general get --output json | jq -r '.response.setting.crawling_user_agent' +fessctl general update --set purge_job_log_day=30 --set notification_login="Maintenance tonight" +fessctl general update --set storage_endpoint=http://s3.example.com:4566 --set storage_access_key=KEY --set storage_secret_key=SECRET --set storage_bucket=fess +``` diff --git a/skills/fessctl/references/features/plugin.md b/skills/fessctl/references/features/plugin.md new file mode 100644 index 0000000..a23221d --- /dev/null +++ b/skills/fessctl/references/features/plugin.md @@ -0,0 +1,45 @@ +# Plugins (fessctl `plugin`) + +## What it is +Plugins are jars (data stores, ingest, script engines, web apps, thumbnail generators, crawlers, LLM clients, and from 15.9 object storage and SSO) that Fess downloads from its plugin repositories into `WEB-INF/plugin`. The Admin UI manages them under **System → Plugin**. + +Available in every supported Fess version (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). Install is `PUT` on 14.x and `POST` on 15.x; fessctl picks the method. The versions on offer follow the Fess line (for example `15.9.0` plugins on Fess 15.9.0). + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `installed` | List the installed plugins. | none | +| `available` | List what the plugin repositories offer. | none | +| `install` | Ask Fess to download and install one plugin. | ` ` | +| `uninstall` | Ask Fess to delete one plugin jar. Asks for confirmation unless `--yes` is given. | ` ` | + +Always reconfirm with `fessctl plugin --help`. + +## Resource JSON shape +```json +{ + "response": { + "plugins": [ + {"name": "fess-ds-csv", "id": "fess-ds-csv:15.9.0", "type": "fess-ds", "version": "15.9.0", + "url": "https://maven.codelibs.org/release/org/codelibs/fess/fess-ds-csv/15.9.0/fess-ds-csv-15.9.0.jar"} + ], + "status": 0 + } +} +``` +`installed` entries have no `url`. + +## Gotchas +- `install` and `uninstall` only report that the request was accepted: Fess downloads or deletes the jar in a background thread and logs any failure instead of reporting it. Check `plugin installed` afterwards (it can take a few seconds), and a plugin that is not installed can still be "uninstalled" successfully. +- A new plugin is active only after Fess is restarted; so is the removal of one. +- `install` makes the Fess server download the jar, so the server needs outbound access to the plugin repository. A name/version pair that is not in `available` is rejected with `invalid name or version`. +- Installing a plugin also deletes other installed versions of the same plugin. +- `uninstall` needs the exact installed version; check `plugin installed`. + +## Examples +```bash +fessctl plugin available --output json | jq -r '.response.plugins[] | select(.type == "fess-ds") | .id' +fessctl plugin install fess-ds-csv 15.9.0 +fessctl plugin installed +fessctl plugin uninstall fess-ds-csv 15.9.0 --yes +``` diff --git a/skills/fessctl/references/features/storage.md b/skills/fessctl/references/features/storage.md new file mode 100644 index 0000000..3b459d4 --- /dev/null +++ b/skills/fessctl/references/features/storage.md @@ -0,0 +1,52 @@ +# Object storage (fessctl `storage`) + +## What it is +The storage browser of the Admin UI (**System → Storage**): list, download, upload and delete objects in the S3-compatible or Google Cloud Storage bucket that Fess is configured to use (`storage_*` keys of `fessctl general`). + +Every supported version has the API (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0), with two differences: +- **Upload does not work on 15.6.1 to 15.8.0.** Those releases declare the upload endpoint as `PUT` only and do not parse a multipart `PUT`; the server answers `file is required.`, so fessctl refuses with a message instead of sending the request. Upload works on 14.19.2 (`POST`) and 15.9.0+ (`POST`). +- **An unreachable or unconfigured storage is reported only from 15.9.0.** Before that `list` answers an empty list (`No Storage items found.`) whatever is wrong. 15.9.0 returns an error such as `Storage Access Error: Access key ID cannot be blank.`, or, for a `storage_type` no plugin serves, `No storage client is registered as StorageClient ... install fess-storage-s3 ... or fess-storage-gcs`. In 15.9.0 the S3 and GCS clients are the `fess-storage-s3` / `fess-storage-gcs` plugins (bundled in the 15.9.0 distribution; see `fessctl plugin installed`). + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List objects and directories under a path. | optional `[PATH]` (default: bucket root) | +| `download` | Write an object to `--file`, or to standard output without it. An existing `--file` is kept unless `--force`. | `` | +| `upload` | Upload a local file as `<--dir>/` (default: bucket root). An existing object of that name is overwritten. | `` | +| `delete` | Delete one object. Asks for confirmation unless `--yes` is given. | `` | + +Always reconfirm with `fessctl storage --help`. + +## Paths +Commands take the plain object path (`docs/2026/report.pdf`, no leading slash); `list` prints the same form in its PATH column. The API itself addresses an object by the URL-safe Base64 of that path (`ZG9jcy8yMDI2L3JlcG9ydC5wZGY=`), which fessctl computes. Directories are prefixes: there is nothing to create or delete. + +## Resource JSON shape +```json +{ + "response": { + "items": [ + {"id": "ZG9jcy8=", "name": "docs", "directory": true, "size": 0, "hashCode": 348235201}, + {"id": "ZG9jcy9hLnR4dA==", "path": "docs", "name": "a.txt", "directory": false, "size": 6, + "lastModified": "2026-10-16T12:28:45Z", "hashCode": 755113370} + ], + "status": 0 + } +} +``` +Item keys are camelCase here (`lastModified`, `hashCode`); `path` is absent for items at the bucket root. + +## Gotchas +- `download` cannot tell a failure from success by status: Fess streams the object after it has committed a `200`, so a missing object or unreachable storage comes back as `200` with `Content-Length: 0`. fessctl treats that as an error. A real empty object is sent chunked and is written as a 0-byte file. +- `delete` of an object that does not exist succeeds (S3 semantics). +- `list` returns at most `storage.max.items.in.page` items (default 1000); there is no paging. +- Uploaded content goes through multipart `file` plus `path`; names are kept as given. + +## Examples +```bash +fessctl storage list +fessctl storage list docs/2026 +fessctl storage download docs/2026/report.pdf --file ./report.pdf +fessctl storage download docs/notes.txt | head +fessctl storage upload ./report.pdf --dir docs/2026 +fessctl storage delete docs/2026/report.pdf --yes +``` diff --git a/skills/fessctl/references/workflows.md b/skills/fessctl/references/workflows.md index 750f348..ab57670 100644 --- a/skills/fessctl/references/workflows.md +++ b/skills/fessctl/references/workflows.md @@ -198,4 +198,4 @@ fessctl badword create --suggest-word internal-test ## Out of scope (today) -These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `backup`, `searchlist`, `stats`, `storage`, `suggest`, `systeminfo`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. +These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `backup`, `searchlist`, `stats`, `suggest`, `systeminfo`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 0d837c3..69ee7da 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -1,10 +1,12 @@ import json from enum import Enum +from pathlib import Path from typing import Dict, List, Optional import httpx from fessctl.config.settings import Settings +from fessctl.utils import encode_to_urlsafe_base64 class Action(Enum): @@ -89,9 +91,15 @@ def send_request( url, headers=headers, json=json_data, params=params, timeout=self.timeout ) elif action == Action.DELETE: - response = httpx.delete( - url, headers=headers, params=params, timeout=self.timeout - ) + if json_data is None: + response = httpx.delete( + url, headers=headers, params=params, timeout=self.timeout + ) + else: + # httpx.delete cannot carry a body; some endpoints (plugin) bind it. + response = httpx.request( + "DELETE", url, headers=headers, params=params, json=json_data, timeout=self.timeout + ) elif action == Action.LIST: # Fess admin list endpoints bind size, page and filters from the # JSON request body and ignore the query string, so send both. @@ -119,6 +127,9 @@ def send_request( content=f"Network error: {str(e)}" ) from e + return self._parse_json(response) + + def _parse_json(self, response) -> dict: # Parse JSON response - Fess API returns JSON even for errors try: return response.json() @@ -712,6 +723,114 @@ def install_theme(self, name: str, version: str) -> dict: url = f"{self.base_url}/api/admin/theme" return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version}) + # General APIs + + def get_general(self) -> dict: + """ + Retrieves the General system settings. + """ + url = f"{self.base_url}/api/admin/general" + return self.send_request(Action.GET, url) + + def update_general(self, setting: dict) -> dict: + """ + Updates the General system settings. The server merges the request with the stored + settings: a key that is left out (or null) keeps its stored value. + """ + url = f"{self.base_url}/api/admin/general" + return self.send_request(Action.EDIT, url, json_data=setting) + + # Plugin APIs + + def get_installed_plugins(self) -> dict: + """ + Retrieves the installed plugins. + """ + url = f"{self.base_url}/api/admin/plugin/installed" + return self.send_request(Action.GET, url) + + def get_available_plugins(self) -> dict: + """ + Retrieves the plugins that can be installed. + """ + url = f"{self.base_url}/api/admin/plugin/available" + return self.send_request(Action.GET, url) + + def install_plugin(self, name: str, version: str) -> dict: + """ + Installs a plugin by name and version. + """ + url = f"{self.base_url}/api/admin/plugin" + return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version}) + + def uninstall_plugin(self, name: str, version: str) -> dict: + """ + Uninstalls a plugin by name and version (the request carries a JSON body). + """ + url = f"{self.base_url}/api/admin/plugin" + return self.send_request(Action.DELETE, url, json_data={"name": name, "version": version}) + + # Storage APIs + # The storage endpoints identify an object by the URL-safe Base64 of its path ("dir/file"). + + def get_storage_items(self, path: str = "") -> dict: + """ + Lists the objects and directories under a path (the bucket root when empty). + """ + url = f"{self.base_url}/api/admin/storage/list" + if path: + url = f"{url}/{encode_to_urlsafe_base64(path)}" + return self.send_request(Action.GET, url) + + def download_storage_file(self, path: str) -> httpx.Response: + """ + Downloads an object. Returns the raw response: the body is the object on success and a + JSON error otherwise, so the caller decides by Content-Type. + """ + url = f"{self.base_url}/api/admin/storage/download/{encode_to_urlsafe_base64(path)}/" + try: + return httpx.get(url, headers=self.admin_api_headers, timeout=self.timeout) + except httpx.RequestError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + + def delete_storage_file(self, path: str) -> dict: + """ + Deletes an object. + """ + url = f"{self.base_url}/api/admin/storage/delete/{encode_to_urlsafe_base64(path)}/" + return self.send_request(Action.DELETE, url) + + def upload_storage_file(self, file_path: Path, directory: str = "") -> dict: + """ + Uploads a local file as multipart/form-data into a directory of the storage. + + Fess 15.6 to 15.8 declare the endpoint as PUT only and do not parse a multipart PUT + (the server answers "file is required."), so no request is sent there. + """ + if (15, 6) <= (self._major_version, self._minor_version) <= (15, 8): + return {"response": { + "status": 1, + "message": "Fess 15.6 to 15.8 cannot receive uploads through the admin API (fixed in 15.9).", + }} + url = f"{self.base_url}/api/admin/storage/upload/" + # no Content-Type: httpx sets the multipart boundary + headers = {"Authorization": self.admin_api_headers["Authorization"]} + try: + with file_path.open("rb") as f: + response = httpx.post( + url, headers=headers, data={"path": directory}, + files={"file": (file_path.name, f)}, timeout=self.timeout + ) + except httpx.RequestError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + return self._parse_json(response) + # WebAuth APIs def create_webauth(self, config: dict) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 9fbe1df..0e37523 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -18,14 +18,14 @@ from fessctl.commands.failureurl import failureurl_app from fessctl.commands.fileauth import fileauth_app from fessctl.commands.fileconfig import fileconfig_app -# from fessctl.commands.general import general_app +from fessctl.commands.general import general_app from fessctl.commands.group import group_app from fessctl.commands.joblog import joblog_app from fessctl.commands.keymatch import keymatch_app from fessctl.commands.labeltype import labeltype_app # from fessctl.commands.log import log_app from fessctl.commands.pathmap import pathmap_app -# from fessctl.commands.plugin import plugin_app +from fessctl.commands.plugin import plugin_app from fessctl.commands.role import role_app from fessctl.commands.relatedcontent import relatedcontent_app from fessctl.commands.relatedquery import relatedquery_app @@ -33,7 +33,7 @@ # from fessctl.commands.searchlist import searchlist_app from fessctl.commands.scheduler import scheduler_app # from fessctl.commands.stats import stats_app -# from fessctl.commands.storage import storage_app +from fessctl.commands.storage import storage_app # from fessctl.commands.suggest import suggest_app from fessctl.commands.tagtype import tagtype_app from fessctl.commands.theme import theme_app @@ -56,14 +56,14 @@ app.add_typer(failureurl_app, name="failureurl") app.add_typer(fileauth_app, name="fileauth") app.add_typer(fileconfig_app, name="fileconfig") -# app.add_typer(general_app, name="general") +app.add_typer(general_app, name="general") app.add_typer(group_app, name="group") app.add_typer(joblog_app, name="joblog") app.add_typer(keymatch_app, name="keymatch") app.add_typer(labeltype_app, name="labeltype") # app.add_typer(log_app, name="log") app.add_typer(pathmap_app, name="pathmap") -# app.add_typer(plugin_app, name="plugin") +app.add_typer(plugin_app, name="plugin") app.add_typer(role_app, name="role") app.add_typer(relatedcontent_app, name="relatedcontent") app.add_typer(relatedquery_app, name="relatedquery") @@ -71,7 +71,7 @@ # app.add_typer(searchlist_app, name="searchlist") app.add_typer(scheduler_app, name="scheduler") # app.add_typer(stats_app, name="stats") -# app.add_typer(storage_app, name="storage") +app.add_typer(storage_app, name="storage") # app.add_typer(suggest_app, name="suggest") app.add_typer(tagtype_app, name="tagtype") app.add_typer(theme_app, name="theme") diff --git a/src/fessctl/commands/general.py b/src/fessctl/commands/general.py new file mode 100644 index 0000000..c12dd1a --- /dev/null +++ b/src/fessctl/commands/general.py @@ -0,0 +1,129 @@ +import json +from typing import List + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_detail_markdown, format_result_markdown + +general_app = typer.Typer() + +# The update request must carry these (@Required in the Fess EditForm); every other setting that +# is left out keeps its stored value. +REQUIRED_KEYS = ( + "day_for_cleanup", + "crawling_thread_count", + "failure_count_threshold", + "csv_file_encoding", +) + +# Settings that the server leaves out of the GET response while they are unset. +UNSET_OMITTED_KEYS = ( + "ldap_provider_url", + "ldap_security_principal", + "ldap_admin_security_principal", + "ldap_admin_security_credentials", + "ldap_base_dn", + "ldap_account_filter", +) + + +@general_app.command("get") +def get_general( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the General system settings. + """ + client = FessAPIClient(Settings()) + result = client.get_general() + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + setting = result.get("response", {}).get("setting", {}) + typer.echo(format_detail_markdown( + "General Settings", setting, [(key, key) for key in setting])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to get General settings. {message} Status code: {status}", "General", "get")) + + if status != 0: + raise typer.Exit(code=status) + + +@general_app.command("update") +def update_general( + settings: List[str] = typer.Option( + [], "--set", help="Setting to change as KEY=VALUE (repeatable). KEY is a name shown by `general get`" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Change General system settings; every setting not given keeps its value. + + Secrets are returned masked by `general get`. A new value replaces the stored secret, but one + made only of '*' or empty is ignored by the server, so a secret cannot be cleared here + (spnego_preauth_password is the exception: empty removes it). For any other setting an empty + value stores an empty string. + """ + changes = {} + for item in settings: + key, separator, value = item.partition("=") + if not separator or not key: + raise typer.BadParameter(f"'{item}' is not KEY=VALUE", param_hint="--set") + changes[key] = value + if not changes: + raise typer.BadParameter("give at least one KEY=VALUE", param_hint="--set") + + client = FessAPIClient(Settings()) + current = client.get_general() + status = current.get("response", {}).get("status", 1) + if status != 0: + message: str = current.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to get General settings. {message} Status code: {status}", "General", "update")) + raise typer.Exit(code=status) + setting = current.get("response", {}).get("setting", {}) + + known = set(setting) | set(UNSET_OMITTED_KEYS) + unknown = [key for key in changes if key not in known] + if unknown: + raise typer.BadParameter( + f"unknown setting(s) for this Fess version: {', '.join(unknown)} (see `general get`)", param_hint="--set") + + request = {key: setting[key] for key in REQUIRED_KEYS if key in setting} + for key, value in changes.items(): + if isinstance(setting.get(key), int): + try: + request[key] = int(value) + except ValueError: + raise typer.BadParameter(f"{key} takes an integer, got '{value}'", param_hint="--set") + else: + request[key] = value + + result = client.update_general(request) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"General settings updated ({', '.join(changes)}).", "General", "update")) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to update General settings. {message} Status code: {status}", "General", "update")) + + if status != 0: + raise typer.Exit(code=status) diff --git a/src/fessctl/commands/plugin.py b/src/fessctl/commands/plugin.py new file mode 100644 index 0000000..d7f884d --- /dev/null +++ b/src/fessctl/commands/plugin.py @@ -0,0 +1,131 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown + +plugin_app = typer.Typer() + + +def _list_plugins(result: dict, title: str, action: str, output: str): + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + plugins = result.get("response", {}).get("plugins", []) + if not plugins: + typer.echo(f"No {title} found.") + else: + typer.echo(format_list_markdown(title, plugins, [ + ("NAME", "name"), + ("VERSION", "version"), + ("TYPE", "type"), + ])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list {title}. {message} Status code: {status}", "Plugin", action)) + + if status != 0: + raise typer.Exit(code=status) + + +@plugin_app.command("installed") +def installed_plugins( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the installed plugins. + """ + client = FessAPIClient(Settings()) + _list_plugins(client.get_installed_plugins(), "Installed Plugins", "installed", output) + + +@plugin_app.command("available") +def available_plugins( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the plugins that can be installed from the configured plugin repositories. + """ + client = FessAPIClient(Settings()) + _list_plugins(client.get_available_plugins(), "Available Plugins", "available", output) + + +@plugin_app.command("install") +def install_plugin( + name: str = typer.Argument(..., help="Plugin name (see `plugin available`)"), + version: str = typer.Argument(..., help="Plugin version"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Install a plugin by name and version. Fess downloads the jar in the background after the + request is accepted, so check `plugin installed`; restart Fess to activate the plugin. + """ + client = FessAPIClient(Settings()) + result = client.install_plugin(name, version) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"Installation of plugin '{name}' {version} requested; Fess downloads it in the background (see `plugin installed`). Restart Fess to activate it.", "Plugin", "install", name)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to install Plugin. {message} Status code: {status}", "Plugin", "install")) + + if status != 0: + raise typer.Exit(code=status) + + +@plugin_app.command("uninstall") +def uninstall_plugin( + name: str = typer.Argument(..., help="Plugin name (see `plugin installed`)"), + version: str = typer.Argument(..., help="Installed plugin version"), + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Uninstall a plugin by name and version. Fess deletes the jar in the background after the + request is accepted, and accepts it even when that plugin is not installed; check + `plugin installed` and restart Fess for the removal to take effect. + """ + if not yes: + typer.confirm(f"Uninstall plugin '{name}' {version}?", abort=True) + + client = FessAPIClient(Settings()) + result = client.uninstall_plugin(name, version) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, f"Removal of plugin '{name}' {version} requested; Fess deletes it in the background (see `plugin installed`). Restart Fess for it to take effect.", "Plugin", "uninstall", name)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to uninstall Plugin. {message} Status code: {status}", "Plugin", "uninstall")) + + if status != 0: + raise typer.Exit(code=status) diff --git a/src/fessctl/commands/storage.py b/src/fessctl/commands/storage.py new file mode 100644 index 0000000..5932702 --- /dev/null +++ b/src/fessctl/commands/storage.py @@ -0,0 +1,172 @@ +import json +from pathlib import Path +from typing import Optional + +import click +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown + +storage_app = typer.Typer() + +# Uploads and downloads move file content, so they get longer than the default request timeout. +TRANSFER_TIMEOUT = 300.0 + + +def _object_path(item: dict) -> str: + """The path to give the other storage commands: the item's directory plus its name.""" + directory = item.get("path") + return f"{directory}/{item['name']}" if directory else item["name"] + + +def _echo_result(result: dict, output: str, success_message: str, failure_message: str, action: str, resource_id: str = ""): + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + typer.echo(format_result_markdown(True, success_message, "Storage", action, resource_id)) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"{failure_message} {message} Status code: {status}", "Storage", action)) + + if status != 0: + raise typer.Exit(code=status) + + +@storage_app.command("list") +def list_storage( + path: str = typer.Argument("", help="Directory to list, e.g. docs/2026 (default: the bucket root)"), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the objects and directories under a path of the configured object storage. + """ + client = FessAPIClient(Settings()) + result = client.get_storage_items(path) + status = result.get("response", {}).get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + else: + if status == 0: + items = result.get("response", {}).get("items", []) + if not items: + typer.echo("No Storage items found.") + else: + rows = [{ + "path": _object_path(item), + "type": "directory" if item.get("directory") else "file", + "size": item.get("size"), + "lastModified": item.get("lastModified"), + } for item in items] + typer.echo(format_list_markdown("Storage Items", rows, [ + ("PATH", "path"), + ("TYPE", "type"), + ("SIZE", "size"), + ("LAST MODIFIED", "lastModified"), + ])) + else: + message: str = result.get("response", {}).get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Storage items. {message} Status code: {status}", "Storage", "list")) + + if status != 0: + raise typer.Exit(code=status) + + +@storage_app.command("download") +def download_storage( + path: str = typer.Argument(..., help="Object path, e.g. docs/report.pdf (see `storage list`)"), + file: Optional[Path] = typer.Option( + None, "--file", "-f", help="Write the object to this file (default: standard output)" + ), + force: bool = typer.Option( + False, "--force", help="Overwrite --file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format for the result (text, json, yaml)" + ), +): + """ + Download an object to a file, or to standard output without --file. + """ + if file is not None and file.exists() and not force: + typer.echo(format_result_markdown(False, f"{file} already exists. Use --force to overwrite it.", "Storage", "download")) + raise typer.Exit(code=1) + + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) + response = client.download_storage_file(path) + + if response.headers.get("content-type", "").startswith("application/json"): + # a failure that Fess could report before streaming + result = response.json() + status = result.get("response", {}).get("status", 1) + _echo_result(result, output, "", "Failed to download Storage object.", "download") + raise typer.Exit(code=status or 1) + if response.headers.get("content-length") == "0": + # Fess starts the response before it reads the object, so a missing object or an + # unreachable storage comes back as a 200 with an explicit empty body (a real empty + # object is sent chunked). + result = {"response": {"status": 1, "message": "The server returned an empty response; check that the object exists and the storage is reachable."}} + _echo_result(result, output, "", "Failed to download Storage object.", "download") + raise typer.Exit(code=1) + + if file is None: + click.get_binary_stream("stdout").write(response.content) + return + + file.write_bytes(response.content) + result = {"response": {"status": 0, "file": str(file), "size": len(response.content)}} + _echo_result(result, output, f"Downloaded '{path}' to {file} ({len(response.content)} bytes).", "", "download", path) + + +@storage_app.command("upload") +def upload_storage( + file: Path = typer.Argument(..., exists=True, dir_okay=False, readable=True, help="Local file to upload"), + directory: str = typer.Option( + "", "--dir", "-d", help="Directory in the storage to upload into, e.g. docs/2026 (default: the bucket root)" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Upload a file into a directory of the object storage under its own file name. + An existing object of that name is overwritten. Not possible on Fess 15.6 to 15.8 + (the server cannot receive it); works on 14.x and 15.9+. + """ + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) + result = client.upload_storage_file(file, directory) + target = f"{directory.strip('/')}/{file.name}" if directory.strip("/") else file.name + _echo_result(result, output, f"Uploaded {file} as '{target}'.", "Failed to upload Storage object.", "upload", target) + + +@storage_app.command("delete") +def delete_storage( + path: str = typer.Argument(..., help="Object path, e.g. docs/report.pdf (see `storage list`)"), + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete an object from the object storage. + """ + if not yes: + typer.confirm(f"Delete storage object '{path}'?", abort=True) + + client = FessAPIClient(Settings()) + result = client.delete_storage_file(path) + _echo_result(result, output, f"Storage object '{path}' deleted successfully.", "Failed to delete Storage object.", "delete", path) diff --git a/tests/commands/test_general.py b/tests/commands/test_general.py new file mode 100644 index 0000000..d29e854 --- /dev/null +++ b/tests/commands/test_general.py @@ -0,0 +1,73 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.general import general_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _get_setting(runner): + result = runner.invoke(general_app, ["get", "--output", "json"]) + assert result.exit_code == 0, f"Get failed: {result.stdout}" + resp = json.loads(result.stdout) + assert resp.get("response", {}).get("status") == 0 + return resp["response"]["setting"] + + +def test_general_get(runner, fess_service): + setting = _get_setting(runner) + for key in ("day_for_cleanup", "crawling_thread_count", "failure_count_threshold", "csv_file_encoding"): + assert key in setting + + result = runner.invoke(general_app, ["get"]) + assert result.exit_code == 0 + assert "| csv_file_encoding |" in result.stdout + + +def test_general_update_changes_only_the_given_settings(runner, fess_service): + before = _get_setting(runner) + new_purge_days = 45 if before["purge_job_log_day"] != 45 else 46 + new_notice = "fessctl-test" if before["notification_login"] != "fessctl-test" else "fessctl-test2" + try: + result = runner.invoke(general_app, [ + "update", + "--set", f"purge_job_log_day={new_purge_days}", + "--set", f"notification_login={new_notice}", + ]) + assert result.exit_code == 0, f"Update failed: {result.stdout}" + + after = _get_setting(runner) + assert after["purge_job_log_day"] == new_purge_days + assert after["notification_login"] == new_notice + unchanged = {k: v for k, v in after.items() if k not in ("purge_job_log_day", "notification_login")} + assert unchanged == {k: v for k, v in before.items() if k in unchanged} + assert set(after) == set(before) + finally: + runner.invoke(general_app, [ + "update", + "--set", f"purge_job_log_day={before['purge_job_log_day']}", + "--set", f"notification_login={before['notification_login']}", + ]) + + assert _get_setting(runner) == before + + +def test_general_update_rejects_unknown_setting(runner, fess_service): + result = runner.invoke(general_app, ["update", "--set", "no_such_setting=1"]) + assert result.exit_code == 2 + + +def test_general_update_validation_error(runner, fess_service): + before = _get_setting(runner) + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=99999"]) + assert result.exit_code != 0 + assert "failed to update general settings" in result.stdout.lower() + assert _get_setting(runner) == before diff --git a/tests/commands/test_plugin.py b/tests/commands/test_plugin.py new file mode 100644 index 0000000..c63570b --- /dev/null +++ b/tests/commands/test_plugin.py @@ -0,0 +1,81 @@ +import json +import time + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.plugin import plugin_app + +# a small data store plugin, published for every Fess line +PLUGIN_NAME = "fess-ds-csv" + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _plugins(runner, sub): + result = runner.invoke(plugin_app, [sub, "--output", "json"]) + assert result.exit_code == 0, f"{sub} failed: {result.stdout}" + resp = json.loads(result.stdout) + assert resp.get("response", {}).get("status") == 0 + plugins = resp["response"].get("plugins", []) + assert isinstance(plugins, list) + return plugins + + +def _wait_until(runner, installed, name, timeout=60): + """Install and uninstall are accepted at once and carried out in a background thread.""" + deadline = time.time() + timeout + while time.time() < deadline: + present = any(p["name"] == name for p in _plugins(runner, "installed")) + if present == installed: + return True + time.sleep(1) + return False + + +def test_plugin_installed(runner, fess_service): + plugins = _plugins(runner, "installed") + assert all("name" in p and "version" in p for p in plugins) + + result = runner.invoke(plugin_app, ["installed"]) + assert result.exit_code == 0 + + +def test_plugin_available(runner, fess_service): + plugins = _plugins(runner, "available") + assert all("name" in p and "version" in p for p in plugins) + + +def test_plugin_install_unknown_plugin(runner, fess_service): + result = runner.invoke(plugin_app, ["install", "no-such-plugin", "0.0.1"]) + assert result.exit_code != 0 + assert "failed to install plugin" in result.stdout.lower() + + +def test_plugin_uninstall_aborts_without_confirmation(runner, fess_service): + result = runner.invoke(plugin_app, ["uninstall", PLUGIN_NAME, "0.0.1"], input="n\n") + assert result.exit_code != 0 + + +def test_plugin_install_and_uninstall(runner, fess_service): + if any(p["name"] == PLUGIN_NAME for p in _plugins(runner, "installed")): + pytest.skip(f"{PLUGIN_NAME} is already installed in the Fess under test") + candidates = [p for p in _plugins(runner, "available") if p["name"] == PLUGIN_NAME] + if not candidates: + pytest.skip(f"the Fess under test lists no {PLUGIN_NAME} (plugin repository unreachable?)") + version = candidates[0]["version"] + + try: + result = runner.invoke(plugin_app, ["install", PLUGIN_NAME, version]) + assert result.exit_code == 0, f"Install failed: {result.stdout}" + assert _wait_until(runner, True, PLUGIN_NAME), f"{PLUGIN_NAME} was not installed in time" + finally: + result = runner.invoke(plugin_app, ["uninstall", PLUGIN_NAME, version, "--yes"]) + assert result.exit_code == 0, f"Uninstall failed: {result.stdout}" + assert _wait_until(runner, False, PLUGIN_NAME), f"{PLUGIN_NAME} was not uninstalled in time" diff --git a/tests/commands/test_storage.py b/tests/commands/test_storage.py new file mode 100644 index 0000000..5d2b3ab --- /dev/null +++ b/tests/commands/test_storage.py @@ -0,0 +1,72 @@ +import json + +import pytest +from typer.testing import CliRunner + +from conftest import fess_version_at_least +from fessctl.commands.storage import storage_app + +# The Fess under test has no object storage configured, so these tests cover how the +# commands behave without a backend. Reading and writing objects is covered by the unit tests +# and was verified by hand against an S3-compatible server. + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_storage_list_without_backend(runner, fess_service): + result = runner.invoke(storage_app, ["list"]) + if fess_version_at_least(15, 9): + # 15.9 reports a storage it cannot reach; the message names the plugin to install + assert result.exit_code != 0 + assert "storage access error" in result.stdout.lower() + else: + # earlier versions answer an unreachable storage with an empty list + assert result.exit_code == 0 + assert "No Storage items found." in result.stdout + + +def test_storage_list_json_without_backend(runner, fess_service): + result = runner.invoke(storage_app, ["list", "docs/sub", "--output", "json"]) + resp = json.loads(result.stdout) + if fess_version_at_least(15, 9): + assert result.exit_code != 0 + assert resp["response"]["status"] != 0 + else: + assert result.exit_code == 0 + assert resp["response"]["items"] == [] + + +def test_storage_delete_aborts_without_confirmation(runner, fess_service): + result = runner.invoke(storage_app, ["delete", "no/such/object.txt"], input="n\n") + assert result.exit_code != 0 + + +def test_storage_download_refuses_to_overwrite(runner, fess_service, tmp_path): + target = tmp_path / "existing.txt" + target.write_text("keep") + result = runner.invoke(storage_app, ["download", "no/such/object.txt", "--file", str(target)]) + assert result.exit_code != 0 + assert target.read_text() == "keep" + + +def test_storage_download_missing_object_fails(runner, fess_service, tmp_path): + target = tmp_path / "out.txt" + result = runner.invoke(storage_app, ["download", "no/such/object.txt", "--file", str(target)]) + assert result.exit_code != 0 + assert "failed to download storage object" in result.stdout.lower() + assert not target.exists() + + +def test_storage_upload_without_backend_fails(runner, fess_service, tmp_path): + source = tmp_path / "fessctl-upload-test.txt" + source.write_text("fessctl") + result = runner.invoke(storage_app, ["upload", str(source), "--dir", "fessctl-test"]) + # 15.6 to 15.8 cannot receive uploads at all; the others fail on the missing backend + assert result.exit_code != 0 + assert "failed to upload storage object" in result.stdout.lower() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 416d359..b17ed3f 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -631,3 +631,195 @@ def test_stop_scheduler(self, mock_put, client): call_url = mock_put.call_args[0][0] assert "scheduler-123" in call_url assert "/stop" in call_url + + +def _ok_response(**extra): + response = Mock() + response.json.return_value = {"response": {"status": 0}} + response.status_code = 200 + response.headers = {} + for key, value in extra.items(): + setattr(response, key, value) + return response + + +class TestDeleteWithBody: + """The plugin endpoint binds the name and version of a DELETE from a JSON body.""" + + @patch("httpx.request") + @patch("httpx.delete") + def test_delete_with_json_data_sends_the_body(self, mock_delete, mock_request, client): + mock_request.return_value = _ok_response() + + client.send_request(Action.DELETE, "http://test/api", json_data={"name": "x"}) + + mock_delete.assert_not_called() + assert mock_request.call_args[0] == ("DELETE", "http://test/api") + assert mock_request.call_args[1]["json"] == {"name": "x"} + + @patch("httpx.request") + @patch("httpx.delete") + def test_delete_without_json_data_is_unchanged(self, mock_delete, mock_request, client): + mock_delete.return_value = _ok_response() + + client.send_request(Action.DELETE, "http://test/api") + + mock_request.assert_not_called() + mock_delete.assert_called_once() + + +class TestGeneralAPIs: + @patch("httpx.get") + def test_get_general(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_general() + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/general" + + @patch("httpx.put") + def test_update_general_uses_put(self, mock_put, client): + mock_put.return_value = _ok_response() + + client.update_general({"app_value": "x"}) + + assert mock_put.call_args[0][0] == "http://localhost:8080/api/admin/general" + assert mock_put.call_args[1]["json"] == {"app_value": "x"} + + @patch("httpx.post") + def test_update_general_uses_post_on_fess14(self, mock_post, client_v14): + mock_post.return_value = _ok_response() + + client_v14.update_general({"app_value": "x"}) + + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/general" + + +class TestPluginAPIs: + @patch("httpx.get") + def test_installed_and_available_urls(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_installed_plugins() + client.get_available_plugins() + + assert [c[0][0] for c in mock_get.call_args_list] == [ + "http://localhost:8080/api/admin/plugin/installed", + "http://localhost:8080/api/admin/plugin/available", + ] + + @patch("httpx.post") + def test_install_plugin(self, mock_post, client): + mock_post.return_value = _ok_response() + + client.install_plugin("fess-ds-csv", "15.9.0") + + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/plugin" + assert mock_post.call_args[1]["json"] == {"name": "fess-ds-csv", "version": "15.9.0"} + + @patch("httpx.put") + def test_install_plugin_uses_put_on_fess14(self, mock_put, client_v14): + mock_put.return_value = _ok_response() + + client_v14.install_plugin("fess-ds-csv", "14.19.2") + + mock_put.assert_called_once() + + @patch("httpx.request") + def test_uninstall_plugin_sends_delete_with_body(self, mock_request, client): + mock_request.return_value = _ok_response() + + client.uninstall_plugin("fess-ds-csv", "15.9.0") + + assert mock_request.call_args[0] == ("DELETE", "http://localhost:8080/api/admin/plugin") + assert mock_request.call_args[1]["json"] == {"name": "fess-ds-csv", "version": "15.9.0"} + + +class TestStorageAPIs: + """Storage objects are addressed by the URL-safe Base64 of their path.""" + + @patch("httpx.get") + def test_list_root_has_no_id(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items() + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/list" + + @patch("httpx.get") + def test_list_encodes_the_path(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items("docs/sub") + + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/list/ZG9jcy9zdWI=" + + @patch("httpx.get") + def test_path_with_characters_unsafe_in_urls_uses_the_urlsafe_alphabet(self, mock_get, client): + mock_get.return_value = _ok_response() + + client.get_storage_items("a?b>c") # standard Base64 is "YT9iPmM=", with no '+' or '/' + client.get_storage_items("???>>>") # standard Base64 "Pz8/Pj4+" has both + + assert mock_get.call_args_list[1][0][0].endswith("/Pz8_Pj4-") + + @patch("httpx.get") + def test_download_returns_the_raw_response(self, mock_get, client): + mock_get.return_value = _ok_response() + + response = client.download_storage_file("docs/a.txt") + + assert response is mock_get.return_value + assert mock_get.call_args[0][0] == "http://localhost:8080/api/admin/storage/download/ZG9jcy9hLnR4dA==/" + + @patch("httpx.get") + def test_download_network_error(self, mock_get, client): + mock_get.side_effect = httpx.RequestError("Connection refused") + + with pytest.raises(FessAPIClientError) as exc_info: + client.download_storage_file("a.txt") + + assert exc_info.value.status_code == -1 + + @patch("httpx.delete") + def test_delete_storage_file(self, mock_delete, client): + mock_delete.return_value = _ok_response() + + client.delete_storage_file("docs/a.txt") + + assert mock_delete.call_args[0][0] == "http://localhost:8080/api/admin/storage/delete/ZG9jcy9hLnR4dA==/" + + @pytest.mark.parametrize("version", ["14.19.2", "15.9.0"]) + @patch("httpx.post") + def test_upload_posts_multipart(self, mock_post, version, mock_settings, tmp_path): + mock_settings.fess_version = version + mock_post.return_value = _ok_response() + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = FessAPIClient(mock_settings).upload_storage_file(source, "docs") + + assert result == {"response": {"status": 0}} + assert mock_post.call_args[0][0] == "http://localhost:8080/api/admin/storage/upload/" + kwargs = mock_post.call_args[1] + assert kwargs["data"] == {"path": "docs"} + assert kwargs["files"]["file"][0] == "report.pdf" + # the multipart Content-Type (with its boundary) must be left to httpx + assert kwargs["headers"] == {"Authorization": "Bearer test-token"} + + @pytest.mark.parametrize("version", ["15.6.1", "15.7.0", "15.8.0"]) + @patch("httpx.request") + @patch("httpx.post") + @patch("httpx.put") + def test_upload_is_refused_where_fess_cannot_receive_it(self, mock_put, mock_post, mock_request, version, mock_settings, tmp_path): + mock_settings.fess_version = version + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = FessAPIClient(mock_settings).upload_storage_file(source, "docs") + + assert result["response"]["status"] == 1 + assert "15.9" in result["response"]["message"] + mock_put.assert_not_called() + mock_post.assert_not_called() + mock_request.assert_not_called() diff --git a/tests/unit/test_general.py b/tests/unit/test_general.py new file mode 100644 index 0000000..bb2c854 --- /dev/null +++ b/tests/unit/test_general.py @@ -0,0 +1,181 @@ +""" +Unit tests for the general command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.general import general_app + +SETTING = { + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "purge_job_log_day": -1, + "notification_login": "", + "storage_secret_key": "**********", +} + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _client(mock_client_class, update_status=0): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 0, "setting": dict(SETTING)}} + mock_client.update_general.return_value = {"response": {"status": update_status}} + mock_client_class.return_value = mock_client + return mock_client + + +class TestGeneralGet: + @patch("fessctl.commands.general.FessAPIClient") + def test_get_text_output(self, mock_client_class, runner): + _client(mock_client_class) + + result = runner.invoke(general_app, ["get"]) + + assert result.exit_code == 0 + assert "| day_for_cleanup | 3 |" in result.stdout + assert "| storage_secret_key | ********** |" in result.stdout + + @patch("fessctl.commands.general.FessAPIClient") + def test_get_json_output(self, mock_client_class, runner): + _client(mock_client_class) + + result = runner.invoke(general_app, ["get", "--output", "json"]) + + assert json.loads(result.stdout)["response"]["setting"]["csv_file_encoding"] == "UTF-8" + + @patch("fessctl.commands.general.FessAPIClient") + def test_get_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["get"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestGeneralUpdate: + @patch("fessctl.commands.general.FessAPIClient") + def test_update_sends_required_keys_and_only_the_changes(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "notification_login=hello", "--set", "purge_job_log_day=45"]) + + assert result.exit_code == 0 + assert "General settings updated (notification_login, purge_job_log_day)." in result.stdout + mock_client.update_general.assert_called_once_with({ + "day_for_cleanup": 3, + "crawling_thread_count": 5, + "failure_count_threshold": -1, + "csv_file_encoding": "UTF-8", + "notification_login": "hello", + "purge_job_log_day": 45, + }) + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_never_echoes_masked_secrets(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + runner.invoke(general_app, ["update", "--set", "notification_login=x"]) + + assert "storage_secret_key" not in mock_client.update_general.call_args[0][0] + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_value_may_contain_equals_and_be_empty(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + runner.invoke(general_app, ["update", "--set", "csv_file_encoding=a=b", "--set", "notification_login="]) + + sent = mock_client.update_general.call_args[0][0] + assert sent["csv_file_encoding"] == "a=b" + assert sent["notification_login"] == "" + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_accepts_settings_the_server_omits_while_unset(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "ldap_provider_url=ldap://localhost:389"]) + + assert result.exit_code == 0 + assert mock_client.update_general.call_args[0][0]["ldap_provider_url"] == "ldap://localhost:389" + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_rejects_unknown_setting(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "no_such_setting=1"]) + + assert result.exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_rejects_non_integer_for_integer_setting(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=abc"]) + + assert result.exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_requires_a_change(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + + assert runner.invoke(general_app, ["update"]).exit_code == 2 + assert runner.invoke(general_app, ["update", "--set", "notification_login"]).exit_code == 2 + mock_client.update_general.assert_not_called() + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_failure_exits_with_status(self, mock_client_class, runner): + mock_client = _client(mock_client_class) + mock_client.update_general.return_value = {"response": {"status": 1, "message": "must be less than or equal to 1000."}} + + result = runner.invoke(general_app, ["update", "--set", "day_for_cleanup=99999"]) + + assert result.exit_code == 1 + assert "must be less than or equal to 1000." in result.stdout + + @patch("fessctl.commands.general.FessAPIClient") + def test_update_stops_when_get_fails(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["update", "--set", "notification_login=x"]) + + assert result.exit_code == 3 + mock_client.update_general.assert_not_called() + + +class TestExitStatusInEveryFormat: + @pytest.mark.parametrize("output", ["json", "yaml"]) + @patch("fessctl.commands.general.FessAPIClient") + def test_get_failure_exits_with_status(self, mock_client_class, runner, output): + mock_client = Mock() + mock_client.get_general.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(general_app, ["get", "--output", output]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + @pytest.mark.parametrize("output", ["json", "yaml"]) + @patch("fessctl.commands.general.FessAPIClient") + def test_update_failure_exits_with_status(self, mock_client_class, runner, output): + mock_client = _client(mock_client_class) + mock_client.update_general.return_value = {"response": {"status": 1, "message": "bad value"}} + + result = runner.invoke(general_app, ["update", "--set", "notification_login=x", "--output", output]) + + assert result.exit_code == 1 diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py new file mode 100644 index 0000000..7c2956b --- /dev/null +++ b/tests/unit/test_plugin.py @@ -0,0 +1,133 @@ +""" +Unit tests for the plugin command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.plugin import plugin_app + +PLUGINS = {"response": {"status": 0, "plugins": [ + {"name": "fess-ds-csv", "id": "fess-ds-csv:15.9.0", "type": "fess-ds", "version": "15.9.0"}, +]}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestPluginList: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_installed_text_output(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_installed_plugins.return_value = PLUGINS + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["installed"]) + + assert result.exit_code == 0 + assert "| fess-ds-csv | 15.9.0 | fess-ds |" in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_available_empty(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_available_plugins.return_value = {"response": {"status": 0, "plugins": []}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["available"]) + + assert result.exit_code == 0 + assert "No Available Plugins found." in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_available_json_output(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_available_plugins.return_value = PLUGINS + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["available", "--output", "json"]) + + assert '"fess-ds-csv"' in result.stdout + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_installed_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_installed_plugins.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["installed"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestPluginInstall: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_install(self, mock_client_class, runner): + mock_client = Mock() + mock_client.install_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["install", "fess-ds-csv", "15.9.0"]) + + assert result.exit_code == 0 + assert "requested" in result.stdout + mock_client.install_plugin.assert_called_once_with("fess-ds-csv", "15.9.0") + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_install_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.install_plugin.return_value = {"response": {"status": 1, "message": "invalid name or version"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["install", "nope", "0.0.1"]) + + assert result.exit_code == 1 + assert "invalid name or version" in result.stdout + + +class TestPluginUninstall: + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_aborts_without_confirmation(self, mock_client_class, runner): + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0"], input="n\n") + + assert result.exit_code != 0 + mock_client_class.return_value.uninstall_plugin.assert_not_called() + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_confirmed(self, mock_client_class, runner): + mock_client = Mock() + mock_client.uninstall_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0"], input="y\n") + + assert result.exit_code == 0 + mock_client.uninstall_plugin.assert_called_once_with("fess-ds-csv", "15.9.0") + + @patch("fessctl.commands.plugin.FessAPIClient") + def test_uninstall_with_yes_skips_prompt(self, mock_client_class, runner): + mock_client = Mock() + mock_client.uninstall_plugin.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(plugin_app, ["uninstall", "fess-ds-csv", "15.9.0", "--yes"]) + + assert result.exit_code == 0 + assert "requested" in result.stdout + + +@pytest.mark.parametrize("output", ["json", "yaml"]) +@patch("fessctl.commands.plugin.FessAPIClient") +def test_failure_exits_with_status_in_every_format(mock_client_class, output): + mock_client = Mock() + failure = {"response": {"status": 1, "message": "invalid name or version"}} + mock_client.install_plugin.return_value = failure + mock_client.get_available_plugins.return_value = failure + mock_client_class.return_value = mock_client + runner = CliRunner() + + assert runner.invoke(plugin_app, ["install", "x", "1", "--output", output]).exit_code == 1 + assert runner.invoke(plugin_app, ["available", "--output", output]).exit_code == 1 diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py new file mode 100644 index 0000000..f602c0d --- /dev/null +++ b/tests/unit/test_storage.py @@ -0,0 +1,264 @@ +""" +Unit tests for the storage command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.storage import storage_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +def _download_response(body=b"hello", content_type="application/octet-stream", content_length=None, json_body=None): + response = Mock() + response.content = body + response.headers = {"content-type": content_type} + if content_length is not None: + response.headers["content-length"] = content_length + response.json.return_value = json_body + return response + + +class TestStorageList: + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_shows_full_object_paths(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 0, "items": [ + {"path": "docs", "name": "sub", "directory": True, "size": 0}, + {"path": "docs", "name": "a.txt", "directory": False, "size": 6, "lastModified": "2026-10-16T12:28:45Z"}, + {"name": "top.txt", "directory": False, "size": 6, "lastModified": "2026-10-16T12:28:46Z"}, + ]}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list", "docs"]) + + assert result.exit_code == 0 + assert "| docs/sub | directory | 0 | - |" in result.stdout + assert "| docs/a.txt | file | 6 | 2026-10-16T12:28:45Z |" in result.stdout + assert "| top.txt | file | 6 | 2026-10-16T12:28:46Z |" in result.stdout + mock_client.get_storage_items.assert_called_once_with("docs") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_defaults_to_the_root(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 0, "items": []}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list"]) + + assert "No Storage items found." in result.stdout + mock_client.get_storage_items.assert_called_once_with("") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.get_storage_items.return_value = {"response": {"status": 1, "message": "Storage Access Error: No storage client is registered"}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["list"]) + + assert result.exit_code == 1 + assert "No storage client is registered" in result.stdout + + +class TestStorageDownload: + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_to_file(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"\x00\x01binary") + mock_client_class.return_value = mock_client + target = tmp_path / "out.bin" + + result = runner.invoke(storage_app, ["download", "docs/a.bin", "--file", str(target)]) + + assert result.exit_code == 0 + assert target.read_bytes() == b"\x00\x01binary" + mock_client.download_storage_file.assert_called_once_with("docs/a.bin") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_to_stdout(self, mock_client_class, runner): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"hello\n") + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["download", "top.txt"]) + + assert result.exit_code == 0 + assert result.stdout_bytes == b"hello\n" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + target = tmp_path / "out.txt" + target.write_text("keep") + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert target.read_text() == "keep" + mock_client_class.return_value.download_storage_file.assert_not_called() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"new") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + target.write_text("old") + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target), "--force"]) + + assert result.exit_code == 0 + assert target.read_text() == "new" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_json_error_body_fails(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response( + content_type="application/json;charset=UTF-8", + json_body={"response": {"status": 1, "message": "The file was not found."}}) + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "The file was not found." in result.stdout + assert not target.exists() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_explicit_empty_body_is_a_failure(self, mock_client_class, runner, tmp_path): + # Fess answers 200 with Content-Length: 0 when it cannot read the object + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"", content_length="0") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "missing.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "empty response" in result.stdout + assert not target.exists() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_empty_object_sent_chunked_is_written(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"") + mock_client_class.return_value = mock_client + target = tmp_path / "empty.txt" + + result = runner.invoke(storage_app, ["download", "empty.txt", "--file", str(target)]) + + assert result.exit_code == 0 + assert target.read_bytes() == b"" + + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_json_output(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_storage_file.return_value = _download_response(b"abc") + mock_client_class.return_value = mock_client + target = tmp_path / "out.txt" + + result = runner.invoke(storage_app, ["download", "top.txt", "--file", str(target), "--output", "json"]) + + assert json.loads(result.stdout) == {"response": {"status": 0, "file": str(target), "size": 3}} + + +class TestStorageUpload: + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source), "--dir", "docs/2026"]) + + assert result.exit_code == 0 + assert "'docs/2026/report.pdf'" in result.stdout + mock_client.upload_storage_file.assert_called_once_with(source, "docs/2026") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_to_root(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source)]) + + assert "'report.pdf'" in result.stdout + mock_client.upload_storage_file.assert_called_once_with(source, "") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_failure_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.upload_storage_file.return_value = {"response": {"status": 1, "message": "Failed to upload report.pdf."}} + mock_client_class.return_value = mock_client + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + result = runner.invoke(storage_app, ["upload", str(source)]) + + assert result.exit_code == 1 + assert "Failed to upload report.pdf." in result.stdout + + @patch("fessctl.commands.storage.FessAPIClient") + def test_upload_missing_file(self, mock_client_class, runner, tmp_path): + result = runner.invoke(storage_app, ["upload", str(tmp_path / "missing.bin")]) + + assert result.exit_code == 2 + mock_client_class.return_value.upload_storage_file.assert_not_called() + + +class TestStorageDelete: + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_aborts_without_confirmation(self, mock_client_class, runner): + result = runner.invoke(storage_app, ["delete", "docs/a.txt"], input="n\n") + + assert result.exit_code != 0 + mock_client_class.return_value.delete_storage_file.assert_not_called() + + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_with_yes(self, mock_client_class, runner): + mock_client = Mock() + mock_client.delete_storage_file.return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["delete", "docs/a.txt", "--yes"]) + + assert result.exit_code == 0 + assert "deleted successfully" in result.stdout + mock_client.delete_storage_file.assert_called_once_with("docs/a.txt") + + @patch("fessctl.commands.storage.FessAPIClient") + def test_delete_failure_exits_with_status(self, mock_client_class, runner): + mock_client = Mock() + mock_client.delete_storage_file.return_value = {"response": {"status": 1, "message": "Failed to delete the file a.txt."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(storage_app, ["delete", "a.txt", "--yes"]) + + assert result.exit_code == 1 + assert "Failed to delete the file a.txt." in result.stdout + + +@pytest.mark.parametrize("output", ["json", "yaml"]) +@patch("fessctl.commands.storage.FessAPIClient") +def test_failure_exits_with_status_in_every_format(mock_client_class, output, tmp_path): + mock_client = Mock() + failure = {"response": {"status": 1, "message": "Storage Access Error"}} + mock_client.get_storage_items.return_value = failure + mock_client.delete_storage_file.return_value = failure + mock_client_class.return_value = mock_client + runner = CliRunner() + + assert runner.invoke(storage_app, ["list", "--output", output]).exit_code == 1 + assert runner.invoke(storage_app, ["delete", "a.txt", "--yes", "--output", output]).exit_code == 1 From 07a589a6ec5f3668bc5d6819f7ec8adaeaea8e2c Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Fri, 16 Oct 2026 21:48:53 +0900 Subject: [PATCH 2/3] fix: give plugin available a longer timeout Fess queries the remote plugin repositories while serving `plugin available`, which can exceed the 5 second default client timeout (CI failed with "Network error: timed out" on 15.6.1). Use a 60 second timeout for that command. --- src/fessctl/commands/plugin.py | 6 +++++- tests/unit/test_plugin.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/fessctl/commands/plugin.py b/src/fessctl/commands/plugin.py index d7f884d..8d7b0f4 100644 --- a/src/fessctl/commands/plugin.py +++ b/src/fessctl/commands/plugin.py @@ -9,6 +9,10 @@ plugin_app = typer.Typer() +# Fess queries the remote plugin repositories while serving `available`, which +# can take longer than the default client timeout. +REMOTE_TIMEOUT = 60.0 + def _list_plugins(result: dict, title: str, action: str, output: str): status = result.get("response", {}).get("status", 1) @@ -58,7 +62,7 @@ def available_plugins( """ List the plugins that can be installed from the configured plugin repositories. """ - client = FessAPIClient(Settings()) + client = FessAPIClient(Settings(), timeout=REMOTE_TIMEOUT) _list_plugins(client.get_available_plugins(), "Available Plugins", "available", output) diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index 7c2956b..42911f3 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -131,3 +131,21 @@ def test_failure_exits_with_status_in_every_format(mock_client_class, output): assert runner.invoke(plugin_app, ["install", "x", "1", "--output", output]).exit_code == 1 assert runner.invoke(plugin_app, ["available", "--output", output]).exit_code == 1 + + +def test_available_uses_a_long_timeout(): + """`available` makes Fess query the remote plugin repositories.""" + from unittest.mock import Mock, patch + + from typer.testing import CliRunner + + from fessctl.commands.plugin import plugin_app + + with patch("fessctl.commands.plugin.FessAPIClient") as mock_client_class: + mock_client = Mock() + mock_client.get_available_plugins.return_value = {"response": {"status": 0, "plugins": []}} + mock_client_class.return_value = mock_client + + CliRunner().invoke(plugin_app, ["available"]) + + assert mock_client_class.call_args.kwargs["timeout"] >= 60 From 1ec17375146c51d67546b3a4eff517110c029b8a Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Fri, 16 Oct 2026 22:09:54 +0900 Subject: [PATCH 3/3] fix: harden the storage commands found while verifying against GCS Verified against fake-gcs-server (Fess 15.9.0 with fess-storage-gcs): - upload: strip leading and trailing slashes from --dir. Fess joins the directory and the name with a slash without normalising, so `-d /a/b/` stored `/a/b//file`, which `list` and `download` could not reach. - list: use the long transfer timeout. Fess waits for the storage backend while serving the request, so an unreachable backend ended in a traceback after the 5 second default. - download: check that the directory of --file exists before fetching the object, instead of crashing with FileNotFoundError after it. --- src/fessctl/api/client.py | 2 +- src/fessctl/commands/storage.py | 8 +++++++- tests/unit/test_client.py | 14 ++++++++++++++ tests/unit/test_storage.py | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 847f865..c377290 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -971,7 +971,7 @@ def upload_storage_file(self, file_path: Path, directory: str = "") -> dict: try: with file_path.open("rb") as f: response = httpx.post( - url, headers=headers, data={"path": directory}, + url, headers=headers, data={"path": directory.strip("/")}, files={"file": (file_path.name, f)}, timeout=self.timeout ) except httpx.RequestError as e: diff --git a/src/fessctl/commands/storage.py b/src/fessctl/commands/storage.py index 5932702..2c2ef3d 100644 --- a/src/fessctl/commands/storage.py +++ b/src/fessctl/commands/storage.py @@ -50,7 +50,9 @@ def list_storage( """ List the objects and directories under a path of the configured object storage. """ - client = FessAPIClient(Settings()) + # Fess talks to the storage backend while serving this request; an unreachable + # backend can take far longer than the default timeout to answer. + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) result = client.get_storage_items(path) status = result.get("response", {}).get("status", 1) @@ -104,6 +106,10 @@ def download_storage( typer.echo(format_result_markdown(False, f"{file} already exists. Use --force to overwrite it.", "Storage", "download")) raise typer.Exit(code=1) + if file is not None and not file.parent.is_dir(): + typer.echo(format_result_markdown(False, f"The directory {file.parent} does not exist.", "Storage", "download")) + raise typer.Exit(code=1) + client = FessAPIClient(Settings(), timeout=TRANSFER_TIMEOUT) response = client.download_storage_file(path) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 19a85b0..b721f6f 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -812,6 +812,20 @@ def test_upload_posts_multipart(self, mock_post, version, mock_settings, tmp_pat # the multipart Content-Type (with its boundary) must be left to httpx assert kwargs["headers"] == {"Authorization": "Bearer test-token"} + @pytest.mark.parametrize("directory,expected", [("/a/b/c/", "a/b/c"), ("/x", "x"), ("", "")]) + @patch("httpx.post") + def test_upload_strips_slashes_around_the_directory(self, mock_post, directory, expected, mock_settings, tmp_path): + """Fess 15.9 joins the directory and the name with a slash without normalising, so + a leading or trailing slash would store an object that cannot be read back.""" + mock_settings.fess_version = "15.9.0" + mock_post.return_value = _ok_response() + source = tmp_path / "report.pdf" + source.write_bytes(b"pdf") + + FessAPIClient(mock_settings).upload_storage_file(source, directory) + + assert mock_post.call_args[1]["data"] == {"path": expected} + @pytest.mark.parametrize("version", ["15.6.1", "15.7.0", "15.8.0"]) @patch("httpx.request") @patch("httpx.post") diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index f602c0d..cb96465 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -67,6 +67,16 @@ def test_list_failure_exits_with_status(self, mock_client_class, runner): assert "No storage client is registered" in result.stdout +class TestStorageListTimeout: + @patch("fessctl.commands.storage.FessAPIClient") + def test_list_waits_for_a_slow_backend(self, mock_client_class, runner): + mock_client_class.return_value.get_storage_items.return_value = {"response": {"status": 0, "items": []}} + + runner.invoke(storage_app, ["list"]) + + assert mock_client_class.call_args.kwargs["timeout"] >= 60 + + class TestStorageDownload: @patch("fessctl.commands.storage.FessAPIClient") def test_download_to_file(self, mock_client_class, runner, tmp_path): @@ -81,6 +91,16 @@ def test_download_to_file(self, mock_client_class, runner, tmp_path): assert target.read_bytes() == b"\x00\x01binary" mock_client.download_storage_file.assert_called_once_with("docs/a.bin") + @patch("fessctl.commands.storage.FessAPIClient") + def test_download_refuses_a_missing_parent_directory(self, mock_client_class, runner, tmp_path): + target = tmp_path / "missing" / "a.txt" + + result = runner.invoke(storage_app, ["download", "docs/a.txt", "--file", str(target)]) + + assert result.exit_code == 1 + assert "does not exist" in result.stdout + mock_client_class.return_value.download_storage_file.assert_not_called() + @patch("fessctl.commands.storage.FessAPIClient") def test_download_to_stdout(self, mock_client_class, runner): mock_client = Mock()