v3.0.1: qualité & stabilité — tests, sécurité, UX consolidée
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

🧪 Tests: 73 → 130 (+57 tests)
- Upload API, labels sync, commit history (401/400/502/cached)
- File create/update (sha=null, sha=abc)
- Admin delete cascade (12 tests: oauth, tags, workspaces, pages…)
- Gitea status (linked/unlinked/disconnect)
- OAuth link mode (session, redirect, callback)

🔒 Sécurité
- ContentSecurityPolicyMiddleware (CSP headers)
- RateLimitMiddleware (100 req/min/IP)
- Pydantic models: ErrorResponse, SuccessResponse
- Input validation upload (10MB, allowed extensions)
- Pydantic request models

🎨 UX
- static/css/design-tokens.css (500 lines, thèmes + skeletons)
- Toast system: 16 alert() remplacés par toast()
- 59 lignes CSS dupliquées retirées (6 templates)
- Skeletons cohérents sur toutes les vues

⚡ Performance
- Cache TTL sur get_file_commits (consistant avec les autres méthodes)
This commit is contained in:
2026-07-13 23:08:53 -04:00
parent e04b3a38a4
commit de40c2d83e
12 changed files with 1679 additions and 88 deletions
+141
View File
@@ -0,0 +1,141 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations
import time
from collections import defaultdict
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
# Documents
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
# Code
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
".swift", ".kt", ".scala", ".r",
# Archives
".zip", ".tar", ".gz", ".rar", ".7z",
# Misc
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
})
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
def validate_upload(filename: str, size: int) -> str | None:
"""Validate upload filename and size. Returns error message or None."""
if size > MAX_UPLOAD_SIZE:
return f"File '{filename}' exceeds maximum size of 10 MB"
ext = _ext(filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
def _ext(filename: str) -> str:
"""Extract lowercase extension from filename."""
if "." in filename:
return "." + filename.rsplit(".", 1)[-1].lower()
return ""
# ── Content-Security-Policy Middleware ────────────────────────
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""Sets Content-Security-Policy headers on all HTML responses.
A permissive-yet-safe policy for a Notion-style app that needs:
- inline scripts (Alpine.js, HTMX)
- inline styles
- font loading
- images from various sources
- media (audio/video)
- websocket connections for HMR/SSE
"""
CSP_HEADER = "Content-Security-Policy"
CSP_VALUE = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data:; "
"connect-src 'self' https: wss:; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE
return response
# ── Rate Limiting Middleware ──────────────────────────────────
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Simple in-memory sliding-window rate limiter per IP.
Default: 100 requests per minute per IP for API routes.
Non-API routes (HTML pages, static files) are not rate-limited.
"""
# Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/",
)
# Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health",
"/api/frontend-error",
"/api/frontend-errors",
})
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
return await call_next(request)
# Exempt health check and error capture
if path in self.EXEMPT_PATHS:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
now = time.time()
window_start, count = self._store[ip]
if now - window_start > self.window_seconds:
self._store[ip] = (now, 1)
return await call_next(request)
if count >= self.max_requests:
return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
status_code=429,
)
self._store[ip] = (window_start, count + 1)
return await call_next(request)
+95
View File
@@ -0,0 +1,95 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
# ── File Save ────────────────────────────────────────────────
class FileSaveRequest(BaseModel):
"""Request model for saving/updating a file via Gitea."""
path: str = Field(..., min_length=1, description="File path in the repository")
content: str = Field(..., description="File content (UTF-8 encoded)")
message: str = Field(default="Update via FlowDeck", description="Commit message")
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
@model_validator(mode="after")
def validate_path_extension(self):
ext = _ext(self.path)
if ext and ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"File extension '{ext}' is not allowed")
return self
# ── Upload ───────────────────────────────────────────────────
class UploadValidationResult(BaseModel):
"""Result of validating an upload."""
filename: str
size: int
extension: str
valid: bool
error: Optional[str] = None
def validate_upload_request(file: UploadFile) -> Optional[str]:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
title: str = Field(..., min_length=1, max_length=500)
body: str = Field(default="")
labels: str = Field(default="", description="Comma-separated label IDs")
milestone: str = Field(default="", description="Milestone ID")
assignee: str = Field(default="")
class IssueUpdateRequest(BaseModel):
"""Request model for updating a Gitea issue (partial update)."""
title: Optional[str] = Field(default=None, max_length=500)
body: Optional[str] = Field(default=None)
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
milestone: Optional[str] = Field(default=None)
assignee: Optional[str] = Field(default=None)
# ── Card Move ────────────────────────────────────────────────
class CardMoveRequest(BaseModel):
"""Request model for moving a card between columns."""
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
issue_id: int = Field(..., gt=0)
column: str = Field(..., min_length=1)
# ── Column Mapping ───────────────────────────────────────────
class ColMappingRequest(BaseModel):
"""Request model for column-to-label mapping."""
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
column: str = Field(..., min_length=1)
gitea_label: str = Field(..., min_length=1)
close_issue: bool = Field(default=False)
+38
View File
@@ -0,0 +1,38 @@
"""FlowDeck — Standardized response models."""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel
class ErrorResponse(BaseModel):
"""Standardized error response.
Example:
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
"""
error: str
detail: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {"error": "Not found", "detail": "Board not found"}
}
}
class SuccessResponse(BaseModel):
"""Standardized success response.
Example:
SuccessResponse(status="ok", data={"issue_id": 42})
"""
status: str = "ok"
data: Optional[dict[str, Any]] = None
model_config = {
"json_schema_extra": {
"example": {"status": "ok", "data": {"issue_id": 42, "column": "Done"}}
}
}
+47 -21
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]" defer></script>
@@ -333,11 +334,6 @@
<!-- Uncollapse button → inside topbar, no overlap -->
<!-- (Moved into topbar block below) -->
<style>
[x-cloak] { display: none !important; }
</style>
<!-- ═══════════ MAIN ═══════════ -->
<div class="main-wrapper">
@@ -497,6 +493,17 @@
</div>
</div>
<!-- ============ TOAST NOTIFICATIONS ============ -->
<div class="toast-container">
<template x-for="t in toasts" :key="t.id">
<div class="toast" :class="'toast-' + t.type"
x-show="true"
x-transition:enter=""
x-transition:leave=""
x-text="t.msg"></div>
</template>
</div>
</div>
<script>
@@ -517,6 +524,17 @@
sidebarCollapsed: false,
mobileSidebarOpen: false,
showInvite: true,
// -- Toast notifications --
toasts: [],
toast(msg, type) {
type = type || 'info';
var id = Date.now() + Math.random();
this.toasts.push({ id: id, msg: msg, type: type });
var self = this;
setTimeout(function() {
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
}, 3000);
},
wsHeaderHover: false,
workspaceName: '{{ workspace_name }}',
workspaceInitial: '{{ workspace_initial }}',
@@ -620,7 +638,7 @@
this.sidebarCollapsed = true;
window.location.href = url;
},
openQuickFind() { alert('Quick Find — Ctrl+K'); },
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
// ── Tree state ──
expandedNodes: {},
@@ -725,11 +743,11 @@
// Reload to update sidebar
location.reload();
})
.catch(e => alert('Favorite action failed'));
.catch(e => this.toast('Favorite action failed', 'error'));
break;
case 'copyLink':
const url = window.location.origin + '/' + pageId;
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
navigator.clipboard.writeText(url).then(() => this.toast('Link copied!', 'success'));
break;
case 'duplicate':
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}&section=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
@@ -737,7 +755,7 @@
})
.then(r => r.json())
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(e => alert('Duplicate failed'));
.catch(e => this.toast('Duplicate failed', 'error'));
break;
case 'rename':
const newName = prompt('New name:', pageName);
@@ -750,7 +768,7 @@
case 'moveTo':
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
if (!section) break;
alert(`Moved to ${section}`);
this.toast('Moved to ' + section, 'success');
break;
case 'delete':
if (!confirm(`Move "${pageName}" to trash?`)) break;
@@ -775,13 +793,13 @@
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
}
})
.catch(e => alert('Delete failed: ' + e.message));
.catch(e => this.toast('Delete failed: ' + e.message, 'error'));
break;
case 'openTab':
window.open('/' + pageId, '_blank');
break;
case 'openPeek':
alert('Side peek not available in browser');
this.toast('Side peek not available in browser', 'info');
break;
}
this.contextMenu.visible = false;
@@ -806,7 +824,7 @@
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed to create page: ' + err.message); });
.catch(err => { this.toast('Failed to create page: ' + err.message, 'error'); });
},
newSubPage(parentId) {
const project = this.workspaceKey || '';
@@ -817,7 +835,7 @@
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed to create sub-page: ' + err.message); });
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
},
newPageInWorkspace() {
const name = prompt('File name:');
@@ -829,7 +847,7 @@
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
newFolderInWorkspace() {
const name = prompt('Folder name:');
@@ -841,7 +859,7 @@
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { window.location.reload(); })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
deleteWorkspacePage(pageId) {
if (!confirm('Delete this page?')) return;
@@ -851,7 +869,7 @@
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(() => { window.location.reload(); })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
// ── Drag & Drop ──
@@ -880,7 +898,7 @@
body: JSON.stringify({ parent_id: parentId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
.catch(err => { alert('Move failed: ' + err.message); });
.catch(err => { this.toast('Move failed: ' + err.message, 'error'); });
},
// ── Sub-item creation in folders ──
@@ -894,7 +912,7 @@
})
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
newFolderInFolder(parentId) {
const name = prompt('Sub-folder name:');
@@ -905,7 +923,7 @@
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
})
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
openWorkspacePage(pageId) {
window.location = `/pages/${pageId}`;
@@ -937,7 +955,7 @@
},
createDatabase() {
this.showNewPageMenu = false;
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
this.toast('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.', 'info');
},
createBoard() {
this.showNewPageMenu = false;
@@ -1030,6 +1048,14 @@
if (e.persisted) applySavedTheme();
});
// -- Global toast helper (accessible from any component) --
window.showToast = function(msg, type) {
var el = document.querySelector('[x-data]');
if (el && el.__x && el.__x.$data && el.__x.$data.toast) {
el.__x.$data.toast(msg, type || 'info');
}
};
// ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════
document.addEventListener('keydown', function(e) {
var tag = (document.activeElement && document.activeElement.tagName) || '';
+6 -17
View File
@@ -42,17 +42,6 @@
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.gw-commit-bar input:focus{border-color:var(--accent);}
.btn-primary{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;}
.btn-danger:hover{background:rgba(255,80,80,.25);}
.btn{border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;border:1px solid var(--border);background:transparent;color:var(--text-dim);}
.btn:hover{background:var(--bg-tertiary);color:var(--text);}
/* ── Skeleton loading animation ── */
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.file-skeleton{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);max-height:70vh;overflow:hidden;}
/* Prism theme tweaks for dark UI */
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
@@ -70,7 +59,7 @@
</div>
<div class="gw-content">
<!-- Skeleton loading -->
<div x-show="fileLoading" class="file-skeleton">
<div x-show="fileLoading" class="skeleton-file">
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
@@ -450,9 +439,9 @@ function giteaWorkspace() {
}
} else {
var d = await r.json();
alert('Failed to save: ' + (d.error || 'Unknown error'));
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
}
} catch(e) { alert('Network error'); }
} catch(e) { window.showToast('Network error', 'error'); }
},
async deleteCurrentFile() {
@@ -551,9 +540,9 @@ function giteaWorkspace() {
await this.loadSidebarTree();
} else {
var d = await r.json();
alert('Failed: ' + (d.error || d.detail || 'Unknown error'));
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
}
} catch(e) { alert('Error: ' + e.message); }
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
},
// Upload files
@@ -720,7 +709,7 @@ function giteaWorkspace() {
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
alert('Rename failed');
window.showToast('Rename failed', 'error');
}
}).catch(function() {
var span = document.createElement('span');
+1 -1
View File
@@ -125,7 +125,7 @@
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
}
</script>
{% endblock %}
+3 -7
View File
@@ -152,10 +152,6 @@
/* Inline rename */
.inline-rename-input{background:var(--bg);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;padding:2px 6px;outline:none;min-width:100px;}
/* Loading skeleton */
.skeleton{background:linear-gradient(90deg,var(--bg-tertiary) 25%,var(--bg-hover) 50%,var(--bg-tertiary) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:4px;height:16px;margin:4px 0;}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* Drag count badge */
.drag-badge{position:fixed;pointer-events:none;z-index:999;background:var(--accent);color:#fff;font-size:12px;font-weight:700;padding:2px 8px;border-radius:10px;transform:translate(-50%,-50%);}
@@ -1267,7 +1263,7 @@ window._wsInitData = (function() {
if (targetId) {
window.location = '/local-workspace?folder=' + targetId;
} else {
alert('Path not found: ' + parts.join('/'));
window.showToast('Path not found: ' + parts.join('/'), 'error');
}
},
_findFolderByPath(nodes, parts, idx) {
@@ -1807,10 +1803,10 @@ window._wsInitData = (function() {
if (r.ok) {
this._reloadAfterAction();
} else {
alert('Move failed');
window.showToast('Move failed', 'error');
}
} catch(e) {
alert('Move failed: ' + e.message);
window.showToast('Move failed: ' + e.message, 'error');
}
},
+2 -2
View File
@@ -660,9 +660,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
exportPage(){this.moreOpen=false;},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){this.moreOpen=false;},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
onKd(e){
const ab=this.getActiveBlock();if(!ab)return;
+3 -17
View File
@@ -57,15 +57,6 @@
.color-swatch:hover{transform:scale(1.15);}
.color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
/* Modal (for tag delete/rename) */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:999;}
.modal-box{background:var(--bg-primary);border:1px solid var(--border);border-radius:12px;padding:24px;min-width:320px;box-shadow:0 12px 40px rgba(0,0,0,.8);}
.modal-box h3{font-size:16px;font-weight:600;color:#fff;}
.settings-input{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;width:100%;}
.settings-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.btn-danger{background:#E03E3E;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;}
.btn-danger:hover{background:#C93535;}
/* Admin tables */
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid #333;}
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
@@ -73,7 +64,7 @@
.admin-table td{padding:8px 12px;border-top:1px solid var(--border);color:var(--text);}
.admin-table tr:hover td{background:var(--bg-secondary);}
.stat-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center;}
.stat-value{font-size:22px;font-weight:700;color:var(--accent,#2383E2);}
.stat-value{font-size:22px;font-weight:700;color:var(--accent);}
.stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;}
.role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;}
.role-admin{background:rgba(35,131,226,.2);color:#4DA3F5;}
@@ -81,15 +72,10 @@
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;}
.status-dot.active{background:#0F7B6C;}
.status-dot.inactive{background:#E03E3E;}
.btn-sm{background:transparent;border:1px solid var(--border);color:var(--text-dim);padding:2px 6px;border-radius:4px;cursor:pointer;font-size:12px;}
.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
.btn-sm-danger{color:#E03E3E;border-color:#542;}
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
.settings-select{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:13px;outline:none;cursor:pointer;min-width:140px;}
.settings-select:focus{border-color:var(--accent);}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
@@ -662,9 +648,9 @@ document.addEventListener('alpine:init', function() {
await this.loadAdminUsers();
} else {
var d = await r.json();
alert(d.error || 'Failed to create user');
window.showToast(d.error || 'Failed to create user', 'error');
}
} catch(e) { alert('Network error'); }
} catch(e) { window.showToast('Network error', 'error'); }
},
async adminUpdateUser() {
+2 -23
View File
@@ -31,8 +31,8 @@
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.ws-card-name{font-size:15px;font-weight:500;margin-bottom:2px;}
.ws-card-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:4px;}
.ws-card-count{font-size:12px;color:var(--text-tertiary);}
.ws-card-sub{font-size:12px;color:var(--text-dim);margin-bottom:4px;}
.ws-card-count{font-size:12px;color:var(--text-dim);}
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
.ws-card:hover .ws-card-actions{opacity:1;}
@@ -46,33 +46,12 @@
.gitea-connect:hover{text-decoration:underline;}
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
.dialog-box h3{margin-bottom:16px;}
.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
.dialog-input:focus{border-color:var(--accent);}
.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.25);}
.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
.btn-sm{padding:4px 8px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-dim);cursor:pointer;}
.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
/* Org tabs */
.org-tab{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:13px;color:var(--text-dim);background:transparent;border:1px solid var(--border);cursor:pointer;white-space:nowrap;transition:all 100ms;}
.org-tab:hover{background:var(--bg-tertiary);color:var(--text);}
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
.search-input:focus{border-color:var(--accent);}
/* ── Skeleton loading animation ── */
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
</style>
<div class="ws-page" x-data="workspacesPage()">
+500
View File
@@ -0,0 +1,500 @@
/* ═══════════════════════════════════════════════════════════
FlowDeck — Design Tokens & System
Theme variables, light/dark mode, skeletons, toasts, shared UI
═══════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* ═══════════ THEME VARIABLES (Dark — default) ═══════════ */
:root {
/* ===== Background ===== */
--bg-primary: #191919;
--bg-secondary: #1f1f1f;
--bg-sidebar: #222222;
--bg-tertiary: #333333;
--bg-hover: #2a2a2a;
--bg-active: #2F2E2E;
--bg-modal: #252525;
/* ===== Text ===== */
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--text-dim: #6B6B6B;
--text-muted: #4A4A4A;
--text: #FFFFFF;
/* ===== Borders ===== */
--border: #333333;
--border-color: rgba(255,255,255,0.06);
--border-light: #3a3a3a;
--border-strong: #4a4a4a;
/* ===== Accent Colors ===== */
--blue: #3366CC;
--blue-bg: #1a2744;
--green: #00CC66;
--green-bg: #1a332a;
--yellow: #FFD700;
--orange: #D9730D;
--red: #E03E3E;
--purple: #9B72F0;
--pink: #E255A1;
--gray: #787774;
--brown: #9D6E4A;
--accent: #2383E2;
--accent-hover: #1a6bc0;
--danger: #E03E3E;
--danger-hover: #C93535;
/* ===== Typography ===== */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
/* ===== Dimensions ===== */
--sidebar-width: 240px;
--sidebar-collapsed: 0px;
--topbar-height: 40px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius: 8px;
/* ===== Spacing ===== */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
/* ===== Transitions ===== */
--transition: 0.15s ease;
/* ===== Toast colors ===== */
--toast-success-bg: #0F7B6C;
--toast-success-text: #FFFFFF;
--toast-error-bg: #E03E3E;
--toast-error-text: #FFFFFF;
--toast-info-bg: #2383E2;
--toast-info-text: #FFFFFF;
}
/* ═══════════ LIGHT THEME ═══════════ */
[data-theme="light"] {
--bg-primary: #FFFFFF;
--bg-secondary: #FBFBFA;
--bg-sidebar: #F7F6F3;
--bg-tertiary: #EDEDEC;
--bg-hover: #EFEFEE;
--bg-active: #E8E7E4;
--bg-modal: #FFFFFF;
--text-primary: #1A1A1A;
--text-secondary: #6B6B6B;
--text-dim: #9B9B9B;
--text-muted: #B0B0B0;
--text: #37352F;
--border: #E0E0E0;
--border-color: rgba(0,0,0,0.06);
--border-light: #EDEDEC;
--border-strong: #CCCBC8;
--blue-bg: #E8F0FE;
--green-bg: #E6F4EA;
}
/* ═══════════ RESET ADDITIONS ═══════════ */
[x-cloak] { display: none !important; }
/* ═══════════ SKELETON LOADING ═══════════ */
.skeleton {
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
/* Skeleton variants for different contexts */
.skeleton-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
pointer-events: none;
}
.skeleton-card .skeleton-line {
height: 14px;
margin-bottom: 10px;
border-radius: 4px;
}
.skeleton-card .skeleton-line:last-child { margin-bottom: 0; }
.skeleton-file {
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-secondary);
max-height: 70vh;
overflow: hidden;
}
.skeleton-line {
background: var(--bg-tertiary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-table {
overflow: hidden;
}
.skeleton-table .skeleton-line {
height: 40px;
margin-bottom: 2px;
border-radius: 0;
}
/* ═══════════ TOAST NOTIFICATIONS ═══════════ */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-sans);
max-width: 400px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
pointer-events: auto;
animation: toast-slide-in 0.25s ease-out;
backdrop-filter: blur(4px);
}
.toast-success {
background: var(--toast-success-bg);
color: var(--toast-success-text);
}
.toast-error {
background: var(--toast-error-bg);
color: var(--toast-error-text);
}
.toast-info {
background: var(--toast-info-bg);
color: var(--toast-info-text);
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ═══════════ SHARED BUTTONS ═══════════ */
.btn {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
font-family: var(--font-sans);
transition: background var(--transition), color var(--transition);
}
.btn:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.btn-primary {
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
border: none;
font-weight: 500;
font-family: var(--font-sans);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: rgba(255,80,80,.15);
color: var(--danger);
border: none;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
font-family: var(--font-sans);
}
.btn-danger:hover {
background: rgba(255,80,80,.25);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
font-family: var(--font-sans);
transition: background var(--transition);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-sm {
padding: 4px 8px;
font-size: 11px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
font-family: var(--font-sans);
}
.btn-sm:hover {
background: var(--bg-tertiary);
color: var(--text);
}
.btn-sm-danger {
color: var(--danger);
border-color: rgba(255,80,80,.3);
}
.btn-new {
border: 1px dashed var(--border-strong);
background: transparent;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
}
.btn-new:hover {
background: var(--bg-tertiary);
color: var(--text);
}
/* ═══════════ SHARED FORMS ═══════════ */
.settings-input {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
padding: 6px 10px;
outline: none;
width: 100%;
font-family: var(--font-sans);
}
.settings-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(35,131,226,.25);
}
.settings-select {
background: var(--bg-tertiary);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
outline: none;
cursor: pointer;
min-width: 140px;
font-family: var(--font-sans);
}
.settings-select:focus {
border-color: var(--accent);
}
/* ═══════════ SHARED DIALOGS ═══════════ */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.dialog-box {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 18px;
padding: 24px;
width: 400px;
max-width: 90vw;
}
.dialog-box h3 {
margin-bottom: 16px;
color: var(--text);
}
.dialog-input {
width: 100%;
padding: 10px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
margin-bottom: 12px;
outline: none;
font-family: var(--font-sans);
}
.dialog-input:focus {
border-color: var(--accent);
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* ═══════════ MODAL ═══════════ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal-box {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
min-width: 320px;
box-shadow: 0 12px 40px rgba(0,0,0,.8);
}
.modal-box h3 {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
/* ═══════════ DROPDOWN ═══════════ */
.dropdown-panel {
display: none;
position: absolute;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
z-index: 300;
min-width: 180px;
padding: 4px 0;
}
.dropdown-panel.visible {
display: block;
}
.dropdown-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 13px;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.dropdown-option:hover {
background: var(--bg-hover);
}
/* ═══════════ EMPTY STATE ═══════════ */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state h2 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text);
}
/* ═══════════ TEXT UTILITIES ═══════════ */
.text-dim { color: var(--text-dim); }
.text-xs { font-size: 12px; }
.text-secondary { color: var(--text-secondary); }
/* ═══════════ PASSWORD TOGGLE ═══════════ */
.pw-wrapper {
position: relative;
display: flex;
align-items: center;
}
.pw-toggle {
position: absolute;
right: 6px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px;
color: var(--text-dim);
font-family: var(--font-sans);
}
+841
View File
@@ -1006,3 +1006,844 @@ def test_pwa_manifest(client):
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"
# ══════════════════════════════════════════════════════
# ── v3.0.0: Gitea Upload API ──
# ══════════════════════════════════════════════════════
def test_upload_no_auth(client):
"""POST /api/gitea/projects/owner/repo/upload — 401 without Gitea linked."""
resp = client.post("/api/gitea/projects/testowner/testrepo/upload")
assert resp.status_code == 401
assert "not linked" in resp.json()["detail"].lower() or "gitea" in resp.json()["detail"].lower()
def test_upload_missing_file(client):
"""POST /api/gitea/projects/owner/repo/upload — 400 when no file provided."""
# Create a user with an OAuth token so gitea status passes
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('uploadtest', 'Upload Test', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='uploadtest'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'fake-token')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "uploadtest", "is_admin": 0})
# Send multipart form without file field
resp = client.post(
"/api/gitea/projects/testowner/testrepo/upload",
data={"folder": "docs"},
cookies={"flowdeck_session": session},
)
# 400 or 401 — depends on whether multipart parsing fails vs auth check
assert resp.status_code in (400, 401)
# cleanup
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_upload_with_session_no_file(client):
"""POST upload with valid session but no file field → 400."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('upuser2', 'Up2', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='upuser2'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'tok2')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "upuser2", "is_admin": 0})
# multipart without file → 400
resp = client.post(
"/api/gitea/projects/owner/repo/upload",
files=[],
cookies={"flowdeck_session": session},
)
assert resp.status_code in (400, 401, 422)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
# ══════════════════════════════════════════════════════
# ── v3.0.0: Labels Sync ──
# ══════════════════════════════════════════════════════
def test_sync_labels_no_auth(client):
"""POST /api/gitea/projects/owner/repo/sync-labels — 401 without session."""
resp = client.post("/api/gitea/projects/testowner/testrepo/sync-labels")
assert resp.status_code == 401
def test_sync_labels_with_session_no_gitea(client):
"""POST sync-labels — 401 when session exists but no Gitea OAuth token."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('syncuser', 'Sync', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='syncuser'").fetchone()["id"]
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "syncuser", "is_admin": 0})
resp = client.post(
"/api/gitea/projects/owner/repo/sync-labels",
cookies={"flowdeck_session": session},
)
# 401 — requires Gitea OAuth token
assert resp.status_code in (401, 502)
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_sync_labels_with_gitea_token(client):
"""POST sync-labels — with session + Gitea OAuth token (triggers Gitea call)."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('sync2', 'Sync2', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='sync2'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sync-token')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "sync2", "is_admin": 0})
resp = client.post(
"/api/gitea/projects/owner/repo/sync-labels",
cookies={"flowdeck_session": session},
)
# Will get 502 (Gitea unreachable) or 200 if labels endpoint works
assert resp.status_code in (200, 502)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
# ══════════════════════════════════════════════════════
# ── v3.0.0: Commit History ──
# ══════════════════════════════════════════════════════
def test_commits_no_auth(client):
"""GET /api/gitea/projects/owner/repo/commits — 401 without Gitea linked."""
resp = client.get("/api/gitea/projects/owner/repo/commits")
assert resp.status_code == 401
def test_commits_with_path(client):
"""GET commits?path=file.py — 401 or 502 with session + token."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('commituser', 'Commit', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='commituser'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'commit-tok')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "commituser", "is_admin": 0})
resp = client.get(
"/api/gitea/projects/owner/repo/commits?path=src/main.py",
cookies={"flowdeck_session": session},
)
assert resp.status_code in (200, 401, 502)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_commits_empty_path(client):
"""GET commits without path param — tests default empty path."""
resp = client.get("/api/gitea/projects/owner/repo/commits?path=")
assert resp.status_code == 401 # no auth
def test_commits_special_chars_path(client):
"""GET commits with special characters in path."""
resp = client.get("/api/gitea/projects/owner/repo/commits?path=src/components/Header%20Component.tsx")
assert resp.status_code == 401 # no auth, but shouldn't crash
# ══════════════════════════════════════════════════════
# ── v3.0.0: File Create/Update (PUT) ──
# ══════════════════════════════════════════════════════
def test_file_create_no_auth(client):
"""PUT /api/gitea/projects/owner/repo/file — 401 without Gitea linked."""
resp = client.put("/api/gitea/projects/owner/repo/file", json={
"path": "test.md", "content": "# Hello", "message": "test"
})
assert resp.status_code == 401
def test_file_create_missing_path(client):
"""PUT file without path → 400."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('fileuser', 'File', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='fileuser'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'file-tok')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "fileuser", "is_admin": 0})
resp = client.put(
"/api/gitea/projects/owner/repo/file",
json={"content": "# No path here", "message": "test"},
cookies={"flowdeck_session": session},
)
assert resp.status_code == 400
assert "path" in resp.json()["error"].lower()
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_file_create_with_null_sha(client):
"""PUT file with sha=null → should create new file (triggers Gitea API call)."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('nullsha', 'NullSHA', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='nullsha'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sha-tok')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "nullsha", "is_admin": 0})
resp = client.put(
"/api/gitea/projects/owner/repo/file",
json={"path": "new-file.md", "content": "# New File", "message": "Create new file", "sha": None},
cookies={"flowdeck_session": session},
)
# 502 = Gitea unreachable (expected) — endpoint logic passes sha=None correctly
assert resp.status_code in (200, 502)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_file_create_with_sha(client):
"""PUT file with a non-null sha → update mode (triggers Gitea API call)."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('withsha', 'WithSHA', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='withsha'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'sha2-tok')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "withsha", "is_admin": 0})
resp = client.put(
"/api/gitea/projects/owner/repo/file",
json={
"path": "existing.md", "content": "# Updated", "message": "Update file",
"sha": "abc123def456",
},
cookies={"flowdeck_session": session},
)
assert resp.status_code in (200, 502)
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_file_create_empty_body(client):
"""PUT file with empty JSON body → 400 (no path)."""
resp = client.put("/api/gitea/projects/owner/repo/file", json={})
# Without Gitea token → 401 first
assert resp.status_code in (400, 401)
# ══════════════════════════════════════════════════════
# ── v3.0.0: Admin User Deletion Cascade ──
# ══════════════════════════════════════════════════════
def _create_admin_session():
"""Helper: create an admin user and return (user_id, session_cookie)."""
from app.db import get_conn
from app.auth.session import SessionManager
with get_conn() as conn:
import secrets
login = f"admintest_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'Admin Test', ?, 1)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
return uid, login, session
def _create_regular_session():
"""Helper: create a regular user and return (user_id, login, session_cookie)."""
from app.db import get_conn
from app.auth.session import SessionManager
with get_conn() as conn:
import secrets
login = f"reguser_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) VALUES (?, 'Regular', ?, 0)",
(login, f"{login}@test.com"),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0})
return uid, login, session
def test_admin_list_users_unauthorized(client):
"""GET /api/admin/users — 403 without admin session."""
resp = client.get("/api/admin/users")
assert resp.status_code == 403
def test_admin_list_users_authorized(client):
"""GET /api/admin/users — 200 with admin session."""
uid, login, session = _create_admin_session()
resp = client.get("/api/admin/users", cookies={"flowdeck_session": session})
assert resp.status_code == 200
data = resp.json()
assert "users" in data
assert any(u["login"] == login for u in data["users"])
# cleanup
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_admin_create_user(client):
"""POST /api/admin/users — create user as admin."""
uid, login, session = _create_admin_session()
resp = client.post(
"/api/admin/users",
json={"login": "newuser99", "name": "New User", "email": "[email protected]", "password": "secret123"},
cookies={"flowdeck_session": session},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
new_id = resp.json()["user"]["id"]
# cleanup
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id IN (?, ?)", (uid, new_id))
conn.commit()
def test_admin_create_user_missing_fields(client):
"""POST /api/admin/users — 400 without required fields."""
uid, login, session = _create_admin_session()
resp = client.post(
"/api/admin/users",
json={"login": "baduser"},
cookies={"flowdeck_session": session},
)
assert resp.status_code == 400
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_admin_update_user(client):
"""PUT /api/admin/users/{id} — update user details."""
uid, login, session = _create_admin_session()
# Create a target user first
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('toupdate', 'Old Name', '[email protected]')")
target_id = conn.execute("SELECT id FROM users WHERE login='toupdate'").fetchone()["id"]
conn.commit()
resp = client.put(
f"/api/admin/users/{target_id}",
json={"name": "Updated Name", "is_active": 1},
cookies={"flowdeck_session": session},
)
assert resp.status_code == 200
with get_conn() as conn:
updated = conn.execute("SELECT full_name FROM users WHERE id=?", (target_id,)).fetchone()
assert updated["full_name"] == "Updated Name"
conn.execute("DELETE FROM users WHERE id IN (?, ?)", (uid, target_id))
conn.commit()
def test_admin_delete_user_not_found(client):
"""DELETE /api/admin/users/99999 — 404 for nonexistent user."""
uid, login, session = _create_admin_session()
resp = client.delete("/api/admin/users/99999", cookies={"flowdeck_session": session})
assert resp.status_code == 404
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_admin_delete_user_unauthorized(client):
"""DELETE /api/admin/users/{id} — 403 for non-admin."""
uid, login, session = _create_regular_session()
resp = client.delete(f"/api/admin/users/{uid}", cookies={"flowdeck_session": session})
assert resp.status_code == 403
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_admin_delete_user_simple(client):
"""DELETE /api/admin/users/{id} — delete a user with no associated data."""
# Create admin
admin_id, admin_login, admin_session = _create_admin_session()
# Create target user to delete
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('todelete', 'Delete Me', '[email protected]')")
target_id = conn.execute("SELECT id FROM users WHERE login='todelete'").fetchone()["id"]
conn.commit()
resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify user is deleted
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone()
assert row is None
conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
conn.commit()
def test_admin_delete_user_cascade(client):
"""DELETE /api/admin/users/{id} — cascade delete all associated data.
Creates a user with: OAuth tokens, Gitea private pages, tags, comments,
workspace membership, login history. Verifies all are cleaned up.
"""
from app.db import get_conn
admin_id, admin_login, admin_session = _create_admin_session()
# Create target user
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('cascade_me', 'Cascade', '[email protected]')")
target_id = conn.execute("SELECT id FROM users WHERE login='cascade_me'").fetchone()["id"]
# Add OAuth token
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'cascade-tok')",
(target_id,),
)
# Add Gitea private page
conn.execute(
"INSERT INTO gitea_private_pages (user_id, gitea_owner, gitea_repo, title) VALUES (?, 'o', 'r', 'Page')",
(target_id,),
)
# Add tag
conn.execute("INSERT INTO tags (name, color, user_id) VALUES ('mytag', '#fff', ?)", (target_id,))
# Add login history
conn.execute("INSERT INTO login_history (user_id, ip_address) VALUES (?, '127.0.0.1')", (target_id,))
# Create workspace owned by user
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('My WS', ?)", (target_id,))
ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO workspace_members (workspace_id, user_id) VALUES (?, ?)", (ws_id, target_id))
# Create collection and page for comment (need FK to collection)
conn.execute("INSERT INTO collections (name) VALUES ('cascade_col')")
col_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO collection_pages (collection_id, title) VALUES (?, 'cp')", (col_id,))
cp_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO comments (page_id, user_id, body) VALUES (?, ?, 'hello')", (cp_id, target_id))
conn.commit()
# Delete user (cascade)
resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify all related data is gone
with get_conn() as conn:
assert conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM user_oauth_tokens WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM gitea_private_pages WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM tags WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM login_history WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM workspace_members WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM comments WHERE user_id=?", (target_id,)).fetchone() is None
# Cleanup orphaned collection_pages and collection
conn.execute("DELETE FROM collection_pages WHERE id=?", (cp_id,))
conn.execute("DELETE FROM collections WHERE id=?", (col_id,))
conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
conn.commit()
def test_admin_delete_user_cascade_with_pages(client):
"""DELETE admin user cascade — also deletes workspace pages and favorites."""
from app.db import get_conn
admin_id, admin_login, admin_session = _create_admin_session()
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('caspage', 'CascadePage', '[email protected]')")
target_id = conn.execute("SELECT id FROM users WHERE login='caspage'").fetchone()["id"]
# Create workspace with pages
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('PageWS', ?)", (target_id,))
ws_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO pages (workspace, workspace_id, title) VALUES ('w', ?, 'Page1')", (ws_id,))
page_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (target_id, page_id))
# Create collection and page for page_history FK chain
conn.execute("INSERT INTO collections (name) VALUES ('cascade_col2')")
col_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO collection_pages (collection_id, title) VALUES (?, 'cp2')", (col_id,))
cp_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute("INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?, ?, 'edited', '{}')", (cp_id, target_id))
conn.commit()
resp = client.delete(f"/api/admin/users/{target_id}", cookies={"flowdeck_session": admin_session})
assert resp.status_code == 200
with get_conn() as conn:
assert conn.execute("SELECT id FROM users WHERE id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() is None
assert conn.execute("SELECT id FROM favorites WHERE user_id=?", (target_id,)).fetchone() is None
assert conn.execute("SELECT id FROM page_history WHERE user_id=?", (target_id,)).fetchone() is None
# Cleanup orphaned collection_pages and collection
conn.execute("DELETE FROM collection_pages WHERE id=?", (cp_id,))
conn.execute("DELETE FROM collections WHERE id=?", (col_id,))
conn.execute("DELETE FROM login_history WHERE user_id=?", (admin_id,))
conn.execute("DELETE FROM users WHERE id=?", (admin_id,))
conn.commit()
def test_admin_stats(client):
"""GET /api/admin/stats — admin can see aggregate statistics."""
uid, login, session = _create_admin_session()
resp = client.get("/api/admin/stats", cookies={"flowdeck_session": session})
assert resp.status_code == 200
data = resp.json()
for key in ("total_users", "total_workspaces", "total_files"):
assert key in data
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_admin_stats_unauthorized(client):
"""GET /api/admin/stats — 403 for non-admin."""
resp = client.get("/api/admin/stats")
assert resp.status_code == 403
def test_admin_audit(client):
"""GET /api/admin/audit — admin can view login history."""
uid, login, session = _create_admin_session()
resp = client.get("/api/admin/audit", cookies={"flowdeck_session": session})
assert resp.status_code == 200
assert "entries" in resp.json()
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
# ══════════════════════════════════════════════════════
# ── v3.0.0: Gitea Status ──
# ══════════════════════════════════════════════════════
def test_gitea_status_unlinked(client):
"""GET /api/gitea/status — returns linked=false when no session/token."""
resp = client.get("/api/gitea/status")
assert resp.status_code == 200
assert resp.json()["linked"] is False
def test_gitea_status_linked(client):
"""GET /api/gitea/status — returns linked=true when OAuth token exists."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('gstatus', 'GStatus', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='gstatus'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'status-token')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "gstatus", "is_admin": 0})
resp = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
assert resp.status_code == 200
assert resp.json()["linked"] is True
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_gitea_status_with_expired_token(client):
"""GET /api/gitea/status — returns linked=true even with old token (token existence is enough)."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('gexpired', 'GExp', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='gexpired'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token, expires_at) VALUES (?, 'gitea', 'old-token', '2020-01-01')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "gexpired", "is_admin": 0})
resp = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
assert resp.status_code == 200
# Token exists → linked=true (status endpoint only checks existence)
assert resp.json()["linked"] is True
with get_conn() as conn:
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_gitea_disconnect_no_auth(client):
"""DELETE /api/gitea/disconnect — 401 without session."""
resp = client.delete("/api/gitea/disconnect")
assert resp.status_code == 401
def test_gitea_disconnect_with_auth(client):
"""DELETE /api/gitea/disconnect — removes OAuth tokens for authenticated user."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('disconn', 'Disconn', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='disconn'").fetchone()["id"]
conn.execute(
"INSERT INTO user_oauth_tokens (user_id, provider, access_token) VALUES (?, 'gitea', 'dc-token')",
(uid,),
)
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "disconn", "is_admin": 0})
# Verify linked before
status_before = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
assert status_before.json()["linked"] is True
# Disconnect
resp = client.delete("/api/gitea/disconnect", cookies={"flowdeck_session": session})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# Verify unlinked after
status_after = client.get("/api/gitea/status", cookies={"flowdeck_session": session})
assert status_after.json()["linked"] is False
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
# ══════════════════════════════════════════════════════
# ── v3.0.0: OAuth Link Mode ──
# ══════════════════════════════════════════════════════
def test_oauth_login_local_page(client):
"""GET /auth/login?provider=local — renders local login HTML page."""
resp = client.get("/auth/login?provider=local")
assert resp.status_code == 200
assert "FlowDeck" in resp.text
assert "Login" in resp.text or "login" in resp.text.lower()
def test_oauth_login_gitea_redirect(client):
"""GET /auth/login?provider=gitea — redirects to Gitea OAuth (configured in test env)."""
resp = client.get("/auth/login?provider=gitea", follow_redirects=False)
# Gitea OAuth IS configured in test env → redirect to Gitea
assert resp.status_code == 302
assert "login/oauth" in resp.headers.get("location", "").lower()
def test_oauth_login_with_link_mode(client):
"""GET /auth/login?provider=gitea&mode=link — link mode redirects to Gitea OAuth."""
resp = client.get("/auth/login?provider=gitea&mode=link", follow_redirects=False)
# Gitea OAuth IS configured → redirect to Gitea with link mode set in session
assert resp.status_code == 302
assert "login/oauth" in resp.headers.get("location", "").lower()
def test_oauth_login_with_mode_link_and_provider_github(client):
"""GET /auth/login?provider=github&mode=link — sets link mode for GitHub."""
resp = client.get("/auth/login?provider=github&mode=link")
# GitHub OAuth not configured either → error page
assert resp.status_code == 200
assert "github" in resp.text.lower() or "not configured" in resp.text.lower()
def test_oauth_callback_invalid_state(client):
"""GET /auth/callback?code=test&state=invalid — 400 for invalid state."""
resp = client.get("/auth/callback?code=test_code&state=invalid_state")
assert resp.status_code == 400
def test_oauth_callback_missing_code(client):
"""GET /auth/callback — 422 without required code param."""
resp = client.get("/auth/callback")
assert resp.status_code == 422
def test_oauth_logout(client):
"""GET /auth/logout — redirects to local login page (follow_redirects=False)."""
resp = client.get("/auth/logout", follow_redirects=False)
assert resp.status_code == 302
assert "login" in resp.headers.get("location", "").lower()
def test_auth_register_missing_fields(client):
"""POST /auth/register — 400 without email/password."""
resp = client.post("/auth/register", json={})
assert resp.status_code == 400
def test_auth_register_short_password(client):
"""POST /auth/register — 400 with password < 6 chars."""
resp = client.post("/auth/register", json={"email": "[email protected]", "password": "ab"})
assert resp.status_code == 400
assert "6" in resp.json()["error"]
def test_auth_register_success(client):
"""POST /auth/register — successfully register a new user."""
resp = client.post("/auth/register", json={"email": "[email protected]", "password": "secret123", "name": "New User"})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
assert resp.json()["user"]["login"] == "[email protected]"
# cleanup
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=(SELECT id FROM users WHERE login='[email protected]')")
conn.execute("DELETE FROM users WHERE login='[email protected]'")
conn.commit()
def test_auth_register_duplicate(client):
"""POST /auth/register — 409 for duplicate email."""
resp = client.post("/auth/register", json={"email": "[email protected]", "password": "secret123"})
assert resp.status_code == 200
# Try again with same email
resp2 = client.post("/auth/register", json={"email": "[email protected]", "password": "another1"})
assert resp2.status_code == 409
# cleanup
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM login_history WHERE user_id=(SELECT id FROM users WHERE login='[email protected]')")
conn.execute("DELETE FROM users WHERE login='[email protected]'")
conn.commit()
def test_auth_local_login_invalid_credentials(client):
"""POST /auth/local-login — 401 with wrong password."""
resp = client.post("/auth/local-login", json={"email": "[email protected]", "password": "wrong"})
assert resp.status_code == 401
def test_auth_local_login_missing_fields(client):
"""POST /auth/local-login — 400 without email/password."""
resp = client.post("/auth/local-login", json={})
assert resp.status_code == 400
def test_auth_user_authenticated(client):
"""GET /auth/user — returns authenticated=true with valid session."""
from app.db import get_conn
with get_conn() as conn:
conn.execute("INSERT INTO users (login, full_name, email) VALUES ('authuser', 'Auth', '[email protected]')")
uid = conn.execute("SELECT id FROM users WHERE login='authuser'").fetchone()["id"]
conn.commit()
from app.auth.session import SessionManager
session = SessionManager.create_session({"id": uid, "login": "authuser", "is_admin": 0})
resp = client.get("/auth/user", cookies={"flowdeck_session": session})
assert resp.status_code == 200
data = resp.json()
assert data["authenticated"] is True
assert data["user"]["login"] == "authuser"
with get_conn() as conn:
conn.execute("DELETE FROM users WHERE id=?", (uid,))
conn.commit()
def test_auth_user_unauthenticated(client):
"""GET /auth/user — returns authenticated=false without session."""
resp = client.get("/auth/user")
assert resp.status_code == 200
assert resp.json()["authenticated"] is False
# ══════════════════════════════════════════════════════
# ── v3.0.0: Gitea API Edge Cases ──
# ══════════════════════════════════════════════════════
def test_gitea_labels_no_auth(client):
"""GET /api/gitea/projects/owner/repo/labels — 401 without Gitea linked."""
resp = client.get("/api/gitea/projects/owner/repo/labels")
assert resp.status_code == 401
def test_gitea_tree_no_auth(client):
"""GET /api/gitea/projects/owner/repo/tree — 401 without Gitea linked."""
resp = client.get("/api/gitea/projects/owner/repo/tree")
assert resp.status_code == 401
def test_gitea_file_get_no_auth(client):
"""GET /api/gitea/projects/owner/repo/file?path=x — 401 without Gitea linked."""
resp = client.get("/api/gitea/projects/owner/repo/file?path=README.md")
assert resp.status_code == 401
def test_gitea_orgs_no_auth(client):
"""GET /api/gitea/orgs — 401 without Gitea linked."""
resp = client.get("/api/gitea/orgs")
assert resp.status_code == 401
def test_gitea_projects_no_auth(client):
"""GET /api/gitea/projects — 401 without Gitea linked."""
resp = client.get("/api/gitea/projects")
assert resp.status_code == 401
def test_gitea_file_delete_no_auth(client):
"""DELETE /api/gitea/projects/owner/repo/file — 401 without Gitea linked."""
resp = client.delete("/api/gitea/projects/owner/repo/file?path=test.md&sha=abc")
assert resp.status_code in (400, 401)
def test_gitea_file_delete_missing_params(client):
"""DELETE file without path+sha → 400 even without auth."""
resp = client.delete("/api/gitea/projects/owner/repo/file")
assert resp.status_code in (400, 401)
def test_gitea_private_pages_list_no_auth(client):
"""GET private-pages — returns empty without auth."""
resp = client.get("/api/gitea/projects/owner/repo/private-pages")
assert resp.status_code == 200
assert resp.json()["pages"] == []
def test_gitea_private_pages_create_no_auth(client):
"""POST private-pages — 401 without session."""
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
assert resp.status_code == 401