setBaseUrl() and setSource() used to fire refresh() inline as soon as both `baseUrl` and `source` were populated — but setToken() never triggered a refresh. QML evaluates literal property assignments before bindings to other objects' properties, so a model declared with literal `source` plus bindings to `BackendConnection.url` / `BackendConnection.token` (the exact shape of make:bridge:window's output) could fire its GET *before* the `token` binding had landed. The unauthenticated request hit Symfony's SessionAuthenticator, came back 401, and the model parked at `ready === false` with an empty list. Mercure subscribed anonymously (the model explicitly clears the SSE client's bearer), so subsequent server-side mutations propagated fine — masking the initial-fetch failure as "list is empty until something changes". Hit by the second window in examples/todo. Both classes now implement QQmlParserStatus and trigger the initial refresh from componentComplete(), where every binding (literal *and* singleton-derived) is guaranteed to have landed. After completion, individual setter changes still trigger refresh inline — so token rotation / URL reassignment after first load behave unchanged. Regression test under framework/qml/tests/tst_reactive_list_model.qml using the v0.2.0 qmltestrunner harness. Adds a TestHttpServer helper that mimics SessionAuthenticator's 401-on-no-bearer behaviour so the regression is observable; verified the test fails against the unfixed production code (`Actual: ""` vs `Expected: "Bearer testtoken"` on the captured Authorization header). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
4.4 KiB
C++
116 lines
4.4 KiB
C++
#include "TestHttpServer.h"
|
|
|
|
#include <QHostAddress>
|
|
#include <QTcpSocket>
|
|
|
|
namespace PhpQml::Bridge::Tests {
|
|
|
|
TestHttpServer::TestHttpServer(QObject* parent)
|
|
: QObject(parent)
|
|
{
|
|
m_server.listen(QHostAddress::LocalHost, 0);
|
|
connect(&m_server, &QTcpServer::newConnection,
|
|
this, &TestHttpServer::onNewConnection);
|
|
}
|
|
|
|
QString TestHttpServer::url() const
|
|
{
|
|
return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort());
|
|
}
|
|
|
|
void TestHttpServer::setResponseBody(const QString& v)
|
|
{
|
|
if (m_responseBody == v) return;
|
|
m_responseBody = v;
|
|
emit responseBodyChanged();
|
|
}
|
|
|
|
void TestHttpServer::setResponseStatus(int v)
|
|
{
|
|
if (m_responseStatus == v) return;
|
|
m_responseStatus = v;
|
|
emit responseStatusChanged();
|
|
}
|
|
|
|
void TestHttpServer::onNewConnection()
|
|
{
|
|
while (auto* sock = m_server.nextPendingConnection()) {
|
|
// One buffer per socket, owned by the socket so it dies with it.
|
|
// (The original thread_local trick leaked between connections.)
|
|
auto* buffer = new QByteArray;
|
|
connect(sock, &QObject::destroyed, [buffer]() { delete buffer; });
|
|
|
|
connect(sock, &QTcpSocket::readyRead, this, [this, sock, buffer]() {
|
|
buffer->append(sock->readAll());
|
|
const int headerEnd = buffer->indexOf("\r\n\r\n");
|
|
if (headerEnd < 0) return;
|
|
|
|
const QByteArray headerBlock = buffer->left(headerEnd);
|
|
buffer->clear();
|
|
|
|
const QList<QByteArray> lines = headerBlock.split('\n');
|
|
QString requestLine;
|
|
QString authHeader;
|
|
for (int i = 0; i < lines.size(); ++i) {
|
|
QByteArray line = lines[i];
|
|
if (line.endsWith('\r')) line.chop(1);
|
|
if (i == 0) {
|
|
requestLine = QString::fromUtf8(line);
|
|
continue;
|
|
}
|
|
const int colon = line.indexOf(':');
|
|
if (colon < 0) continue;
|
|
const QByteArray name = line.left(colon).trimmed();
|
|
const QByteArray value = line.mid(colon + 1).trimmed();
|
|
if (name.compare("Authorization", Qt::CaseInsensitive) == 0) {
|
|
authHeader = QString::fromUtf8(value);
|
|
}
|
|
}
|
|
|
|
// Only count + capture metrics for /api/… GETs. SSE reconnect
|
|
// attempts from MercureClient hit /.well-known/mercure on the
|
|
// same port and would otherwise inflate the request count and
|
|
// overwrite the captured headers we want to assert against.
|
|
const bool isApiGet = requestLine.startsWith(QStringLiteral("GET /api/"));
|
|
if (isApiGet) {
|
|
if (m_lastRequestLine != requestLine) {
|
|
m_lastRequestLine = requestLine;
|
|
emit lastRequestLineChanged();
|
|
}
|
|
if (m_lastAuthHeader != authHeader) {
|
|
m_lastAuthHeader = authHeader;
|
|
emit lastAuthHeaderChanged();
|
|
}
|
|
++m_apiGetCount;
|
|
emit apiGetCountChanged();
|
|
}
|
|
|
|
// For /api/ routes, mimic SessionAuthenticator and reject
|
|
// requests without an Authorization header. This is what
|
|
// exposes the property-order race in the regression test:
|
|
// pre-fix, the GET went out unauthenticated, this server
|
|
// returned 401, and the model parked with `ready === false`.
|
|
const bool needAuth = isApiGet;
|
|
const bool isAuthed = !authHeader.isEmpty();
|
|
const bool reject = needAuth && !isAuthed;
|
|
const int status = reject ? 401 : m_responseStatus;
|
|
const QByteArray body = reject
|
|
? QByteArrayLiteral(R"({"type":"about:blank","title":"Unauthorized","status":401})")
|
|
: m_responseBody.toUtf8();
|
|
|
|
QByteArray resp;
|
|
resp.append("HTTP/1.1 ").append(QByteArray::number(status))
|
|
.append(' ').append(status == 200 ? "OK" : "STATUS").append("\r\n");
|
|
resp.append("Content-Type: application/json\r\n");
|
|
resp.append("Content-Length: ").append(QByteArray::number(body.size())).append("\r\n");
|
|
resp.append("Connection: close\r\n\r\n");
|
|
resp.append(body);
|
|
sock->write(resp);
|
|
sock->disconnectFromHost();
|
|
});
|
|
connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater);
|
|
}
|
|
}
|
|
|
|
} // namespace PhpQml::Bridge::Tests
|