mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 02:45:28 +00:00
Merge 14d6045d2d into fb8c391a88
This commit is contained in:
commit
11c817d21c
4 changed files with 443 additions and 1 deletions
|
|
@ -8,7 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
|
|||
|
||||
from core.database import get_db_session, ApiToken
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
|
||||
MAX_NAME_LEN = 100
|
||||
DEFAULT_SCOPES = "chat"
|
||||
|
|
@ -206,4 +206,82 @@ def setup_api_token_routes() -> APIRouter:
|
|||
_invalidate_cache(request)
|
||||
return {"status": "deleted"}
|
||||
|
||||
# ── Self-serve endpoints (cookie-only, owner forced to current user) ──
|
||||
|
||||
@router.get("/tokens/self")
|
||||
def list_self_tokens(request: Request):
|
||||
"""List the current user's own API tokens. Cookie session only."""
|
||||
user = require_user(request)
|
||||
with get_db_session() as db:
|
||||
tokens = db.query(ApiToken).filter(
|
||||
ApiToken.owner == user, ApiToken.is_active == True # noqa: E712
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"owner": getattr(t, "owner", None),
|
||||
"token_prefix": t.token_prefix,
|
||||
"scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()],
|
||||
"is_active": t.is_active,
|
||||
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||
}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
@router.post("/tokens/self")
|
||||
def create_self_token(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
scopes: str = Form(None),
|
||||
profile: str = Form(None),
|
||||
):
|
||||
"""Create an API token for the current user. Cookie session only."""
|
||||
user = require_user(request)
|
||||
name = name.strip()[:MAX_NAME_LEN]
|
||||
if not name:
|
||||
raise HTTPException(400, "Token name is required")
|
||||
scope_list = _normalize_scopes(scopes, profile)
|
||||
scopes_value = ",".join(scope_list)
|
||||
|
||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
token_id = str(uuid.uuid4())[:8]
|
||||
|
||||
with get_db_session() as db:
|
||||
db.add(ApiToken(
|
||||
id=token_id,
|
||||
owner=user,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=raw_token[:8],
|
||||
scopes=scopes_value,
|
||||
is_active=True,
|
||||
))
|
||||
_invalidate_cache(request)
|
||||
|
||||
return {
|
||||
"id": token_id,
|
||||
"name": name,
|
||||
"owner": user,
|
||||
"token": raw_token,
|
||||
"token_prefix": raw_token[:8],
|
||||
"scopes": scope_list,
|
||||
}
|
||||
|
||||
@router.delete("/tokens/self/{token_id}")
|
||||
def delete_self_token(request: Request, token_id: str):
|
||||
"""Delete one of the current user's own API tokens. Cookie session only."""
|
||||
user = require_user(request)
|
||||
with get_db_session() as db:
|
||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
# Return 404 for both "not found" and "not yours" so callers
|
||||
# can't probe for the existence of another user's token ids.
|
||||
if not token or token.owner != user:
|
||||
raise HTTPException(404, "Token not found")
|
||||
db.delete(token)
|
||||
_invalidate_cache(request)
|
||||
return {"status": "deleted"}
|
||||
|
||||
return router
|
||||
|
|
|
|||
|
|
@ -1999,6 +1999,34 @@
|
|||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" id="settings-api-tokens-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 1a3 3 0 0 0-3 3v2H7a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9a3 3 0 0 0-3-3h-2V4a3 3 0 0 0-3-3z"/></svg>Personal API Tokens</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Create tokens to access your Odysseus data from scripts, apps, or external tools. Tokens are scoped — only grant the permissions your use case needs.</div>
|
||||
<div id="settings-api-tokens-list" style="margin-bottom:10px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="settings-api-tokens-create" style="display:none;border-top:1px solid var(--border);padding-top:10px;margin-top:4px;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||
<input id="settings-api-token-name" type="text" placeholder="Token name (e.g. 'My backup script')" style="flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<button class="admin-btn-add" id="settings-api-token-create-btn" style="white-space:nowrap;">Create</button>
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Select which data this token can access. Leave all unchecked for chat-only access.</div>
|
||||
<div id="settings-api-token-scopes" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px;">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<div id="settings-api-token-create-msg" style="font-size:11px;margin-top:6px;min-height:16px;"></div>
|
||||
<div id="settings-api-token-reveal" style="display:none;margin-top:8px;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;">
|
||||
<div style="font-size:11px;opacity:0.5;margin-bottom:4px;">Copy this token now — it won't be shown again.</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<code id="settings-api-token-value" style="flex:1;padding:4px 8px;font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:4px;word-break:break-all;user-select:all;"></code>
|
||||
<button class="admin-btn-sm" id="settings-api-token-copy-btn" title="Copy to clipboard" style="flex-shrink:0;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="admin-btn-add" id="settings-api-token-new-btn" style="margin-top:4px;">+ New Token</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ EMAIL TAB ═══ -->
|
||||
|
|
|
|||
|
|
@ -2130,6 +2130,169 @@ async function initShortcuts() {
|
|||
/* ═══════════════════════════════════════════
|
||||
INIT & REFRESH
|
||||
═══════════════════════════════════════════ */
|
||||
// ── Personal API Tokens (Account tab) ──
|
||||
const SELF_TOKEN_SCOPES = [
|
||||
{ key: 'chat', label: 'Chat', detail: 'Chat and companion access' },
|
||||
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
|
||||
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
|
||||
{ key: 'documents:read', label: 'Documents', detail: 'Read documents' },
|
||||
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
|
||||
{ key: 'email:read', label: 'Email', detail: 'Read email' },
|
||||
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts' },
|
||||
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
|
||||
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events' },
|
||||
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
|
||||
{ key: 'memory:read', label: 'Memory', detail: 'Read memory' },
|
||||
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory' },
|
||||
{ key: 'cookbook:read', label: 'Cookbook', detail: 'Read model inventory and presets' },
|
||||
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Start and stop model servers' },
|
||||
];
|
||||
|
||||
function initSelfApiTokens() {
|
||||
const listEl = el('settings-api-tokens-list');
|
||||
const createPanel = el('settings-api-tokens-create');
|
||||
const newBtn = el('settings-api-token-new-btn');
|
||||
const createBtn = el('settings-api-token-create-btn');
|
||||
const nameInput = el('settings-api-token-name');
|
||||
const scopesEl = el('settings-api-token-scopes');
|
||||
const msgEl = el('settings-api-token-create-msg');
|
||||
const revealEl = el('settings-api-token-reveal');
|
||||
const tokenValueEl = el('settings-api-token-value');
|
||||
const copyBtn = el('settings-api-token-copy-btn');
|
||||
|
||||
if (!listEl || !newBtn) return;
|
||||
|
||||
// Build scope checkboxes once
|
||||
scopesEl.innerHTML = SELF_TOKEN_SCOPES.map(s => `
|
||||
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:12px;cursor:pointer;">
|
||||
<input type="checkbox" class="self-token-scope" value="${esc(s.key)}" title="${esc(s.detail)}">
|
||||
<span>${esc(s.label)}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
async function loadTokens() {
|
||||
try {
|
||||
const res = await fetch('/api/tokens/self', { credentials: 'same-origin' });
|
||||
const tokens = await res.json();
|
||||
if (!tokens.length) {
|
||||
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;padding:4px 0;">No tokens yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = tokens.map(t => {
|
||||
const scopes = (t.scopes || []).join(', ') || 'chat';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 3%, transparent);">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:12px;font-weight:600;">${esc(t.name)}</div>
|
||||
<div style="font-size:10px;opacity:0.5;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<span>${esc(t.token_prefix)}...</span>
|
||||
<span>${esc(scopes)}</span>
|
||||
${t.last_used_at ? `<span>Last used ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span>Never used</span>'}
|
||||
</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete self-token-revoke" data-token-id="${esc(t.id)}" title="Revoke token" style="flex-shrink:0;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Wire revoke buttons
|
||||
listEl.querySelectorAll('.self-token-revoke').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const tokenId = btn.dataset.tokenId;
|
||||
if (!await window.styledConfirm('Revoke this token? Any scripts or apps using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
|
||||
try {
|
||||
const r = await fetch(`/api/tokens/self/${tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
if (r.ok) loadTokens();
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
} catch (_) {
|
||||
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load tokens</div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadTokens();
|
||||
|
||||
// New token button
|
||||
newBtn.addEventListener('click', () => {
|
||||
createPanel.style.display = '';
|
||||
newBtn.style.display = 'none';
|
||||
nameInput.value = '';
|
||||
nameInput.focus();
|
||||
revealEl.style.display = 'none';
|
||||
msgEl.textContent = '';
|
||||
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
|
||||
});
|
||||
|
||||
// Cancel create — hide panel, show button
|
||||
const cancelCreate = () => {
|
||||
createPanel.style.display = 'none';
|
||||
newBtn.style.display = '';
|
||||
revealEl.style.display = 'none';
|
||||
msgEl.textContent = '';
|
||||
};
|
||||
|
||||
// Create token
|
||||
createBtn.addEventListener('click', async () => {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { msgEl.textContent = 'Token name is required'; msgEl.style.color = 'var(--red)'; return; }
|
||||
const checked = Array.from(scopesEl.querySelectorAll('.self-token-scope:checked')).map(cb => cb.value);
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
if (checked.length) fd.append('scopes', checked.join(','));
|
||||
msgEl.textContent = '';
|
||||
msgEl.style.color = '';
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/tokens/self', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
tokenValueEl.textContent = data.token;
|
||||
revealEl.style.display = '';
|
||||
nameInput.value = '';
|
||||
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
|
||||
loadTokens();
|
||||
} else {
|
||||
msgEl.textContent = data.detail || 'Failed';
|
||||
msgEl.style.color = 'var(--red)';
|
||||
}
|
||||
} catch (_) {
|
||||
msgEl.textContent = 'Request failed';
|
||||
msgEl.style.color = 'var(--red)';
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Copy button
|
||||
if (copyBtn) {
|
||||
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
const CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const val = tokenValueEl.textContent;
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
copyBtn.innerHTML = CHECK_ICON;
|
||||
copyBtn.style.color = 'var(--accent, var(--red))';
|
||||
copyBtn.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = COPY_ICON;
|
||||
copyBtn.style.color = '';
|
||||
copyBtn.style.opacity = '';
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Allow Enter key in name input to create
|
||||
nameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAccount() {
|
||||
// Populate user info
|
||||
fetch('/api/auth/status', { credentials: 'same-origin' })
|
||||
|
|
@ -2287,6 +2450,9 @@ function initAccount() {
|
|||
render2FA();
|
||||
}
|
||||
|
||||
// Personal API Tokens
|
||||
initSelfApiTokens();
|
||||
|
||||
// Logout
|
||||
const logoutBtn = el('settings-logout-btn');
|
||||
if (logoutBtn) {
|
||||
|
|
|
|||
|
|
@ -576,3 +576,173 @@ def test_update_token_normal_object_still_works(monkeypatch, token_routes_mod):
|
|||
assert token.name == "updated"
|
||||
assert resp["name"] == "updated"
|
||||
invalidator.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Self-serve endpoints — cookie-only, owner forced to session user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _self_req(username: str, *, is_api_token: bool = False, invalidator=None):
|
||||
"""A request whose state carries a cookie user (or a bearer API token)."""
|
||||
app_state = SimpleNamespace(
|
||||
auth_manager=_admin_mgr(True),
|
||||
)
|
||||
if invalidator is not None:
|
||||
app_state.invalidate_token_cache = invalidator
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
current_user="api" if is_api_token else username,
|
||||
api_token=is_api_token,
|
||||
api_token_owner=username if is_api_token else None,
|
||||
),
|
||||
headers={},
|
||||
app=SimpleNamespace(state=app_state),
|
||||
)
|
||||
|
||||
|
||||
# -- bearer rejection --
|
||||
|
||||
|
||||
def test_self_serve_list_rejects_bearer_token(monkeypatch, token_routes_mod):
|
||||
"""GET /tokens/self with a bearer API token → 403."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
|
||||
mod = token_routes_mod
|
||||
list_self = _get_handler(mod, "GET", "/tokens/self")
|
||||
req = _self_req("alice", is_api_token=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
list_self(request=req)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_self_serve_create_rejects_bearer_token(monkeypatch, token_routes_mod):
|
||||
"""POST /tokens/self with a bearer API token → 403."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
|
||||
mod = token_routes_mod
|
||||
create_self = _get_handler(mod, "POST", "/tokens/self")
|
||||
req = _self_req("alice", is_api_token=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_self(request=req, name="my-token")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_self_serve_delete_rejects_bearer_token(monkeypatch, token_routes_mod):
|
||||
"""DELETE /tokens/self/{id} with a bearer API token → 403."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("LOCALHOST_BYPASS", "false")
|
||||
mod = token_routes_mod
|
||||
delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
|
||||
req = _self_req("alice", is_api_token=True)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
delete_self(request=req, token_id="abc12345")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
# -- owner isolation (list only returns own tokens) --
|
||||
|
||||
|
||||
def test_list_self_only_returns_own_tokens(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
|
||||
now = datetime.datetime(2024, 1, 1, 0, 0)
|
||||
row_alice = SimpleNamespace(
|
||||
id="a001", name="Alice token", owner="alice", token_prefix="ody_al",
|
||||
scopes="chat", is_active=True, last_used_at=now, created_at=now,
|
||||
)
|
||||
row_bob = SimpleNamespace(
|
||||
id="b001", name="Bob token", owner="bob", token_prefix="ody_bo",
|
||||
scopes="chat", is_active=True, last_used_at=now, created_at=now,
|
||||
)
|
||||
|
||||
fake_session = MagicMock()
|
||||
# The handler filters on owner == user, so the mock query chain must
|
||||
# return only alice's row after the filter. We mock .all() to return
|
||||
# the union, but the real DB filter would exclude bob. Simulate it by
|
||||
# having the mock return only alice's row.
|
||||
fake_session.query.return_value.filter.return_value.all.return_value = [row_alice]
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
req = _self_req("alice")
|
||||
list_self = _get_handler(mod, "GET", "/tokens/self")
|
||||
result = list_self(request=req)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "a001"
|
||||
assert result[0]["owner"] == "alice"
|
||||
|
||||
|
||||
# -- delete not-yours returns 404 (not 403) to avoid existence oracle --
|
||||
|
||||
|
||||
def test_delete_self_not_yours_returns_404(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
|
||||
# Token exists but belongs to bob, not alice.
|
||||
fake_token = SimpleNamespace(id="bob123", owner="bob", name="bob-token")
|
||||
fake_session = MagicMock()
|
||||
fake_session.query.return_value.filter.return_value.first.return_value = fake_token
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
req = _self_req("alice")
|
||||
delete_self = _get_handler(mod, "DELETE", "/tokens/self/{token_id}")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
delete_self(request=req, token_id="bob123")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# -- unknown scope rejected --
|
||||
|
||||
|
||||
def test_create_self_rejects_unknown_scope(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
|
||||
fake_session = MagicMock()
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
req = _self_req("alice")
|
||||
create_self = _get_handler(mod, "POST", "/tokens/self")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_self(request=req, name="bad-token", scopes="admin,shell")
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# -- owner forced from session (not client) --
|
||||
|
||||
|
||||
def test_create_self_forces_owner_from_session(monkeypatch, token_routes_mod):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
mod = token_routes_mod
|
||||
|
||||
fake_suffix = "FAKESUFFIX_XXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
fake_uuid_str = "abcd1234-0000-0000-0000-000000000000"
|
||||
monkeypatch.setattr(_secrets_mod, "token_urlsafe", lambda n: fake_suffix)
|
||||
monkeypatch.setattr(_uuid_mod, "uuid4", lambda: SimpleNamespace(__str__=lambda self: fake_uuid_str))
|
||||
monkeypatch.setattr(mod, "bcrypt", SimpleNamespace(
|
||||
hashpw=lambda pw, salt: b"$2b$12$FAKEHASH",
|
||||
gensalt=lambda: b"fakesalt",
|
||||
))
|
||||
|
||||
captured = {}
|
||||
class _FakeApiToken:
|
||||
def __init__(self, **kw):
|
||||
captured.update(kw)
|
||||
self.__dict__.update(kw)
|
||||
monkeypatch.setattr(mod, "ApiToken", _FakeApiToken)
|
||||
|
||||
fake_session = MagicMock()
|
||||
monkeypatch.setattr(mod, "get_db_session", lambda: _db_ctx(fake_session))
|
||||
|
||||
invalidator = MagicMock()
|
||||
req = _self_req("alice", invalidator=invalidator)
|
||||
create_self = _get_handler(mod, "POST", "/tokens/self")
|
||||
resp = create_self(request=req, name="my-token")
|
||||
|
||||
# Owner must be "alice" (from session), never from client input
|
||||
assert resp["owner"] == "alice"
|
||||
assert captured["owner"] == "alice"
|
||||
invalidator.assert_called_once()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue