Files
superset2/tests/integration_tests/tasks/api_tests.py
2026-02-09 10:45:56 -08:00

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