credentials: offer the approved Confluence origin as a fixed choice
The origin check is an exact match including the context path, so users had to type "https://collab.lge.com/main" precisely. GET /api/v1/config now returns the approved origins in canonical form (non-secret: they are the only destinations the backend will talk to), and the UI swaps the URL text field for a select listing them, keeping the element id, focus handling and the Test connection flow unchanged. The text field remains the fallback when the fetch fails. Backend validation of the submitted URL is untouched. Mock server serves the endpoint; contract, API and e2e tests cover it.
This commit is contained in:
parent
1d7b867986
commit
77768ba3ca
@ -49,10 +49,10 @@ make run # scripts/run-backend.sh deploy/confluence-web.env
|
|||||||
```
|
```
|
||||||
|
|
||||||
The backend binds `127.0.0.1:8000` by default and serves the UI at
|
The backend binds `127.0.0.1:8000` by default and serves the UI at
|
||||||
`http://127.0.0.1:8000/`. Open it in a browser, click the key icon, enter the
|
`http://127.0.0.1:8000/`. Open it in a browser, click the key icon, pick the
|
||||||
Confluence base URL **including its context path** (for example
|
Confluence origin (the list comes from `CONFLUENCE_WEB_APPROVED_ORIGINS`, each
|
||||||
`https://collab.lge.com/main`, which must match an approved origin), your
|
entry including its context path, for example `https://collab.lge.com/main`),
|
||||||
personal access token (PAT), test the connection, then ask a question. On
|
enter your personal access token (PAT), test the connection, then ask a question. On
|
||||||
instances that allow anonymous REST reads, "Test connection" proves the
|
instances that allow anonymous REST reads, "Test connection" proves the
|
||||||
destination is reachable but cannot prove the PAT is valid; a wrong token then
|
destination is reachable but cannot prove the PAT is valid; a wrong token then
|
||||||
shows up as denied or missing pages. Credentials live only in browser memory and in the backend
|
shows up as denied or missing pages. Credentials live only in browser memory and in the backend
|
||||||
|
|||||||
@ -27,7 +27,7 @@ backend/
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CONFLUENCE_WEB_APPROVED_ORIGINS` | `https://approved.example.com` | Comma-separated list of approved Confluence base origins and context paths. |
|
| `CONFLUENCE_WEB_APPROVED_ORIGINS` | `https://approved.example.com` | Comma-separated list of approved Confluence base origins and context paths. Exposed in canonical form by `GET /api/v1/config`, which the UI uses to offer the origin as a fixed choice. |
|
||||||
| `CONFLUENCE_WEB_CORPORATE_CA_PATH` | `None` | Path to corporate CA bundle for TLS verification if needed. |
|
| `CONFLUENCE_WEB_CORPORATE_CA_PATH` | `None` | Path to corporate CA bundle for TLS verification if needed. |
|
||||||
| `CONFLUENCE_WEB_MODEL_PROVIDER` | `fake` | Model provider adapter (`fake`, `openai`). |
|
| `CONFLUENCE_WEB_MODEL_PROVIDER` | `fake` | Model provider adapter (`fake`, `openai`). |
|
||||||
| `CONFLUENCE_WEB_MODEL_NAME` | `fake-model` | Model name (e.g. `gpt-4o`). |
|
| `CONFLUENCE_WEB_MODEL_NAME` | `fake-model` | Model name (e.g. `gpt-4o`). |
|
||||||
|
|||||||
@ -38,7 +38,7 @@ from backend.errors import (
|
|||||||
)
|
)
|
||||||
from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter
|
from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter
|
||||||
from backend.runner import QueryRunner
|
from backend.runner import QueryRunner
|
||||||
from backend.settings import Settings, validate_confluence_url
|
from backend.settings import Settings, canonicalize_url, validate_confluence_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -454,6 +454,24 @@ def create_app(
|
|||||||
get_or_set_session_cookie(request, resp, session_store)
|
get_or_set_session_cookie(request, resp, session_store)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
@app.get("/api/v1/config")
|
||||||
|
async def public_config():
|
||||||
|
"""Non-secret deployment facts the UI needs before any credentials exist.
|
||||||
|
|
||||||
|
The approved Confluence origins are the destinations this backend will talk
|
||||||
|
to; the UI offers them as a fixed choice so users cannot mistype the origin or
|
||||||
|
its context path. Validation of the submitted URL is unchanged.
|
||||||
|
"""
|
||||||
|
origins: list[str] = []
|
||||||
|
for origin in app_settings.approved_confluence_origins:
|
||||||
|
try:
|
||||||
|
canonical = canonicalize_url(origin)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if canonical not in origins:
|
||||||
|
origins.append(canonical)
|
||||||
|
return JSONResponse(content={"approved_origins": origins})
|
||||||
|
|
||||||
@app.post("/api/v1/queue/join")
|
@app.post("/api/v1/queue/join")
|
||||||
async def queue_join(req: QueueJoinRequest, request: Request):
|
async def queue_join(req: QueueJoinRequest, request: Request):
|
||||||
check_same_origin(request)
|
check_same_origin(request)
|
||||||
|
|||||||
@ -47,6 +47,7 @@ frontend/
|
|||||||
|
|
||||||
1. **In-Memory Credentials**:
|
1. **In-Memory Credentials**:
|
||||||
- Confluence Base URL and Personal Access Token (PAT) reside strictly in browser JavaScript memory.
|
- Confluence Base URL and Personal Access Token (PAT) reside strictly in browser JavaScript memory.
|
||||||
|
- The URL field is a select filled from `GET /api/v1/config` (the backend's approved origins, canonical form). If that fetch fails, the plain text input stays as the fallback.
|
||||||
- Never written to `localStorage`, `sessionStorage`, cookies, query parameters, console logs, or exported files.
|
- Never written to `localStorage`, `sessionStorage`, cookies, query parameters, console logs, or exported files.
|
||||||
- A `cw_session` HttpOnly cookie is set by the origin for artifact download ownership.
|
- A `cw_session` HttpOnly cookie is set by the origin for artifact download ownership.
|
||||||
2. **Content Security Policy (CSP)**:
|
2. **Content Security Policy (CSP)**:
|
||||||
|
|||||||
@ -1040,6 +1040,11 @@ body {
|
|||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select.modal-input {
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-input:focus {
|
.modal-input:focus {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||||
|
|||||||
@ -837,6 +837,13 @@ export function createMockServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// API Endpoint 1: POST /api/v1/auth/verify
|
// API Endpoint 1: POST /api/v1/auth/verify
|
||||||
|
// Non-secret deployment facts: the approved Confluence origins offered as a fixed choice.
|
||||||
|
if (pathname === '/api/v1/config' && method === 'GET') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||||
|
res.end(JSON.stringify({ approved_origins: ['https://approved.example.com'] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
|
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', (chunk) => { body += chunk; });
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
|||||||
@ -124,7 +124,7 @@
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<span class="field-hint">Must be a valid HTTP or HTTPS URL (max 8 KiB)</span>
|
<span id="cred-url-hint" class="field-hint">Must be a valid HTTP or HTTPS URL (max 8 KiB)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@ -230,6 +230,32 @@ export async function joinQueue({ signal } = {}) {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches non-secret deployment facts: the approved Confluence origins the backend accepts.
|
||||||
|
* @param {{ signal?: AbortSignal }} [options]
|
||||||
|
* @returns {Promise<{ approved_origins: string[] }>}
|
||||||
|
*/
|
||||||
|
export async function fetchConfig({ signal } = {}) {
|
||||||
|
const response = await fetch('/api/v1/config', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await parseApiError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || !Array.isArray(data.approved_origins) || !data.approved_origins.every((o) => typeof o === 'string' && o.trim())) {
|
||||||
|
const err = new Error('Invalid config response.');
|
||||||
|
err.code = 'invalid_response';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls the current session's ticket status. Every call refreshes the server-side heartbeat.
|
* Polls the current session's ticket status. Every call refreshes the server-side heartbeat.
|
||||||
* @param {{ signal?: AbortSignal }} [options]
|
* @param {{ signal?: AbortSignal }} [options]
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Credentials remain strictly in browser memory and are never persisted or logged.
|
* Credentials remain strictly in browser memory and are never persisted or logged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue } from './api.js';
|
import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue, fetchConfig } from './api.js';
|
||||||
import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
|
import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
|
||||||
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
|
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
|
||||||
import { mountThinkingOrb } from './orb.js';
|
import { mountThinkingOrb } from './orb.js';
|
||||||
@ -37,7 +37,7 @@ let viewPrompt, viewLoading, viewResult;
|
|||||||
let promptInput, submitBtn, promptError;
|
let promptInput, submitBtn, promptError;
|
||||||
let keyBtn, credIndicator, credStatusSr;
|
let keyBtn, credIndicator, credStatusSr;
|
||||||
let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm;
|
let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm;
|
||||||
let credUrlInput, credPatInput, togglePatBtn, modalFeedback;
|
let credUrlInput, credUrlHint, credPatInput, togglePatBtn, modalFeedback;
|
||||||
let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred;
|
let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred;
|
||||||
let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn;
|
let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn;
|
||||||
let outputContent, sectionNav, resultWarnings;
|
let outputContent, sectionNav, resultWarnings;
|
||||||
@ -77,6 +77,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
modalCloseBtn = document.getElementById('modal-close-btn');
|
modalCloseBtn = document.getElementById('modal-close-btn');
|
||||||
credentialsForm = document.getElementById('credentials-form');
|
credentialsForm = document.getElementById('credentials-form');
|
||||||
credUrlInput = document.getElementById('cred-url');
|
credUrlInput = document.getElementById('cred-url');
|
||||||
|
credUrlHint = document.getElementById('cred-url-hint');
|
||||||
credPatInput = document.getElementById('cred-pat');
|
credPatInput = document.getElementById('cred-pat');
|
||||||
togglePatBtn = document.getElementById('toggle-pat-btn');
|
togglePatBtn = document.getElementById('toggle-pat-btn');
|
||||||
modalFeedback = document.getElementById('modal-feedback');
|
modalFeedback = document.getElementById('modal-feedback');
|
||||||
@ -119,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
setupLoadingEvents();
|
setupLoadingEvents();
|
||||||
setupResultEvents();
|
setupResultEvents();
|
||||||
updateCredentialIndicator();
|
updateCredentialIndicator();
|
||||||
|
loadApprovedOrigins();
|
||||||
|
|
||||||
// Best-effort ticket release on tab close/navigation while a ticket may still be held
|
// Best-effort ticket release on tab close/navigation while a ticket may still be held
|
||||||
// (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land,
|
// (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land,
|
||||||
@ -442,10 +444,7 @@ function setupModalEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Typing in inputs invalidates any in-flight test connection
|
// Typing in inputs invalidates any in-flight test connection
|
||||||
credUrlInput.addEventListener('input', () => {
|
bindUrlFieldEvents(credUrlInput);
|
||||||
abortActiveVerify();
|
|
||||||
btnTestCred.disabled = false;
|
|
||||||
});
|
|
||||||
credPatInput.addEventListener('input', () => {
|
credPatInput.addEventListener('input', () => {
|
||||||
abortActiveVerify();
|
abortActiveVerify();
|
||||||
btnTestCred.disabled = false;
|
btnTestCred.disabled = false;
|
||||||
@ -554,10 +553,10 @@ function openCredentialsModal() {
|
|||||||
modalFeedback.textContent = '';
|
modalFeedback.textContent = '';
|
||||||
|
|
||||||
if (committedCredentials) {
|
if (committedCredentials) {
|
||||||
credUrlInput.value = committedCredentials.url;
|
setUrlFieldValue(committedCredentials.url);
|
||||||
credPatInput.value = committedCredentials.pat;
|
credPatInput.value = committedCredentials.pat;
|
||||||
} else {
|
} else {
|
||||||
credUrlInput.value = draftCredentials.url || '';
|
setUrlFieldValue(draftCredentials.url || '');
|
||||||
credPatInput.value = draftCredentials.pat || '';
|
credPatInput.value = draftCredentials.pat || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,6 +564,73 @@ function openCredentialsModal() {
|
|||||||
credUrlInput.focus();
|
credUrlInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any edit or choice in the URL field invalidates an in-flight test connection.
|
||||||
|
* @param {HTMLElement} field
|
||||||
|
*/
|
||||||
|
function bindUrlFieldEvents(field) {
|
||||||
|
for (const type of ['input', 'change']) {
|
||||||
|
field.addEventListener(type, () => {
|
||||||
|
abortActiveVerify();
|
||||||
|
btnTestCred.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the URL field value. A select falls back to its first option when the value is not
|
||||||
|
* one of the approved origins, so the field never shows an empty choice.
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
|
function setUrlFieldValue(value) {
|
||||||
|
credUrlInput.value = value;
|
||||||
|
if (credUrlInput.tagName === 'SELECT' && credUrlInput.selectedIndex === -1) {
|
||||||
|
credUrlInput.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the backend for the approved Confluence origins and, when it answers, turns the URL
|
||||||
|
* text field into a fixed choice among them. The text field stays as the fallback for a
|
||||||
|
* backend without the endpoint or a failed fetch, so the modal always works.
|
||||||
|
*/
|
||||||
|
async function loadApprovedOrigins() {
|
||||||
|
let origins;
|
||||||
|
try {
|
||||||
|
({ approved_origins: origins } = await fetchConfig());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (origins.length === 0) return;
|
||||||
|
applyApprovedOrigins(origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the URL text input with a select listing the approved origins, keeping the
|
||||||
|
* element id so labels, focus handling and tests are unchanged.
|
||||||
|
* @param {string[]} origins
|
||||||
|
*/
|
||||||
|
function applyApprovedOrigins(origins) {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.id = credUrlInput.id;
|
||||||
|
select.className = credUrlInput.className;
|
||||||
|
select.required = true;
|
||||||
|
for (const origin of origins) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = origin;
|
||||||
|
option.textContent = origin;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
const previous = credUrlInput.value.trim();
|
||||||
|
credUrlInput.replaceWith(select);
|
||||||
|
credUrlInput = select;
|
||||||
|
bindUrlFieldEvents(select);
|
||||||
|
setUrlFieldValue(previous);
|
||||||
|
credUrlHint.textContent = origins.length === 1
|
||||||
|
? 'The only Confluence origin this server is approved to reach'
|
||||||
|
: 'Confluence origins this server is approved to reach';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes credentials modal and restores focus to key button.
|
* Closes credentials modal and restores focus to key button.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -27,6 +27,15 @@ describe('Mock Server and Wire Contract Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('GET /api/v1/config returns the approved origins with no-store', async () => {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/v1/config`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||||
|
const data = await res.json();
|
||||||
|
assert.deepEqual(Object.keys(data), ['approved_origins']);
|
||||||
|
assert.deepEqual(data.approved_origins, ['https://approved.example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
test('GET / sets cw_session cookie and serves security headers', async () => {
|
test('GET / sets cw_session cookie and serves security headers', async () => {
|
||||||
const res = await fetch(`${BASE_URL}/`);
|
const res = await fetch(`${BASE_URL}/`);
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
|
|||||||
@ -155,6 +155,18 @@ async function runTests() {
|
|||||||
const focusedId = await cdp.eval('document.activeElement.id');
|
const focusedId = await cdp.eval('document.activeElement.id');
|
||||||
assert.equal(focusedId, 'cred-url', 'URL input should be focused on open');
|
assert.equal(focusedId, 'cred-url', 'URL input should be focused on open');
|
||||||
|
|
||||||
|
// The URL field is a fixed choice among the server's approved origins (GET /api/v1/config).
|
||||||
|
const urlField = await cdp.eval(`({
|
||||||
|
tag: document.getElementById("cred-url").tagName,
|
||||||
|
options: Array.from(document.getElementById("cred-url").options || []).map((o) => o.value),
|
||||||
|
value: document.getElementById("cred-url").value,
|
||||||
|
hint: document.getElementById("cred-url-hint").textContent
|
||||||
|
})`);
|
||||||
|
assert.equal(urlField.tag, 'SELECT', 'URL field must become a select once config loads');
|
||||||
|
assert.deepEqual(urlField.options, ['https://approved.example.com']);
|
||||||
|
assert.equal(urlField.value, 'https://approved.example.com', 'First approved origin must be preselected');
|
||||||
|
assert.ok(urlField.hint.includes('approved'), `Hint should explain the fixed choice: ${urlField.hint}`);
|
||||||
|
|
||||||
// Test Cancel closes modal and restores focus
|
// Test Cancel closes modal and restores focus
|
||||||
await cdp.eval('document.getElementById("btn-cancel-cred").click()');
|
await cdp.eval('document.getElementById("btn-cancel-cred").click()');
|
||||||
const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")');
|
const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")');
|
||||||
|
|||||||
56
tests/backend/test_config_api.py
Normal file
56
tests/backend/test_config_api.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""API tests for GET /api/v1/config: approved origins offered to the UI as a fixed choice."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
from backend.artifacts import ArtifactStore
|
||||||
|
from backend.containers import FakeContainerManager
|
||||||
|
from backend.dev.fake_peer import ScriptedContainerPeer
|
||||||
|
from backend.model import FakeModelAdapter
|
||||||
|
from backend.settings import Settings
|
||||||
|
|
||||||
|
from tests.backend.conftest import make_test_confluence_client_factory
|
||||||
|
|
||||||
|
|
||||||
|
def build_app(tmp_path: Path, origins: list[str]):
|
||||||
|
settings = Settings(approved_confluence_origins=origins)
|
||||||
|
return create_app(
|
||||||
|
settings=settings,
|
||||||
|
container_manager=FakeContainerManager(lambda: ScriptedContainerPeer(scenario="standard")),
|
||||||
|
artifact_store=ArtifactStore(tmp_path / "artifacts"),
|
||||||
|
model_adapter=FakeModelAdapter(),
|
||||||
|
confluence_client_factory=make_test_confluence_client_factory(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_lists_canonical_approved_origins(tmp_path: Path):
|
||||||
|
app = build_app(tmp_path, ["https://Collab.Example.com/main/", "https://approved.example.com", "https://approved.example.com/"])
|
||||||
|
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
|
||||||
|
resp = await client.get("/api/v1/config")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers.get("Cache-Control") == "no-store"
|
||||||
|
assert resp.json() == {"approved_origins": ["https://collab.example.com/main", "https://approved.example.com"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_needs_no_session_or_origin_header(tmp_path: Path):
|
||||||
|
app = build_app(tmp_path, ["https://approved.example.com"])
|
||||||
|
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
|
||||||
|
resp = await client.get("/api/v1/config")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "set-cookie" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_rejects_other_methods(tmp_path: Path):
|
||||||
|
app = build_app(tmp_path, ["https://approved.example.com"])
|
||||||
|
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client:
|
||||||
|
resp = await client.post("/api/v1/config", json={}, headers={"Origin": "http://testserver"})
|
||||||
|
assert resp.status_code == 405
|
||||||
Loading…
x
Reference in New Issue
Block a user