Every state machine in the new slice claims to fail closed; a claim like that is only worth what its negative tests cost. Seven standalone test executables — envelope and freshness, group transitions, identity and signatures, inbound processing, storage protectors, the vault, and file chunks — feed each boundary its malformed, replayed, truncated, and conflicting inputs alongside the happy paths. They hang off the Telegram target as build dependencies, so a slice that stops compiling or a vector that stops matching turns up in every build, not on the day someone remembers to run the tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
771 lines
24 KiB
C++
771 lines
24 KiB
C++
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "e2e_cloud/core/envelope.h"
|
|
#include "e2e_cloud/core/envelope_codec.h"
|
|
#include "e2e_cloud/core/freshness_gate.h"
|
|
#include "e2e_cloud/core/outbox.h"
|
|
#include "e2e_cloud/group/group_state.h"
|
|
#include "e2e_cloud/transport/outbox_upload_controller.h"
|
|
#include "e2e_cloud/transport/telegram_carrier_transport.h"
|
|
|
|
#include <cstdio>
|
|
#include <optional>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
using namespace E2ECloud;
|
|
|
|
template <typename Id>
|
|
[[nodiscard]] Id FilledId(std::uint8_t value) {
|
|
auto result = Id();
|
|
result.bytes.fill(value);
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] int Fail(const char *message) {
|
|
std::fprintf(stderr, "%s\n", message);
|
|
return 1;
|
|
}
|
|
|
|
[[nodiscard]] Checkpoint MakeCheckpoint(
|
|
ConversationId conversationId,
|
|
std::uint64_t generation,
|
|
std::uint8_t hashValue) {
|
|
return {
|
|
.conversationId = conversationId,
|
|
.generation = generation,
|
|
.stateHash = FilledId<Digest>(hashValue),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] FreshnessResponse MakeResponse(
|
|
const Checkpoint &checkpoint,
|
|
ChallengeNonce nonce) {
|
|
return {
|
|
.conversationId = checkpoint.conversationId,
|
|
.nonce = nonce,
|
|
.checkpoint = checkpoint,
|
|
.witnessAccountId = FilledId<AccountId>(4),
|
|
.witnessClientId = FilledId<ClientId>(5),
|
|
.authenticatedProof = QByteArray("proof"),
|
|
};
|
|
}
|
|
|
|
class TestVerifier final : public FreshnessResponseVerifier {
|
|
public:
|
|
[[nodiscard]] bool verify(
|
|
const FreshnessResponse &response) const override {
|
|
++calls;
|
|
return accept && !response.authenticatedProof.isEmpty();
|
|
}
|
|
|
|
bool accept = true;
|
|
mutable int calls = 0;
|
|
|
|
};
|
|
|
|
class TestOutboxStore final : public ProtectedOutboxStore {
|
|
public:
|
|
bool append(PendingMessage message) override {
|
|
if (failAppend) {
|
|
return false;
|
|
}
|
|
for (const auto &item : items) {
|
|
if (item.draft.objectId == message.objectId) {
|
|
return false;
|
|
}
|
|
}
|
|
items.push_back({
|
|
.draft = std::move(message),
|
|
.stage = OutboxItemStage::Draft,
|
|
.sealed = std::nullopt,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
[[nodiscard]] std::optional<OutboxItem> front(
|
|
ConversationId conversationId) const override {
|
|
for (const auto &item : items) {
|
|
if (item.draft.conversationId == conversationId) {
|
|
return item;
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
bool replaceWithSealed(
|
|
ObjectId objectId,
|
|
EncodedEnvelope envelope) override {
|
|
if (failReplace) {
|
|
return false;
|
|
}
|
|
for (auto &item : items) {
|
|
if (item.draft.objectId == objectId) {
|
|
item.stage = OutboxItemStage::Sealed;
|
|
item.sealed = std::move(envelope);
|
|
item.draft.plaintext.clear();
|
|
item.draft.authenticatedData.clear();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool remove(ObjectId objectId) override {
|
|
if (failRemove) {
|
|
return false;
|
|
}
|
|
for (auto i = items.begin(); i != items.end(); ++i) {
|
|
if (i->draft.objectId == objectId) {
|
|
items.erase(i);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::vector<OutboxItem> items;
|
|
bool failAppend = false;
|
|
bool failReplace = false;
|
|
bool failRemove = false;
|
|
|
|
};
|
|
|
|
class TestProtector final : public OutboundMessageProtector {
|
|
public:
|
|
[[nodiscard]] std::optional<EncodedEnvelope> protectIdempotently(
|
|
const MlsSealRequest &request) override {
|
|
++calls;
|
|
if (fail) {
|
|
return std::nullopt;
|
|
}
|
|
return EncodedEnvelope{
|
|
.conversationId = wrongConversation
|
|
? FilledId<ConversationId>(99)
|
|
: request.conversationId,
|
|
.objectId = request.objectId,
|
|
.bytes = QByteArray("sealed:") + request.plaintext,
|
|
};
|
|
}
|
|
|
|
int calls = 0;
|
|
bool fail = false;
|
|
bool wrongConversation = false;
|
|
|
|
};
|
|
|
|
class TestTransport final : public TelegramTransport {
|
|
public:
|
|
void uploadExact(
|
|
EncodedEnvelope envelope,
|
|
UploadCallback callback) override {
|
|
uploads.push_back(std::move(envelope));
|
|
callbacks.push_back(std::move(callback));
|
|
}
|
|
|
|
void download(
|
|
ConversationId,
|
|
DownloadCallback callback) override {
|
|
callback({
|
|
.result = UploadResult::Accepted,
|
|
.untrustedObjects = {},
|
|
});
|
|
}
|
|
|
|
void finish(std::size_t index, UploadResult result) {
|
|
callbacks.at(index)(result);
|
|
}
|
|
|
|
std::vector<EncodedEnvelope> uploads;
|
|
std::vector<UploadCallback> callbacks;
|
|
|
|
};
|
|
|
|
class TestCarrierBackend final : public TelegramCarrierBackend {
|
|
public:
|
|
void uploadDocument(
|
|
QByteArray bytes,
|
|
QString filename,
|
|
QString mimeType,
|
|
UploadCallback callback) override {
|
|
uploadedBytes.push_back(std::move(bytes));
|
|
filenames.push_back(std::move(filename));
|
|
mimeTypes.push_back(std::move(mimeType));
|
|
uploadCallbacks.push_back(std::move(callback));
|
|
}
|
|
|
|
void sendUploadedDocument(
|
|
std::uint64_t telegramPeerId,
|
|
UploadedCarrierFile file,
|
|
QString filename,
|
|
QString mimeType,
|
|
SendCallback callback) override {
|
|
peerIds.push_back(telegramPeerId);
|
|
tokens.push_back(std::move(file.backendToken));
|
|
filenames.push_back(std::move(filename));
|
|
mimeTypes.push_back(std::move(mimeType));
|
|
sendCallbacks.push_back(std::move(callback));
|
|
}
|
|
|
|
void downloadDocuments(
|
|
std::uint64_t telegramPeerId,
|
|
DownloadCallback callback) override {
|
|
peerIds.push_back(telegramPeerId);
|
|
downloadCallbacks.push_back(std::move(callback));
|
|
}
|
|
|
|
std::vector<QByteArray> uploadedBytes;
|
|
std::vector<QString> filenames;
|
|
std::vector<QString> mimeTypes;
|
|
std::vector<std::uint64_t> peerIds;
|
|
std::vector<QByteArray> tokens;
|
|
std::vector<UploadCallback> uploadCallbacks;
|
|
std::vector<SendCallback> sendCallbacks;
|
|
std::vector<DownloadCallback> downloadCallbacks;
|
|
|
|
};
|
|
|
|
[[nodiscard]] PendingMessage MakeMessage(ConversationId conversationId) {
|
|
return {
|
|
.conversationId = conversationId,
|
|
.objectId = FilledId<ObjectId>(7),
|
|
.plaintext = QByteArray("message"),
|
|
.authenticatedData = QByteArray("metadata"),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] TransportEnvelope MakeEnvelope() {
|
|
return {
|
|
.conversationId = FilledId<ConversationId>(1),
|
|
.objectKind = ObjectKind::MlsApplication,
|
|
.senderAccountId = FilledId<AccountId>(2),
|
|
.senderClientId = FilledId<ClientId>(3),
|
|
.telegramPeerIdBinding = 42,
|
|
.epochOrGeneration = 8,
|
|
.objectId = FilledId<ObjectId>(4),
|
|
.payloadHash = FilledId<Digest>(5),
|
|
.payload = QByteArray("payload"),
|
|
.authenticationData = QByteArray("authenticated"),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioEnvelopeValidation() {
|
|
auto envelope = MakeEnvelope();
|
|
if (ValidateEnvelope(envelope) != EnvelopeValidationError::None) {
|
|
return Fail("valid transport envelope was rejected");
|
|
}
|
|
envelope.applicationProtocolVersion = kApplicationProtocolVersion + 1;
|
|
if (ValidateEnvelope(envelope)
|
|
!= EnvelopeValidationError::UnsupportedVersion) {
|
|
return Fail("unsupported transport envelope version was accepted");
|
|
}
|
|
envelope.applicationProtocolVersion = kApplicationProtocolVersion;
|
|
envelope.objectKind = static_cast<ObjectKind>(1000);
|
|
if (ValidateEnvelope(envelope)
|
|
!= EnvelopeValidationError::UnknownObjectKind) {
|
|
return Fail("unknown transport envelope kind was accepted");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioHistoryAccessBoundary() {
|
|
if (!IsValidHistoryAccess({
|
|
.mode = HistoryAccessMode::None,
|
|
.boundaryEventId = {},
|
|
})
|
|
|| !IsValidHistoryAccess({
|
|
.mode = HistoryAccessMode::FromJoin,
|
|
.boundaryEventId = {},
|
|
})
|
|
|| !IsValidHistoryAccess({
|
|
.mode = HistoryAccessMode::Full,
|
|
.boundaryEventId = {},
|
|
})
|
|
|| IsValidHistoryAccess({
|
|
.mode = HistoryAccessMode::Since,
|
|
.boundaryEventId = {},
|
|
})
|
|
|| !IsValidHistoryAccess({
|
|
.mode = HistoryAccessMode::Since,
|
|
.boundaryEventId = FilledId<ObjectId>(3),
|
|
})) {
|
|
return Fail("history boundary accepted an ambiguous policy");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioEnvelopeCodec() {
|
|
const auto envelope = MakeEnvelope();
|
|
const auto codec = EnvelopeCodecV1();
|
|
const auto first = codec.encode(envelope);
|
|
const auto second = codec.encode(envelope);
|
|
if (!first
|
|
|| first != second
|
|
|| codec.decode(*first) != envelope
|
|
|| first->bytes.size() != 202
|
|
|| std::uint8_t(first->bytes[11]) != 1
|
|
|| std::uint8_t(first->bytes[45]) != 8
|
|
|| std::uint8_t(first->bytes[101]) != 42
|
|
|| std::uint8_t(first->bytes[109]) != 8
|
|
|| std::uint8_t(first->bytes[177]) != 7
|
|
|| std::uint8_t(first->bytes[188]) != 13) {
|
|
return Fail("version one envelope codec was not deterministic");
|
|
}
|
|
auto trailing = *first;
|
|
trailing.bytes.append('x');
|
|
if (codec.decode(trailing)) {
|
|
return Fail("envelope codec accepted trailing bytes");
|
|
}
|
|
auto mismatched = *first;
|
|
mismatched.objectId = FilledId<ObjectId>(99);
|
|
if (codec.decode(mismatched)) {
|
|
return Fail("envelope codec accepted a mismatched transport identity");
|
|
}
|
|
auto truncated = *first;
|
|
truncated.bytes.chop(1);
|
|
if (codec.decode(truncated)) {
|
|
return Fail("envelope codec accepted truncated authentication data");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFreshnessConfirmation() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
if (gate.sendingAllowed()
|
|
|| gate.administrationAllowed()
|
|
|| !gate.beginChallenge(nonce)) {
|
|
return Fail("freshness gate opened before witness confirmation");
|
|
}
|
|
auto wrong = MakeResponse(checkpoint, FilledId<ChallengeNonce>(9));
|
|
if (gate.acceptResponse(wrong, verifier)
|
|
!= FreshnessResponseResult::WrongChallenge
|
|
|| verifier.calls
|
|
|| gate.state() != FreshnessState::WaitingForWitness) {
|
|
return Fail("wrong freshness challenge changed gate state");
|
|
}
|
|
const auto response = MakeResponse(checkpoint, nonce);
|
|
if (gate.acceptResponse(response, verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| verifier.calls != 1
|
|
|| !gate.sendingAllowed()
|
|
|| !gate.administrationAllowed()) {
|
|
return Fail("valid freshness witness did not open the gate");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFreshnessResynchronization() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto known = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto current = MakeCheckpoint(conversationId, 10, 4);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(known);
|
|
auto verifier = TestVerifier();
|
|
if (!gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(current, nonce), verifier)
|
|
!= FreshnessResponseResult::ResynchronizationRequired
|
|
|| gate.sendingAllowed()
|
|
|| gate.administrationAllowed()
|
|
|| gate.resynchronizationTarget() != current
|
|
|| gate.completeResynchronization(known)
|
|
|| !gate.completeResynchronization(current)
|
|
|| !gate.sendingAllowed()
|
|
|| gate.knownCheckpoint() != current) {
|
|
return Fail("newer witness state bypassed required resynchronization");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFreshnessFork() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto known = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto fork = MakeCheckpoint(conversationId, 8, 9);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(known);
|
|
auto verifier = TestVerifier();
|
|
if (!gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(fork, nonce), verifier)
|
|
!= FreshnessResponseResult::ForkDetected
|
|
|| gate.state() != FreshnessState::Forked
|
|
|| gate.sendingAllowed()
|
|
|| gate.beginChallenge(FilledId<ChallengeNonce>(8))) {
|
|
return Fail("conflicting checkpoint did not fail closed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioOutboxWaitsForFreshness() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
auto gate = FreshnessGate(MakeCheckpoint(conversationId, 8, 2));
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
const auto queued = coordinator.enqueue(MakeMessage(conversationId));
|
|
const auto dispatch = coordinator.dispatchNext();
|
|
if (queued != EnqueueResult::Queued
|
|
|| dispatch.result != OutboxDispatchResult::AwaitingFreshness
|
|
|| dispatch.envelope
|
|
|| protector.calls
|
|
|| store.items.size() != 1) {
|
|
return Fail("pending outbox content escaped before freshness");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioOutboxSendsAfterFreshness() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| protector.calls) {
|
|
return Fail("confirmed outbox content was not sent exactly once");
|
|
}
|
|
const auto dispatch = coordinator.dispatchNext();
|
|
if (dispatch.result != OutboxDispatchResult::Ready
|
|
|| !dispatch.envelope
|
|
|| protector.calls != 1
|
|
|| coordinator.dispatchNext().result
|
|
!= OutboxDispatchResult::UploadInProgress
|
|
|| !coordinator.acknowledgeUploaded(dispatch.envelope->objectId)
|
|
|| !store.items.empty()
|
|
|| coordinator.dispatchNext().result != OutboxDispatchResult::Empty) {
|
|
return Fail("confirmed outbox content was not acknowledged exactly once");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioOutboxRetriesExactCiphertext() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| protector.calls) {
|
|
return Fail("outbox retry setup failed");
|
|
}
|
|
const auto first = coordinator.dispatchNext();
|
|
if (first.result != OutboxDispatchResult::Ready
|
|
|| !first.envelope
|
|
|| !coordinator.markUploadFailed(first.envelope->objectId)) {
|
|
return Fail("outbox upload failure was not retained for retry");
|
|
}
|
|
const auto second = coordinator.dispatchNext();
|
|
if (second.result != OutboxDispatchResult::Ready
|
|
|| !second.envelope
|
|
|| first.envelope != second.envelope
|
|
|| protector.calls != 1
|
|
|| store.items.size() != 1
|
|
|| store.items.front().stage != OutboxItemStage::Sealed
|
|
|| !store.items.front().draft.plaintext.isEmpty()
|
|
|| !coordinator.acknowledgeUploaded(second.envelope->objectId)
|
|
|| !store.items.empty()) {
|
|
return Fail("outbox retry regenerated or lost sealed ciphertext");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioOutboxRejectsProtectorMismatch() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
protector.wrongConversation = true;
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| coordinator.dispatchNext().result
|
|
!= OutboxDispatchResult::InvalidItem
|
|
|| store.items.size() != 1) {
|
|
return Fail("mismatched protected envelope reached transport");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioUploadControllerAcknowledgesRpcSuccess() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto transport = TestTransport();
|
|
auto completions = std::vector<UploadCompletion>();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
auto controller = OutboxUploadController(
|
|
coordinator,
|
|
transport,
|
|
[&](UploadCompletion completion) {
|
|
completions.push_back(completion);
|
|
});
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| controller.pump() != UploadPumpResult::Started
|
|
|| !controller.uploadInProgress()
|
|
|| controller.pump() != UploadPumpResult::UploadInProgress
|
|
|| transport.uploads.size() != 1
|
|
|| store.items.size() != 1) {
|
|
return Fail("upload controller did not retain an in-flight envelope");
|
|
}
|
|
transport.finish(0, TelegramTransport::UploadResult::Accepted);
|
|
if (controller.uploadInProgress()
|
|
|| !store.items.empty()
|
|
|| completions.size() != 1
|
|
|| completions.front().objectId != transport.uploads.front().objectId
|
|
|| completions.front().transportResult
|
|
!= TelegramTransport::UploadResult::Accepted
|
|
|| !completions.front().outboxUpdated
|
|
|| controller.pump() != UploadPumpResult::Empty) {
|
|
return Fail("successful Telegram RPC did not acknowledge the outbox");
|
|
}
|
|
transport.finish(0, TelegramTransport::UploadResult::Accepted);
|
|
if (completions.size() != 1) {
|
|
return Fail("duplicate upload callback changed the outbox twice");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioUploadControllerRetriesExactEnvelope() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto transport = TestTransport();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
auto controller = OutboxUploadController(
|
|
coordinator,
|
|
transport,
|
|
nullptr);
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted
|
|
|| controller.pump() != UploadPumpResult::Started) {
|
|
return Fail("upload retry setup failed");
|
|
}
|
|
transport.finish(0, TelegramTransport::UploadResult::RetryableError);
|
|
if (controller.uploadInProgress()
|
|
|| store.items.size() != 1
|
|
|| controller.pump() != UploadPumpResult::Started
|
|
|| transport.uploads.size() != 2
|
|
|| transport.uploads[0] != transport.uploads[1]
|
|
|| protector.calls != 1) {
|
|
return Fail("upload controller did not retry exact ciphertext");
|
|
}
|
|
transport.finish(1, TelegramTransport::UploadResult::Accepted);
|
|
if (!store.items.empty()) {
|
|
return Fail("retried upload did not leave the outbox");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioUploadCallbackCannotOutliveController() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto checkpoint = MakeCheckpoint(conversationId, 8, 2);
|
|
const auto nonce = FilledId<ChallengeNonce>(3);
|
|
auto gate = FreshnessGate(checkpoint);
|
|
auto verifier = TestVerifier();
|
|
auto store = TestOutboxStore();
|
|
auto protector = TestProtector();
|
|
auto transport = TestTransport();
|
|
auto coordinator = OutboxCoordinator(gate, store, protector);
|
|
if (coordinator.enqueue(MakeMessage(conversationId))
|
|
!= EnqueueResult::Queued
|
|
|| !gate.beginChallenge(nonce)
|
|
|| gate.acceptResponse(MakeResponse(checkpoint, nonce), verifier)
|
|
!= FreshnessResponseResult::Accepted) {
|
|
return Fail("upload callback lifetime setup failed");
|
|
}
|
|
{
|
|
auto controller = OutboxUploadController(
|
|
coordinator,
|
|
transport,
|
|
nullptr);
|
|
if (controller.pump() != UploadPumpResult::Started) {
|
|
return Fail("upload callback lifetime setup did not start");
|
|
}
|
|
}
|
|
transport.finish(0, TelegramTransport::UploadResult::Accepted);
|
|
if (store.items.size() != 1) {
|
|
return Fail("destroyed upload controller handled a late callback");
|
|
}
|
|
auto replacement = OutboxUploadController(
|
|
coordinator,
|
|
transport,
|
|
nullptr);
|
|
if (replacement.pump() != UploadPumpResult::Started
|
|
|| transport.uploads.size() != 2
|
|
|| transport.uploads[0] != transport.uploads[1]) {
|
|
return Fail("destroyed upload controller left the outbox stuck");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioCarrierAcknowledgesOnlyAfterSendMedia() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
const auto objectId = FilledId<ObjectId>(7);
|
|
auto backend = TestCarrierBackend();
|
|
auto results = std::vector<TelegramTransport::UploadResult>();
|
|
auto transport = TelegramCarrierTransport(
|
|
conversationId,
|
|
42,
|
|
backend);
|
|
transport.uploadExact({
|
|
.conversationId = conversationId,
|
|
.objectId = objectId,
|
|
.bytes = QByteArray("exact ciphertext"),
|
|
}, [&](TelegramTransport::UploadResult result) {
|
|
results.push_back(result);
|
|
});
|
|
if (backend.uploadedBytes != std::vector<QByteArray>{
|
|
QByteArray("exact ciphertext") }
|
|
|| backend.uploadCallbacks.size() != 1
|
|
|| !backend.sendCallbacks.empty()
|
|
|| !results.empty()) {
|
|
return Fail("carrier bypassed the Telegram upload stage");
|
|
}
|
|
backend.uploadCallbacks.front()(
|
|
TelegramTransport::UploadResult::Accepted,
|
|
UploadedCarrierFile{ QByteArray("uploaded token") });
|
|
if (backend.sendCallbacks.size() != 1
|
|
|| backend.peerIds != std::vector<std::uint64_t>{ 42 }
|
|
|| backend.tokens != std::vector<QByteArray>{
|
|
QByteArray("uploaded token") }
|
|
|| !results.empty()) {
|
|
return Fail("carrier acknowledged before messages.sendMedia");
|
|
}
|
|
backend.sendCallbacks.front()(
|
|
TelegramTransport::UploadResult::Accepted);
|
|
if (results != std::vector<TelegramTransport::UploadResult>{
|
|
TelegramTransport::UploadResult::Accepted }
|
|
|| backend.filenames.size() != 2
|
|
|| backend.filenames[0] != backend.filenames[1]
|
|
|| backend.mimeTypes.size() != 2
|
|
|| backend.mimeTypes[0] != backend.mimeTypes[1]) {
|
|
return Fail("carrier did not preserve generic document metadata");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioCarrierPropagatesUploadFailure() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
auto backend = TestCarrierBackend();
|
|
auto result = std::optional<TelegramTransport::UploadResult>();
|
|
auto transport = TelegramCarrierTransport(
|
|
conversationId,
|
|
42,
|
|
backend);
|
|
transport.uploadExact({
|
|
.conversationId = conversationId,
|
|
.objectId = FilledId<ObjectId>(7),
|
|
.bytes = QByteArray("ciphertext"),
|
|
}, [&](TelegramTransport::UploadResult value) {
|
|
result = value;
|
|
});
|
|
backend.uploadCallbacks.front()(
|
|
TelegramTransport::UploadResult::RetryableError,
|
|
UploadedCarrierFile());
|
|
if (result != TelegramTransport::UploadResult::RetryableError
|
|
|| !backend.sendCallbacks.empty()) {
|
|
return Fail("carrier converted an upload failure into a send");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioCarrierDownloadsOnlyUntrustedBytes() {
|
|
const auto conversationId = FilledId<ConversationId>(1);
|
|
auto backend = TestCarrierBackend();
|
|
auto result = std::optional<TelegramTransport::DownloadResult>();
|
|
auto transport = TelegramCarrierTransport(
|
|
conversationId,
|
|
42,
|
|
backend);
|
|
transport.download(
|
|
conversationId,
|
|
[&](TelegramTransport::DownloadResult value) {
|
|
result = std::move(value);
|
|
});
|
|
if (backend.downloadCallbacks.size() != 1 || result) {
|
|
return Fail("carrier download did not remain asynchronous");
|
|
}
|
|
backend.downloadCallbacks.front()(
|
|
TelegramTransport::UploadResult::Accepted,
|
|
{ QByteArray("untrusted one"), QByteArray("untrusted two") });
|
|
if (!result
|
|
|| result->result != TelegramTransport::UploadResult::Accepted
|
|
|| result->untrustedObjects != std::vector<QByteArray>{
|
|
QByteArray("untrusted one"),
|
|
QByteArray("untrusted two") }) {
|
|
return Fail("carrier transport interpreted untrusted protocol bytes");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int, char *[]) {
|
|
for (const auto scenario : {
|
|
ScenarioEnvelopeValidation,
|
|
ScenarioHistoryAccessBoundary,
|
|
ScenarioEnvelopeCodec,
|
|
ScenarioFreshnessConfirmation,
|
|
ScenarioFreshnessResynchronization,
|
|
ScenarioFreshnessFork,
|
|
ScenarioOutboxWaitsForFreshness,
|
|
ScenarioOutboxSendsAfterFreshness,
|
|
ScenarioOutboxRetriesExactCiphertext,
|
|
ScenarioOutboxRejectsProtectorMismatch,
|
|
ScenarioUploadControllerAcknowledgesRpcSuccess,
|
|
ScenarioUploadControllerRetriesExactEnvelope,
|
|
ScenarioUploadCallbackCannotOutliveController,
|
|
ScenarioCarrierAcknowledgesOnlyAfterSendMedia,
|
|
ScenarioCarrierPropagatesUploadFailure,
|
|
ScenarioCarrierDownloadsOnlyUntrustedBytes,
|
|
}) {
|
|
if (const auto result = scenario()) {
|
|
return result;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|