MyFSIO v0.2.0 Release #12
@@ -157,10 +157,7 @@ class LocalKeyEncryption(EncryptionProvider):
|
|||||||
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
def decrypt(self, ciphertext: bytes, nonce: bytes, encrypted_data_key: bytes,
|
||||||
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
key_id: str, context: Dict[str, str] | None = None) -> bytes:
|
||||||
"""Decrypt data using envelope encryption."""
|
"""Decrypt data using envelope encryption."""
|
||||||
# Decrypt the data key
|
|
||||||
data_key = self._decrypt_data_key(encrypted_data_key)
|
data_key = self._decrypt_data_key(encrypted_data_key)
|
||||||
|
|
||||||
# Decrypt the data
|
|
||||||
aesgcm = AESGCM(data_key)
|
aesgcm = AESGCM(data_key)
|
||||||
try:
|
try:
|
||||||
return aesgcm.decrypt(nonce, ciphertext, None)
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
||||||
|
|||||||
@@ -975,8 +975,7 @@ def _object_tagging_handler(bucket_name: str, object_key: str) -> Response:
|
|||||||
return _error_response("NoSuchKey", message, 404)
|
return _error_response("NoSuchKey", message, 404)
|
||||||
current_app.logger.info("Object tags deleted", extra={"bucket": bucket_name, "key": object_key})
|
current_app.logger.info("Object tags deleted", extra={"bucket": bucket_name, "key": object_key})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
# PUT
|
|
||||||
payload = request.get_data(cache=False) or b""
|
payload = request.get_data(cache=False) or b""
|
||||||
try:
|
try:
|
||||||
tags = _parse_tagging_document(payload)
|
tags = _parse_tagging_document(payload)
|
||||||
@@ -1044,7 +1043,7 @@ def _bucket_cors_handler(bucket_name: str) -> Response:
|
|||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
current_app.logger.info("Bucket CORS deleted", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket CORS deleted", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
# PUT
|
|
||||||
payload = request.get_data(cache=False) or b""
|
payload = request.get_data(cache=False) or b""
|
||||||
if not payload.strip():
|
if not payload.strip():
|
||||||
try:
|
try:
|
||||||
@@ -1290,8 +1289,7 @@ def _bucket_lifecycle_handler(bucket_name: str) -> Response:
|
|||||||
storage.set_bucket_lifecycle(bucket_name, None)
|
storage.set_bucket_lifecycle(bucket_name, None)
|
||||||
current_app.logger.info("Bucket lifecycle deleted", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket lifecycle deleted", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
# PUT
|
|
||||||
payload = request.get_data(cache=False) or b""
|
payload = request.get_data(cache=False) or b""
|
||||||
if not payload.strip():
|
if not payload.strip():
|
||||||
return _error_response("MalformedXML", "Request body is required", 400)
|
return _error_response("MalformedXML", "Request body is required", 400)
|
||||||
@@ -1454,8 +1452,7 @@ def _bucket_quota_handler(bucket_name: str) -> Response:
|
|||||||
return _error_response("NoSuchBucket", str(exc), 404)
|
return _error_response("NoSuchBucket", str(exc), 404)
|
||||||
current_app.logger.info("Bucket quota deleted", extra={"bucket": bucket_name})
|
current_app.logger.info("Bucket quota deleted", extra={"bucket": bucket_name})
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
# PUT
|
|
||||||
payload = request.get_json(silent=True)
|
payload = request.get_json(silent=True)
|
||||||
if not payload:
|
if not payload:
|
||||||
return _error_response("MalformedRequest", "Request body must be JSON with quota limits", 400)
|
return _error_response("MalformedRequest", "Request body must be JSON with quota limits", 400)
|
||||||
|
|||||||
@@ -968,8 +968,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Warning alert for unreachable endpoint (shown by JS if endpoint is down) -->
|
|
||||||
<div id="replication-endpoint-warning" class="alert alert-danger d-none mb-4" role="alert">
|
<div id="replication-endpoint-warning" class="alert alert-danger d-none mb-4" role="alert">
|
||||||
<div class="d-flex align-items-start">
|
<div class="d-flex align-items-start">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="flex-shrink-0 me-2" viewBox="0 0 16 16">
|
||||||
@@ -1783,7 +1782,6 @@
|
|||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
<script>
|
<script>
|
||||||
// Auto-indent for JSON textareas
|
|
||||||
function setupJsonAutoIndent(textarea) {
|
function setupJsonAutoIndent(textarea) {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
@@ -1795,15 +1793,12 @@
|
|||||||
const end = this.selectionEnd;
|
const end = this.selectionEnd;
|
||||||
const value = this.value;
|
const value = this.value;
|
||||||
|
|
||||||
// Get the current line
|
|
||||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
const currentLine = value.substring(lineStart, start);
|
const currentLine = value.substring(lineStart, start);
|
||||||
|
|
||||||
// Calculate base indentation (leading whitespace of current line)
|
|
||||||
const indentMatch = currentLine.match(/^(\s*)/);
|
const indentMatch = currentLine.match(/^(\s*)/);
|
||||||
let indent = indentMatch ? indentMatch[1] : '';
|
let indent = indentMatch ? indentMatch[1] : '';
|
||||||
|
|
||||||
// Check if the line ends with { or [ (should increase indent)
|
|
||||||
const trimmedLine = currentLine.trim();
|
const trimmedLine = currentLine.trim();
|
||||||
const lastChar = trimmedLine.slice(-1);
|
const lastChar = trimmedLine.slice(-1);
|
||||||
|
|
||||||
@@ -1811,42 +1806,34 @@
|
|||||||
let insertAfter = '';
|
let insertAfter = '';
|
||||||
|
|
||||||
if (lastChar === '{' || lastChar === '[') {
|
if (lastChar === '{' || lastChar === '[') {
|
||||||
// Add extra indentation
|
|
||||||
newIndent = indent + ' ';
|
newIndent = indent + ' ';
|
||||||
|
|
||||||
// Check if we need to add closing bracket on new line
|
|
||||||
const charAfterCursor = value.substring(start, start + 1).trim();
|
const charAfterCursor = value.substring(start, start + 1).trim();
|
||||||
if ((lastChar === '{' && charAfterCursor === '}') ||
|
if ((lastChar === '{' && charAfterCursor === '}') ||
|
||||||
(lastChar === '[' && charAfterCursor === ']')) {
|
(lastChar === '[' && charAfterCursor === ']')) {
|
||||||
insertAfter = '\n' + indent;
|
insertAfter = '\n' + indent;
|
||||||
}
|
}
|
||||||
} else if (lastChar === ',' || lastChar === ':') {
|
} else if (lastChar === ',' || lastChar === ':') {
|
||||||
// Keep same indentation for continuation
|
|
||||||
newIndent = indent;
|
newIndent = indent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert newline with proper indentation
|
|
||||||
const insertion = '\n' + newIndent + insertAfter;
|
const insertion = '\n' + newIndent + insertAfter;
|
||||||
const newValue = value.substring(0, start) + insertion + value.substring(end);
|
const newValue = value.substring(0, start) + insertion + value.substring(end);
|
||||||
|
|
||||||
this.value = newValue;
|
this.value = newValue;
|
||||||
|
|
||||||
// Set cursor position after the indentation
|
|
||||||
const newCursorPos = start + 1 + newIndent.length;
|
const newCursorPos = start + 1 + newIndent.length;
|
||||||
this.selectionStart = this.selectionEnd = newCursorPos;
|
this.selectionStart = this.selectionEnd = newCursorPos;
|
||||||
|
|
||||||
// Trigger input event for any listeners
|
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Tab key for indentation
|
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const start = this.selectionStart;
|
const start = this.selectionStart;
|
||||||
const end = this.selectionEnd;
|
const end = this.selectionEnd;
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Outdent: remove 2 spaces from start of line
|
|
||||||
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
const lineContent = this.value.substring(lineStart, start);
|
const lineContent = this.value.substring(lineStart, start);
|
||||||
if (lineContent.startsWith(' ')) {
|
if (lineContent.startsWith(' ')) {
|
||||||
@@ -1855,7 +1842,6 @@
|
|||||||
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
|
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Indent: insert 2 spaces
|
|
||||||
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
||||||
this.selectionStart = this.selectionEnd = start + 2;
|
this.selectionStart = this.selectionEnd = start + 2;
|
||||||
}
|
}
|
||||||
@@ -1865,7 +1851,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply auto-indent to policy editor textarea
|
|
||||||
setupJsonAutoIndent(document.getElementById('policyDocument'));
|
setupJsonAutoIndent(document.getElementById('policyDocument'));
|
||||||
|
|
||||||
const formatBytes = (bytes) => {
|
const formatBytes = (bytes) => {
|
||||||
@@ -1970,24 +1955,21 @@
|
|||||||
let isLoadingObjects = false;
|
let isLoadingObjects = false;
|
||||||
let hasMoreObjects = false;
|
let hasMoreObjects = false;
|
||||||
let currentFilterTerm = '';
|
let currentFilterTerm = '';
|
||||||
let pageSize = 5000; // Load large batches for virtual scrolling
|
let pageSize = 5000;
|
||||||
let currentPrefix = ''; // Current folder prefix for navigation
|
let currentPrefix = '';
|
||||||
let allObjects = []; // All loaded object metadata (lightweight)
|
let allObjects = [];
|
||||||
let urlTemplates = null; // URL templates from API for constructing object URLs
|
let urlTemplates = null;
|
||||||
|
|
||||||
// Helper to build URL from template by replacing KEY_PLACEHOLDER with encoded key
|
|
||||||
const buildUrlFromTemplate = (template, key) => {
|
const buildUrlFromTemplate = (template, key) => {
|
||||||
if (!template) return '';
|
if (!template) return '';
|
||||||
return template.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/'));
|
return template.replace('KEY_PLACEHOLDER', encodeURIComponent(key).replace(/%2F/g, '/'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Virtual scrolling state
|
const ROW_HEIGHT = 53;
|
||||||
const ROW_HEIGHT = 53; // Height of each table row in pixels
|
const BUFFER_ROWS = 10;
|
||||||
const BUFFER_ROWS = 10; // Extra rows to render above/below viewport
|
let visibleItems = [];
|
||||||
let visibleItems = []; // Current items to display (filtered by folder/search)
|
let renderedRange = { start: 0, end: 0 };
|
||||||
let renderedRange = { start: 0, end: 0 }; // Currently rendered row indices
|
|
||||||
|
|
||||||
// Create a row element from object data (for virtual scrolling)
|
|
||||||
const createObjectRow = (obj, displayKey = null) => {
|
const createObjectRow = (obj, displayKey = null) => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.dataset.objectRow = '';
|
tr.dataset.objectRow = '';
|
||||||
@@ -2110,16 +2092,12 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============== VIRTUAL SCROLLING SYSTEM ==============
|
|
||||||
|
|
||||||
// Spacer elements for virtual scroll height
|
|
||||||
let topSpacer = null;
|
let topSpacer = null;
|
||||||
let bottomSpacer = null;
|
let bottomSpacer = null;
|
||||||
|
|
||||||
const initVirtualScrollElements = () => {
|
const initVirtualScrollElements = () => {
|
||||||
if (!objectsTableBody) return;
|
if (!objectsTableBody) return;
|
||||||
|
|
||||||
// Create spacer rows if they don't exist
|
|
||||||
if (!topSpacer) {
|
if (!topSpacer) {
|
||||||
topSpacer = document.createElement('tr');
|
topSpacer = document.createElement('tr');
|
||||||
topSpacer.id = 'virtual-top-spacer';
|
topSpacer.id = 'virtual-top-spacer';
|
||||||
@@ -2131,38 +2109,33 @@
|
|||||||
bottomSpacer.innerHTML = '<td colspan="4" style="padding: 0; border: none;"></td>';
|
bottomSpacer.innerHTML = '<td colspan="4" style="padding: 0; border: none;"></td>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute which items should be visible based on current view
|
|
||||||
const computeVisibleItems = () => {
|
const computeVisibleItems = () => {
|
||||||
const items = [];
|
const items = [];
|
||||||
const folders = new Set();
|
const folders = new Set();
|
||||||
|
|
||||||
allObjects.forEach(obj => {
|
allObjects.forEach(obj => {
|
||||||
if (!obj.key.startsWith(currentPrefix)) return;
|
if (!obj.key.startsWith(currentPrefix)) return;
|
||||||
|
|
||||||
const remainder = obj.key.slice(currentPrefix.length);
|
const remainder = obj.key.slice(currentPrefix.length);
|
||||||
const slashIndex = remainder.indexOf('/');
|
const slashIndex = remainder.indexOf('/');
|
||||||
|
|
||||||
if (slashIndex === -1) {
|
if (slashIndex === -1) {
|
||||||
// File in current folder - filter on the displayed filename (remainder)
|
|
||||||
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
|
if (!currentFilterTerm || remainder.toLowerCase().includes(currentFilterTerm)) {
|
||||||
items.push({ type: 'file', data: obj, displayKey: remainder });
|
items.push({ type: 'file', data: obj, displayKey: remainder });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Folder
|
|
||||||
const folderName = remainder.slice(0, slashIndex);
|
const folderName = remainder.slice(0, slashIndex);
|
||||||
const folderPath = currentPrefix + folderName + '/';
|
const folderPath = currentPrefix + folderName + '/';
|
||||||
if (!folders.has(folderPath)) {
|
if (!folders.has(folderPath)) {
|
||||||
folders.add(folderPath);
|
folders.add(folderPath);
|
||||||
// Filter on the displayed folder name only
|
|
||||||
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
|
if (!currentFilterTerm || folderName.toLowerCase().includes(currentFilterTerm)) {
|
||||||
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
items.push({ type: 'folder', path: folderPath, displayKey: folderName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: folders first, then files
|
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||||
@@ -2173,36 +2146,30 @@
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render only the visible rows based on scroll position
|
|
||||||
const renderVirtualRows = () => {
|
const renderVirtualRows = () => {
|
||||||
if (!objectsTableBody || !scrollContainer) return;
|
if (!objectsTableBody || !scrollContainer) return;
|
||||||
|
|
||||||
const containerHeight = scrollContainer.clientHeight;
|
const containerHeight = scrollContainer.clientHeight;
|
||||||
const scrollTop = scrollContainer.scrollTop;
|
const scrollTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
// Calculate visible range
|
|
||||||
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS);
|
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS);
|
||||||
const endIndex = Math.min(visibleItems.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS);
|
const endIndex = Math.min(visibleItems.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS);
|
||||||
|
|
||||||
// Skip if range hasn't changed significantly
|
|
||||||
if (startIndex === renderedRange.start && endIndex === renderedRange.end) return;
|
if (startIndex === renderedRange.start && endIndex === renderedRange.end) return;
|
||||||
|
|
||||||
renderedRange = { start: startIndex, end: endIndex };
|
renderedRange = { start: startIndex, end: endIndex };
|
||||||
|
|
||||||
// Clear and rebuild
|
|
||||||
objectsTableBody.innerHTML = '';
|
objectsTableBody.innerHTML = '';
|
||||||
|
|
||||||
// Add top spacer
|
|
||||||
initVirtualScrollElements();
|
initVirtualScrollElements();
|
||||||
topSpacer.querySelector('td').style.height = `${startIndex * ROW_HEIGHT}px`;
|
topSpacer.querySelector('td').style.height = `${startIndex * ROW_HEIGHT}px`;
|
||||||
objectsTableBody.appendChild(topSpacer);
|
objectsTableBody.appendChild(topSpacer);
|
||||||
|
|
||||||
// Render visible rows
|
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
const item = visibleItems[i];
|
const item = visibleItems[i];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
|
||||||
let row;
|
let row;
|
||||||
if (item.type === 'folder') {
|
if (item.type === 'folder') {
|
||||||
row = createFolderRow(item.path, item.displayKey);
|
row = createFolderRow(item.path, item.displayKey);
|
||||||
@@ -2212,33 +2179,28 @@
|
|||||||
row.dataset.virtualIndex = i;
|
row.dataset.virtualIndex = i;
|
||||||
objectsTableBody.appendChild(row);
|
objectsTableBody.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add bottom spacer
|
|
||||||
const remainingRows = visibleItems.length - endIndex;
|
const remainingRows = visibleItems.length - endIndex;
|
||||||
bottomSpacer.querySelector('td').style.height = `${remainingRows * ROW_HEIGHT}px`;
|
bottomSpacer.querySelector('td').style.height = `${remainingRows * ROW_HEIGHT}px`;
|
||||||
objectsTableBody.appendChild(bottomSpacer);
|
objectsTableBody.appendChild(bottomSpacer);
|
||||||
|
|
||||||
// Re-attach handlers to new rows
|
|
||||||
attachRowHandlers();
|
attachRowHandlers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced scroll handler for virtual scrolling
|
|
||||||
let scrollTimeout = null;
|
let scrollTimeout = null;
|
||||||
const handleVirtualScroll = () => {
|
const handleVirtualScroll = () => {
|
||||||
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
|
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
|
||||||
scrollTimeout = requestAnimationFrame(renderVirtualRows);
|
scrollTimeout = requestAnimationFrame(renderVirtualRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh the virtual list (after data changes or navigation)
|
|
||||||
const refreshVirtualList = () => {
|
const refreshVirtualList = () => {
|
||||||
visibleItems = computeVisibleItems();
|
visibleItems = computeVisibleItems();
|
||||||
renderedRange = { start: -1, end: -1 }; // Force re-render
|
renderedRange = { start: -1, end: -1 };
|
||||||
|
|
||||||
if (visibleItems.length === 0) {
|
if (visibleItems.length === 0) {
|
||||||
if (allObjects.length === 0 && !hasMoreObjects) {
|
if (allObjects.length === 0 && !hasMoreObjects) {
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
} else {
|
} else {
|
||||||
// Empty folder
|
|
||||||
objectsTableBody.innerHTML = `
|
objectsTableBody.innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="py-5">
|
<td colspan="4" class="py-5">
|
||||||
@@ -2262,7 +2224,6 @@
|
|||||||
updateFolderViewStatus();
|
updateFolderViewStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update status bar
|
|
||||||
const updateFolderViewStatus = () => {
|
const updateFolderViewStatus = () => {
|
||||||
const folderViewStatusEl = document.getElementById('folder-view-status');
|
const folderViewStatusEl = document.getElementById('folder-view-status');
|
||||||
if (!folderViewStatusEl) return;
|
if (!folderViewStatusEl) return;
|
||||||
@@ -2277,8 +2238,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============== DATA LOADING ==============
|
|
||||||
|
|
||||||
const loadObjects = async (append = false) => {
|
const loadObjects = async (append = false) => {
|
||||||
if (isLoadingObjects) return;
|
if (isLoadingObjects) return;
|
||||||
isLoadingObjects = true;
|
isLoadingObjects = true;
|
||||||
@@ -2290,7 +2249,6 @@
|
|||||||
allObjects = [];
|
allObjects = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading spinner when loading more
|
|
||||||
if (append && loadMoreSpinner) {
|
if (append && loadMoreSpinner) {
|
||||||
loadMoreSpinner.classList.remove('d-none');
|
loadMoreSpinner.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
@@ -2359,7 +2317,6 @@
|
|||||||
updateLoadMoreButton();
|
updateLoadMoreButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh virtual scroll view
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(currentPrefix);
|
renderBreadcrumb(currentPrefix);
|
||||||
|
|
||||||
@@ -2379,7 +2336,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const attachRowHandlers = () => {
|
const attachRowHandlers = () => {
|
||||||
// Attach handlers to object rows
|
|
||||||
const objectRows = document.querySelectorAll('[data-object-row]');
|
const objectRows = document.querySelectorAll('[data-object-row]');
|
||||||
objectRows.forEach(row => {
|
objectRows.forEach(row => {
|
||||||
if (row.dataset.handlersAttached) return;
|
if (row.dataset.handlersAttached) return;
|
||||||
@@ -2405,14 +2361,12 @@
|
|||||||
toggleRowSelection(row, selectCheckbox.checked);
|
toggleRowSelection(row, selectCheckbox.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore selection state
|
|
||||||
if (selectedRows.has(row.dataset.key)) {
|
if (selectedRows.has(row.dataset.key)) {
|
||||||
selectCheckbox.checked = true;
|
selectCheckbox.checked = true;
|
||||||
row.classList.add('table-active');
|
row.classList.add('table-active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach handlers to folder rows
|
|
||||||
const folderRows = document.querySelectorAll('.folder-row');
|
const folderRows = document.querySelectorAll('.folder-row');
|
||||||
folderRows.forEach(row => {
|
folderRows.forEach(row => {
|
||||||
if (row.dataset.handlersAttached) return;
|
if (row.dataset.handlersAttached) return;
|
||||||
@@ -2423,7 +2377,6 @@
|
|||||||
const checkbox = row.querySelector('[data-folder-select]');
|
const checkbox = row.querySelector('[data-folder-select]');
|
||||||
checkbox?.addEventListener('change', (e) => {
|
checkbox?.addEventListener('change', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Select all objects in this folder
|
|
||||||
const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath));
|
const folderObjects = allObjects.filter(obj => obj.key.startsWith(folderPath));
|
||||||
folderObjects.forEach(obj => {
|
folderObjects.forEach(obj => {
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
@@ -2450,31 +2403,26 @@
|
|||||||
updateBulkDeleteState();
|
updateBulkDeleteState();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll container reference (needed for virtual scrolling)
|
|
||||||
const scrollSentinel = document.getElementById('scroll-sentinel');
|
const scrollSentinel = document.getElementById('scroll-sentinel');
|
||||||
const scrollContainer = document.querySelector('.objects-table-container');
|
const scrollContainer = document.querySelector('.objects-table-container');
|
||||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||||
|
|
||||||
// Virtual scroll: listen to scroll events
|
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', handleVirtualScroll, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load More button click handler (fallback)
|
|
||||||
loadMoreBtn?.addEventListener('click', () => {
|
loadMoreBtn?.addEventListener('click', () => {
|
||||||
if (hasMoreObjects && !isLoadingObjects) {
|
if (hasMoreObjects && !isLoadingObjects) {
|
||||||
loadObjects(true);
|
loadObjects(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show/hide Load More button based on hasMoreObjects
|
|
||||||
function updateLoadMoreButton() {
|
function updateLoadMoreButton() {
|
||||||
if (loadMoreBtn) {
|
if (loadMoreBtn) {
|
||||||
loadMoreBtn.classList.toggle('d-none', !hasMoreObjects);
|
loadMoreBtn.classList.toggle('d-none', !hasMoreObjects);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-load more when near bottom (for loading all data)
|
|
||||||
if (scrollSentinel && scrollContainer) {
|
if (scrollSentinel && scrollContainer) {
|
||||||
const containerObserver = new IntersectionObserver((entries) => {
|
const containerObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
@@ -2484,7 +2432,7 @@
|
|||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
root: scrollContainer,
|
root: scrollContainer,
|
||||||
rootMargin: '500px', // Load more earlier for smoother experience
|
rootMargin: '500px',
|
||||||
threshold: 0
|
threshold: 0
|
||||||
});
|
});
|
||||||
containerObserver.observe(scrollSentinel);
|
containerObserver.observe(scrollSentinel);
|
||||||
@@ -2503,7 +2451,6 @@
|
|||||||
viewportObserver.observe(scrollSentinel);
|
viewportObserver.observe(scrollSentinel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page size selector (now controls batch size)
|
|
||||||
const pageSizeSelect = document.getElementById('page-size-select');
|
const pageSizeSelect = document.getElementById('page-size-select');
|
||||||
pageSizeSelect?.addEventListener('change', (e) => {
|
pageSizeSelect?.addEventListener('change', (e) => {
|
||||||
pageSize = parseInt(e.target.value, 10);
|
pageSize = parseInt(e.target.value, 10);
|
||||||
@@ -2669,14 +2616,11 @@
|
|||||||
return tr;
|
return tr;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Instant client-side folder navigation (no server round-trip!)
|
|
||||||
const navigateToFolder = (prefix) => {
|
const navigateToFolder = (prefix) => {
|
||||||
currentPrefix = prefix;
|
currentPrefix = prefix;
|
||||||
|
|
||||||
// Scroll to top when navigating
|
|
||||||
if (scrollContainer) scrollContainer.scrollTop = 0;
|
if (scrollContainer) scrollContainer.scrollTop = 0;
|
||||||
|
|
||||||
// Instant re-render from already-loaded data
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
renderBreadcrumb(prefix);
|
renderBreadcrumb(prefix);
|
||||||
|
|
||||||
@@ -2710,9 +2654,9 @@
|
|||||||
if (keyCell && currentPrefix) {
|
if (keyCell && currentPrefix) {
|
||||||
const displayName = obj.key.slice(currentPrefix.length);
|
const displayName = obj.key.slice(currentPrefix.length);
|
||||||
keyCell.textContent = displayName;
|
keyCell.textContent = displayName;
|
||||||
keyCell.closest('.object-key').title = obj.key; // Full path in tooltip
|
keyCell.closest('.object-key').title = obj.key;
|
||||||
} else if (keyCell) {
|
} else if (keyCell) {
|
||||||
keyCell.textContent = obj.key; // Reset to full key at root
|
keyCell.textContent = obj.key;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2887,7 +2831,6 @@
|
|||||||
bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting;
|
bulkDeleteConfirm.disabled = selectedCount === 0 || bulkDeleting;
|
||||||
}
|
}
|
||||||
if (selectAllCheckbox) {
|
if (selectAllCheckbox) {
|
||||||
// With virtual scrolling, count files in current folder from visibleItems
|
|
||||||
const filesInView = visibleItems.filter(item => item.type === 'file');
|
const filesInView = visibleItems.filter(item => item.type === 'file');
|
||||||
const total = filesInView.length;
|
const total = filesInView.length;
|
||||||
const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length;
|
const visibleSelectedCount = filesInView.filter(item => selectedRows.has(item.data.key)).length;
|
||||||
@@ -3524,9 +3467,6 @@
|
|||||||
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
document.getElementById('object-search')?.addEventListener('input', (event) => {
|
||||||
currentFilterTerm = event.target.value.toLowerCase();
|
currentFilterTerm = event.target.value.toLowerCase();
|
||||||
updateFilterWarning();
|
updateFilterWarning();
|
||||||
|
|
||||||
// Use the virtual scrolling system for filtering - it properly handles
|
|
||||||
// both folder view and flat view, and works with large object counts
|
|
||||||
refreshVirtualList();
|
refreshVirtualList();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3886,10 +3826,8 @@
|
|||||||
selectAllCheckbox?.addEventListener('change', (event) => {
|
selectAllCheckbox?.addEventListener('change', (event) => {
|
||||||
const shouldSelect = Boolean(event.target?.checked);
|
const shouldSelect = Boolean(event.target?.checked);
|
||||||
|
|
||||||
// Get all file items in the current view (works with virtual scrolling)
|
|
||||||
const filesInView = visibleItems.filter(item => item.type === 'file');
|
const filesInView = visibleItems.filter(item => item.type === 'file');
|
||||||
|
|
||||||
// Update selectedRows directly using object keys (not DOM elements)
|
|
||||||
filesInView.forEach(item => {
|
filesInView.forEach(item => {
|
||||||
if (shouldSelect) {
|
if (shouldSelect) {
|
||||||
selectedRows.set(item.data.key, item.data);
|
selectedRows.set(item.data.key, item.data);
|
||||||
@@ -3898,12 +3836,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update folder checkboxes in DOM (folders are always rendered)
|
|
||||||
document.querySelectorAll('[data-folder-select]').forEach(cb => {
|
document.querySelectorAll('[data-folder-select]').forEach(cb => {
|
||||||
cb.checked = shouldSelect;
|
cb.checked = shouldSelect;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update any currently rendered object checkboxes
|
|
||||||
document.querySelectorAll('[data-object-row]').forEach((row) => {
|
document.querySelectorAll('[data-object-row]').forEach((row) => {
|
||||||
const checkbox = row.querySelector('[data-object-select]');
|
const checkbox = row.querySelector('[data-object-select]');
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
@@ -3917,7 +3853,6 @@
|
|||||||
|
|
||||||
bulkDownloadButton?.addEventListener('click', async () => {
|
bulkDownloadButton?.addEventListener('click', async () => {
|
||||||
if (!bulkDownloadEndpoint) return;
|
if (!bulkDownloadEndpoint) return;
|
||||||
// Use selectedRows which tracks all selected objects (not just rendered ones)
|
|
||||||
const selected = Array.from(selectedRows.keys());
|
const selected = Array.from(selectedRows.keys());
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
@@ -4085,7 +4020,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bucket name validation for replication setup
|
|
||||||
const targetBucketInput = document.getElementById('target_bucket');
|
const targetBucketInput = document.getElementById('target_bucket');
|
||||||
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
|
const targetBucketFeedback = document.getElementById('target_bucket_feedback');
|
||||||
|
|
||||||
@@ -4120,7 +4054,6 @@
|
|||||||
targetBucketInput?.addEventListener('input', updateBucketNameValidation);
|
targetBucketInput?.addEventListener('input', updateBucketNameValidation);
|
||||||
targetBucketInput?.addEventListener('blur', updateBucketNameValidation);
|
targetBucketInput?.addEventListener('blur', updateBucketNameValidation);
|
||||||
|
|
||||||
// Prevent form submission if bucket name is invalid
|
|
||||||
const replicationForm = targetBucketInput?.closest('form');
|
const replicationForm = targetBucketInput?.closest('form');
|
||||||
replicationForm?.addEventListener('submit', (e) => {
|
replicationForm?.addEventListener('submit', (e) => {
|
||||||
const name = targetBucketInput.value.trim();
|
const name = targetBucketInput.value.trim();
|
||||||
@@ -4133,7 +4066,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Policy JSON validation and formatting
|
|
||||||
const formatPolicyBtn = document.getElementById('formatPolicyBtn');
|
const formatPolicyBtn = document.getElementById('formatPolicyBtn');
|
||||||
const policyValidationStatus = document.getElementById('policyValidationStatus');
|
const policyValidationStatus = document.getElementById('policyValidationStatus');
|
||||||
const policyValidBadge = document.getElementById('policyValidBadge');
|
const policyValidBadge = document.getElementById('policyValidBadge');
|
||||||
@@ -4176,12 +4108,10 @@
|
|||||||
policyTextarea.value = JSON.stringify(parsed, null, 2);
|
policyTextarea.value = JSON.stringify(parsed, null, 2);
|
||||||
validatePolicyJson();
|
validatePolicyJson();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Show error in validation
|
|
||||||
validatePolicyJson();
|
validatePolicyJson();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize policy validation on page load
|
|
||||||
if (policyTextarea && policyPreset?.value === 'custom') {
|
if (policyTextarea && policyPreset?.value === 'custom') {
|
||||||
validatePolicyJson();
|
validatePolicyJson();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@
|
|||||||
|
|
||||||
const searchInput = document.getElementById('bucket-search');
|
const searchInput = document.getElementById('bucket-search');
|
||||||
const bucketItems = document.querySelectorAll('.bucket-item');
|
const bucketItems = document.querySelectorAll('.bucket-item');
|
||||||
const noBucketsMsg = document.querySelector('.text-center.py-5'); // The "No buckets found" empty state
|
const noBucketsMsg = document.querySelector('.text-center.py-5');
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
|||||||
@@ -306,8 +306,7 @@
|
|||||||
const data = Object.fromEntries(formData.entries());
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing connection...</div>';
|
resultDiv.innerHTML = '<div class="text-info"><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Testing connection...</div>';
|
||||||
|
|
||||||
// Use AbortController to timeout client-side after 20 seconds
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||||||
|
|
||||||
@@ -394,8 +393,6 @@
|
|||||||
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
|
form.action = "{{ url_for('ui.delete_connection', connection_id='CONN_ID') }}".replace('CONN_ID', id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check connection health for each connection in the table
|
|
||||||
// Uses staggered requests to avoid overwhelming the server
|
|
||||||
async function checkConnectionHealth(connectionId, statusEl) {
|
async function checkConnectionHealth(connectionId, statusEl) {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -432,13 +429,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stagger health checks to avoid all requests at once
|
|
||||||
const connectionRows = document.querySelectorAll('tr[data-connection-id]');
|
const connectionRows = document.querySelectorAll('tr[data-connection-id]');
|
||||||
connectionRows.forEach((row, index) => {
|
connectionRows.forEach((row, index) => {
|
||||||
const connectionId = row.getAttribute('data-connection-id');
|
const connectionId = row.getAttribute('data-connection-id');
|
||||||
const statusEl = row.querySelector('.connection-status');
|
const statusEl = row.querySelector('.connection-status');
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
// Stagger requests by 200ms each
|
|
||||||
setTimeout(() => checkConnectionHealth(connectionId, statusEl), index * 200);
|
setTimeout(() => checkConnectionHealth(connectionId, statusEl), index * 200);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -456,7 +456,6 @@
|
|||||||
{{ super() }}
|
{{ super() }}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// Auto-indent for JSON textareas
|
|
||||||
function setupJsonAutoIndent(textarea) {
|
function setupJsonAutoIndent(textarea) {
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
|
||||||
@@ -468,61 +467,49 @@
|
|||||||
const end = this.selectionEnd;
|
const end = this.selectionEnd;
|
||||||
const value = this.value;
|
const value = this.value;
|
||||||
|
|
||||||
// Get the current line
|
|
||||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
const currentLine = value.substring(lineStart, start);
|
const currentLine = value.substring(lineStart, start);
|
||||||
|
|
||||||
// Calculate base indentation (leading whitespace of current line)
|
|
||||||
const indentMatch = currentLine.match(/^(\s*)/);
|
const indentMatch = currentLine.match(/^(\s*)/);
|
||||||
let indent = indentMatch ? indentMatch[1] : '';
|
let indent = indentMatch ? indentMatch[1] : '';
|
||||||
|
|
||||||
// Check if the line ends with { or [ (should increase indent)
|
|
||||||
const trimmedLine = currentLine.trim();
|
const trimmedLine = currentLine.trim();
|
||||||
const lastChar = trimmedLine.slice(-1);
|
const lastChar = trimmedLine.slice(-1);
|
||||||
|
|
||||||
// Check the character before cursor
|
|
||||||
const charBeforeCursor = value.substring(start - 1, start).trim();
|
const charBeforeCursor = value.substring(start - 1, start).trim();
|
||||||
|
|
||||||
let newIndent = indent;
|
let newIndent = indent;
|
||||||
let insertAfter = '';
|
let insertAfter = '';
|
||||||
|
|
||||||
if (lastChar === '{' || lastChar === '[') {
|
if (lastChar === '{' || lastChar === '[') {
|
||||||
// Add extra indentation
|
|
||||||
newIndent = indent + ' ';
|
newIndent = indent + ' ';
|
||||||
|
|
||||||
// Check if we need to add closing bracket on new line
|
|
||||||
const charAfterCursor = value.substring(start, start + 1).trim();
|
const charAfterCursor = value.substring(start, start + 1).trim();
|
||||||
if ((lastChar === '{' && charAfterCursor === '}') ||
|
if ((lastChar === '{' && charAfterCursor === '}') ||
|
||||||
(lastChar === '[' && charAfterCursor === ']')) {
|
(lastChar === '[' && charAfterCursor === ']')) {
|
||||||
insertAfter = '\n' + indent;
|
insertAfter = '\n' + indent;
|
||||||
}
|
}
|
||||||
} else if (lastChar === ',' || lastChar === ':') {
|
} else if (lastChar === ',' || lastChar === ':') {
|
||||||
// Keep same indentation for continuation
|
|
||||||
newIndent = indent;
|
newIndent = indent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert newline with proper indentation
|
|
||||||
const insertion = '\n' + newIndent + insertAfter;
|
const insertion = '\n' + newIndent + insertAfter;
|
||||||
const newValue = value.substring(0, start) + insertion + value.substring(end);
|
const newValue = value.substring(0, start) + insertion + value.substring(end);
|
||||||
|
|
||||||
this.value = newValue;
|
this.value = newValue;
|
||||||
|
|
||||||
// Set cursor position after the indentation
|
|
||||||
const newCursorPos = start + 1 + newIndent.length;
|
const newCursorPos = start + 1 + newIndent.length;
|
||||||
this.selectionStart = this.selectionEnd = newCursorPos;
|
this.selectionStart = this.selectionEnd = newCursorPos;
|
||||||
|
|
||||||
// Trigger input event for any listeners
|
|
||||||
this.dispatchEvent(new Event('input', { bubbles: true }));
|
this.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Tab key for indentation
|
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const start = this.selectionStart;
|
const start = this.selectionStart;
|
||||||
const end = this.selectionEnd;
|
const end = this.selectionEnd;
|
||||||
|
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
// Outdent: remove 2 spaces from start of line
|
|
||||||
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
|
const lineStart = this.value.lastIndexOf('\n', start - 1) + 1;
|
||||||
const lineContent = this.value.substring(lineStart, start);
|
const lineContent = this.value.substring(lineStart, start);
|
||||||
if (lineContent.startsWith(' ')) {
|
if (lineContent.startsWith(' ')) {
|
||||||
@@ -531,7 +518,6 @@
|
|||||||
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
|
this.selectionStart = this.selectionEnd = Math.max(lineStart, start - 2);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Indent: insert 2 spaces
|
|
||||||
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
|
||||||
this.selectionStart = this.selectionEnd = start + 2;
|
this.selectionStart = this.selectionEnd = start + 2;
|
||||||
}
|
}
|
||||||
@@ -541,7 +527,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply auto-indent to policy editor textareas
|
|
||||||
setupJsonAutoIndent(document.getElementById('policyEditorDocument'));
|
setupJsonAutoIndent(document.getElementById('policyEditorDocument'));
|
||||||
setupJsonAutoIndent(document.getElementById('createUserPolicies'));
|
setupJsonAutoIndent(document.getElementById('createUserPolicies'));
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ def client(app):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers(app):
|
def auth_headers(app):
|
||||||
# Create a test user and return headers
|
|
||||||
# Using the user defined in conftest.py
|
|
||||||
return {
|
return {
|
||||||
"X-Access-Key": "test",
|
"X-Access-Key": "test",
|
||||||
"X-Secret-Key": "secret"
|
"X-Secret-Key": "secret"
|
||||||
@@ -75,19 +73,16 @@ def test_multipart_upload_flow(client, auth_headers):
|
|||||||
|
|
||||||
def test_abort_multipart_upload(client, auth_headers):
|
def test_abort_multipart_upload(client, auth_headers):
|
||||||
client.put("/abort-bucket", headers=auth_headers)
|
client.put("/abort-bucket", headers=auth_headers)
|
||||||
|
|
||||||
# Initiate
|
|
||||||
resp = client.post("/abort-bucket/file.txt?uploads", headers=auth_headers)
|
resp = client.post("/abort-bucket/file.txt?uploads", headers=auth_headers)
|
||||||
upload_id = fromstring(resp.data).find("UploadId").text
|
upload_id = fromstring(resp.data).find("UploadId").text
|
||||||
|
|
||||||
# Abort
|
|
||||||
resp = client.delete(f"/abort-bucket/file.txt?uploadId={upload_id}", headers=auth_headers)
|
resp = client.delete(f"/abort-bucket/file.txt?uploadId={upload_id}", headers=auth_headers)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# Try to upload part (should fail)
|
|
||||||
resp = client.put(
|
resp = client.put(
|
||||||
f"/abort-bucket/file.txt?partNumber=1&uploadId={upload_id}",
|
f"/abort-bucket/file.txt?partNumber=1&uploadId={upload_id}",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
data=b"data"
|
data=b"data"
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404 # NoSuchUpload
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ class TestLocalKeyEncryption:
|
|||||||
|
|
||||||
key_path = tmp_path / "keys" / "master.key"
|
key_path = tmp_path / "keys" / "master.key"
|
||||||
provider = LocalKeyEncryption(key_path)
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
# Access master key to trigger creation
|
|
||||||
key = provider.master_key
|
key = provider.master_key
|
||||||
|
|
||||||
assert key_path.exists()
|
assert key_path.exists()
|
||||||
assert len(key) == 32 # 256-bit key
|
assert len(key) == 32
|
||||||
|
|
||||||
def test_load_existing_master_key(self, tmp_path):
|
def test_load_existing_master_key(self, tmp_path):
|
||||||
"""Test loading an existing master key."""
|
"""Test loading an existing master key."""
|
||||||
@@ -49,16 +48,14 @@ class TestLocalKeyEncryption:
|
|||||||
provider = LocalKeyEncryption(key_path)
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
plaintext = b"Hello, World! This is a test message."
|
plaintext = b"Hello, World! This is a test message."
|
||||||
|
|
||||||
# Encrypt
|
|
||||||
result = provider.encrypt(plaintext)
|
result = provider.encrypt(plaintext)
|
||||||
|
|
||||||
assert result.ciphertext != plaintext
|
assert result.ciphertext != plaintext
|
||||||
assert result.key_id == "local"
|
assert result.key_id == "local"
|
||||||
assert len(result.nonce) == 12
|
assert len(result.nonce) == 12
|
||||||
assert len(result.encrypted_data_key) > 0
|
assert len(result.encrypted_data_key) > 0
|
||||||
|
|
||||||
# Decrypt
|
|
||||||
decrypted = provider.decrypt(
|
decrypted = provider.decrypt(
|
||||||
result.ciphertext,
|
result.ciphertext,
|
||||||
result.nonce,
|
result.nonce,
|
||||||
@@ -79,12 +76,9 @@ class TestLocalKeyEncryption:
|
|||||||
|
|
||||||
result1 = provider.encrypt(plaintext)
|
result1 = provider.encrypt(plaintext)
|
||||||
result2 = provider.encrypt(plaintext)
|
result2 = provider.encrypt(plaintext)
|
||||||
|
|
||||||
# Different encrypted data keys
|
|
||||||
assert result1.encrypted_data_key != result2.encrypted_data_key
|
assert result1.encrypted_data_key != result2.encrypted_data_key
|
||||||
# Different nonces
|
|
||||||
assert result1.nonce != result2.nonce
|
assert result1.nonce != result2.nonce
|
||||||
# Different ciphertexts
|
|
||||||
assert result1.ciphertext != result2.ciphertext
|
assert result1.ciphertext != result2.ciphertext
|
||||||
|
|
||||||
def test_generate_data_key(self, tmp_path):
|
def test_generate_data_key(self, tmp_path):
|
||||||
@@ -95,30 +89,26 @@ class TestLocalKeyEncryption:
|
|||||||
provider = LocalKeyEncryption(key_path)
|
provider = LocalKeyEncryption(key_path)
|
||||||
|
|
||||||
plaintext_key, encrypted_key = provider.generate_data_key()
|
plaintext_key, encrypted_key = provider.generate_data_key()
|
||||||
|
|
||||||
assert len(plaintext_key) == 32
|
assert len(plaintext_key) == 32
|
||||||
assert len(encrypted_key) > 32 # nonce + ciphertext + tag
|
assert len(encrypted_key) > 32
|
||||||
|
|
||||||
# Verify we can decrypt the key
|
|
||||||
decrypted_key = provider._decrypt_data_key(encrypted_key)
|
decrypted_key = provider._decrypt_data_key(encrypted_key)
|
||||||
assert decrypted_key == plaintext_key
|
assert decrypted_key == plaintext_key
|
||||||
|
|
||||||
def test_decrypt_with_wrong_key_fails(self, tmp_path):
|
def test_decrypt_with_wrong_key_fails(self, tmp_path):
|
||||||
"""Test that decryption fails with wrong master key."""
|
"""Test that decryption fails with wrong master key."""
|
||||||
from app.encryption import LocalKeyEncryption, EncryptionError
|
from app.encryption import LocalKeyEncryption, EncryptionError
|
||||||
|
|
||||||
# Create two providers with different keys
|
|
||||||
key_path1 = tmp_path / "master1.key"
|
key_path1 = tmp_path / "master1.key"
|
||||||
key_path2 = tmp_path / "master2.key"
|
key_path2 = tmp_path / "master2.key"
|
||||||
|
|
||||||
provider1 = LocalKeyEncryption(key_path1)
|
provider1 = LocalKeyEncryption(key_path1)
|
||||||
provider2 = LocalKeyEncryption(key_path2)
|
provider2 = LocalKeyEncryption(key_path2)
|
||||||
|
|
||||||
# Encrypt with provider1
|
|
||||||
plaintext = b"Secret message"
|
plaintext = b"Secret message"
|
||||||
result = provider1.encrypt(plaintext)
|
result = provider1.encrypt(plaintext)
|
||||||
|
|
||||||
# Try to decrypt with provider2
|
|
||||||
with pytest.raises(EncryptionError):
|
with pytest.raises(EncryptionError):
|
||||||
provider2.decrypt(
|
provider2.decrypt(
|
||||||
result.ciphertext,
|
result.ciphertext,
|
||||||
@@ -195,19 +185,16 @@ class TestStreamingEncryptor:
|
|||||||
key_path = tmp_path / "master.key"
|
key_path = tmp_path / "master.key"
|
||||||
provider = LocalKeyEncryption(key_path)
|
provider = LocalKeyEncryption(key_path)
|
||||||
encryptor = StreamingEncryptor(provider, chunk_size=1024)
|
encryptor = StreamingEncryptor(provider, chunk_size=1024)
|
||||||
|
|
||||||
# Create test data
|
original_data = b"A" * 5000 + b"B" * 5000 + b"C" * 5000
|
||||||
original_data = b"A" * 5000 + b"B" * 5000 + b"C" * 5000 # 15KB
|
|
||||||
stream = io.BytesIO(original_data)
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
# Encrypt
|
|
||||||
encrypted_stream, metadata = encryptor.encrypt_stream(stream)
|
encrypted_stream, metadata = encryptor.encrypt_stream(stream)
|
||||||
encrypted_data = encrypted_stream.read()
|
encrypted_data = encrypted_stream.read()
|
||||||
|
|
||||||
assert encrypted_data != original_data
|
assert encrypted_data != original_data
|
||||||
assert metadata.algorithm == "AES256"
|
assert metadata.algorithm == "AES256"
|
||||||
|
|
||||||
# Decrypt
|
|
||||||
encrypted_stream = io.BytesIO(encrypted_data)
|
encrypted_stream = io.BytesIO(encrypted_data)
|
||||||
decrypted_stream = encryptor.decrypt_stream(encrypted_stream, metadata)
|
decrypted_stream = encryptor.decrypt_stream(encrypted_stream, metadata)
|
||||||
decrypted_data = decrypted_stream.read()
|
decrypted_data = decrypted_stream.read()
|
||||||
@@ -318,8 +305,7 @@ class TestClientEncryptionHelper:
|
|||||||
assert "key" in key_info
|
assert "key" in key_info
|
||||||
assert key_info["algorithm"] == "AES-256-GCM"
|
assert key_info["algorithm"] == "AES-256-GCM"
|
||||||
assert "created_at" in key_info
|
assert "created_at" in key_info
|
||||||
|
|
||||||
# Verify key is 256 bits
|
|
||||||
key = base64.b64decode(key_info["key"])
|
key = base64.b64decode(key_info["key"])
|
||||||
assert len(key) == 32
|
assert len(key) == 32
|
||||||
|
|
||||||
@@ -424,8 +410,7 @@ class TestKMSManager:
|
|||||||
|
|
||||||
assert key is not None
|
assert key is not None
|
||||||
assert key.key_id == "test-key"
|
assert key.key_id == "test-key"
|
||||||
|
|
||||||
# Non-existent key
|
|
||||||
assert kms.get_key("non-existent") is None
|
assert kms.get_key("non-existent") is None
|
||||||
|
|
||||||
def test_enable_disable_key(self, tmp_path):
|
def test_enable_disable_key(self, tmp_path):
|
||||||
@@ -438,15 +423,12 @@ class TestKMSManager:
|
|||||||
kms = KMSManager(keys_path, master_key_path)
|
kms = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
kms.create_key("Test key", key_id="test-key")
|
kms.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
# Initially enabled
|
|
||||||
assert kms.get_key("test-key").enabled
|
assert kms.get_key("test-key").enabled
|
||||||
|
|
||||||
# Disable
|
|
||||||
kms.disable_key("test-key")
|
kms.disable_key("test-key")
|
||||||
assert not kms.get_key("test-key").enabled
|
assert not kms.get_key("test-key").enabled
|
||||||
|
|
||||||
# Enable
|
|
||||||
kms.enable_key("test-key")
|
kms.enable_key("test-key")
|
||||||
assert kms.get_key("test-key").enabled
|
assert kms.get_key("test-key").enabled
|
||||||
|
|
||||||
@@ -502,12 +484,10 @@ class TestKMSManager:
|
|||||||
context = {"bucket": "test-bucket", "key": "test-key"}
|
context = {"bucket": "test-bucket", "key": "test-key"}
|
||||||
|
|
||||||
ciphertext = kms.encrypt("test-key", plaintext, context)
|
ciphertext = kms.encrypt("test-key", plaintext, context)
|
||||||
|
|
||||||
# Decrypt with same context succeeds
|
|
||||||
decrypted, _ = kms.decrypt(ciphertext, context)
|
decrypted, _ = kms.decrypt(ciphertext, context)
|
||||||
assert decrypted == plaintext
|
assert decrypted == plaintext
|
||||||
|
|
||||||
# Decrypt with different context fails
|
|
||||||
with pytest.raises(EncryptionError):
|
with pytest.raises(EncryptionError):
|
||||||
kms.decrypt(ciphertext, {"different": "context"})
|
kms.decrypt(ciphertext, {"different": "context"})
|
||||||
|
|
||||||
@@ -526,8 +506,7 @@ class TestKMSManager:
|
|||||||
|
|
||||||
assert len(plaintext_key) == 32
|
assert len(plaintext_key) == 32
|
||||||
assert len(encrypted_key) > 0
|
assert len(encrypted_key) > 0
|
||||||
|
|
||||||
# Decrypt the encrypted key
|
|
||||||
decrypted_key = kms.decrypt_data_key("test-key", encrypted_key)
|
decrypted_key = kms.decrypt_data_key("test-key", encrypted_key)
|
||||||
|
|
||||||
assert decrypted_key == plaintext_key
|
assert decrypted_key == plaintext_key
|
||||||
@@ -560,14 +539,9 @@ class TestKMSManager:
|
|||||||
kms.create_key("Key 2", key_id="key-2")
|
kms.create_key("Key 2", key_id="key-2")
|
||||||
|
|
||||||
plaintext = b"Data to re-encrypt"
|
plaintext = b"Data to re-encrypt"
|
||||||
|
|
||||||
# Encrypt with key-1
|
|
||||||
ciphertext1 = kms.encrypt("key-1", plaintext)
|
ciphertext1 = kms.encrypt("key-1", plaintext)
|
||||||
|
|
||||||
# Re-encrypt with key-2
|
|
||||||
ciphertext2 = kms.re_encrypt(ciphertext1, "key-2")
|
ciphertext2 = kms.re_encrypt(ciphertext1, "key-2")
|
||||||
|
|
||||||
# Decrypt with key-2
|
|
||||||
decrypted, key_id = kms.decrypt(ciphertext2)
|
decrypted, key_id = kms.decrypt(ciphertext2)
|
||||||
|
|
||||||
assert decrypted == plaintext
|
assert decrypted == plaintext
|
||||||
@@ -587,7 +561,7 @@ class TestKMSManager:
|
|||||||
|
|
||||||
assert len(random1) == 32
|
assert len(random1) == 32
|
||||||
assert len(random2) == 32
|
assert len(random2) == 32
|
||||||
assert random1 != random2 # Very unlikely to be equal
|
assert random1 != random2
|
||||||
|
|
||||||
def test_keys_persist_across_instances(self, tmp_path):
|
def test_keys_persist_across_instances(self, tmp_path):
|
||||||
"""Test that keys persist and can be loaded by new instances."""
|
"""Test that keys persist and can be loaded by new instances."""
|
||||||
@@ -595,15 +569,13 @@ class TestKMSManager:
|
|||||||
|
|
||||||
keys_path = tmp_path / "kms_keys.json"
|
keys_path = tmp_path / "kms_keys.json"
|
||||||
master_key_path = tmp_path / "master.key"
|
master_key_path = tmp_path / "master.key"
|
||||||
|
|
||||||
# Create key with first instance
|
|
||||||
kms1 = KMSManager(keys_path, master_key_path)
|
kms1 = KMSManager(keys_path, master_key_path)
|
||||||
kms1.create_key("Test key", key_id="test-key")
|
kms1.create_key("Test key", key_id="test-key")
|
||||||
|
|
||||||
plaintext = b"Persistent encryption test"
|
plaintext = b"Persistent encryption test"
|
||||||
ciphertext = kms1.encrypt("test-key", plaintext)
|
ciphertext = kms1.encrypt("test-key", plaintext)
|
||||||
|
|
||||||
# Create new instance and verify key works
|
|
||||||
kms2 = KMSManager(keys_path, master_key_path)
|
kms2 = KMSManager(keys_path, master_key_path)
|
||||||
|
|
||||||
decrypted, key_id = kms2.decrypt(ciphertext)
|
decrypted, key_id = kms2.decrypt(ciphertext)
|
||||||
@@ -664,31 +636,27 @@ class TestEncryptedStorage:
|
|||||||
encryption = EncryptionManager(config)
|
encryption = EncryptionManager(config)
|
||||||
|
|
||||||
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
||||||
|
|
||||||
# Create bucket with encryption config
|
|
||||||
storage.create_bucket("test-bucket")
|
storage.create_bucket("test-bucket")
|
||||||
storage.set_bucket_encryption("test-bucket", {
|
storage.set_bucket_encryption("test-bucket", {
|
||||||
"Rules": [{"SSEAlgorithm": "AES256"}]
|
"Rules": [{"SSEAlgorithm": "AES256"}]
|
||||||
})
|
})
|
||||||
|
|
||||||
# Put object
|
|
||||||
original_data = b"This is secret data that should be encrypted"
|
original_data = b"This is secret data that should be encrypted"
|
||||||
stream = io.BytesIO(original_data)
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
meta = encrypted_storage.put_object(
|
meta = encrypted_storage.put_object(
|
||||||
"test-bucket",
|
"test-bucket",
|
||||||
"secret.txt",
|
"secret.txt",
|
||||||
stream,
|
stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert meta is not None
|
assert meta is not None
|
||||||
|
|
||||||
# Verify file on disk is encrypted (not plaintext)
|
|
||||||
file_path = storage_root / "test-bucket" / "secret.txt"
|
file_path = storage_root / "test-bucket" / "secret.txt"
|
||||||
stored_data = file_path.read_bytes()
|
stored_data = file_path.read_bytes()
|
||||||
assert stored_data != original_data
|
assert stored_data != original_data
|
||||||
|
|
||||||
# Get object - should be decrypted
|
|
||||||
data, metadata = encrypted_storage.get_object_data("test-bucket", "secret.txt")
|
data, metadata = encrypted_storage.get_object_data("test-bucket", "secret.txt")
|
||||||
|
|
||||||
assert data == original_data
|
assert data == original_data
|
||||||
@@ -711,14 +679,12 @@ class TestEncryptedStorage:
|
|||||||
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
encrypted_storage = EncryptedObjectStorage(storage, encryption)
|
||||||
|
|
||||||
storage.create_bucket("test-bucket")
|
storage.create_bucket("test-bucket")
|
||||||
# No encryption config
|
|
||||||
|
|
||||||
original_data = b"Unencrypted data"
|
original_data = b"Unencrypted data"
|
||||||
stream = io.BytesIO(original_data)
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
encrypted_storage.put_object("test-bucket", "plain.txt", stream)
|
encrypted_storage.put_object("test-bucket", "plain.txt", stream)
|
||||||
|
|
||||||
# Verify file on disk is NOT encrypted
|
|
||||||
file_path = storage_root / "test-bucket" / "plain.txt"
|
file_path = storage_root / "test-bucket" / "plain.txt"
|
||||||
stored_data = file_path.read_bytes()
|
stored_data = file_path.read_bytes()
|
||||||
assert stored_data == original_data
|
assert stored_data == original_data
|
||||||
@@ -744,20 +710,17 @@ class TestEncryptedStorage:
|
|||||||
|
|
||||||
original_data = b"Explicitly encrypted data"
|
original_data = b"Explicitly encrypted data"
|
||||||
stream = io.BytesIO(original_data)
|
stream = io.BytesIO(original_data)
|
||||||
|
|
||||||
# Request encryption explicitly
|
|
||||||
encrypted_storage.put_object(
|
encrypted_storage.put_object(
|
||||||
"test-bucket",
|
"test-bucket",
|
||||||
"encrypted.txt",
|
"encrypted.txt",
|
||||||
stream,
|
stream,
|
||||||
server_side_encryption="AES256",
|
server_side_encryption="AES256",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify file is encrypted
|
|
||||||
file_path = storage_root / "test-bucket" / "encrypted.txt"
|
file_path = storage_root / "test-bucket" / "encrypted.txt"
|
||||||
stored_data = file_path.read_bytes()
|
stored_data = file_path.read_bytes()
|
||||||
assert stored_data != original_data
|
assert stored_data != original_data
|
||||||
|
|
||||||
# Get object - should be decrypted
|
|
||||||
data, _ = encrypted_storage.get_object_data("test-bucket", "encrypted.txt")
|
data, _ = encrypted_storage.get_object_data("test-bucket", "encrypted.txt")
|
||||||
assert data == original_data
|
assert data == original_data
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ def kms_client(tmp_path):
|
|||||||
"ENCRYPTION_MASTER_KEY_PATH": str(tmp_path / "master.key"),
|
"ENCRYPTION_MASTER_KEY_PATH": str(tmp_path / "master.key"),
|
||||||
"KMS_KEYS_PATH": str(tmp_path / "kms_keys.json"),
|
"KMS_KEYS_PATH": str(tmp_path / "kms_keys.json"),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Create default IAM config with admin user
|
|
||||||
iam_config = {
|
iam_config = {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
@@ -83,7 +82,6 @@ class TestKMSKeyManagement:
|
|||||||
|
|
||||||
def test_list_keys(self, kms_client, auth_headers):
|
def test_list_keys(self, kms_client, auth_headers):
|
||||||
"""Test listing KMS keys."""
|
"""Test listing KMS keys."""
|
||||||
# Create some keys
|
|
||||||
kms_client.post("/kms/keys", json={"Description": "Key 1"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"Description": "Key 1"}, headers=auth_headers)
|
||||||
kms_client.post("/kms/keys", json={"Description": "Key 2"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"Description": "Key 2"}, headers=auth_headers)
|
||||||
|
|
||||||
@@ -97,7 +95,6 @@ class TestKMSKeyManagement:
|
|||||||
|
|
||||||
def test_get_key(self, kms_client, auth_headers):
|
def test_get_key(self, kms_client, auth_headers):
|
||||||
"""Test getting a specific key."""
|
"""Test getting a specific key."""
|
||||||
# Create a key
|
|
||||||
create_response = kms_client.post(
|
create_response = kms_client.post(
|
||||||
"/kms/keys",
|
"/kms/keys",
|
||||||
json={"KeyId": "test-key", "Description": "Test key"},
|
json={"KeyId": "test-key", "Description": "Test key"},
|
||||||
@@ -120,36 +117,28 @@ class TestKMSKeyManagement:
|
|||||||
|
|
||||||
def test_delete_key(self, kms_client, auth_headers):
|
def test_delete_key(self, kms_client, auth_headers):
|
||||||
"""Test deleting a key."""
|
"""Test deleting a key."""
|
||||||
# Create a key
|
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
# Delete it
|
|
||||||
response = kms_client.delete("/kms/keys/test-key", headers=auth_headers)
|
response = kms_client.delete("/kms/keys/test-key", headers=auth_headers)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
|
|
||||||
# Verify it's gone
|
|
||||||
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
assert get_response.status_code == 404
|
assert get_response.status_code == 404
|
||||||
|
|
||||||
def test_enable_disable_key(self, kms_client, auth_headers):
|
def test_enable_disable_key(self, kms_client, auth_headers):
|
||||||
"""Test enabling and disabling a key."""
|
"""Test enabling and disabling a key."""
|
||||||
# Create a key
|
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
# Disable
|
|
||||||
response = kms_client.post("/kms/keys/test-key/disable", headers=auth_headers)
|
response = kms_client.post("/kms/keys/test-key/disable", headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify disabled
|
|
||||||
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
assert get_response.get_json()["KeyMetadata"]["Enabled"] is False
|
assert get_response.get_json()["KeyMetadata"]["Enabled"] is False
|
||||||
|
|
||||||
# Enable
|
|
||||||
response = kms_client.post("/kms/keys/test-key/enable", headers=auth_headers)
|
response = kms_client.post("/kms/keys/test-key/enable", headers=auth_headers)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
# Verify enabled
|
|
||||||
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
get_response = kms_client.get("/kms/keys/test-key", headers=auth_headers)
|
||||||
assert get_response.get_json()["KeyMetadata"]["Enabled"] is True
|
assert get_response.get_json()["KeyMetadata"]["Enabled"] is True
|
||||||
|
|
||||||
@@ -159,13 +148,11 @@ class TestKMSEncryption:
|
|||||||
|
|
||||||
def test_encrypt_decrypt(self, kms_client, auth_headers):
|
def test_encrypt_decrypt(self, kms_client, auth_headers):
|
||||||
"""Test encrypting and decrypting data."""
|
"""Test encrypting and decrypting data."""
|
||||||
# Create a key
|
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "test-key"}, headers=auth_headers)
|
||||||
|
|
||||||
plaintext = b"Hello, World!"
|
plaintext = b"Hello, World!"
|
||||||
plaintext_b64 = base64.b64encode(plaintext).decode()
|
plaintext_b64 = base64.b64encode(plaintext).decode()
|
||||||
|
|
||||||
# Encrypt
|
|
||||||
encrypt_response = kms_client.post(
|
encrypt_response = kms_client.post(
|
||||||
"/kms/encrypt",
|
"/kms/encrypt",
|
||||||
json={"KeyId": "test-key", "Plaintext": plaintext_b64},
|
json={"KeyId": "test-key", "Plaintext": plaintext_b64},
|
||||||
@@ -177,8 +164,7 @@ class TestKMSEncryption:
|
|||||||
|
|
||||||
assert "CiphertextBlob" in encrypt_data
|
assert "CiphertextBlob" in encrypt_data
|
||||||
assert encrypt_data["KeyId"] == "test-key"
|
assert encrypt_data["KeyId"] == "test-key"
|
||||||
|
|
||||||
# Decrypt
|
|
||||||
decrypt_response = kms_client.post(
|
decrypt_response = kms_client.post(
|
||||||
"/kms/decrypt",
|
"/kms/decrypt",
|
||||||
json={"CiphertextBlob": encrypt_data["CiphertextBlob"]},
|
json={"CiphertextBlob": encrypt_data["CiphertextBlob"]},
|
||||||
@@ -198,8 +184,7 @@ class TestKMSEncryption:
|
|||||||
plaintext = b"Contextualized data"
|
plaintext = b"Contextualized data"
|
||||||
plaintext_b64 = base64.b64encode(plaintext).decode()
|
plaintext_b64 = base64.b64encode(plaintext).decode()
|
||||||
context = {"purpose": "testing", "bucket": "my-bucket"}
|
context = {"purpose": "testing", "bucket": "my-bucket"}
|
||||||
|
|
||||||
# Encrypt with context
|
|
||||||
encrypt_response = kms_client.post(
|
encrypt_response = kms_client.post(
|
||||||
"/kms/encrypt",
|
"/kms/encrypt",
|
||||||
json={
|
json={
|
||||||
@@ -212,8 +197,7 @@ class TestKMSEncryption:
|
|||||||
|
|
||||||
assert encrypt_response.status_code == 200
|
assert encrypt_response.status_code == 200
|
||||||
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
||||||
|
|
||||||
# Decrypt with same context succeeds
|
|
||||||
decrypt_response = kms_client.post(
|
decrypt_response = kms_client.post(
|
||||||
"/kms/decrypt",
|
"/kms/decrypt",
|
||||||
json={
|
json={
|
||||||
@@ -224,8 +208,7 @@ class TestKMSEncryption:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert decrypt_response.status_code == 200
|
assert decrypt_response.status_code == 200
|
||||||
|
|
||||||
# Decrypt with wrong context fails
|
|
||||||
wrong_context_response = kms_client.post(
|
wrong_context_response = kms_client.post(
|
||||||
"/kms/decrypt",
|
"/kms/decrypt",
|
||||||
json={
|
json={
|
||||||
@@ -325,11 +308,9 @@ class TestKMSReEncrypt:
|
|||||||
|
|
||||||
def test_re_encrypt(self, kms_client, auth_headers):
|
def test_re_encrypt(self, kms_client, auth_headers):
|
||||||
"""Test re-encrypting data with a different key."""
|
"""Test re-encrypting data with a different key."""
|
||||||
# Create two keys
|
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "key-1"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "key-1"}, headers=auth_headers)
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "key-2"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "key-2"}, headers=auth_headers)
|
||||||
|
|
||||||
# Encrypt with key-1
|
|
||||||
plaintext = b"Data to re-encrypt"
|
plaintext = b"Data to re-encrypt"
|
||||||
encrypt_response = kms_client.post(
|
encrypt_response = kms_client.post(
|
||||||
"/kms/encrypt",
|
"/kms/encrypt",
|
||||||
@@ -341,8 +322,7 @@ class TestKMSReEncrypt:
|
|||||||
)
|
)
|
||||||
|
|
||||||
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
ciphertext = encrypt_response.get_json()["CiphertextBlob"]
|
||||||
|
|
||||||
# Re-encrypt with key-2
|
|
||||||
re_encrypt_response = kms_client.post(
|
re_encrypt_response = kms_client.post(
|
||||||
"/kms/re-encrypt",
|
"/kms/re-encrypt",
|
||||||
json={
|
json={
|
||||||
@@ -357,8 +337,7 @@ class TestKMSReEncrypt:
|
|||||||
|
|
||||||
assert data["SourceKeyId"] == "key-1"
|
assert data["SourceKeyId"] == "key-1"
|
||||||
assert data["KeyId"] == "key-2"
|
assert data["KeyId"] == "key-2"
|
||||||
|
|
||||||
# Verify new ciphertext can be decrypted
|
|
||||||
decrypt_response = kms_client.post(
|
decrypt_response = kms_client.post(
|
||||||
"/kms/decrypt",
|
"/kms/decrypt",
|
||||||
json={"CiphertextBlob": data["CiphertextBlob"]},
|
json={"CiphertextBlob": data["CiphertextBlob"]},
|
||||||
@@ -398,7 +377,7 @@ class TestKMSRandom:
|
|||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
|
|
||||||
random_bytes = base64.b64decode(data["Plaintext"])
|
random_bytes = base64.b64decode(data["Plaintext"])
|
||||||
assert len(random_bytes) == 32 # Default is 32 bytes
|
assert len(random_bytes) == 32
|
||||||
|
|
||||||
|
|
||||||
class TestClientSideEncryption:
|
class TestClientSideEncryption:
|
||||||
@@ -422,11 +401,9 @@ class TestClientSideEncryption:
|
|||||||
|
|
||||||
def test_client_encrypt_decrypt(self, kms_client, auth_headers):
|
def test_client_encrypt_decrypt(self, kms_client, auth_headers):
|
||||||
"""Test client-side encryption and decryption."""
|
"""Test client-side encryption and decryption."""
|
||||||
# Generate a key
|
|
||||||
key_response = kms_client.post("/kms/client/generate-key", headers=auth_headers)
|
key_response = kms_client.post("/kms/client/generate-key", headers=auth_headers)
|
||||||
key = key_response.get_json()["key"]
|
key = key_response.get_json()["key"]
|
||||||
|
|
||||||
# Encrypt
|
|
||||||
plaintext = b"Client-side encrypted data"
|
plaintext = b"Client-side encrypted data"
|
||||||
encrypt_response = kms_client.post(
|
encrypt_response = kms_client.post(
|
||||||
"/kms/client/encrypt",
|
"/kms/client/encrypt",
|
||||||
@@ -439,8 +416,7 @@ class TestClientSideEncryption:
|
|||||||
|
|
||||||
assert encrypt_response.status_code == 200
|
assert encrypt_response.status_code == 200
|
||||||
encrypted = encrypt_response.get_json()
|
encrypted = encrypt_response.get_json()
|
||||||
|
|
||||||
# Decrypt
|
|
||||||
decrypt_response = kms_client.post(
|
decrypt_response = kms_client.post(
|
||||||
"/kms/client/decrypt",
|
"/kms/client/decrypt",
|
||||||
json={
|
json={
|
||||||
@@ -461,7 +437,6 @@ class TestEncryptionMaterials:
|
|||||||
|
|
||||||
def test_get_encryption_materials(self, kms_client, auth_headers):
|
def test_get_encryption_materials(self, kms_client, auth_headers):
|
||||||
"""Test getting encryption materials for client-side S3 encryption."""
|
"""Test getting encryption materials for client-side S3 encryption."""
|
||||||
# Create a key
|
|
||||||
kms_client.post("/kms/keys", json={"KeyId": "s3-key"}, headers=auth_headers)
|
kms_client.post("/kms/keys", json={"KeyId": "s3-key"}, headers=auth_headers)
|
||||||
|
|
||||||
response = kms_client.post(
|
response = kms_client.post(
|
||||||
@@ -477,8 +452,7 @@ class TestEncryptionMaterials:
|
|||||||
assert "EncryptedKey" in data
|
assert "EncryptedKey" in data
|
||||||
assert data["KeyId"] == "s3-key"
|
assert data["KeyId"] == "s3-key"
|
||||||
assert data["Algorithm"] == "AES-256-GCM"
|
assert data["Algorithm"] == "AES-256-GCM"
|
||||||
|
|
||||||
# Verify key is 256 bits
|
|
||||||
key = base64.b64decode(data["PlaintextKey"])
|
key = base64.b64decode(data["PlaintextKey"])
|
||||||
assert len(key) == 32
|
assert len(key) == 32
|
||||||
|
|
||||||
@@ -489,8 +463,7 @@ class TestKMSAuthentication:
|
|||||||
def test_unauthenticated_request_fails(self, kms_client):
|
def test_unauthenticated_request_fails(self, kms_client):
|
||||||
"""Test that unauthenticated requests are rejected."""
|
"""Test that unauthenticated requests are rejected."""
|
||||||
response = kms_client.get("/kms/keys")
|
response = kms_client.get("/kms/keys")
|
||||||
|
|
||||||
# Should fail with 403 (no credentials)
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_invalid_credentials_fail(self, kms_client):
|
def test_invalid_credentials_fail(self, kms_client):
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import pytest
|
|||||||
from xml.etree.ElementTree import fromstring
|
from xml.etree.ElementTree import fromstring
|
||||||
|
|
||||||
|
|
||||||
# Helper to create file-like stream
|
|
||||||
def _stream(data: bytes):
|
def _stream(data: bytes):
|
||||||
return io.BytesIO(data)
|
return io.BytesIO(data)
|
||||||
|
|
||||||
@@ -19,13 +18,11 @@ class TestListObjectsV2:
|
|||||||
"""Tests for ListObjectsV2 endpoint."""
|
"""Tests for ListObjectsV2 endpoint."""
|
||||||
|
|
||||||
def test_list_objects_v2_basic(self, client, signer, storage):
|
def test_list_objects_v2_basic(self, client, signer, storage):
|
||||||
# Create bucket and objects
|
|
||||||
storage.create_bucket("v2-test")
|
storage.create_bucket("v2-test")
|
||||||
storage.put_object("v2-test", "file1.txt", _stream(b"hello"))
|
storage.put_object("v2-test", "file1.txt", _stream(b"hello"))
|
||||||
storage.put_object("v2-test", "file2.txt", _stream(b"world"))
|
storage.put_object("v2-test", "file2.txt", _stream(b"world"))
|
||||||
storage.put_object("v2-test", "folder/file3.txt", _stream(b"nested"))
|
storage.put_object("v2-test", "folder/file3.txt", _stream(b"nested"))
|
||||||
|
|
||||||
# ListObjectsV2 request
|
|
||||||
headers = signer("GET", "/v2-test?list-type=2")
|
headers = signer("GET", "/v2-test?list-type=2")
|
||||||
resp = client.get("/v2-test", query_string={"list-type": "2"}, headers=headers)
|
resp = client.get("/v2-test", query_string={"list-type": "2"}, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -46,7 +43,6 @@ class TestListObjectsV2:
|
|||||||
storage.put_object("prefix-test", "photos/2024/mar.jpg", _stream(b"mar"))
|
storage.put_object("prefix-test", "photos/2024/mar.jpg", _stream(b"mar"))
|
||||||
storage.put_object("prefix-test", "docs/readme.md", _stream(b"readme"))
|
storage.put_object("prefix-test", "docs/readme.md", _stream(b"readme"))
|
||||||
|
|
||||||
# List with prefix and delimiter
|
|
||||||
headers = signer("GET", "/prefix-test?list-type=2&prefix=photos/&delimiter=/")
|
headers = signer("GET", "/prefix-test?list-type=2&prefix=photos/&delimiter=/")
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
"/prefix-test",
|
"/prefix-test",
|
||||||
@@ -56,11 +52,10 @@ class TestListObjectsV2:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
root = fromstring(resp.data)
|
root = fromstring(resp.data)
|
||||||
# Should show common prefixes for 2023/ and 2024/
|
|
||||||
prefixes = [el.find("Prefix").text for el in root.findall("CommonPrefixes")]
|
prefixes = [el.find("Prefix").text for el in root.findall("CommonPrefixes")]
|
||||||
assert "photos/2023/" in prefixes
|
assert "photos/2023/" in prefixes
|
||||||
assert "photos/2024/" in prefixes
|
assert "photos/2024/" in prefixes
|
||||||
assert len(root.findall("Contents")) == 0 # No direct files under photos/
|
assert len(root.findall("Contents")) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestPutBucketVersioning:
|
class TestPutBucketVersioning:
|
||||||
@@ -78,7 +73,6 @@ class TestPutBucketVersioning:
|
|||||||
resp = client.put("/version-test", query_string={"versioning": ""}, data=payload, headers=headers)
|
resp = client.put("/version-test", query_string={"versioning": ""}, data=payload, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Verify via GET
|
|
||||||
headers = signer("GET", "/version-test?versioning")
|
headers = signer("GET", "/version-test?versioning")
|
||||||
resp = client.get("/version-test", query_string={"versioning": ""}, headers=headers)
|
resp = client.get("/version-test", query_string={"versioning": ""}, headers=headers)
|
||||||
root = fromstring(resp.data)
|
root = fromstring(resp.data)
|
||||||
@@ -110,15 +104,13 @@ class TestDeleteBucketTagging:
|
|||||||
storage.create_bucket("tag-delete-test")
|
storage.create_bucket("tag-delete-test")
|
||||||
storage.set_bucket_tags("tag-delete-test", [{"Key": "env", "Value": "test"}])
|
storage.set_bucket_tags("tag-delete-test", [{"Key": "env", "Value": "test"}])
|
||||||
|
|
||||||
# Delete tags
|
|
||||||
headers = signer("DELETE", "/tag-delete-test?tagging")
|
headers = signer("DELETE", "/tag-delete-test?tagging")
|
||||||
resp = client.delete("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
resp = client.delete("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# Verify tags are gone
|
|
||||||
headers = signer("GET", "/tag-delete-test?tagging")
|
headers = signer("GET", "/tag-delete-test?tagging")
|
||||||
resp = client.get("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
resp = client.get("/tag-delete-test", query_string={"tagging": ""}, headers=headers)
|
||||||
assert resp.status_code == 404 # NoSuchTagSet
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteBucketCors:
|
class TestDeleteBucketCors:
|
||||||
@@ -130,15 +122,13 @@ class TestDeleteBucketCors:
|
|||||||
{"AllowedOrigins": ["*"], "AllowedMethods": ["GET"]}
|
{"AllowedOrigins": ["*"], "AllowedMethods": ["GET"]}
|
||||||
])
|
])
|
||||||
|
|
||||||
# Delete CORS
|
|
||||||
headers = signer("DELETE", "/cors-delete-test?cors")
|
headers = signer("DELETE", "/cors-delete-test?cors")
|
||||||
resp = client.delete("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
resp = client.delete("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# Verify CORS is gone
|
|
||||||
headers = signer("GET", "/cors-delete-test?cors")
|
headers = signer("GET", "/cors-delete-test?cors")
|
||||||
resp = client.get("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
resp = client.get("/cors-delete-test", query_string={"cors": ""}, headers=headers)
|
||||||
assert resp.status_code == 404 # NoSuchCORSConfiguration
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestGetBucketLocation:
|
class TestGetBucketLocation:
|
||||||
@@ -173,7 +163,6 @@ class TestBucketAcl:
|
|||||||
def test_put_bucket_acl(self, client, signer, storage):
|
def test_put_bucket_acl(self, client, signer, storage):
|
||||||
storage.create_bucket("acl-put-test")
|
storage.create_bucket("acl-put-test")
|
||||||
|
|
||||||
# PUT with canned ACL header
|
|
||||||
headers = signer("PUT", "/acl-put-test?acl")
|
headers = signer("PUT", "/acl-put-test?acl")
|
||||||
headers["x-amz-acl"] = "public-read"
|
headers["x-amz-acl"] = "public-read"
|
||||||
resp = client.put("/acl-put-test", query_string={"acl": ""}, headers=headers)
|
resp = client.put("/acl-put-test", query_string={"acl": ""}, headers=headers)
|
||||||
@@ -188,7 +177,6 @@ class TestCopyObject:
|
|||||||
storage.create_bucket("copy-dst")
|
storage.create_bucket("copy-dst")
|
||||||
storage.put_object("copy-src", "original.txt", _stream(b"original content"))
|
storage.put_object("copy-src", "original.txt", _stream(b"original content"))
|
||||||
|
|
||||||
# Copy object
|
|
||||||
headers = signer("PUT", "/copy-dst/copied.txt")
|
headers = signer("PUT", "/copy-dst/copied.txt")
|
||||||
headers["x-amz-copy-source"] = "/copy-src/original.txt"
|
headers["x-amz-copy-source"] = "/copy-src/original.txt"
|
||||||
resp = client.put("/copy-dst/copied.txt", headers=headers)
|
resp = client.put("/copy-dst/copied.txt", headers=headers)
|
||||||
@@ -199,7 +187,6 @@ class TestCopyObject:
|
|||||||
assert root.find("ETag") is not None
|
assert root.find("ETag") is not None
|
||||||
assert root.find("LastModified") is not None
|
assert root.find("LastModified") is not None
|
||||||
|
|
||||||
# Verify copy exists
|
|
||||||
path = storage.get_object_path("copy-dst", "copied.txt")
|
path = storage.get_object_path("copy-dst", "copied.txt")
|
||||||
assert path.read_bytes() == b"original content"
|
assert path.read_bytes() == b"original content"
|
||||||
|
|
||||||
@@ -208,7 +195,6 @@ class TestCopyObject:
|
|||||||
storage.create_bucket("meta-dst")
|
storage.create_bucket("meta-dst")
|
||||||
storage.put_object("meta-src", "source.txt", _stream(b"data"), metadata={"old": "value"})
|
storage.put_object("meta-src", "source.txt", _stream(b"data"), metadata={"old": "value"})
|
||||||
|
|
||||||
# Copy with REPLACE directive
|
|
||||||
headers = signer("PUT", "/meta-dst/target.txt")
|
headers = signer("PUT", "/meta-dst/target.txt")
|
||||||
headers["x-amz-copy-source"] = "/meta-src/source.txt"
|
headers["x-amz-copy-source"] = "/meta-src/source.txt"
|
||||||
headers["x-amz-metadata-directive"] = "REPLACE"
|
headers["x-amz-metadata-directive"] = "REPLACE"
|
||||||
@@ -216,7 +202,6 @@ class TestCopyObject:
|
|||||||
resp = client.put("/meta-dst/target.txt", headers=headers)
|
resp = client.put("/meta-dst/target.txt", headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Verify new metadata (note: header keys are Title-Cased)
|
|
||||||
meta = storage.get_object_metadata("meta-dst", "target.txt")
|
meta = storage.get_object_metadata("meta-dst", "target.txt")
|
||||||
assert "New" in meta or "new" in meta
|
assert "New" in meta or "new" in meta
|
||||||
assert "old" not in meta and "Old" not in meta
|
assert "old" not in meta and "Old" not in meta
|
||||||
@@ -229,7 +214,6 @@ class TestObjectTagging:
|
|||||||
storage.create_bucket("obj-tag-test")
|
storage.create_bucket("obj-tag-test")
|
||||||
storage.put_object("obj-tag-test", "tagged.txt", _stream(b"content"))
|
storage.put_object("obj-tag-test", "tagged.txt", _stream(b"content"))
|
||||||
|
|
||||||
# PUT tags
|
|
||||||
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
|
payload = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Tagging>
|
<Tagging>
|
||||||
<TagSet>
|
<TagSet>
|
||||||
@@ -247,7 +231,6 @@ class TestObjectTagging:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# GET tags
|
|
||||||
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
||||||
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -257,12 +240,10 @@ class TestObjectTagging:
|
|||||||
assert tags["project"] == "demo"
|
assert tags["project"] == "demo"
|
||||||
assert tags["env"] == "test"
|
assert tags["env"] == "test"
|
||||||
|
|
||||||
# DELETE tags
|
|
||||||
headers = signer("DELETE", "/obj-tag-test/tagged.txt?tagging")
|
headers = signer("DELETE", "/obj-tag-test/tagged.txt?tagging")
|
||||||
resp = client.delete("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
resp = client.delete("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# Verify empty
|
|
||||||
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
headers = signer("GET", "/obj-tag-test/tagged.txt?tagging")
|
||||||
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
resp = client.get("/obj-tag-test/tagged.txt", query_string={"tagging": ""}, headers=headers)
|
||||||
root = fromstring(resp.data)
|
root = fromstring(resp.data)
|
||||||
@@ -272,7 +253,6 @@ class TestObjectTagging:
|
|||||||
storage.create_bucket("tag-limit")
|
storage.create_bucket("tag-limit")
|
||||||
storage.put_object("tag-limit", "file.txt", _stream(b"x"))
|
storage.put_object("tag-limit", "file.txt", _stream(b"x"))
|
||||||
|
|
||||||
# Try to set 11 tags (limit is 10)
|
|
||||||
tags = "".join(f"<Tag><Key>key{i}</Key><Value>val{i}</Value></Tag>" for i in range(11))
|
tags = "".join(f"<Tag><Key>key{i}</Key><Value>val{i}</Value></Tag>" for i in range(11))
|
||||||
payload = f"<Tagging><TagSet>{tags}</TagSet></Tagging>".encode()
|
payload = f"<Tagging><TagSet>{tags}</TagSet></Tagging>".encode()
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,9 @@ class TestUIBucketEncryption:
|
|||||||
"""Encryption card should be visible on bucket detail page."""
|
"""Encryption card should be visible on bucket detail page."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login first
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -81,15 +80,12 @@ class TestUIBucketEncryption:
|
|||||||
"""Should be able to enable AES-256 encryption."""
|
"""Should be able to enable AES-256 encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Get CSRF token
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
# Enable AES-256 encryption
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"/ui/buckets/test-bucket/encryption",
|
||||||
data={
|
data={
|
||||||
@@ -102,15 +98,13 @@ class TestUIBucketEncryption:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
html = response.data.decode("utf-8")
|
html = response.data.decode("utf-8")
|
||||||
# Should see success message or enabled state
|
|
||||||
assert "AES-256" in html or "encryption enabled" in html.lower()
|
assert "AES-256" in html or "encryption enabled" in html.lower()
|
||||||
|
|
||||||
def test_enable_kms_encryption(self, tmp_path):
|
def test_enable_kms_encryption(self, tmp_path):
|
||||||
"""Should be able to enable KMS encryption."""
|
"""Should be able to enable KMS encryption."""
|
||||||
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
app = _make_encryption_app(tmp_path, kms_enabled=True)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Create a KMS key first
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
kms = app.extensions.get("kms")
|
kms = app.extensions.get("kms")
|
||||||
if kms:
|
if kms:
|
||||||
@@ -118,15 +112,12 @@ class TestUIBucketEncryption:
|
|||||||
key_id = key.key_id
|
key_id = key.key_id
|
||||||
else:
|
else:
|
||||||
pytest.skip("KMS not available")
|
pytest.skip("KMS not available")
|
||||||
|
|
||||||
# Login
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Get CSRF token
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
# Enable KMS encryption
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"/ui/buckets/test-bucket/encryption",
|
||||||
data={
|
data={
|
||||||
@@ -146,11 +137,9 @@ class TestUIBucketEncryption:
|
|||||||
"""Should be able to disable encryption."""
|
"""Should be able to disable encryption."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# First enable encryption
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
@@ -162,8 +151,7 @@ class TestUIBucketEncryption:
|
|||||||
"algorithm": "AES256",
|
"algorithm": "AES256",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Now disable it
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
@@ -184,13 +172,12 @@ class TestUIBucketEncryption:
|
|||||||
"""Invalid encryption algorithm should be rejected."""
|
"""Invalid encryption algorithm should be rejected."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"/ui/buckets/test-bucket/encryption",
|
||||||
data={
|
data={
|
||||||
@@ -200,23 +187,21 @@ class TestUIBucketEncryption:
|
|||||||
},
|
},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
html = response.data.decode("utf-8")
|
html = response.data.decode("utf-8")
|
||||||
assert "Invalid" in html or "danger" in html
|
assert "Invalid" in html or "danger" in html
|
||||||
|
|
||||||
def test_encryption_persists_in_config(self, tmp_path):
|
def test_encryption_persists_in_config(self, tmp_path):
|
||||||
"""Encryption config should persist in bucket config."""
|
"""Encryption config should persist in bucket config."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login
|
|
||||||
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "test", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# Enable encryption
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
client.post(
|
client.post(
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"/ui/buckets/test-bucket/encryption",
|
||||||
data={
|
data={
|
||||||
@@ -225,8 +210,7 @@ class TestUIBucketEncryption:
|
|||||||
"algorithm": "AES256",
|
"algorithm": "AES256",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify it's stored
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
storage = app.extensions["object_storage"]
|
storage = app.extensions["object_storage"]
|
||||||
config = storage.get_bucket_encryption("test-bucket")
|
config = storage.get_bucket_encryption("test-bucket")
|
||||||
@@ -243,14 +227,12 @@ class TestUIEncryptionWithoutPermission:
|
|||||||
"""Read-only user should not be able to change encryption settings."""
|
"""Read-only user should not be able to change encryption settings."""
|
||||||
app = _make_encryption_app(tmp_path)
|
app = _make_encryption_app(tmp_path)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
|
||||||
# Login as readonly user
|
|
||||||
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
client.post("/ui/login", data={"access_key": "readonly", "secret_key": "secret"}, follow_redirects=True)
|
||||||
|
|
||||||
# This should fail or be rejected
|
|
||||||
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
response = client.get("/ui/buckets/test-bucket?tab=properties")
|
||||||
csrf_token = get_csrf_token(response)
|
csrf_token = get_csrf_token(response)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/ui/buckets/test-bucket/encryption",
|
"/ui/buckets/test-bucket/encryption",
|
||||||
data={
|
data={
|
||||||
@@ -260,9 +242,7 @@ class TestUIEncryptionWithoutPermission:
|
|||||||
},
|
},
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should either redirect with error or show permission denied
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
html = response.data.decode("utf-8")
|
html = response.data.decode("utf-8")
|
||||||
# Should contain error about permission denied
|
|
||||||
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
assert "Access denied" in html or "permission" in html.lower() or "not authorized" in html.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user