This commit is contained in:
Amaury Chamayou 2024-04-17 08:48:38 +01:00 коммит произвёл GitHub
Родитель dac39e3c56
Коммит c6815f30d0
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
15 изменённых файлов: 619 добавлений и 38 удалений

Просмотреть файл

@ -1080,6 +1080,11 @@ if(BUILD_TESTS)
)
target_link_libraries(http_test PRIVATE http_parser.host)
add_unit_test(
http_etag_test
${CMAKE_CURRENT_SOURCE_DIR}/src/http/test/http_etag_test.cpp
)
add_unit_test(
frontend_test
${CMAKE_CURRENT_SOURCE_DIR}/src/js/wrap.cpp

Просмотреть файл

@ -148,3 +148,10 @@ JavaScript FFI Plugins
.. doxygenfunction:: ccfapp::get_js_plugins
:project: CCF
HTTP Entity Tags Matching
-------------------------
.. doxygenclass:: ccf::http::Matcher
:project: CCF
:members:

Просмотреть файл

@ -315,4 +315,53 @@ Receipts from this endpoint will then look like:
{'left': 'abc9bcbeff670930c34ebdab0f2d57b56e9d393e4dccdccf2db59b5e34507422'}],
'signature': 'MGUCMHYBgZ3gySdkJ+STUL13EURVBd8354ULC11l/kjx20IwpXrg/aDYLWYf7tsGwqUxPwIxAMH2wJDd9wpwbQrULpaAx5XEifpUfOriKtYo7XiFr05J+BV10U39xa9GBS49OK47QA=='}}
Note that the ``claims_digest`` is deliberately omitted from ``leaf_components``, and must be re-computed by digesting the ``msg``.
Note that the ``claims_digest`` is deliberately omitted from ``leaf_components``, and must be re-computed by digesting the ``msg``.
Client-side Concurrency Control
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Clients of a CCF application submit transactions concurrently.
Single transactions are always handled atomically by the service, transparently for clients and for application writers. But some clients may wish to submit sequences of multiple, dependent transactions. For example:
1. Client reads value at key ``K``
2. Performs some client-side processing
3. Writes a new value at key ``K``, but only if the server-side value has not changed
Implementing `If-Match` and `If-None-Match` HTTP headers in endpoint logic is a common pattern for this use case. The following endpoints of the C++ logging app demonstrate this:
POST /app/log/public
~~~~~~~~~~~~~~~~~~~~
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: public_table_post_match
:end-before: SNIPPET_END: public_table_post_match
:dedent:
And before returning ``200 OK``:
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: public_table_post_etag
:end-before: SNIPPET_END: public_table_post_etag
:dedent:
GET /app/log/public/{idx}
~~~~~~~~~~~~~~~~~~~~~~~~~
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: public_table_get_match
:end-before: SNIPPET_END: public_table_get_match
:dedent:
DELETE /app/log/public/{idx}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. literalinclude:: ../../samples/apps/logging/logging.cpp
:language: cpp
:start-after: SNIPPET_START: public_table_delete_match
:end-before: SNIPPET_END: public_table_delete_match
:dedent:
The framework provides a :cpp:class:`ccf::http::Matcher` class, which can be used to evaluate these conditions.

Просмотреть файл

@ -4,7 +4,7 @@
namespace ccf
{
enum FrameFormat : uint8_t
enum class FrameFormat : uint8_t
{
http = 0
};

76
include/ccf/http_etag.h Normal file
Просмотреть файл

@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the Apache 2.0 License.
#pragma once
#define FMT_HEADER_ONLY
#include <regex>
#include <set>
#include <string>
namespace ccf
{
namespace http
{
/** Utility class to resolve If-Match and If-None-Match as described
* in https://www.rfc-editor.org/rfc/rfc9110#field.if-match
*/
class Matcher
{
private:
/// If-Match header is present and has the value "*"
bool any_value = false;
/// If-Match header is present and has specific etag values
std::set<std::string> if_etags;
public:
/** Construct a Matcher from a match header
*
* Note: Weak tags are not supported.
*/
Matcher(const std::string& match_header)
{
if (match_header == "*")
{
any_value = true;
return;
}
std::regex etag_rx("\\\"([0-9a-f]+)\\\",?\\s*");
auto etags_begin = std::sregex_iterator(
match_header.begin(), match_header.end(), etag_rx);
auto etags_end = std::sregex_iterator();
ssize_t last_matched = 0;
for (std::sregex_iterator i = etags_begin; i != etags_end; ++i)
{
if (i->position() != last_matched)
{
throw std::runtime_error("Invalid If-Match header");
}
std::smatch match = *i;
if_etags.insert(match[1].str());
last_matched = match.position() + match.length();
}
ssize_t last_index_in_header = match_header.size();
if (last_matched != last_index_in_header || if_etags.empty())
{
throw std::runtime_error("Invalid If-Match header");
}
}
/// Check if a given ETag matches the If-Match/If-None-Match header
bool matches(const std::string& etag) const
{
return any_value || if_etags.contains(etag);
}
/// Check if the header will match any ETag (*)
bool is_any() const
{
return any_value;
}
};
}
}

Просмотреть файл

@ -62,7 +62,8 @@ namespace ccf
*/
namespace jsonhandler
{
using JsonAdapterResponse = std::variant<ErrorDetails, nlohmann::json>;
using JsonAdapterResponse =
std::variant<ErrorDetails, RedirectDetails, nlohmann::json>;
char const* pack_to_content_type(serdes::Pack p);
@ -93,6 +94,8 @@ namespace ccf
jsonhandler::JsonAdapterResponse make_error(
http_status status, const std::string& code, const std::string& msg);
jsonhandler::JsonAdapterResponse make_redirect(http_status status);
using HandlerJsonParamsAndForward =
std::function<jsonhandler::JsonAdapterResponse(
endpoints::EndpointContext& ctx, nlohmann::json&& params)>;

Просмотреть файл

@ -82,6 +82,7 @@ namespace ccf
ERROR(UnsupportedContentType)
ERROR(RequestBodyTooLarge)
ERROR(RequestHeaderTooLarge)
ERROR(PreconditionFailed)
// CCF-specific errors
// client-facing:

13
include/ccf/redirect.h Normal file
Просмотреть файл

@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the Apache 2.0 License.
#pragma once
#include "ccf/http_status.h"
namespace ccf
{
struct RedirectDetails
{
http_status status;
};
}

Просмотреть файл

@ -8,6 +8,7 @@
#include "ccf/http_header_map.h"
#include "ccf/http_responder.h"
#include "ccf/odata_error.h"
#include "ccf/redirect.h"
#include "ccf/rest_verb.h"
#include "ccf/service/signed_req.h"
#include "ccf/tx_id.h"
@ -99,7 +100,7 @@ namespace ccf
/// Returns value associated with named header, or nullopt of this header
/// was not present.
virtual std::optional<std::string> get_request_header(
const std::string_view& name) = 0;
const std::string_view& name) const = 0;
/// Returns full URL provided in request, rather than split into path +
/// query.

Просмотреть файл

@ -11,6 +11,7 @@
#include "ccf/ds/hash.h"
#include "ccf/endpoints/authentication/all_of_auth.h"
#include "ccf/historical_queries_adapter.h"
#include "ccf/http_etag.h"
#include "ccf/http_query.h"
#include "ccf/indexing/strategies/seqnos_by_key_bucketed.h"
#include "ccf/indexing/strategy.h"
@ -44,6 +45,27 @@ namespace loggingapp
};
// SNIPPET_END: custom_identity
struct MatchHeaders
{
std::optional<std::string> if_match;
std::optional<std::string> if_none_match;
MatchHeaders(const std::shared_ptr<ccf::RpcContext>& rpc_ctx) :
if_match(rpc_ctx->get_request_header("if-match")),
if_none_match(rpc_ctx->get_request_header("if-none-match"))
{}
bool conflict() const
{
return if_match.has_value() && if_none_match.has_value();
}
bool empty() const
{
return !if_match.has_value() && !if_none_match.has_value();
}
};
// SNIPPET_START: custom_auth_policy
class CustomAuthPolicy : public ccf::AuthnPolicy
{
@ -560,7 +582,7 @@ namespace loggingapp
}
return ccf::make_error(
HTTP_STATUS_BAD_REQUEST,
HTTP_STATUS_NOT_FOUND,
ccf::errors::ResourceNotFound,
fmt::format("No such record: {}.", id));
};
@ -760,6 +782,57 @@ namespace loggingapp
ctx.tx.template rw<RecordsMap>(public_records(ctx));
// SNIPPET_END: public_table_access
const auto id = params["id"].get<size_t>();
// SNIPPET_START: public_table_post_match
MatchHeaders match_headers(ctx.rpc_ctx);
if (match_headers.conflict())
{
return ccf::make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::InvalidHeaderValue,
"Cannot have both If-Match and If-None-Match headers.");
}
// The presence of a Match header requires a read dependency
// to check the value matches the constraint
if (!match_headers.empty())
{
auto current_value = records_handle->get(id);
if (current_value.has_value())
{
crypto::Sha256Hash value_digest(current_value.value());
auto etag = value_digest.hex_str();
// On a POST operation, If-Match failing or If-None-Match passing
// both return a 412 Precondition Failed to be returned, and no
// side-effect.
if (match_headers.if_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_match.value());
if (!matcher.matches(etag))
{
return ccf::make_error(
HTTP_STATUS_PRECONDITION_FAILED,
ccf::errors::PreconditionFailed,
"Resource has changed.");
}
}
if (match_headers.if_none_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_none_match.value());
if (matcher.matches(etag))
{
return ccf::make_error(
HTTP_STATUS_PRECONDITION_FAILED,
ccf::errors::PreconditionFailed,
"Resource has changed.");
}
}
}
}
// SNIPPET_END: public_table_post_match
records_handle->put(id, in.msg);
// SNIPPET_START: set_claims_digest
if (in.record_claim)
@ -768,6 +841,13 @@ namespace loggingapp
}
// SNIPPET_END: set_claims_digest
CCF_APP_INFO("Storing {} = {}", id, in.msg);
// SNIPPET_START: public_table_post_etag
crypto::Sha256Hash value_digest(in.msg);
// Succesful calls set an ETag
ctx.rpc_ctx->set_response_header("ETag", value_digest.hex_str());
// SNIPPET_END: public_table_post_etag
return ccf::make_success(true);
};
// SNIPPET_END: record_public
@ -799,15 +879,57 @@ namespace loggingapp
ctx.tx.template ro<RecordsMap>(public_records(ctx));
auto record = public_records_handle->get(id);
// SNIPPET_START: public_table_get_match
// If there is not value, the response is always Not Found
// regardless of Match headers
if (record.has_value())
{
MatchHeaders match_headers(ctx.rpc_ctx);
if (match_headers.conflict())
{
return ccf::make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::InvalidHeaderValue,
"Cannot have both If-Match and If-None-Match headers.");
}
// If a record is present, compute an Entity Tag, and apply
// If-Match and If-None-Match.
crypto::Sha256Hash value_digest(record.value());
const auto etag = value_digest.hex_str();
if (match_headers.if_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_match.value());
if (!matcher.matches(etag))
{
return ccf::make_error(
HTTP_STATUS_PRECONDITION_FAILED,
ccf::errors::PreconditionFailed,
"Resource has changed.");
}
}
// On a GET, If-None-Match passing returns 304 Not Modified
if (match_headers.if_none_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_none_match.value());
if (matcher.matches(etag))
{
return ccf::make_redirect(HTTP_STATUS_NOT_MODIFIED);
}
}
// Succesful calls set an ETag
ctx.rpc_ctx->set_response_header("ETag", etag);
CCF_APP_INFO("Fetching {} = {}", id, record.value());
return ccf::make_success(LoggingGet::Out{record.value()});
}
// SNIPPET_END: public_table_get_match
CCF_APP_INFO("Fetching - no entry for {}", id);
return ccf::make_error(
HTTP_STATUS_BAD_REQUEST,
HTTP_STATUS_NOT_FOUND,
ccf::errors::ResourceNotFound,
fmt::format("No such record: {}.", id));
};
@ -838,10 +960,56 @@ namespace loggingapp
auto records_handle =
ctx.tx.template rw<RecordsMap>(public_records(ctx));
auto had = records_handle->has(id);
records_handle->remove(id);
auto current_value = records_handle->get(id);
return ccf::make_success(LoggingRemove::Out{had});
// SNIPPET_START: public_table_delete_match
// If there is no value, we don't need to look at the Match
// headers to report that the value is deleted (200 OK)
if (current_value.has_value())
{
MatchHeaders match_headers(ctx.rpc_ctx);
if (match_headers.conflict())
{
return ccf::make_error(
HTTP_STATUS_BAD_REQUEST,
ccf::errors::InvalidHeaderValue,
"Cannot have both If-Match and If-None-Match headers.");
}
if (!match_headers.empty())
{
// If a Match header is present, we need to compute the ETag
// to resolve the constraints
crypto::Sha256Hash value_digest(current_value.value());
const auto etag = value_digest.hex_str();
if (match_headers.if_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_match.value());
if (!matcher.matches(etag))
{
return ccf::make_error(
HTTP_STATUS_PRECONDITION_FAILED,
ccf::errors::PreconditionFailed,
"Resource has changed.");
}
}
if (match_headers.if_none_match.has_value())
{
ccf::http::Matcher matcher(match_headers.if_none_match.value());
if (matcher.matches(etag))
{
return ccf::make_redirect(HTTP_STATUS_NOT_MODIFIED);
}
}
}
}
// SNIPPET_END: public_table_delete_match
// Succesful calls remove the value, and therefore do not set an ETag
records_handle->remove(id);
return ccf::make_success(LoggingRemove::Out{current_value.has_value()});
};
make_endpoint(
"/log/public",

Просмотреть файл

@ -4,6 +4,7 @@
#include "ccf/http_consts.h"
#include "ccf/odata_error.h"
#include "ccf/redirect.h"
#include "ccf/rpc_context.h"
#include "http/http_accept.h"
#include "node/rpc/rpc_exception.h"
@ -144,35 +145,44 @@ namespace ccf
}
else
{
const auto body = std::get_if<nlohmann::json>(&res);
if (body->is_null())
auto redirect = std::get_if<RedirectDetails>(&res);
if (redirect != nullptr)
{
ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
ctx->set_response_status(redirect->status);
}
else
{
ctx->set_response_status(HTTP_STATUS_OK);
const auto packing = get_response_pack(ctx, request_packing);
switch (packing)
const auto body = std::get_if<nlohmann::json>(&res);
if (body->is_null())
{
case serdes::Pack::Text:
{
const auto s = body->dump();
ctx->set_response_body(std::vector<uint8_t>(s.begin(), s.end()));
break;
}
case serdes::Pack::MsgPack:
{
ctx->set_response_body(nlohmann::json::to_msgpack(*body));
break;
}
default:
{
throw std::logic_error("Unhandled serdes::Pack");
}
ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
}
else
{
ctx->set_response_status(HTTP_STATUS_OK);
const auto packing = get_response_pack(ctx, request_packing);
switch (packing)
{
case serdes::Pack::Text:
{
const auto s = body->dump();
ctx->set_response_body(
std::vector<uint8_t>(s.begin(), s.end()));
break;
}
case serdes::Pack::MsgPack:
{
ctx->set_response_body(nlohmann::json::to_msgpack(*body));
break;
}
default:
{
throw std::logic_error("Unhandled serdes::Pack");
}
}
ctx->set_response_header(
http::headers::CONTENT_TYPE, pack_to_content_type(packing));
}
ctx->set_response_header(
http::headers::CONTENT_TYPE, pack_to_content_type(packing));
}
}
}
@ -202,6 +212,11 @@ namespace ccf
return ErrorDetails{status, code, msg};
}
jsonhandler::JsonAdapterResponse make_redirect(http_status status)
{
return RedirectDetails{status};
}
endpoints::EndpointFunction json_adapter(const HandlerJsonParamsAndForward& f)
{
return [f](endpoints::EndpointContext& ctx) {

Просмотреть файл

@ -192,7 +192,7 @@ namespace http
}
virtual std::optional<std::string> get_request_header(
const std::string_view& name) override
const std::string_view& name) const override
{
const auto it = request_headers.find(name);
if (it != request_headers.end())

Просмотреть файл

@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the Apache 2.0 License.
#include "ccf/http_etag.h"
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <string>
#define FMT_HEADER_ONLY
#include <fmt/format.h>
TEST_CASE("If-Match: *")
{
ccf::http::Matcher im("*");
REQUIRE(im.is_any());
REQUIRE(im.matches(""));
REQUIRE(im.matches("abc"));
}
TEST_CASE("If-Match: \"abc\"")
{
ccf::http::Matcher im("\"abc\"");
REQUIRE(!im.is_any());
REQUIRE(!im.matches(""));
REQUIRE(im.matches("abc"));
REQUIRE(!im.matches("def"));
}
TEST_CASE("If-Match: \"abc\", \"def\"")
{
ccf::http::Matcher im("\"abc\", \"def\"");
REQUIRE(!im.is_any());
REQUIRE(!im.matches(""));
REQUIRE(im.matches("abc"));
REQUIRE(im.matches("def"));
REQUIRE(!im.matches("ghi"));
}
TEST_CASE("If-Match invalid inputs")
{
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im(""), std::runtime_error, "Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("not etags"),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("\"abc\", not etags"),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("not etags, \"abc\""),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("W/\"abc\""),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("W/\"abc\", \"def\""),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("\"abc\", \"def"),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im("\"abc\",, \"def\""),
std::runtime_error,
"Invalid If-Match header");
REQUIRE_THROWS_AS_MESSAGE(
ccf::http::Matcher im(",\"abc\""),
std::runtime_error,
"Invalid If-Match header");
}

Просмотреть файл

@ -32,10 +32,10 @@ import infra.crypto
from infra.runner import ConcurrentRunner
from hashlib import sha256
from infra.member import AckException
import e2e_common_endpoints
from types import MappingProxyType
import threading
import copy
import e2e_common_endpoints
from loguru import logger as LOG
@ -410,7 +410,7 @@ def test_remove(network, args):
else:
check(
r,
error=lambda status, msg: status == http.HTTPStatus.BAD_REQUEST.value
error=lambda status, msg: status == http.HTTPStatus.NOT_FOUND.value
and msg.json()["error"]["code"] == "ResourceNotFound",
)
@ -455,7 +455,7 @@ def test_clear(network, args):
check(
get_r,
error=lambda status, msg: status
== http.HTTPStatus.BAD_REQUEST.value,
== http.HTTPStatus.NOT_FOUND.value,
)
# Make sure no-one else is still looking for these
@ -1754,7 +1754,7 @@ def test_committed_index(network, args, timeout=5):
network.txs.delete(log_id, priv=True)
r = network.txs.request(log_id, priv=True)
assert r.status_code == http.HTTPStatus.BAD_REQUEST.value, r.status_code
assert r.status_code == http.HTTPStatus.NOT_FOUND.value, r.status_code
assert r.body.json()["error"]["message"] == f"No such record: {log_id}."
assert r.body.json()["error"]["code"] == "ResourceNotFound"
@ -1798,6 +1798,172 @@ def test_basic_constraints(network, args):
assert basic_constraints.value.ca is False
def test_etags(network, args):
primary, _ = network.find_primary()
with primary.client("user0") as c:
doc = {"id": 999999, "msg": "hello world"}
etag = sha256(doc["msg"].encode()).hexdigest()
# POST ETag matches value
r = c.post("/app/log/public", doc)
assert r.status_code == http.HTTPStatus.OK
assert r.headers["ETag"] == etag, r.headers["ETag"]
# GET ETag matches value
r = c.get(f"/app/log/public?id={doc['id']}")
assert r.status_code == http.HTTPStatus.OK
assert r.headers["ETag"] == etag, r.headers["ETag"]
# GET If-Match: * for missing resource still returns 404
r = c.get("/app/log/public?id=999998", headers={"If-Match": "*"})
assert r.status_code == http.HTTPStatus.NOT_FOUND
# GET If-Match: * for existing resource returns 200
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": "*"})
assert r.status_code == http.HTTPStatus.OK
assert r.headers["ETag"] == etag, r.headers["ETag"]
# GET If-Match: mismatching ETag returns 412
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
assert r.body.json()["error"]["code"] == "PreconditionFailed"
# GET If-Match: matching ETag returns 200
r = c.get(f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'})
assert r.status_code == http.HTTPStatus.OK
assert r.body.json() == {"msg": doc["msg"]}
# GET If-Match: multiple ETags including matching returns 200
r = c.get(
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}", "abc"'}
)
assert r.status_code == http.HTTPStatus.OK
assert r.body.json() == {"msg": doc["msg"]}
doc = {"id": 999999, "msg": "saluton mondo"}
# POST If-Match: mismatching ETag returns 412
r = c.post("/app/log/public", doc, headers={"If-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
# POST If-Match: matching ETag returns 200
r = c.post("/app/log/public", doc, headers={"If-Match": f'"{etag}"'})
assert r.status_code == http.HTTPStatus.OK
etag = sha256(doc["msg"].encode()).hexdigest()
assert r.headers["ETag"] == etag, r.headers["ETag"]
# POST If-Match: mutiple ETags, first one matching returns 200
r = c.post("/app/log/public", doc, headers={"If-Match": f'"{etag}", "abc"'})
assert r.status_code == http.HTTPStatus.OK
etag = sha256(doc["msg"].encode()).hexdigest()
assert r.headers["ETag"] == etag, r.headers["ETag"]
# POST If-Match: mutiple ETags, one, not the first, matching returns 200
r = c.post("/app/log/public", doc, headers={"If-Match": f'"abc", "{etag}"'})
assert r.status_code == http.HTTPStatus.OK
etag = sha256(doc["msg"].encode()).hexdigest()
assert r.headers["ETag"] == etag, r.headers["ETag"]
# POST If-Match: multiple, none matching, returns 412
r = c.post("/app/log/public", doc, headers={"If-Match": '"abc", "def"'})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
# POST If-None-Match: * on existing resource returns 412
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
etag = sha256(doc["msg"].encode()).hexdigest()
# POST If-None-Match: matching ETag on existing resource returns 412
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
etag = sha256(doc["msg"].encode()).hexdigest()
# DELETE If-Match: mismatching ETag returns 412
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.PRECONDITION_FAILED
# DELETE If-Match: matching ETag returns 200
r = c.delete(
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'}
)
assert r.status_code == http.HTTPStatus.OK
# DELETE If-Match: missing resource still returns 200
r = c.delete(
f"/app/log/public?id={doc['id']}", headers={"If-Match": f'"{etag}"'}
)
assert r.status_code == http.HTTPStatus.OK
# DELETE If-Match: mismatching ETag for missing resouce still returns 200
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.OK
# Restore resource
r = c.post("/app/log/public", doc)
assert r.status_code == http.HTTPStatus.OK
assert r.headers["ETag"] == etag, r.headers["ETag"]
# GET If-None-Match: * for existing resource returns 304
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
# GET If-None-Match: matching ETag for existing resource returns 304
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": f'"{etag}"'})
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
# GET If-None-Match: mismatching ETag for existing resource returns 200
r = c.get("/app/log/public?id=999999", headers={"If-None-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.OK
assert r.body.json() == {"msg": doc["msg"]}
assert r.headers["ETag"] == etag, r.headers["ETag"]
# DELETE If-None-Match: * on missing resource returns 304
r = c.delete("/app/log/public?id=999998", headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.OK
# DELETE If-None-Match: on mismatching ETag for missing resource 200
r = c.delete("/app/log/public?id=999998", headers={"If-None-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.OK
# DELETE If-None-Match: * on existing resource is 304
r = c.delete(f"/app/log/public?id={doc['id']}", headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
r = c.get(f"/app/log/public?id={doc['id']}")
assert r.status_code == http.HTTPStatus.OK
# DELETE If-None-Match: matching ETag on existing resource is 304
r = c.delete(
f"/app/log/public?id={doc['id']}", headers={"If-None-Match": f'"{etag}"'}
)
assert r.status_code == http.HTTPStatus.NOT_MODIFIED
r = c.get(f"/app/log/public?id={doc['id']}")
assert r.status_code == http.HTTPStatus.OK
# DELETE If-None-Match: mismatching ETag on existing resource is 200
r = c.delete(
f"/app/log/public?id={doc['id']}", headers={"If-None-Match": '"abc"'}
)
assert r.status_code == http.HTTPStatus.OK
r = c.get(f"/app/log/public?id={doc['id']}")
assert r.status_code == http.HTTPStatus.NOT_FOUND
# POST If-None-Match: * on deleted returns 200
r = c.post("/app/log/public", doc, headers={"If-None-Match": "*"})
assert r.status_code == http.HTTPStatus.OK
etag = sha256(doc["msg"].encode()).hexdigest()
r = c.get(f"/app/log/public?id={doc['id']}")
assert r.status_code == http.HTTPStatus.OK
# POST If-None-Match: mismatching ETag returns 200
r = c.post("/app/log/public", doc, headers={"If-None-Match": '"abc"'})
assert r.status_code == http.HTTPStatus.OK
etag = sha256(doc["msg"].encode()).hexdigest()
return network
def run_udp_tests(args):
# Register secondary interface as an UDP socket on all nodes
udp_interface = infra.interfaces.make_secondary_interface("udp", "udp_interface")
@ -1882,6 +2048,8 @@ def run(args):
test_historical_receipts(network, args)
test_historical_receipts_with_claims(network, args)
test_genesis_receipt(network, args)
if args.package == "samples/apps/logging/liblogging":
test_etags(network, args)
def run_parsing_errors(args):

Просмотреть файл

@ -274,7 +274,7 @@ class Response:
return (
f"<{status_color}>{self.status_code}</> "
+ (
f"<yellow>[Redirect to -> {self.headers['location']}]</> "
f"<yellow>[Redirect to -> {self.headers.get('location')}]</> "
if redirect
else ""
)