diff --git a/.github/readme-ids.json b/.github/readme-ids.json index d8316d0..864d76c 100644 --- a/.github/readme-ids.json +++ b/.github/readme-ids.json @@ -1,11 +1,11 @@ { - "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" + "version": "7.0", + "authentication": "", + "base_operations": "", + "file_operations": "", + "system_admin_account_operations": "", + "team_admin_account_operations": "", + "user_account_operations": "", + "ping_and_info": "", + "python_scheduler": "" } diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 3130dca..50f7425 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -15,9 +15,9 @@ on: workflow_dispatch: inputs: version: - description: "SeaTable version (e.g. 6.1.8)" + description: "SeaTable version" required: true - default: "6.1.8" + default: "7.0.5" image: description: "Docker Hub repository" required: true @@ -28,13 +28,14 @@ on: - "seatable/seatable-enterprise-testing" env: - DEFAULT_VERSION: "6.2.12" + DEFAULT_VERSION: "7.0.5" DEFAULT_IMAGE: "seatable/seatable-enterprise-testing" + DTABLE_SERVER_VERSION: "7.0.3-testing" jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v6 @@ -67,8 +68,11 @@ jobs: - name: Start SeaTable ${{ steps.version.outputs.version }} working-directory: version-compare + env: + SEATABLE_IMAGE: ${{ steps.version.outputs.image }} + SEATABLE_VERSION: ${{ steps.version.outputs.version }} run: | - SEATABLE_IMAGE=${{ steps.version.outputs.image }} SEATABLE_VERSION=${{ steps.version.outputs.version }} docker compose up -d + docker compose up -d ./setup.sh - name: Run API tests diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 2e00905..5fdefb8 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -10,7 +10,7 @@ env: jobs: postman: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/rdme-openapi.yml b/.github/workflows/rdme-openapi.yml index 0d266be..b209a05 100644 --- a/.github/workflows/rdme-openapi.yml +++ b/.github/workflows/rdme-openapi.yml @@ -30,8 +30,9 @@ jobs: # Job 1: Publish OpenAPI specs and docs to ReadMe.com # ----------------------------------------------------------------------- publish: + if: false # temporarily disabled name: Publish to ReadMe - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v4 @@ -175,8 +176,9 @@ jobs: # ----------------------------------------------------------------------- deploy-static: name: Deploy static files - if: github.ref_name == github.event.repository.default_branch - runs-on: ubuntu-latest + if: false # temporarily disabled + # if: github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-26.04 needs: publish steps: - name: Check out repo diff --git a/.github/workflows/version-compare.yml b/.github/workflows/version-compare.yml index 3dcc7c7..0abc410 100644 --- a/.github/workflows/version-compare.yml +++ b/.github/workflows/version-compare.yml @@ -38,7 +38,7 @@ on: jobs: compare: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Check out repo uses: actions/checkout@v4 diff --git a/README.md b/README.md index a6fd7bf..1b99a74 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ cd version-compare export SEATABLE_IMAGE=seatable/seatable-enterprise export SEATABLE_VERSION=6.0.10 +# seatable/dtable-server-js or seatable/dtable-server-go +export DTABLE_SERVER_IMAGE=seatable/dtable-server-js +export DTABLE_SERVER_VERSION=7.0.3-testing + # Create license file cp "SOURCE" seatable-license.txt diff --git a/authentication.yaml b/authentication.yaml index a9a3767..90cdd2a 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.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/base_operations.yaml b/base_operations.yaml index 4f4c30d..f8738fb 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.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/file_operations.yaml b/file_operations.yaml index 18abb0c..e0f30e8 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.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/ping_and_info.yaml b/ping_and_info.yaml index b103d75..8afe28b 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.2" + version: "7.0" servers: - url: "https://{server}" variables: @@ -115,10 +115,14 @@ paths: - Ping summary: Ping dtable Server operationId: pingDtableServer - description: >- + description: |- Check the status of the dtable-server. Base related operations are carried out by the dtable-server. These operations are included in the section **Base Operations**. + + > 🚧 Not exposed by default + > + > As of SeaTable 7.0, the default NGINX configuration no longer routes `/dtable-server/` and this endpoint returns `404`. responses: "200": description: Service is available and running @@ -134,7 +138,12 @@ paths: - Ping summary: Ping dtable-db Server operationId: pingDtableDbServer - description: Check the status of the dtable-db server. + description: |- + Check the status of the dtable-db server. + + > 🚧 Not exposed by default + > + > As of SeaTable 7.0, the default NGINX configuration no longer routes `/dtable-db/` and this endpoint returns `404`. responses: "200": description: Service is available and running diff --git a/python-scheduler.yaml b/python-scheduler.yaml index fc0914e..a32f681 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.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/system_admin_account_operations.yaml b/system_admin_account_operations.yaml index 1faf561..b46227e 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.2" + version: "7.0" servers: - url: "https://{server}" variables: diff --git a/team_admin_account_operations.yaml b/team_admin_account_operations.yaml index a2b85d1..c8e0af0 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.2" + version: "7.0" 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/__snapshots__/test_system_admin/test_getSystemInformation.json b/tests/__snapshots__/test_system_admin/test_getSystemInformation.json index aa74e0d..5848b8f 100644 --- a/tests/__snapshots__/test_system_admin/test_getSystemInformation.json +++ b/tests/__snapshots__/test_system_admin/test_getSystemInformation.json @@ -5,11 +5,16 @@ "archived_row_count": "int", "dtable_server_info": [ { + "assigned_keys_count": 0, + "backend": "", "enable_cluster": false, "last_dtable_saving_count": "int", "last_period_operations_count": "int", "loaded_dtables_count": "int", + "local_node_url": "http://dtable-server:5000", + "node_id": "", "operation_count_since_up": "int", + "start_time": "", "web_socket_count": "int" } ], 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_ping.py b/tests/test_ping.py index 56f67e9..94e5bfb 100644 --- a/tests/test_ping.py +++ b/tests/test_ping.py @@ -30,20 +30,6 @@ def test_pingServerWithAuth(account_token: Secret): assert response.status_code == 200 assert response.text.strip('"') == 'pong' -def test_pingDtableServer(): - case: Case = ping_and_info_schema.find_operation_by_id('pingDtableServer').Case() - response = case.call() - - assert response.status_code == 200 - assert response.text.strip() == 'pong' - -def test_pingDtableDbServer(): - case: Case = ping_and_info_schema.find_operation_by_id('pingDtableDbServer').Case() - response = case.call() - - assert response.status_code == 200 - assert response.json()['ret'] == 'pong' - def test_pingApiGateway(): case: Case = ping_and_info_schema.find_operation_by_id('pingApiGateway').Case() response = case.call() diff --git a/user_account_operations.yaml b/user_account_operations.yaml index ee4e22f..730ff84 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.2" + version: "7.0" 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 diff --git a/version-compare/config/seatable-nginx.conf b/version-compare/config/seatable-nginx.conf new file mode 100644 index 0000000..cb7fe73 --- /dev/null +++ b/version-compare/config/seatable-nginx.conf @@ -0,0 +1,113 @@ +log_format seatableformat '\$http_x_forwarded_for \$remote_addr [\$time_local] "\$request" \$status \$body_bytes_sent "\$http_referer" "\$http_user_agent" \$upstream_response_time'; + +server { + listen 80; + listen [::]:80; + server_name _; + + proxy_set_header X-Forwarded-For $remote_addr; + + # CORS settings to allow API access from api.seatable.com + proxy_hide_header 'Access-Control-Allow-Origin'; + add_header 'Access-Control-Allow-Origin' 'https://api.seatable.com' always; + add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Accept, authorization, token, deviceType, x-seafile-otp' always; + if ($request_method = 'OPTIONS') { + return 204; + } + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_read_timeout 1200s; + client_max_body_size 0; + + access_log /opt/nginx-logs/dtable-web.access.log seatableformat; + error_log /opt/nginx-logs/dtable-web.error.log; + } + + location /seafhttp { + rewrite ^/seafhttp(.*)$ $1 break; + proxy_pass http://127.0.0.1:8082; + + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_request_buffering off; + proxy_connect_timeout 36000s; + proxy_read_timeout 36000s; + proxy_send_timeout 36000s; + send_timeout 36000s; + client_max_body_size 0; + + access_log /opt/nginx-logs/seafhttp.access.log seatableformat; + error_log /opt/nginx-logs/seafhttp.error.log; + } + + location /media { + root /opt/seatable/seatable-server-latest/dtable-web; + access_log off; + error_log off; + } + + location /api-gateway/ { + proxy_pass http://127.0.0.1:7780/; + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header Access-Control-Allow-Origin; + proxy_hide_header Access-Control-Allow-Methods; + proxy_hide_header Access-Control-Allow-Headers; + + client_max_body_size 10m; + + access_log /opt/nginx-logs/api-gateway.access.log seatableformat; + error_log /opt/nginx-logs/api-gateway.error.log; + } + + location /api-gateway/socket.io/ { + proxy_pass http://127.0.0.1:7780/socket.io/; + + # websocket proxying + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection upgrade; + + proxy_redirect off; + proxy_buffers 8 32k; + proxy_buffer_size 64k; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-NginX-Proxy true; + + access_log /opt/nginx-logs/socket-io.access.log seatableformat; + error_log /opt/nginx-logs/socket-io.error.log; + } + + location /internal-thumbnails/ { + internal; + alias /opt/seatable/seahub-data/thumbnail/; + + add_header X-Served-By "Nginx-via-X-Accel"; + expires 7d; + add_header Cache-Control "public, no-transform"; + + access_log /opt/nginx-logs/internal-thumbnails.access.log seatableformat; + error_log /opt/nginx-logs/internal-thumbnails.error.log; + } + + # Deprecated endpoints + location /dtable-server { + return 404 'This endpoint is deprecated. Please migrate to the API Gateway: https://forum.seatable.com/t/important-changes-to-the-seatable-cloud-api-in-version-5-3/6677'; + } + + location /dtable-db { + return 404 'This endpoint is deprecated. Please migrate to the API Gateway: https://forum.seatable.com/t/important-changes-to-the-seatable-cloud-api-in-version-5-3/6677'; + } +} diff --git a/version-compare/docker-compose.yml b/version-compare/docker-compose.yml index bcad3fe..7dae2c5 100644 --- a/version-compare/docker-compose.yml +++ b/version-compare/docker-compose.yml @@ -20,8 +20,10 @@ services: - SEATABLE_ADMIN_EMAIL=admin@example.com - SEATABLE_ADMIN_PASSWORD=admin1234 - TIME_ZONE=Europe/Berlin + - INNER_DTABLE_SERVER_URL=http://dtable-server:5000 volumes: - ./seatable-data:/shared + - ./config/seatable-nginx.conf:/etc/nginx/sites-enabled/default - type: bind source: ./seatable-license.txt target: /shared/seatable/seatable-license.txt @@ -37,8 +39,43 @@ services: networks: - backend + dtable-server: + image: ${DTABLE_SERVER_IMAGE:-seatable/dtable-server-js}:${DTABLE_SERVER_VERSION:?Variable is not set} + container_name: dtable-server + restart: unless-stopped + environment: + - SEATABLE_MYSQL_DB_HOST=mariadb + - SEATABLE_MYSQL_DB_USER=root + - SEATABLE_MYSQL_DB_PORT=3306 + - SEATABLE_MYSQL_DB_PASSWORD=seatable + - SEATABLE_MYSQL_DB_DTABLE_DB_NAME=dtable_db + - SEATABLE_MYSQL_DB_CCNET_DB_NAME=ccnet_db + - SEATABLE_MYSQL_DB_SEAFILE_DB_NAME=seafile_db + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=seatable + - JWT_PRIVATE_KEY=test-jwt-private-key-for-ci + - TIME_ZONE=Europe/Berlin + - TZ=Europe/Berlin + - INNER_DTABLE_WEB_SERVICE_URL=http://seatable-server:80 + - INNER_DTABLE_DB_URL=http://seatable-server:7777 + volumes: + - ./seatable-data:/shared + - type: bind + source: ./seatable-license.txt + target: /shared/seatable/seatable-license.txt + bind: + create_host_path: false + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + mariadb: - image: mariadb:11.8.3-noble + image: mariadb:11.8.5-noble container_name: mariadb command: ["mariadbd", "--innodb_snapshot_isolation=OFF"] environment: @@ -46,8 +83,6 @@ services: - MYSQL_LOG_CONSOLE=true - MARIADB_AUTO_UPGRADE=1 - TZ=${TIME_ZONE} - volumes: - - ./init-databases.sql:/docker-entrypoint-initdb.d/init-databases.sql:ro networks: - backend healthcheck: @@ -58,7 +93,7 @@ services: timeout: 10s redis: - image: redis:8.2.2-bookworm + image: redis:8.4.0-bookworm container_name: redis command: ["redis-server", "--requirepass", "seatable"] networks: diff --git a/version-compare/init-databases.sql b/version-compare/init-databases.sql deleted file mode 100644 index 0a040bc..0000000 --- a/version-compare/init-databases.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE DATABASE IF NOT EXISTS `dtable_db`; -CREATE DATABASE IF NOT EXISTS `ccnet_db`; -CREATE DATABASE IF NOT EXISTS `seafile_db`; diff --git a/version-compare/setup.sh b/version-compare/setup.sh index ea533a4..ca315d1 100755 --- a/version-compare/setup.sh +++ b/version-compare/setup.sh @@ -18,7 +18,7 @@ echo "Waiting for SeaTable to become available..." start_time=$(date +%s) while true; do - if curl -sf "${SEATABLE_SERVER}/dtable-server/ping/" > /dev/null 2>&1; then + if curl -sf "${SEATABLE_SERVER}/api-gateway/api/v2/ping/" > /dev/null 2>&1; then echo "SeaTable is ready." break fi @@ -197,4 +197,15 @@ SETTINGS echo "Restarting SeaTable to apply settings..." docker exec seatable-server /templates/seatable.sh restart +# FIXME: dtable-server boots in parallel with seatable-server and only symlinks +# /opt/seatable/storage-data -> /shared/seatable/storage-data if that directory +# already exists. On a fresh data directory it does not, so dtable-server keeps a +# container-local storage-data, cannot find any base, and every base operation +# fails with HTTP 500. The container must be *recreated* (not just restarted) — +# a restart keeps the local directory and the symlink step fails with +# "cannot overwrite directory". Remove once dtable-server creates the symlink +# unconditionally. +echo "Recreating dtable-server..." +docker compose up -d --force-recreate dtable-server + echo "Setup complete."