diff --git a/.github/readme-ids.json b/.github/readme-ids.json index 53e125a..d8316d0 100644 --- a/.github/readme-ids.json +++ b/.github/readme-ids.json @@ -1,11 +1,11 @@ { - "version": "6.1", - "authentication": "6978dfd7217b10efe565eeee", - "base_operations": "6978dfd7217b10efe565eef6", - "file_operations": "6978dfd7217b10efe565eef5", - "system_admin_account_operations": "6978dfd7217b10efe565eef2", - "team_admin_account_operations": "6978dfd7217b10efe565eef1", - "user_account_operations": "6978dfd7217b10efe565eef3", - "ping_and_info": "6978dfd7217b10efe565eef0", - "python_scheduler": "6978dfd7217b10efe565eef4" + "version": "6.2", + "authentication": "69d4dc0c1422831f8d6fbb8e", + "base_operations": "69d4dc0c1422831f8d6fbb96", + "file_operations": "69d4dc0c1422831f8d6fbb95", + "system_admin_account_operations": "69d4dc0c1422831f8d6fbb92", + "team_admin_account_operations": "69d4dc0c1422831f8d6fbb91", + "user_account_operations": "69d4dc0c1422831f8d6fbb93", + "ping_and_info": "69d4dc0c1422831f8d6fbb90", + "python_scheduler": "69d4dc0c1422831f8d6fbb94" } diff --git a/.github/sync-with-postman.sh b/.github/sync-with-postman.sh index 687000c..dd66590 100755 --- a/.github/sync-with-postman.sh +++ b/.github/sync-with-postman.sh @@ -33,10 +33,11 @@ jq '{"collection": .}' < postman/collection.json > postman/collection.wrapped.js DESCRIPTION="This is the reference for the SeaTable API. On this page you will find everything you need to know to use SeaTable's API. - The SeaTable API is organized around REST. This means: SeaTable's API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. +The SeaTable API is organized around REST. This means: SeaTable's API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs. - With SeaTable, you can design individual databases, workflows and apps in no time at all - without any programming knowledge. - Our no-code solution combines the intuitive operation of tables with the power of modern database and app builder functions and also impresses as a flexible low-code platform for all users. +With SeaTable, you can design individual databases, workflows and apps in no time at all - without any programming knowledge. + +Our no-code solution combines the intuitive operation of tables with the power of modern database and app builder functions and also impresses as a flexible low-code platform for all users. " # Set description (required for Postman verification) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index a25ab3c..3130dca 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -28,8 +28,8 @@ on: - "seatable/seatable-enterprise-testing" env: - DEFAULT_VERSION: "6.1.8" - DEFAULT_IMAGE: "seatable/seatable-enterprise" + DEFAULT_VERSION: "6.2.12" + DEFAULT_IMAGE: "seatable/seatable-enterprise-testing" jobs: diff --git a/.github/workflows/rdme-openapi.yml b/.github/workflows/rdme-openapi.yml index 9c4de00..0d266be 100644 --- a/.github/workflows/rdme-openapi.yml +++ b/.github/workflows/rdme-openapi.yml @@ -10,13 +10,13 @@ # Process for new version: # 1. git checkout -b vX.Y # 2. Login to https://dash.readme.com/login and fork the API definition to the new version. -# 3. Get the new category IDs from https://dash.readme.com/project/seatable/vX.Y/reference -# or via API: https://docs.readme.com/main/reference/getcategories +# 3a. Get the new category IDs from https://dash.readme.com/project/seatable/vX.Y/reference +# 3b. Update .github/readme-ids.json with the new IDs. +# 4a. Get the _id (of SeaTable API) via API: https://docs.readme.com/main/reference/getcategories # (use the API key from https://dash.readme.com/project/seatable/vX.Y/api-key as password - username stays empty) -# 4. Update .github/readme-ids.json with the new IDs. -# 5. Update the "category" value in all intro/*.md frontmatter. -# 6. Update the version in all YAML spec files. -# 7. git add . && git commit && git push --set-upstream origin vX.Y +# 4b. Update the "category" value in all intro/*.md frontmatter. +# 5. Update the version in all YAML spec files. +# 6. git add . && git commit && git push --set-upstream origin vX.Y name: Publish diff --git a/authentication.yaml b/authentication.yaml index ab829d4..a9a3767 100644 --- a/authentication.yaml +++ b/authentication.yaml @@ -3,7 +3,7 @@ info: title: Authentication description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: diff --git a/base_operations.yaml b/base_operations.yaml index 878bec3..fc12828 100644 --- a/base_operations.yaml +++ b/base_operations.yaml @@ -3,7 +3,7 @@ info: title: Base Operations description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: @@ -3348,6 +3348,12 @@ paths: link_rows: {} summaries: {} colors: {} + "400": + description: A view with this name already exists in the table. + content: + application/json: + example: + error_msg: params invalid /api-gateway/api/v2/dtables/{base_uuid}/views/{view_name}/: get: tags: @@ -3549,6 +3555,12 @@ paths: link_rows: {} summaries: {} colors: {} + "400": + description: The target view name already exists in the table. + content: + application/json: + example: + error_msg: params invalid delete: tags: - Views @@ -3702,6 +3714,11 @@ paths: conditions: [] permission_type: "" permitted_users: [] + "400": + description: A column with this name already exists in the table. + content: + application/json: + example: Column ColA exists. put: tags: - Columns @@ -3773,6 +3790,13 @@ paths: data: null permission_type: "" permitted_users: [] + "400": + description: A column with this name already exists in the table. + content: + application/json: + example: + error_type: column_already_exists + error_message: column name already exists delete: tags: - Columns @@ -4288,7 +4312,9 @@ paths: content: application/json: schema: - type: object + type: array + items: + type: object example: - id: 1 author: 28d006e7d1754bb4afa2c2cb7369d244@auth.local @@ -4314,6 +4340,33 @@ paths: created_at: "2021-01-15T13:53:13.000Z" updated_at: "2021-01-15T13:53:13.000Z" resolved: 1 + post: + tags: + - Row Comments + summary: Create Row Comment + operationId: createRowComment + description: >- + Create a comment for a certain row. + security: + - BaseTokenAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/create_row_comment" + parameters: + - $ref: "#/components/parameters/base_uuid" + - $ref: "#/components/parameters/table_id" + - $ref: "#/components/parameters/row_id" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + example: + success:true /api-gateway/api/v2/dtables/{base_uuid}/comments/{comment_id}/: delete: tags: @@ -4321,13 +4374,7 @@ paths: summary: Delete Comment operationId: deleteComment deprecated: true - description: >- - Delete a certain comment by its ID. - - - > 🚧 Creating comments via the API Gateway is not yet supported. - This will be fixed in SeaTable 6.2. Until then, comments can only be - created via `POST /api/v2.1/dtables/{dtable_uuid}/comments/` (dtable-web). + description: Delete a certain comment by its ID. security: - BaseTokenAuth: [] parameters: @@ -4348,13 +4395,7 @@ paths: summary: Get Comment operationId: getComment deprecated: true - description: >- - Get the details of a certain comment with its ID. - - - > 🚧 Creating comments via the API Gateway is not yet supported. - This will be fixed in SeaTable 6.2. Until then, comments can only be - created via `POST /api/v2.1/dtables/{dtable_uuid}/comments/` (dtable-web). + description: Get the details of a certain comment with its ID. security: - BaseTokenAuth: [] parameters: diff --git a/file_operations.yaml b/file_operations.yaml index 3a9b763..18abb0c 100644 --- a/file_operations.yaml +++ b/file_operations.yaml @@ -3,7 +3,7 @@ info: title: File Operations description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: @@ -263,8 +263,8 @@ paths: example: download_link: >- https://cloud.seatable.io/seafhttp/files/12345678-1234-4672-834c-4eca40dc104b/test.png - "400": - description: Bad Request — file not found at the given path. + "404": + description: Not Found — file not found at the given path. content: application/json: schema: diff --git a/intro/authentication.md b/intro/authentication.md index 21ada17..633742f 100644 --- a/intro/authentication.md +++ b/intro/authentication.md @@ -1,7 +1,7 @@ --- title: Authentication excerpt: Learn to master the authentication flows of SeaTable. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: authentication --- diff --git a/intro/changelog.md b/intro/changelog.md index a02628d..653ef35 100644 --- a/intro/changelog.md +++ b/intro/changelog.md @@ -1,7 +1,7 @@ --- title: Changelog excerpt: This page lists changes made to the Web API and its documentation. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: changelog --- diff --git a/intro/errors.md b/intro/errors.md index 3fb9b2a..39fcf3f 100644 --- a/intro/errors.md +++ b/intro/errors.md @@ -1,7 +1,7 @@ --- title: Status Codes excerpt: The HTTP response codes indicate success or error. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: errors --- diff --git a/intro/help.md b/intro/help.md index 7f18842..94fcaa4 100644 --- a/intro/help.md +++ b/intro/help.md @@ -1,7 +1,7 @@ --- title: Help & Support excerpt: Have you found a bug or need help? Here you can get it. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: help --- diff --git a/intro/introduction.md b/intro/introduction.md index 4580164..7a199fa 100644 --- a/intro/introduction.md +++ b/intro/introduction.md @@ -1,7 +1,7 @@ --- title: Introduction excerpt: This is the reference for the SeaTable API. On this page you will find everything you need to know to use SeaTable's API. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: introduction --- diff --git a/intro/limits.md b/intro/limits.md index eb7c3d1..ab8ba30 100644 --- a/intro/limits.md +++ b/intro/limits.md @@ -1,7 +1,7 @@ --- title: Limits excerpt: Get an overview of all rate and size limits of the SeaTable API. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: limits --- diff --git a/intro/models.md b/intro/models.md index 56f8537..f306955 100644 --- a/intro/models.md +++ b/intro/models.md @@ -1,7 +1,7 @@ --- title: Models excerpt: This page describes the different objects used in SeaTable. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: models --- diff --git a/intro/requirement-self-hosted.md b/intro/requirement-self-hosted.md index 63e654b..905a2c4 100644 --- a/intro/requirement-self-hosted.md +++ b/intro/requirement-self-hosted.md @@ -1,7 +1,7 @@ --- title: Try It! with your own Server excerpt: Prerequisites to use api.seatable.com with your own self-hosted SeaTable server. -category: 6978dfd7217b10efe565eee6 +category: 69d4dc0c1422831f8d6fbb86 isReference: true slug: requirement-self-hosted --- diff --git a/ping_and_info.yaml b/ping_and_info.yaml index 3e17b37..b103d75 100644 --- a/ping_and_info.yaml +++ b/ping_and_info.yaml @@ -3,7 +3,7 @@ info: title: Ping and Server Info description: >- The official SeaTable API Reference (OpenAPI 3.0) - Part "Ping and Server Info". - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: diff --git a/python-scheduler.yaml b/python-scheduler.yaml index f9f3acb..fc0914e 100644 --- a/python-scheduler.yaml +++ b/python-scheduler.yaml @@ -3,7 +3,7 @@ info: title: Python Scheduler description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: diff --git a/system_admin_account_operations.yaml b/system_admin_account_operations.yaml index e5ca341..1faf561 100644 --- a/system_admin_account_operations.yaml +++ b/system_admin_account_operations.yaml @@ -3,7 +3,7 @@ info: title: "Account Operations: System admin" description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: @@ -2620,7 +2620,11 @@ paths: "200": description: OK content: - # Actually application/x-zip-compressed, but dtable-web does not accept this in Accept header + # Response body is actually "application/x-zip-compressed", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the "Accept" + # header on this admin endpoint (only */* or application/json work). Keep the workaround + # until the backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_adminExportBase_accepts_zip_content_type. application/json: {} # Common Dataset diff --git a/team_admin_account_operations.yaml b/team_admin_account_operations.yaml index 963ae4e..0bf880d 100644 --- a/team_admin_account_operations.yaml +++ b/team_admin_account_operations.yaml @@ -3,7 +3,7 @@ info: title: Account Operations - Team admin description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: @@ -451,6 +451,7 @@ paths: type: string last_login: type: string + nullable: true self_usage: type: integer quota: @@ -2302,6 +2303,46 @@ paths: ], "total_count": 3, } + /api/v2.1/org/{org_id}/admin/audit-logs/: + get: + tags: + - Activities & Logs + summary: List Audit Logs + operationId: listAuditLogs + description: Retrieves audit logs. + security: + - AccountTokenAuth: [] + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + - $ref: "#/components/parameters/org_id" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + example: + audit_log_list: + - username: 58e399fe8a3548abb7d7e346f5c94a30@auth.local + name: Org Admin + operation: group_rename + org_id: 87 + detail: + id: 210 + name: New Group Name + old_name: Test Group + created_at: "2026-06-03T14:21:28+02:00" + - username: 58e399fe8a3548abb7d7e346f5c94a30@auth.local + name: Org Admin + operation: group_create + org_id: 87 + detail: + id: 210 + name: Test Group + created_at: "2026-06-03T14:21:28+02:00" + count: 2 /api/v2.1/org/{org_id}/admin/group-member-audit/: get: tags: diff --git a/tests/API_ISSUES.md b/tests/API_ISSUES.md index 39ed08e..f86c33f 100644 --- a/tests/API_ISSUES.md +++ b/tests/API_ISSUES.md @@ -134,11 +134,9 @@ Sending `is_admin: "true"` (as string) works correctly and returns 200. | 5 | Invalid date: silently stored as `null` | Medium | Yes — return error | | 7 | Read-only columns: inconsistent enforcement | Medium | Yes — same as #4 | | 9 | DELETE /links/ reports success for non-existing links | Medium | Yes — return count 0 | -| 12 | No createRowComment endpoint | Medium | Yes — re-added in 6.1 | | 17 | addNewUser returns 403 for license limit | Medium | Yes — use 409 or 402 | | 22 | updateColumn to link type creates broken column | Medium | Yes — validate column_data | | 23 | Temp API-Token creation uses GET instead of POST | Low | Yes — use POST | -| 26 | getFileDownloadLink returns 400 instead of 404 | Low | Yes — return 404 | | 24 | app_name inconsistently used as path vs body param | Low | No — document behavior | | 25 | Base-Token endpoints return different field sets | Low | No — document behavior | | 2 | Select columns: unknown options auto-created | Low | No — acceptable behavior | @@ -262,14 +260,6 @@ Unlinking a row pair that doesn't exist returns `{ deleted_links_count: 1, succe **Recommendation:** Return the actual count of deleted links. -### 12. No createRowComment endpoint - -**Severity:** Medium - -There is no API endpoint to create a row comment. The `create_row_comment` schema exists in `base_operations.yaml` but no POST operation is defined on `/api-gateway/api/v2/dtables/{base_uuid}/comments/` (returns 405 Method Not Allowed). - -**Status:** Will be re-added in 6.2. - ### 17. addNewUser returns 403 for license limit **Severity:** Medium @@ -445,12 +435,3 @@ The five ping endpoints return three different response formats: **Workaround:** OpenAPI spec in this repository has been adjusted to match actual behavior. -### 26. getFileDownloadLink returns 400 instead of 404 for non-existent file - -**Severity:** Low — inconsistent error codes - -`GET /api/v2.1/dtable/app-download-link/?path=/files/2020-01/nonexistent.txt` returns 400 with `{"error_msg": "path ... not found."}`. In contrast, `DELETE /api/v2.1/dtable/app-asset/` correctly returns 404 with `{"error_msg": "File not found."}` for the same situation. - -A missing file is a 404 case, not a 400 (bad request). - -**Recommendation:** Return 404 from `getFileDownloadLink` when the file does not exist, consistent with `DeleteBaseAsset`. diff --git a/tests/COVERAGE.md b/tests/COVERAGE.md index f4d8480..b2e8ceb 100644 --- a/tests/COVERAGE.md +++ b/tests/COVERAGE.md @@ -160,17 +160,18 @@ Generated: 2026-03-20 - [x] `POST /api/v2.1/user-list/` - listPublicUserInfos - [ ] `PUT /api/v2.1/user/contact-email/` - updateEmailAddress -### Import & Export (0/9) - -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/append-excel-csv-to-table/` - appendToTableFromFile -- [ ] `GET /dtable/external-links/{external_link_token}/download-zip/` - exportBaseFromExternalLink -- [ ] `GET /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/` - exportBigDataView -- [ ] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/` - exportTable -- [ ] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/` - exportView -- [ ] `POST /api/v2.1/workspace/{workspace_id}/import-dtable/` - importBasefromDTableFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-base/` - importBasefromFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-table/` - importTableFromFile -- [ ] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/update-table-via-excel-csv/` - updateFromFile +### Import & Export (10/10) + +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/append-excel-csv-to-table/` - appendToTableFromFile +- [x] `GET /dtable/external-links/{external_link_token}/download-zip/` - exportBaseFromExternalLink +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-dtable/` - exportBase +- [x] `GET /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/` - exportBigDataView +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/` - exportTable (xfail: 406 on real Accept header, backend not yet fixed) +- [x] `GET /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/` - exportView (xfail: 406 on real Accept header, backend not yet fixed) +- [x] `POST /api/v2.1/workspace/{workspace_id}/import-dtable/` - importBasefromDTableFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-base/` - importBasefromFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/import-excel-csv-to-table/` - importTableFromFile +- [x] `POST /api/v2.1/workspace/{workspace_id}/synchronous-import/update-table-via-excel-csv/` - updateFromFile ### Bases (9/15) @@ -498,9 +499,9 @@ Generated: 2026-03-20 - [ ] `PUT /api/v2.1/admin/abuse-reports/{abuse_report_id}/` - updateAbuseReport - [ ] `PUT /api/v2.1/admin/virus-files/{virus_id}/` - updateVirusFile -### Export (0/1) +### Export (1/1) -- [ ] `GET /api/v2.1/admin/dtables/{base_uuid}/synchronous-export/export-dtable/` - exportBase +- [x] `GET /api/v2.1/admin/dtables/{base_uuid}/synchronous-export/export-dtable/` - exportBase (xfail: 406 on real Accept header, backend not yet fixed) ### Statistics (0/7) diff --git a/tests/__snapshots__/test_base_operations/test_createBase.json b/tests/__snapshots__/test_base_operations/test_createBase.json index 3f2554b..9f55f8a 100644 --- a/tests/__snapshots__/test_base_operations/test_createBase.json +++ b/tests/__snapshots__/test_base_operations/test_createBase.json @@ -1,5 +1,6 @@ { "table": { + "backend": "js", "color": null, "created_at": "str", "icon": null, diff --git a/tests/conftest.py b/tests/conftest.py index 6061259..dc41a24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,6 +111,7 @@ class Base: """Class for storing base info""" workspace_id: int uuid: str + name: str # Hide base token from console output by setting repr=False token: str = field(repr=False) # Temporary API token for file uploads @@ -190,7 +191,7 @@ def base(account_token: Secret): api_token = get_api_token(account_token, workspace_id, base_name) # Yield back to the test function - yield Base(workspace_id=workspace_id, uuid=base_uuid, token=base_token, api_token=api_token.value) + yield Base(workspace_id=workspace_id, uuid=base_uuid, name=base_name, token=base_token, api_token=api_token.value) if CLEANUP_AFTER_TESTS == 'True': # Delete base to not cause any issues on future test runs diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..a51df5c --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# Treat every xfail as strict: a test marked xfail that unexpectedly passes (XPASS) +# is reported as a failure. This forces cleanup when the underlying issue is fixed. +xfail_strict = true diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py new file mode 100644 index 0000000..259b625 --- /dev/null +++ b/tests/test_audit_logs.py @@ -0,0 +1,368 @@ +import pytest +from conftest import ( + CLEANUP_AFTER_TESTS, + TeamAdmin, + authentication_schema, + generate_password, + system_admin_account_operations, + team_admin_account_operations, + user_account_operations, +) +from random import randint +from schemathesis import Case + + +def _headers(team: TeamAdmin) -> dict: + return {'Authorization': f'Bearer {team.account_token}'} + + +def _team_admin_call(team: TeamAdmin, operation_id: str, **kwargs): + """Call a team-admin operation as the org admin.""" + case: Case = team_admin_account_operations.find_operation_by_id(operation_id).Case(**kwargs) + return case.call(headers=_headers(team)) + + +def _user_call(team: TeamAdmin, operation_id: str, **kwargs): + """Call a user-level operation as the team admin. + + The org audit log only records operations performed through the user-level + endpoints; team-admin endpoints are not audited at all. + """ + case: Case = user_account_operations.find_operation_by_id(operation_id).Case(**kwargs) + return case.call(headers=_headers(team)) + + +def _audit_log_operations(team: TeamAdmin) -> list[str]: + """Return the `operation` value of every audit log entry stored for the org.""" + response = _team_admin_call( + team, 'listAuditLogs', + path_parameters={'org_id': team.team_id}, + query={'page': 1, 'per_page': 100}, + ) + assert response.status_code == 200 + return [e['operation'] for e in response.json()['audit_log_list']] + + +def _assert_audited(team: TeamAdmin, operation: str): + assert operation in _audit_log_operations(team), \ + f'operation {operation!r} was not stored in the audit log' + + +def _unique(prefix: str) -> str: + # Group/base names may only contain letters, numbers, blank, hyphen, dot, quote or + # underscore — a numeric suffix keeps them unique within the shared org. + return f'{prefix} {randint(1, 1_000_000)}' + + +def _new_group(team: TeamAdmin, name: str) -> tuple[int, int]: + """Create a group (the caller becomes owner); return (group_id, workspace_id).""" + response = _user_call(team, 'createGroup', body={'name': name}) + assert response.status_code in (200, 201), f'createGroup failed: {response.status_code} {response.text}' + groups = _user_call(team, 'listGroups').json() + group_id = next(g['id'] for g in groups if g['name'] == name) + workspaces = _user_call(team, 'listWorkspaces').json()['workspace_list'] + workspace_id = next(w['id'] for w in workspaces if w['type'] == 'group' and w['name'] == name) + return group_id, workspace_id + + +def _new_base(team: TeamAdmin, name: str, workspace_id: int | None = None) -> tuple[int, str]: + """Create a base (personal, or in a group workspace); return (workspace_id, uuid).""" + body = {'name': name} + if workspace_id is not None: + body['workspace_id'] = workspace_id + table = _user_call(team, 'createBase', body=body).json()['table'] + return table['workspace_id'], table['uuid'] + + +def _add_user(team: TeamAdmin, label: str) -> str: + """Add a throwaway org member; return its user id. Emails are unique per call because + SeaTable reserves them permanently at the ccnet layer once used.""" + response = _team_admin_call(team, 'addUser', path_parameters={'org_id': team.team_id}, body={ + 'email': f'audit-{label}-{randint(1, 1_000_000)}@example.com', + 'name': label, + 'password': generate_password(), + }) + assert response.status_code == 200, f'addUser failed: {response.status_code} {response.text}' + return response.json()['email'] + + +@pytest.fixture(scope='module') +def audit_team(system_admin_account_token) -> TeamAdmin: + """A dedicated org shared by every test in this module (built once). + + The conftest `team` fixture is function-scoped; a module-scoped org keeps the + audit-log tests from each paying the org-creation cost. + """ + sys_headers = {'Authorization': f'Bearer {system_admin_account_token.value}'} + # Admin emails are reserved at the ccnet layer once used, so make it unique per run. + admin_email = f'automated-testing-audit-admin-{randint(1, 1_000_000)}@seatable.io' + admin_password = generate_password() + + response = system_admin_account_operations.find_operation_by_id('addTeam').Case(body={ + 'org_name': f'automated-testing-audit-org-{randint(1, 10000)}', + 'admin_email': admin_email, + 'password': admin_password, + 'with_workspace': True, + }).call(headers=sys_headers) + assert response.status_code == 200 + team_id = response.json()['org_id'] + + response = authentication_schema.find_operation_by_id('getAccountTokenfromUsername').Case( + body={'username': admin_email, 'password': admin_password}, + ).call() + assert response.status_code == 200 + + yield TeamAdmin(team_id=team_id, account_token=response.json()['token']) + + if CLEANUP_AFTER_TESTS == 'True': + system_admin_account_operations.find_operation_by_id('deleteTeam').Case( + path_parameters={'org_id': team_id}, + ).call(headers=sys_headers) + + +@pytest.mark.needs_large_license +def test_listAuditLogs(team: TeamAdmin): + response = _team_admin_call( + team, 'listAuditLogs', + path_parameters={'org_id': team.team_id}, + query={'page': 1, 'per_page': 25}, + ) + + assert response.status_code == 200 + data = response.json() + assert 'count' in data + + +# Reasons for the xfail markers below: the org audit log does not record personal-workspace +# base lifecycle, and account deletion is only reachable via the never-audited team-admin +# endpoint. Those actions still run; we expect them to be absent from the log. +_NOT_AUDITED_BASE = 'org audit log does not record personal-workspace base lifecycle' +_NOT_AUDITED_ACCOUNT = 'account deletion is only reachable via the never-audited team-admin endpoint' + + +@pytest.mark.needs_large_license +def test_group_create_is_audited(audit_team: TeamAdmin): + _new_group(audit_team, _unique('Audit Group')) + _assert_audited(audit_team, 'group_create') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason='team-admin endpoints are not captured in the audit log', strict=True) +def test_addGroup_is_audited(team: TeamAdmin): + """Creating a group via the team-admin `addGroup` should be audited, but isn't: + team-admin endpoints write nothing to the audit log (only the user-level `createGroup` + is captured). Uses a fresh org so no user-level group_create entry masks the result.""" + admin_id = _team_admin_call(team, 'listTeamUsers', + path_parameters={'org_id': team.team_id}).json()['user_list'][0]['email'] + _team_admin_call(team, 'addGroup', path_parameters={'org_id': team.team_id}, + body={'group_name': _unique('Audit Group'), 'group_owner': admin_id}) + _assert_audited(team, 'group_create') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason='team-admin endpoints are not captured in the audit log', strict=True) +def test_updateGroup_rename_is_audited(team: TeamAdmin): + """Renaming a group via the team-admin `updateGroup` should be audited, but isn't: + team-admin endpoints write nothing to the audit log (only the user-level `updateGroup` + is captured). Uses a fresh org so no user-level group_rename entry masks the result.""" + name = _unique('Audit Group') + group_id, _ = _new_group(team, name) + _team_admin_call(team, 'updateGroup', path_parameters={'org_id': team.team_id, 'group_id': group_id}, + body={'new_group_name': f'{name} Renamed'}) + _assert_audited(team, 'group_rename') + + +@pytest.mark.needs_large_license +def test_group_rename_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Group') + group_id, _ = _new_group(audit_team, name) + _user_call(audit_team, 'updateGroup', path_parameters={'group_id': group_id}, + body={'name': f'{name} Renamed'}) + _assert_audited(audit_team, 'group_rename') + + +@pytest.mark.needs_large_license +def test_group_base_create_is_audited(audit_team: TeamAdmin): + _, workspace_id = _new_group(audit_team, _unique('Audit Group')) + _new_base(audit_team, _unique('Audit Group Base'), workspace_id=workspace_id) + _assert_audited(audit_team, 'group_base_create') + + +@pytest.mark.needs_large_license +def test_group_base_rename_is_audited(audit_team: TeamAdmin): + _, workspace_id = _new_group(audit_team, _unique('Audit Group')) + base_name = _unique('Audit Group Base') + _new_base(audit_team, base_name, workspace_id=workspace_id) + _user_call(audit_team, 'updateBase', path_parameters={'workspace_id': workspace_id}, + body={'name': base_name, 'new_name': f'{base_name} Renamed'}) + _assert_audited(audit_team, 'group_base_rename') + + +@pytest.mark.needs_large_license +def test_group_base_delete_is_audited(audit_team: TeamAdmin): + _, workspace_id = _new_group(audit_team, _unique('Audit Group')) + base_name = _unique('Audit Group Base') + _new_base(audit_team, base_name, workspace_id=workspace_id) + _user_call(audit_team, 'deleteBase', path_parameters={'workspace_id': workspace_id}, + body={'name': base_name}) + _assert_audited(audit_team, 'group_base_delete') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason='team-admin endpoints are not captured in the audit log', strict=True) +def test_deleteBase_is_audited(team: TeamAdmin): + """Deleting a base via the team-admin `deleteBase` should be audited, but isn't: + team-admin endpoints write nothing to the audit log (only the user-level `deleteBase` + is captured). Uses a group base in a fresh org so no user-level group_base_delete entry + masks the result, and so the user-level equivalent really would be audited.""" + _, workspace_id = _new_group(team, _unique('Audit Group')) + _, uuid = _new_base(team, _unique('Audit Group Base'), workspace_id=workspace_id) + _team_admin_call(team, 'deleteBase', path_parameters={'org_id': team.team_id, 'base_uuid': uuid}) + _assert_audited(team, 'group_base_delete') + + +@pytest.mark.needs_large_license +def test_group_base_restore_is_audited(audit_team: TeamAdmin): + group_id, workspace_id = _new_group(audit_team, _unique('Audit Group')) + base_name = _unique('Audit Group Base') + _, uuid = _new_base(audit_team, base_name, workspace_id=workspace_id) + _user_call(audit_team, 'deleteBase', path_parameters={'workspace_id': workspace_id}, + body={'name': base_name}) + _user_call(audit_team, 'restoreGroupTrashedBase', + path_parameters={'group_id': group_id, 'base_uuid': uuid}) + _assert_audited(audit_team, 'group_base_restore') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason='team-admin endpoints are not captured in the audit log', strict=True) +def test_restoreBaseFromTrash_is_audited(team: TeamAdmin): + """Restoring a base via the team-admin `restoreBaseFromTrash` should be audited, but isn't: + team-admin endpoints write nothing to the audit log (only the user-level + `restoreGroupTrashedBase` is captured). Uses a group base in a fresh org so no user-level + group_base_restore entry masks the result, and so the user-level equivalent really would + be audited. The base is deleted via the user-level endpoint to land it in the trash bin.""" + _, workspace_id = _new_group(team, _unique('Audit Group')) + base_name = _unique('Audit Group Base') + _, uuid = _new_base(team, base_name, workspace_id=workspace_id) + _user_call(team, 'deleteBase', path_parameters={'workspace_id': workspace_id}, + body={'name': base_name}) + _team_admin_call(team, 'restoreBaseFromTrash', path_parameters={'org_id': team.team_id, 'base_uuid': uuid}) + _assert_audited(team, 'group_base_restore') + + +@pytest.mark.needs_large_license +def test_group_delete_is_audited(audit_team: TeamAdmin): + group_id, _ = _new_group(audit_team, _unique('Audit Group')) + _user_call(audit_team, 'deleteGroup', path_parameters={'group_id': group_id}) + _assert_audited(audit_team, 'group_delete') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason='team-admin endpoints are not captured in the audit log', strict=True) +def test_deleteGroup_is_audited(team: TeamAdmin): + """Deleting a group via the team-admin `deleteGroup` should be audited, but isn't: + team-admin endpoints write nothing to the audit log (only the user-level `deleteGroup` + is captured). Uses a fresh org so no user-level group_delete entry masks the result. + The freshly created group is empty, which `deleteGroup` requires.""" + group_id, _ = _new_group(team, _unique('Audit Group')) + _team_admin_call(team, 'deleteGroup', path_parameters={'org_id': team.team_id, 'group_id': group_id}) + _assert_audited(team, 'group_delete') + + +@pytest.mark.needs_large_license +def test_group_transfer_is_audited(audit_team: TeamAdmin): + group_id, _ = _new_group(audit_team, _unique('Audit Group')) + target = _add_user(audit_team, 'transfer-target') + _user_call(audit_team, 'updateGroup', path_parameters={'group_id': group_id}, + body={'owner': target}) + _assert_audited(audit_team, 'group_transfer') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason=_NOT_AUDITED_BASE, strict=True) +def test_base_create_is_audited(audit_team: TeamAdmin): + _new_base(audit_team, _unique('Audit Base')) + _assert_audited(audit_team, 'base_create') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason=_NOT_AUDITED_BASE, strict=True) +def test_base_rename_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + _user_call(audit_team, 'updateBase', path_parameters={'workspace_id': workspace_id}, + body={'name': name, 'new_name': f'{name} Renamed'}) + _assert_audited(audit_team, 'base_rename') + + +@pytest.mark.needs_large_license +def test_base_external_link_create_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + _user_call(audit_team, 'createBaseExternalLink', + path_parameters={'workspace_id': workspace_id, 'base_name': name}, + body={'expire_days': 7}) + _assert_audited(audit_team, 'base_external_link_create') + + +@pytest.mark.needs_large_license +def test_base_external_link_delete_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + token = _user_call(audit_team, 'createBaseExternalLink', + path_parameters={'workspace_id': workspace_id, 'base_name': name}, + body={'expire_days': 7}).json()['token'] + _user_call(audit_team, 'deleteExternalLink', + path_parameters={'workspace_id': workspace_id, 'base_name': name, + 'external_link_token': token}) + _assert_audited(audit_team, 'base_external_link_delete') + + +@pytest.mark.needs_large_license +def test_base_invite_link_create_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + _user_call(audit_team, 'createInviteLink', + body={'table_name': name, 'workspace_id': workspace_id, 'permission': 'rw'}) + _assert_audited(audit_team, 'base_invite_link_create') + + +@pytest.mark.needs_large_license +def test_base_invite_link_delete_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + token = _user_call(audit_team, 'createInviteLink', + body={'table_name': name, 'workspace_id': workspace_id, 'permission': 'rw'}).json()['token'] + _user_call(audit_team, 'deleteInviteLink', path_parameters={'invite_link_token': token}) + _assert_audited(audit_team, 'base_invite_link_delete') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason=_NOT_AUDITED_BASE, strict=True) +def test_base_delete_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + _user_call(audit_team, 'deleteBase', path_parameters={'workspace_id': workspace_id}, + body={'name': name}) + _assert_audited(audit_team, 'base_delete') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason=_NOT_AUDITED_BASE, strict=True) +def test_base_restore_is_audited(audit_team: TeamAdmin): + name = _unique('Audit Base') + workspace_id, _ = _new_base(audit_team, name) + _user_call(audit_team, 'deleteBase', path_parameters={'workspace_id': workspace_id}, + body={'name': name}) + base_id = next(b['id'] for b in _user_call(audit_team, 'listTrashedBases').json()['trash_dtable_list'] + if b['name'] == name) + _user_call(audit_team, 'restoreTrashedBase', path_parameters={'trashed_base_id': base_id}) + _assert_audited(audit_team, 'base_restore') + + +@pytest.mark.needs_large_license +@pytest.mark.xfail(reason=_NOT_AUDITED_ACCOUNT, strict=True) +def test_account_delete_is_audited(audit_team: TeamAdmin): + user_id = _add_user(audit_team, 'delete-target') + _team_admin_call(audit_team, 'deleteUser', path_parameters={'org_id': audit_team.team_id, 'user_id': user_id}) + _assert_audited(audit_team, 'account_delete') diff --git a/tests/test_columns.py b/tests/test_columns.py index ef97c08..23ffb35 100644 --- a/tests/test_columns.py +++ b/tests/test_columns.py @@ -378,3 +378,67 @@ def test_deleteColumn(base: Base): column_names = [c['name'] for c in response.json()['columns']] assert 'keep' in column_names assert 'delete-me' not in column_names + + +def test_insertColumn_duplicate_name_returns_400(base: Base): + table_name = 'test_insertColumn_duplicate' + create_table(base, table_name, [{'column_name': 'existing', 'column_type': 'text'}]) + + path_parameters = {'base_uuid': base.uuid} + headers = {'Authorization': f'Bearer {base.token}'} + body = {'table_name': table_name, 'column_name': 'existing', 'column_type': 'text'} + + # Inserting a column whose name collides with an existing column is rejected + case: Case = base_operations_schema.find_operation_by_id('insertColumn') \ + .Case(path_parameters=path_parameters, body=body, headers=headers) + response = case.call() + + assert response.status_code == 400 + # application/json is also enforced by the conformance hook against the documented 400 + assert response.headers['content-type'][0].startswith('application/json') + + +@pytest.mark.xfail( + reason="insertColumn returns the plain-text body 'Column ColA exists.' with an " + "application/json content-type on a duplicate-column 400 (not valid JSON)", +) +def test_insertColumn_duplicate_name_returns_json(base: Base): + table_name = 'test_insertColumn_duplicate_json' + create_table(base, table_name, [{'column_name': 'existing', 'column_type': 'text'}]) + + path_parameters = {'base_uuid': base.uuid} + headers = {'Authorization': f'Bearer {base.token}'} + body = {'table_name': table_name, 'column_name': 'existing', 'column_type': 'text'} + + case: Case = base_operations_schema.find_operation_by_id('insertColumn') \ + .Case(path_parameters=path_parameters, body=body, headers=headers) + response = case.call() + + assert response.status_code == 400 + # The body should be valid JSON; currently raises JSONDecodeError (plain text) + response.json() + + +def test_updateColumn_rename_collision_returns_400(base: Base): + table_name = 'test_updateColumn_collision' + create_table(base, table_name, [ + {'column_name': 'col-a', 'column_type': 'text'}, + {'column_name': 'col-b', 'column_type': 'text'}, + ]) + + path_parameters = {'base_uuid': base.uuid} + headers = {'Authorization': f'Bearer {base.token}'} + body = { + 'op_type': 'rename_column', + 'table_name': table_name, + 'column': 'col-b', + 'new_column_name': 'col-a', + } + + # Renaming a column to collide with an existing column name is rejected + case: Case = base_operations_schema.find_operation_by_id('updateColumn') \ + .Case(path_parameters=path_parameters, body=body, headers=headers) + response = case.call() + + assert response.status_code == 400 + assert response.headers['content-type'][0].startswith('application/json') diff --git a/tests/test_comments.py b/tests/test_comments.py index 473e0c7..5d36d6a 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -83,25 +83,44 @@ def test_getNumberOfComments(base: Base): assert response.status_code == 200 -def _create_comment(base: Base, row_id: str) -> int: - """Helper: create a comment via direct API call, returns comment_id.""" - import os, requests - server = os.environ['SEATABLE_SERVER'] - resp = requests.post( - f'{server}/api-gateway/api/v2/dtables/{base.uuid}/comments/', - json={'row_id': row_id, 'comment': 'Test comment from automated tests'}, - headers=_headers(base), - ) - assert resp.status_code in (200, 201), f'Failed to create comment: {resp.status_code} {resp.text}' - return resp.json()['id'] +def _table_id(base: Base, table_name: str) -> str: + metadata: Case = base_operations_schema.find_operation_by_id('getMetadata') \ + .Case(path_parameters={'base_uuid': base.uuid}, headers=_headers(base)) + tables = metadata.call().json()['metadata']['tables'] + return next(t['_id'] for t in tables if t['name'] == table_name) + + +def _list_comment_ids(base: Base, row_id: str) -> list[int]: + """createRowComment does not return the new comment's id, so look it up via listRowComments.""" + case: Case = base_operations_schema.find_operation_by_id('listRowComments') \ + .Case(path_parameters={'base_uuid': base.uuid}, query={'row_id': row_id}, headers=_headers(base)) + data = case.call().json() + # API returns [] when no comments exist, {"comments": [...]} otherwise. + comments = data['comments'] if isinstance(data, dict) else data + return [c['id'] for c in comments] -@pytest.mark.skip(reason="Creating comments via API Gateway not yet supported — will be fixed in 6.2") def test_getComment(base: Base): table_name = 'test_getComment' create_table(base, table_name, SIMPLE_COLUMNS) row_ids = append_rows(base, table_name, [{'text': 'comment target'}]) - comment_id = _create_comment(base, row_ids[0]) + + comment_text = 'Test comment from automated tests' + create: Case = base_operations_schema.find_operation_by_id('createRowComment') \ + .Case( + path_parameters={'base_uuid': base.uuid}, + query={'table_id': _table_id(base, table_name), 'row_id': row_ids[0]}, + body={'comment': comment_text}, + headers=_headers(base), + ) + create_response = create.call() + assert create_response.status_code == 200, \ + f'Failed to create comment: {create_response.status_code} {create_response.text}' + + # createRowComment does not return the comment ID, so we need to fetch all comments for this row + comment_ids = _list_comment_ids(base, row_ids[0]) + assert len(comment_ids) == 1 + comment_id = comment_ids[0] case: Case = base_operations_schema.find_operation_by_id('getComment') \ .Case( @@ -113,15 +132,28 @@ def test_getComment(base: Base): assert response.status_code == 200 data = response.json() assert data['id'] == comment_id - assert 'comment' in data + assert data['comment'] == comment_text -@pytest.mark.skip(reason="Creating comments via API Gateway not yet supported — will be fixed in 6.2") def test_deleteComment(base: Base): table_name = 'test_deleteComment' create_table(base, table_name, SIMPLE_COLUMNS) row_ids = append_rows(base, table_name, [{'text': 'delete target'}]) - comment_id = _create_comment(base, row_ids[0]) + + create: Case = base_operations_schema.find_operation_by_id('createRowComment') \ + .Case( + path_parameters={'base_uuid': base.uuid}, + query={'table_id': _table_id(base, table_name), 'row_id': row_ids[0]}, + body={'comment': 'Test comment from automated tests'}, + headers=_headers(base), + ) + create_response = create.call() + assert create_response.status_code == 200, \ + f'Failed to create comment: {create_response.status_code} {create_response.text}' + + comment_ids = _list_comment_ids(base, row_ids[0]) + assert len(comment_ids) == 1 + comment_id = comment_ids[0] case: Case = base_operations_schema.find_operation_by_id('deleteComment') \ .Case( diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..1d6f384 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,196 @@ +"""Tests for the base/table/view export endpoints. + +Background (ticket): the export endpoints return binary bodies with a specific +Content-Type (``application/x-zip-compressed`` for the ``.dtable`` zip exports, +``application/ms-excel`` for the Excel exports). Historically dtable-web rejected +those exact values in the request ``Accept`` header with +``406 Could not satisfy the request Accept header``, which forced the OpenAPI spec +to declare ``application/json`` as a workaround. + +As of the version tested here, the bug is only *partially* fixed: + +* Fixed – the two ``.dtable`` zip exports in the user API accept their real + media type. These are covered by regular (passing) tests below. +* Broken – the two Excel exports and the sys-admin ``export-dtable`` still return + 406 for their real media type (only ``*/*`` / ``application/json`` work). These + are covered by ``xfail`` tests that assert the *desired* behaviour, so they will + turn into ``XPASS`` once the backend is fixed — that is the signal to drop the + ``application/json`` workaround from the spec and remove the ``xfail`` marker. +""" + +import pytest +import requests +from conftest import ( + Base, BASE_URL, Secret, + user_account_operations, system_admin_account_operations, + base_operations_schema, +) +from schemathesis import Case + +from test_base_operations import create_table, append_rows + + +SIMPLE_COLUMNS = [ + {'column_name': 'text', 'column_type': 'text'}, + {'column_name': 'number', 'column_type': 'number'}, +] + +ZIP_CONTENT_TYPE = 'application/x-zip-compressed' +EXCEL_CONTENT_TYPE = 'application/ms-excel' + + +def _first_table_and_view(base: Base, table_name: str) -> dict: + """Create a populated table and return its id/name plus its default view id/name.""" + create_table(base, table_name, SIMPLE_COLUMNS) + append_rows(base, table_name, [{'text': 'a', 'number': 1}, {'text': 'b', 'number': 2}]) + + headers = {'Authorization': f'Bearer {base.token}'} + case: Case = base_operations_schema.find_operation_by_id('getMetadata') \ + .Case(path_parameters={'base_uuid': base.uuid}, headers=headers) + response = case.call() + assert response.status_code == 200 + + table = next(t for t in response.json()['metadata']['tables'] if t['name'] == table_name) + view = table['views'][0] + return { + 'table_id': table['_id'], + 'table_name': table['name'], + 'view_id': view['_id'], + 'view_name': view['name'], + } + + +# --- Fixed: .dtable zip exports accept their real media type ----------------- + +def test_exportBase(base: Base, account_token: Secret): + """Export a base as a .dtable zip, requesting the real media type via Accept.""" + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-dtable/', + params={'dtable_name': base.name}, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': ZIP_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 + + +def test_exportBaseFromExternalLink(base: Base, account_token: Secret): + """Download a base via its external link, requesting the real media type via Accept.""" + # Create a read-only external link for the base + create_case: Case = user_account_operations.find_operation_by_id('createBaseExternalLink').Case( + path_parameters={'workspace_id': base.workspace_id, 'base_name': base.name}, + ) + create_response = create_case.call(headers={'Authorization': f'Bearer {account_token.value}'}) + assert create_response.status_code == 200 + external_link_token = create_response.json()['token'] + + # Download the zip (public endpoint, no auth required) + response = requests.get( + f'{BASE_URL}/dtable/external-links/{external_link_token}/download-zip/', + headers={'Accept': ZIP_CONTENT_TYPE}, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 + + +def test_exportBigDataView(base: Base, account_token: Secret): + """Trigger a big-data view Excel export; the endpoint returns an async task id.""" + table = _first_table_and_view(base, 'test_exportBigDataView') + + case: Case = user_account_operations.find_operation_by_id('exportBigDataView').Case( + path_parameters={'workspace_id': base.workspace_id, 'base_name': base.name}, + query={'table_id': table['table_id'], 'view_id': table['view_id']}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call() + + assert response.status_code == 200 + assert isinstance(response.json()['task_id'], str) + + +# --- Still broken: Excel exports and admin export reject their real media type --- +# +# These use plain ``requests`` (not schemathesis ``Case``) on purpose: the spec +# still declares ``application/json`` as a workaround, so routing through the +# schemathesis ``after_call`` hook would make the test fail on content-type +# conformance even after the backend is fixed, defeating the XPASS signal. + +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/ms-excel' is sent in the Accept header (only */* / application/json work)", +) +def test_exportTable_accepts_excel_content_type(base: Base, account_token: Secret): + """The table Excel export should accept its real media type in the Accept header.""" + table = _first_table_and_view(base, 'test_exportTable') + + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-table-to-excel/', + params={ + 'table_id': table['table_id'], + 'table_name': table['table_name'], + 'dtable_name': base.name, + }, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': EXCEL_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == EXCEL_CONTENT_TYPE + assert len(response.content) > 0 + + +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/ms-excel' is sent in the Accept header (only */* / application/json work)", +) +def test_exportView_accepts_excel_content_type(base: Base, account_token: Secret): + """The view Excel export should accept its real media type in the Accept header.""" + table = _first_table_and_view(base, 'test_exportView') + + response = requests.get( + f'{BASE_URL}/api/v2.1/workspace/{base.workspace_id}/synchronous-export/export-view-to-excel/', + params={ + 'table_id': table['table_id'], + 'table_name': table['table_name'], + 'dtable_name': base.name, + 'view_id': table['view_id'], + 'view_name': table['view_name'], + }, + headers={ + 'Authorization': f'Bearer {account_token.value}', + 'Accept': EXCEL_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == EXCEL_CONTENT_TYPE + assert len(response.content) > 0 + + +@pytest.mark.needs_large_license +@pytest.mark.xfail( + reason="dtable-web returns 406 'Could not satisfy the request Accept header' when " + "'application/x-zip-compressed' is sent in the Accept header on the admin export " + "endpoint (only */* / application/json work)", +) +def test_adminExportBase_accepts_zip_content_type(base: Base, system_admin_account_token: Secret): + """The sys-admin base export should accept its real media type in the Accept header.""" + response = requests.get( + f'{BASE_URL}/api/v2.1/admin/dtables/{base.uuid}/synchronous-export/export-dtable/', + headers={ + 'Authorization': f'Bearer {system_admin_account_token.value}', + 'Accept': ZIP_CONTENT_TYPE, + }, + ) + + assert response.status_code == 200 + assert response.headers['Content-Type'] == ZIP_CONTENT_TYPE + assert len(response.content) > 0 diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py index f5dc256..d49bd5b 100644 --- a/tests/test_file_operations.py +++ b/tests/test_file_operations.py @@ -167,7 +167,7 @@ def test_download_link_nonexistent_file(base: Base): .Case(query={'path': '/files/2020-01/nonexistent.txt'}) response = case.call(headers=_api_headers(base)) - assert response.status_code == 400 + assert response.status_code == 404 def test_delete_nonexistent_file(base: Base): diff --git a/tests/test_group_members.py b/tests/test_group_members.py index 0a1a26e..e55e9a1 100644 --- a/tests/test_group_members.py +++ b/tests/test_group_members.py @@ -60,7 +60,6 @@ def test_group_member_lifecycle(account_token: Secret, system_admin_account_toke delete_group(account_token, group_id) -@pytest.mark.xfail(reason="API issue #35: is_admin as JSON boolean crashes with 500 — expects string 'true'/'false'") def test_updateGroupRole(account_token: Secret, system_admin_account_token: Secret): headers = {'Authorization': f'Bearer {account_token.value}'} group_id, workspace_id = create_group(account_token, 'test-update-role') @@ -76,7 +75,7 @@ def test_updateGroupRole(account_token: Secret, system_admin_account_token: Secr ) case.call(headers=headers) - # Update role — crashes because is_admin is sent as JSON boolean + # Update role case: Case = user_account_operations.find_operation_by_id('updateGroupRole') \ .Case( path_parameters={'group_id': group_id, 'group_member': admin_user_id}, diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..aefcdc4 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,162 @@ +"""Tests for the base/table import endpoints (Import & Export group). + +These go through schemathesis ``Case`` like the rest of the suite, so the +``after_call`` hook validates every response against the OpenAPI schema. The +multipart file part itself is passed via ``case.call(files=..., data=...)`` +(forwarded to ``requests``): schemathesis's own multipart serializer never emits +a filename, but these endpoints read ``request.FILES`` and reject a file part +without one (``{"error_msg": "file invalid."}``). + +Bases created by the import-to-base endpoints are made inside the shared ``base`` +fixture's workspace and registered with ``cleanup_bases`` so they are deleted +before the module's group teardown (a group with remaining bases cannot be +deleted). +""" + +import pytest +from conftest import Base, Secret, base_operations_schema, user_account_operations +from schemathesis import Case + +from test_base_operations import create_table, append_rows + + +NAME_NUMBER_COLUMNS = [ + {'column_name': 'Name', 'column_type': 'text'}, + {'column_name': 'Number', 'column_type': 'number'}, +] + +# CSV whose header row matches NAME_NUMBER_COLUMNS +CSV = b'Name,Number\nAlice,1\nBob,2\n' + + +@pytest.fixture +def cleanup_bases(base: Base, account_token: Secret): + """Delete bases created during a test before the module's group teardown runs + (a group with remaining bases cannot be deleted). Register a base name before + creating it; cleanup is best-effort.""" + names: list[str] = [] + yield names + for name in names: + case: Case = user_account_operations.find_operation_by_id('deleteBase').Case( + path_parameters={'workspace_id': base.workspace_id}, + body={'name': name}, + ) + case.call(headers={'Authorization': f'Bearer {account_token.value}'}) + + +def _list_rows(base: Base, table_name: str) -> list[dict]: + """Read a table's rows back once (keyed by column name).""" + case: Case = base_operations_schema.find_operation_by_id('listRows').Case( + path_parameters={'base_uuid': base.uuid}, + query={'table_name': table_name, 'convert_keys': True}, + headers={'Authorization': f'Bearer {base.token}'}, + ) + response = case.call() + assert response.status_code == 200 + return response.json()['rows'] + + +def test_importBasefromFile(base: Base, account_token: Secret, cleanup_bases: list[str]): + """Create a new base by uploading a CSV file.""" + cleanup_bases.append('test_importBasefromFile') + case: Case = user_account_operations.find_operation_by_id('importBasefromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call(files={'dtable': ('test_importBasefromFile.csv', CSV, 'text/csv')}) + + assert response.status_code == 200 + assert response.json()['success'] is True + + +def test_importBasefromDTableFile(base: Base, account_token: Secret, cleanup_bases: list[str]): + """Create a new base by uploading a .dtable file (obtained by exporting a base).""" + cleanup_bases.append('test_importBasefromDTableFile') + headers = {'Authorization': f'Bearer {account_token.value}'} + + # Export the shared base to get a valid .dtable file to import + export_case: Case = user_account_operations.find_operation_by_id('exportBase').Case( + path_parameters={'workspace_id': base.workspace_id}, + query={'dtable_name': base.name}, + headers=headers, + ) + exported = export_case.call() + assert exported.status_code == 200 + + case: Case = user_account_operations.find_operation_by_id('importBasefromDTableFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers=headers, + ) + response = case.call( + files={'dtable': ('test_importBasefromDTableFile.dtable', exported.content, 'application/x-zip-compressed')}, + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data['task_id'], str) + assert data['table']['name'] == 'test_importBasefromDTableFile' + + +def test_importTableFromFile(base: Base, account_token: Secret): + """Import a CSV file as a new table in an existing base.""" + case: Case = user_account_operations.find_operation_by_id('importTableFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid}, + files={'file': ('test_importTableFromFile.csv', CSV, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + # The new table is named after the uploaded file + rows = _list_rows(base, 'test_importTableFromFile') + assert len(rows) == 2 + + +def test_appendToTableFromFile(base: Base, account_token: Secret): + """Append rows from a CSV file to an existing table.""" + table_name = 'test_appendToTableFromFile' + create_table(base, table_name, NAME_NUMBER_COLUMNS) + + case: Case = user_account_operations.find_operation_by_id('appendToTableFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid, 'table_name': table_name}, + files={'file': ('append.csv', CSV, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + rows = _list_rows(base, table_name) + assert {r['Name'] for r in rows} == {'Alice', 'Bob'} + + +def test_updateFromFile(base: Base, account_token: Secret): + """Update matched rows and insert unmatched rows from a CSV file.""" + table_name = 'test_updateFromFile' + create_table(base, table_name, NAME_NUMBER_COLUMNS) + append_rows(base, table_name, [{'Name': 'Alice', 'Number': 1}]) + + # Alice matches the existing row (updated to 99); Charlie is new (inserted) + csv = b'Name,Number\nAlice,99\nCharlie,3\n' + case: Case = user_account_operations.find_operation_by_id('updateFromFile').Case( + path_parameters={'workspace_id': base.workspace_id}, + headers={'Authorization': f'Bearer {account_token.value}'}, + ) + response = case.call( + data={'dtable_uuid': base.uuid, 'table_name': table_name, 'selected_columns': 'Name'}, + files={'file': ('update.csv', csv, 'text/csv')}, + ) + + assert response.status_code == 200 + assert response.json()['success'] is True + + rows = {r['Name']: r['Number'] for r in _list_rows(base, table_name)} + assert rows['Alice'] == 99 + assert rows['Charlie'] == 3 diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 8f9084f..36ff4c2 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -75,7 +75,6 @@ def test_markBaseNotificationAsSeen(base: Base): assert response.status_code == 200 -@pytest.mark.xfail(reason="API returns 400 'parameters invalid' — request body format differs from spec") def test_sendToastNotification(base: Base): """Send a toast notification. Requires at least one recipient user.""" import os, requests @@ -94,8 +93,8 @@ def test_sendToastNotification(base: Base): .Case( path_parameters={'base_uuid': base.uuid}, body={ - 'to_users': [user_id], - 'msg_type': 'toast', + 'to_user': user_id, + 'toast_type': 'toast', 'detail': {'table_id': '0000', 'msg': 'Hello from test'}, }, headers=_headers(base), diff --git a/tests/test_sql_functions.py b/tests/test_sql_functions.py index aadee0a..c9fd833 100644 --- a/tests/test_sql_functions.py +++ b/tests/test_sql_functions.py @@ -387,9 +387,9 @@ def test_isomonth(self, base: Base): data = _sql(base, f"SELECT isomonth('2025-01-01 11:00:00') FROM `{TABLE}` LIMIT 1") assert _val(data) == '2025-01' - def test_hours_not_supported_in_sql(self, base: Base): - """hours() is documented in functions.md but NOT supported in SQL queries.""" - assert _sql_error(base, f"SELECT hours('2025-02-14 13:00', '2025-02-14 15:00') FROM `{TABLE}` LIMIT 1") == 400 + def test_hours(self, base: Base): + data = _sql(base, f"SELECT hours('2025-02-14 13:00', '2025-02-14 15:00') FROM `{TABLE}` LIMIT 1") + assert _val(data) == 2 def test_months(self, base: Base): data = _sql(base, f"SELECT months('2025-02-01', '2025-05-01') FROM `{TABLE}` LIMIT 1") diff --git a/tests/test_views.py b/tests/test_views.py index d02512c..f8f94f6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -153,3 +153,54 @@ def test_deleteView(base: Base): assert response.status_code == 200 view_names = [v['name'] for v in response.json()['views']] assert 'View To Delete' not in view_names + + +def test_createView_duplicate_name_returns_400(base: Base): + table_name = 'test_createView_duplicate' + create_table(base, table_name, SIMPLE_COLUMNS) + + path_parameters = {'base_uuid': base.uuid} + query = {'table_name': table_name} + headers = {'Authorization': f'Bearer {base.token}'} + body = {'name': 'Collision View', 'type': 'table'} + + # Create the view once + case: Case = base_operations_schema.find_operation_by_id('createView') \ + .Case(path_parameters=path_parameters, query=query, body=body, headers=headers) + response = case.call() + assert response.status_code == 200 + + # Creating a second view with the same name is rejected + case = base_operations_schema.find_operation_by_id('createView') \ + .Case(path_parameters=path_parameters, query=query, body=body, headers=headers) + response = case.call() + + assert response.status_code == 400 + # application/json is also enforced by the conformance hook against the documented 400 + assert response.headers['content-type'][0].startswith('application/json') + + +def test_updateView_rename_collision_returns_400(base: Base): + table_name = 'test_updateView_collision' + create_table(base, table_name, SIMPLE_COLUMNS) + + path_parameters = {'base_uuid': base.uuid} + query = {'table_name': table_name} + headers = {'Authorization': f'Bearer {base.token}'} + + # Create a second view (the table already has a "Default View") + body = {'name': 'Second View', 'type': 'table'} + case: Case = base_operations_schema.find_operation_by_id('createView') \ + .Case(path_parameters=path_parameters, query=query, body=body, headers=headers) + response = case.call() + assert response.status_code == 200 + + # Renaming it to collide with the existing "Default View" is rejected + rename_path = {'base_uuid': base.uuid, 'view_name': 'Second View'} + rename_body = {'name': 'Default View'} + case = base_operations_schema.find_operation_by_id('updateView') \ + .Case(path_parameters=rename_path, query=query, body=rename_body, headers=headers) + response = case.call() + + assert response.status_code == 400 + assert response.headers['content-type'][0].startswith('application/json') diff --git a/user_account_operations.yaml b/user_account_operations.yaml index 4134ae4..234667f 100644 --- a/user_account_operations.yaml +++ b/user_account_operations.yaml @@ -3,7 +3,7 @@ info: title: Account Operations - User description: >- The official SeaTable API Reference (OpenAPI 3.0). - version: "6.1" + version: "6.2" servers: - url: "https://{server}" variables: @@ -3319,7 +3319,9 @@ paths: content: application/json: schema: - type: object + type: array + items: + type: object example: - id: 1 parent_group_id: 0 @@ -4479,8 +4481,7 @@ paths: "200": description: OK content: - # TODO: Actually "application/x-zip-compressed", but dtable-web does not accept this when specified inside an "Accept" header - application/json: {} + application/x-zip-compressed: {} /api/v2.1/workspace/{workspace_id}/synchronous-export/export-table-to-excel/: get: tags: @@ -4499,7 +4500,11 @@ paths: "200": description: OK content: - # TODO: Actually "application/ms-excel", but dtable-web does not accept this when specified inside an "Accept" header + # Response body is actually "application/ms-excel", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the + # "Accept" header (only */* or application/json work). Keep the workaround until the + # backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_exportTable_accepts_excel_content_type. application/json: {} /api/v2.1/workspace/{workspace_id}/synchronous-export/export-view-to-excel/: get: @@ -4521,7 +4526,11 @@ paths: "200": description: OK content: - # TODO: Actually "application/ms-excel", but dtable-web does not accept this when specified inside an "Accept" header + # Response body is actually "application/ms-excel", but dtable-web still returns + # 406 "Could not satisfy the request Accept header" when that value is sent in the + # "Accept" header (only */* or application/json work). Keep the workaround until the + # backend is fixed; regression is tracked by the xfail test + # tests/test_export.py::test_exportView_accepts_excel_content_type. application/json: {} /dtable/external-links/{external_link_token}/download-zip/: get: @@ -4538,8 +4547,7 @@ paths: "200": description: OK content: - # TODO: Actually "application/x-zip-compressed", but dtable-web does not accept this when specified inside an "Accept" header - application/json: {} + application/x-zip-compressed: {} /api/v2.1/workspace/{workspace_id}/dtable/{base_name}/convert-big-data-view-to-excel/: get: