Compare commits

..

1 Commits

Author SHA1 Message Date
Marcelo Elizeche Landó
b8f952558f align blueprint import schema with 200 result response 2026-05-06 13:29:35 -03:00
9 changed files with 34 additions and 148 deletions

View File

@@ -1,36 +0,0 @@
from django.db.models import F, QuerySet
from rest_framework.filters import OrderingFilter
from rest_framework.request import Request
from rest_framework.views import APIView
class NullsAwareOrderingFilter(OrderingFilter):
"""OrderingFilter that sorts NULL values consistently.
For any nullable field, NULLs are treated as the smallest possible value:
- ascending → NULLs appear first (nulls_first=True)
- descending → NULLs appear last (nulls_last=True)
"""
def _nullable_field_names(self, queryset: QuerySet) -> set[str]:
return {f.name for f in queryset.model._meta.get_fields() if hasattr(f, "null") and f.null}
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView):
queryset = super().filter_queryset(request, queryset, view)
ordering = queryset.query.order_by
if not ordering:
return queryset
nullable = self._nullable_field_names(queryset)
new_ordering = []
changed = False
for term in ordering:
name = term.lstrip("-")
if name in nullable:
changed = True
if term.startswith("-"):
new_ordering.append(F(name).desc(nulls_last=True))
else:
new_ordering.append(F(name).asc(nulls_first=True))
else:
new_ordering.append(term)
return queryset.order_by(*new_ordering) if changed else queryset

View File

@@ -1,59 +0,0 @@
from django.db.models import OrderBy
from django.test import TestCase
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory
from authentik.api.ordering import NullsAwareOrderingFilter
from authentik.core.models import Token, User
class MockView:
ordering_fields = "__all__"
ordering = None
class TestNullsAwareOrderingFilter(TestCase):
def setUp(self):
self.filter = NullsAwareOrderingFilter()
self.view = MockView()
factory = APIRequestFactory()
self._req = lambda ordering: Request(factory.get("/", {"ordering": ordering}))
def _order_by(self, model, ordering):
qs = model.objects.all()
return self.filter.filter_queryset(self._req(ordering), qs, self.view).query.order_by
def test_nullable_asc_nulls_first(self):
"""Ascending sort on a nullable field rewrites to nulls_first=True."""
(expr,) = self._order_by(User, "last_login")
self.assertIsInstance(expr, OrderBy)
self.assertFalse(expr.descending)
self.assertTrue(expr.nulls_first)
def test_nullable_desc_nulls_last(self):
"""Descending sort on a nullable field rewrites to nulls_last=True."""
(expr,) = self._order_by(User, "-last_login")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)
def test_non_nullable_passes_through(self):
"""Non-nullable fields are left as plain string terms."""
(expr,) = self._order_by(User, "username")
self.assertEqual(expr, "username")
def test_mixed_ordering(self):
"""Only nullable terms are rewritten; non-nullable terms pass through unchanged."""
first, second = self._order_by(User, "username,-last_login")
self.assertEqual(first, "username")
self.assertIsInstance(second, OrderBy)
self.assertTrue(second.descending)
self.assertTrue(second.nulls_last)
def test_expires_nullable(self):
"""expires on ExpiringModel is nullable and is rewritten correctly."""
(expr,) = self._order_by(Token, "-expires")
self.assertIsInstance(expr, OrderBy)
self.assertTrue(expr.descending)
self.assertTrue(expr.nulls_last)

View File

@@ -217,10 +217,7 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
@extend_schema(
request={"multipart/form-data": BlueprintUploadSerializer},
responses={
204: BlueprintImportResultSerializer,
400: BlueprintImportResultSerializer,
},
responses={200: BlueprintImportResultSerializer},
)
@action(url_path="import", detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
@validate(
@@ -247,21 +244,13 @@ class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
import_response = self.BlueprintImportResultSerializer(
data={
"logs": [],
"success": False,
"logs": [LogEventSerializer(log).data for log in logs],
"success": valid,
}
)
import_response.is_valid(raise_exception=True)
import_response.initial_data["logs"] = [LogEventSerializer(log).data for log in logs]
import_response.initial_data["success"] = valid
import_response.is_valid()
if not valid:
return Response(data=import_response.initial_data, status=200)
successful = importer.apply()
import_response.initial_data["success"] = successful
import_response.is_valid()
if not successful:
return Response(data=import_response.initial_data, status=200)
if valid:
import_response.initial_data["success"] = importer.apply()
import_response.is_valid()
return Response(data=import_response.initial_data, status=200)

View File

@@ -3,6 +3,7 @@
from json import dumps, loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import dump
@@ -141,6 +142,20 @@ class TestBlueprintsV1API(APITestCase):
)
self.assertEqual(res.status_code, 200)
def test_api_import_invalid_blueprint_returns_result_payload(self):
"""Invalid blueprint content returns a result payload instead of a 400 response."""
file = SimpleUploadedFile("invalid-blueprint.yaml", b'{"version": 3}')
res = self.client.post(
reverse("authentik_api:blueprintinstance-import-"),
data={"file": file},
format="multipart",
)
self.assertEqual(res.status_code, 200)
self.assertFalse(res.json()["success"])
self.assertGreater(len(res.json()["logs"]), 0)
def test_api_import_unknown_path(self):
"""Path not in available blueprints is rejected (covers api.py:56)."""
res = self.client.post(

View File

@@ -221,7 +221,7 @@ REST_FRAMEWORK = {
"authentik.api.search.ql.QLSearch",
"authentik.rbac.filters.ObjectFilter",
"django_filters.rest_framework.DjangoFilterBackend",
"authentik.api.ordering.NullsAwareOrderingFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_PERMISSION_CLASSES": ("authentik.rbac.permissions.ObjectPermissions",),
"DEFAULT_AUTHENTICATION_CLASSES": (

View File

@@ -8,8 +8,8 @@
"url": "https://github.com/goauthentik/authentik.git"
},
"scripts": {
"clean": "tsc -b --clean tsconfig.json tsconfig.esm.json",
"build": "npm run clean && tsc -b tsconfig.json tsconfig.esm.json",
"clean": "tsc -b --clean tsconfig.json tsconfig.esm.json",
"prepare": "npm run build"
},
"main": "./dist/index.js",

View File

@@ -9678,18 +9678,14 @@ paths:
security:
- authentik: []
responses:
'204':
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/BlueprintImportResult'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/BlueprintImportResult'
description: ''
$ref: '#/components/responses/ValidationErrorResponse'
'403':
$ref: '#/components/responses/GenericErrorResponse'
/oauth2/access_tokens/:

View File

@@ -73,10 +73,6 @@ export class UserListPage extends WithLicenseSummary(
max-width: var(--pf-c-avatar--Width);
vertical-align: middle;
}
.pf-c-card.tree .pf-c-card__body {
padding-left: 0;
padding-right: 0;
}
`,
];
@@ -96,7 +92,7 @@ export class UserListPage extends WithLicenseSummary(
public pageIcon = "pf-icon pf-icon-user";
@property({ type: String })
public order = "-last_login";
public order = "last_login";
@property({ type: String })
public activePath: string;
@@ -372,7 +368,7 @@ export class UserListPage extends WithLicenseSummary(
protected renderSidebarBefore(): TemplateResult {
return html`<aside aria-labelledby="sidebar-left-panel-header" class="pf-c-sidebar__panel">
<div class="pf-c-card tree">
<div class="pf-c-card">
<div
role="heading"
aria-level="2"

View File

@@ -2,8 +2,6 @@ import "#elements/LoadingOverlay";
import Styles from "./index.entrypoint.css";
import { writeToClipboard } from "#common/clipboard";
import { Interface } from "#elements/Interface";
import { WithBrandConfig } from "#elements/mixins/branding";
@@ -157,35 +155,22 @@ export class RacInterface extends WithBrandConfig(Interface) {
if (/^text\//.exec(mimetype)) {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => {
data += text;
};
reader.onend = () => {
const trimmed = data.trim();
// Some remote sessions (notably SSH) push empty clipboard
// payloads that would otherwise clobber the user's local
// clipboard, breaking subsequent paste attempts. Ignore
// them so the local clipboard remains intact.
if (!trimmed) {
console.debug("authentik/rac: ignored empty remote clipboard payload");
return;
}
this._previousClipboardValue = trimmed;
writeToClipboard(trimmed);
this._previousClipboardValue = data;
navigator.clipboard.writeText(data);
};
} else {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
const item = new ClipboardItem({
[blob.type]: blob,
});
writeToClipboard(item);
navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
};
}
console.debug("authentik/rac: updated clipboard from remote");