mirror of
https://github.com/apache/superset.git
synced 2026-04-08 02:45:22 +00:00
539 lines
19 KiB
Python
539 lines
19 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
"""Integration tests for Task REST API"""
|
|
|
|
from contextlib import contextmanager
|
|
from typing import Generator
|
|
|
|
import prison
|
|
from superset_core.api.tasks import TaskStatus
|
|
|
|
from superset import db
|
|
from superset.models.tasks import Task
|
|
from superset.utils import json
|
|
from tests.integration_tests.base_tests import SupersetTestCase
|
|
from tests.integration_tests.constants import (
|
|
ADMIN_USERNAME,
|
|
GAMMA_USERNAME,
|
|
)
|
|
|
|
|
|
class TestTaskApi(SupersetTestCase):
|
|
"""Tests for Task REST API"""
|
|
|
|
TASK_API_BASE = "api/v1/task"
|
|
|
|
@contextmanager
|
|
def _create_tasks(self) -> Generator[list[Task], None, None]:
|
|
"""
|
|
Context manager to create test tasks with guaranteed cleanup.
|
|
|
|
Uses TaskDAO to create tasks, testing the actual production code path.
|
|
|
|
Usage:
|
|
with self._create_tasks() as tasks:
|
|
# Use tasks in test
|
|
# Cleanup happens automatically even if test fails
|
|
"""
|
|
from superset_core.api.tasks import TaskScope
|
|
|
|
from superset.daos.tasks import TaskDAO
|
|
|
|
admin = self.get_user("admin")
|
|
gamma = self.get_user("gamma")
|
|
|
|
tasks = []
|
|
|
|
try:
|
|
# Create tasks with different statuses using TaskDAO
|
|
for i in range(5):
|
|
task_key = f"test_task_{i}"
|
|
|
|
# Create task using DAO (this tests the dedup_key creation logic)
|
|
task = TaskDAO.create_task(
|
|
task_type="test_type",
|
|
task_key=task_key,
|
|
task_name=f"Test Task {i}",
|
|
scope=TaskScope.PRIVATE,
|
|
user_id=admin.id,
|
|
payload={"test": "data"},
|
|
)
|
|
|
|
# Set created_by for test purposes (DAO uses Flask-AppBuilder context)
|
|
task.created_by = admin
|
|
|
|
# Alternate between pending and finished tasks
|
|
if i % 2 != 0:
|
|
# Simulate realistic task lifecycle: PENDING → IN_PROGRESS → SUCCESS
|
|
# This sets both started_at (on IN_PROGRESS) and ended_at (on
|
|
# SUCCESS) so duration_seconds returns a valid value
|
|
task.set_status(TaskStatus.IN_PROGRESS)
|
|
task.set_status(TaskStatus.SUCCESS)
|
|
|
|
db.session.commit()
|
|
tasks.append(task)
|
|
|
|
# Create pending task for gamma user (use PENDING so it can be aborted)
|
|
gamma_task = TaskDAO.create_task(
|
|
task_type="test_type",
|
|
task_key="gamma_task",
|
|
task_name="Gamma Task",
|
|
scope=TaskScope.PRIVATE,
|
|
user_id=gamma.id,
|
|
payload={"user": "gamma"},
|
|
)
|
|
# Set created_by for test purposes
|
|
gamma_task.created_by = gamma
|
|
db.session.commit()
|
|
tasks.append(gamma_task)
|
|
|
|
yield tasks
|
|
finally:
|
|
# Cleanup happens here regardless of test success/failure
|
|
for task in tasks:
|
|
try:
|
|
db.session.delete(task)
|
|
except Exception: # noqa: S110
|
|
# Task may already be deleted or session may be in bad state
|
|
pass
|
|
try:
|
|
db.session.commit()
|
|
except Exception:
|
|
# Rollback if commit fails
|
|
db.session.rollback()
|
|
|
|
def test_info_task(self):
|
|
"""
|
|
Task API: Test info endpoint
|
|
"""
|
|
self.login(ADMIN_USERNAME)
|
|
uri = f"{self.TASK_API_BASE}/_info"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert "permissions" in data
|
|
|
|
def test_get_task_by_uuid(self):
|
|
"""
|
|
Task API: Test get task by UUID and verify dedup_key is hashed
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
# Get a pending task to verify active dedup_key format
|
|
task = (
|
|
db.session.query(Task)
|
|
.filter_by(
|
|
created_by_fk=admin.id,
|
|
status=TaskStatus.PENDING.value,
|
|
task_type="test_type",
|
|
)
|
|
.first()
|
|
)
|
|
assert task is not None
|
|
|
|
# Verify active task has hashed dedup_key (64 chars for SHA-256)
|
|
assert len(task.dedup_key) == 64
|
|
assert all(c in "0123456789abcdef" for c in task.dedup_key)
|
|
assert task.dedup_key != str(task.uuid)
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
# Compare strings since JSON response contains string UUID
|
|
assert data["result"]["uuid"] == str(task.uuid)
|
|
assert data["result"]["id"] == task.id
|
|
|
|
def test_get_task_not_found(self):
|
|
"""
|
|
Task API: Test get task not found with non-existent UUID
|
|
"""
|
|
self.login(ADMIN_USERNAME)
|
|
# Use a valid UUID that doesn't exist in the database
|
|
uri = f"{self.TASK_API_BASE}/00000000-0000-0000-0000-000000000000"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 404
|
|
|
|
def test_get_task_invalid_uuid(self):
|
|
"""
|
|
Task API: Test get task with invalid UUID
|
|
"""
|
|
self.login(ADMIN_USERNAME)
|
|
uri = f"{self.TASK_API_BASE}/invalid-uuid"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 404
|
|
|
|
def test_get_task_list(self):
|
|
"""
|
|
Task API: Test get task list
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
uri = f"{self.TASK_API_BASE}/"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert data["count"] >= 6 # At least the fixtures we created
|
|
assert "result" in data
|
|
|
|
def test_get_task_list_filtered_by_status(self):
|
|
"""
|
|
Task API: Test get task list filtered by status
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
arguments = {
|
|
"filters": [
|
|
{"col": "status", "opr": "eq", "value": TaskStatus.PENDING.value}
|
|
]
|
|
}
|
|
uri = f"{self.TASK_API_BASE}/?q={prison.dumps(arguments)}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
for task in data["result"]:
|
|
assert task["status"] == TaskStatus.PENDING.value
|
|
|
|
def test_get_task_list_filtered_by_type(self):
|
|
"""
|
|
Task API: Test get task list filtered by type
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
arguments = {
|
|
"filters": [{"col": "task_type", "opr": "eq", "value": "test_type"}]
|
|
}
|
|
uri = f"{self.TASK_API_BASE}/?q={prison.dumps(arguments)}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert data["count"] >= 6
|
|
for task in data["result"]:
|
|
assert task["task_type"] == "test_type"
|
|
|
|
def test_get_task_list_ordered(self):
|
|
"""
|
|
Task API: Test get task list with ordering
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
arguments = {
|
|
"order_column": "created_on",
|
|
"order_direction": "desc",
|
|
}
|
|
uri = f"{self.TASK_API_BASE}/?q={prison.dumps(arguments)}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert len(data["result"]) > 0
|
|
|
|
def test_get_task_list_paginated(self):
|
|
"""
|
|
Task API: Test get task list with pagination
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
arguments = {"page": 0, "page_size": 2}
|
|
uri = f"{self.TASK_API_BASE}/?q={prison.dumps(arguments)}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert len(data["result"]) <= 2
|
|
assert data["count"] >= 6
|
|
|
|
def test_cancel_task_by_uuid(self):
|
|
"""
|
|
Task API: Test cancel task by UUID
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
task = (
|
|
db.session.query(Task)
|
|
.filter_by(created_by_fk=admin.id, status=TaskStatus.PENDING.value)
|
|
.first()
|
|
)
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/cancel"
|
|
rv = self.client.post(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
# Compare strings since JSON response contains string UUID
|
|
assert data["task"]["uuid"] == str(task.uuid)
|
|
assert data["task"]["status"] == TaskStatus.ABORTED.value
|
|
assert data["action"] == "aborted"
|
|
|
|
def test_cancel_task_not_found(self):
|
|
"""
|
|
Task API: Test cancel task not found with non-existent UUID
|
|
"""
|
|
self.login(ADMIN_USERNAME)
|
|
uri = f"{self.TASK_API_BASE}/00000000-0000-0000-0000-000000000000/cancel"
|
|
rv = self.client.post(uri)
|
|
assert rv.status_code == 404
|
|
|
|
def test_cancel_task_not_owned(self):
|
|
"""
|
|
Task API: Test cancel task not owned by user
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(GAMMA_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
# Try to cancel admin's task as gamma user
|
|
task = db.session.query(Task).filter_by(created_by_fk=admin.id).first()
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/cancel"
|
|
rv = self.client.post(uri)
|
|
assert rv.status_code == 404
|
|
|
|
def test_cancel_task_admin_can_cancel_others(self):
|
|
"""
|
|
Task API: Test admin can cancel other users' tasks
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
gamma = self.get_user("gamma")
|
|
|
|
# Admin cancels gamma's task
|
|
task = db.session.query(Task).filter_by(created_by_fk=gamma.id).first()
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/cancel"
|
|
rv = self.client.post(uri)
|
|
assert rv.status_code == 200
|
|
|
|
def test_get_task_status_by_uuid(self):
|
|
"""
|
|
Task API: Test get task status by UUID
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
task = db.session.query(Task).filter_by(created_by_fk=admin.id).first()
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/status"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert "status" in data
|
|
assert data["status"] == task.status
|
|
|
|
def test_get_task_status_not_found(self):
|
|
"""
|
|
Task API: Test get task status not found with non-existent UUID
|
|
"""
|
|
self.login(ADMIN_USERNAME)
|
|
uri = f"{self.TASK_API_BASE}/00000000-0000-0000-0000-000000000000/status"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 404
|
|
|
|
def test_get_task_status_not_owned(self):
|
|
"""
|
|
Task API: Test non-owner can't see task status
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(GAMMA_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
# Try to get status of admin's task as gamma user
|
|
task = db.session.query(Task).filter_by(created_by_fk=admin.id).first()
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/status"
|
|
rv = self.client.get(uri)
|
|
# Should be forbidden due to base filter
|
|
assert rv.status_code == 404
|
|
|
|
def test_get_task_status_admin_can_see_others(self):
|
|
"""
|
|
Task API: Test admin can see other users' task status
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
gamma = self.get_user("gamma")
|
|
|
|
# Admin gets gamma's task status
|
|
task = db.session.query(Task).filter_by(created_by_fk=gamma.id).first()
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}/status"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
assert data["status"] == task.status
|
|
|
|
def test_get_task_list_user_sees_own_tasks(self):
|
|
"""
|
|
Task API: Test non-admin user only sees their own tasks
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(GAMMA_USERNAME)
|
|
gamma = self.get_user("gamma")
|
|
|
|
uri = f"{self.TASK_API_BASE}/"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
# Gamma should only see their own task
|
|
for task in data["result"]:
|
|
assert task["created_by"]["id"] == gamma.id
|
|
|
|
def test_get_task_list_admin_sees_all_tasks(self):
|
|
"""
|
|
Task API: Test admin sees all tasks
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
|
|
uri = f"{self.TASK_API_BASE}/"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
# Admin should see all tasks
|
|
assert data["count"] >= 6
|
|
|
|
def test_task_response_schema(self):
|
|
"""
|
|
Task API: Test response schema includes all expected fields
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
task = db.session.query(Task).filter_by(created_by_fk=admin.id).first()
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
result = data["result"]
|
|
|
|
# Check all expected fields are present
|
|
expected_fields = [
|
|
"id",
|
|
"uuid",
|
|
"task_key",
|
|
"task_type",
|
|
"task_name",
|
|
"status",
|
|
"created_on",
|
|
"created_on_delta_humanized",
|
|
"changed_on",
|
|
"changed_by",
|
|
"started_at",
|
|
"ended_at",
|
|
"created_by",
|
|
"user_id",
|
|
"payload",
|
|
"properties",
|
|
"duration_seconds",
|
|
"scope",
|
|
"subscriber_count",
|
|
"subscribers",
|
|
]
|
|
|
|
for field in expected_fields:
|
|
assert field in result, f"Field {field} missing from response"
|
|
|
|
# Verify properties is a dict with expected structure
|
|
properties = result["properties"]
|
|
assert isinstance(properties, dict)
|
|
|
|
def test_task_payload_serialization(self):
|
|
"""
|
|
Task API: Test payload is properly serialized as dict
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
task = (
|
|
db.session.query(Task)
|
|
.filter_by(created_by_fk=admin.id, task_type="test_type")
|
|
.first()
|
|
)
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
payload = data["result"]["payload"]
|
|
|
|
# Payload should be a dict, not a string
|
|
assert isinstance(payload, dict)
|
|
assert "test" in payload
|
|
assert payload["test"] == "data"
|
|
|
|
def test_task_computed_properties(self):
|
|
"""
|
|
Task API: Test computed properties in response
|
|
|
|
This test verifies that computed properties (status, duration_seconds)
|
|
are correctly returned in the API response. Internal DB columns like
|
|
dedup_key are tested in unit tests (test_find_by_task_key_finished_not_found).
|
|
"""
|
|
with self._create_tasks():
|
|
self.login(ADMIN_USERNAME)
|
|
admin = self.get_user("admin")
|
|
|
|
# Get a successful task
|
|
task = (
|
|
db.session.query(Task)
|
|
.filter_by(created_by_fk=admin.id, status=TaskStatus.SUCCESS.value)
|
|
.first()
|
|
)
|
|
assert task is not None
|
|
|
|
uri = f"{self.TASK_API_BASE}/{task.uuid}"
|
|
rv = self.client.get(uri)
|
|
assert rv.status_code == 200
|
|
|
|
data = json.loads(rv.data.decode("utf-8"))
|
|
result = data["result"]
|
|
|
|
# Check status field (computed properties are now derived from status)
|
|
assert result["status"] == TaskStatus.SUCCESS.value
|
|
|
|
# Properties dict should exist and be a dict
|
|
assert "properties" in result
|
|
assert isinstance(result["properties"], dict)
|
|
|
|
# Verify duration_seconds is not null for completed tasks with timestamps
|
|
# (requires both started_at and ended_at to be set)
|
|
if result.get("started_at") and result.get("ended_at"):
|
|
assert result["duration_seconds"] is not None
|
|
assert result["duration_seconds"] >= 0.0
|