-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_paste_model.cpp
More file actions
1720 lines (1501 loc) · 84.6 KB
/
Copy pathtest_paste_model.cpp
File metadata and controls
1720 lines (1501 loc) · 84.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// SPDX-License-Identifier: Apache-2.0
//
// PasteModel's model-level suite: ordinary CRUD, the burn-after-read
// semantics (including the atomicity guarantee under genuine socket
// concurrency), expiry through the injectable clock, the store-error
// classification branches, and the security/protocol cases
// `examples/pastebin/README.md`'s "Required tests" section assigns to this
// rung. Every case builds its own `DbFixture` (rung 0's convention) so it
// starts from a freshly migrated, real on-disk schema.
// Lightweight::DataMapper::CreateInternal's own if-constexpr chain
// (DataMapper.hpp) has a trailing `return {};` that MSVC's flow analysis
// proves unreachable for PasteModel's specific Record instantiation --
// entirely inside that third-party header, not any call site in this file.
// /external:W0 (this file's own target already demotes Lightweight's
// headers to SYSTEM, per morph_add_rung.cmake) does not suppress it here:
// the diagnosis is instantiation-driven and MSVC ties it to the template's
// first instantiation point in the TU, not merely "reported at a line
// inside the external header" -- a known MSVC limitation with templates in
// headers marked external. File-scoped instead of scoped to one call site,
// since several call sites in this file instantiate the same template.
#if defined(_MSC_VER)
#pragma warning(disable : 4702)
#endif
#include <Lightweight/DataMapper/DataMapper.hpp>
#include <Lightweight/SqlConnection.hpp>
#include <Lightweight/SqlError.hpp>
#include <Lightweight/SqlLogger.hpp>
#include <Lightweight/SqlStatement.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <memory>
#include <morph/core/registry.hpp>
#include <morph/core/wire.hpp>
#include <morph/qt/qt_websocket_server.hpp>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include "clock.hpp"
#include "pastebin/app/app.hpp"
#include "pastebin/core/errors.hpp"
#include "pastebin/db/database.hpp"
#include "pastebin/db/paste_entity.hpp"
#include "pastebin/models/paste_model.hpp"
#include "testkit/backend_rig.hpp"
#include "testkit/db_busy_fixture.hpp"
#include "testkit/db_fixture.hpp"
#include "testkit/db_pool_drain.hpp"
#include "testkit/pump.hpp"
namespace {
using morph::ladder::testkit::awaitQt;
using morph::ladder::testkit::BackendRig;
using morph::ladder::testkit::DbFixture;
using morph::ladder::testkit::drainPoolIdleMappers;
using morph::ladder::testkit::Mode;
using morph::ladder::testkit::pumpUntil;
// ─────────────────────────────────────────────────────────────────────────
// Small assertion helpers
// ─────────────────────────────────────────────────────────────────────────
/// @brief An engaged `Reads` as a plain whole number; `-1` when disengaged,
/// so an unexpectedly-empty quantity fails an assertion loudly rather
/// than dereferencing an empty optional.
[[nodiscard]] std::int64_t countOf(const pastebin::Reads& reads) {
return reads.hasValue() ? ::morph::math::floor(*reads) : -1;
}
[[nodiscard]] pastebin::CreatePaste makeCreate(std::string content, std::string syntax = "text") {
pastebin::CreatePaste create;
create.content = std::move(content);
create.syntax = std::move(syntax);
return create;
}
/// @brief The instant `morph::ladder::now()` currently reads, shifted by
/// @p delta — the standard way this suite moves time without sleeping.
[[nodiscard]] ::morph::time::DateTime nowPlus(std::chrono::milliseconds delta) {
return *morph::ladder::now() + delta;
}
/// @brief Mirrors `paste_model.cpp`'s anonymous-namespace `toEpochMs` (not
/// visible from this TU) so a test can seed `PasteRecord::expiresAtMs`
/// directly, bypassing `CreatePaste`'s `Timestamp`-typed action field.
[[nodiscard]] std::int64_t toEpochMs(const ::morph::time::DateTime& instant) {
return instant.value.time_since_epoch().count();
}
// ─────────────────────────────────────────────────────────────────────────
// The animal-name keyspace, mirrored from `src/models/paste_model.cpp`
// ─────────────────────────────────────────────────────────────────────────
//
// Deliberately duplicated rather than exported: those arrays are the model
// TU's own anonymous-namespace implementation detail, and making them public
// API purely for a test would widen the model's surface for no other caller.
// The duplication cannot silently rot, because the keyspace-exhaustion case
// below fills *every* id these arrays can spell and then requires
// `CreatePaste` to fail — if the real arrays ever gain an entry this copy
// lacks, that create finds a free id and the test fails loudly.
constexpr std::array<std::string_view, 16> kAnimals = {
"cat", "dog", "fox", "owl", "bee", "ant", "elk", "ram", "yak", "cod", "eel", "hen", "pig", "cow", "bat", "jay",
};
constexpr std::array<std::string_view, 16> kAdjectives = {
"red", "blue", "gold", "dark", "swift", "calm", "bold", "wild",
"keen", "grey", "warm", "cool", "sharp", "quiet", "loud", "soft",
};
constexpr int kSuffixes = 1000; // paste_model.cpp's uniform_int_distribution<int>{0, 999}
constexpr std::size_t kCombos = kAdjectives.size() * kAnimals.size();
/// @brief Inserts every `<adjective>-<animal>-<0..999>` id for the first
/// @p comboCount adjective/animal pairs, occupying that share of the
/// keyspace so `CreatePaste`'s allocation genuinely collides.
///
/// One `INSERT ... SELECT` over a recursive CTE rather than @p comboCount
/// x 1000 `DataMapper::Create` round trips: occupying a quarter of the
/// keyspace is 64,000 rows, which is seconds of ODBC round trips and
/// milliseconds of SQLite.
void occupyKeyspace(std::size_t comboCount) {
std::string combos;
std::size_t emitted = 0;
for (const auto& adjective : kAdjectives) {
for (const auto& animal : kAnimals) {
if (emitted >= comboCount) {
break;
}
if (emitted > 0) {
combos += " UNION ALL ";
}
combos += "SELECT '";
combos += adjective;
combos += '-';
combos += animal;
combos += "' AS prefix";
++emitted;
}
}
REQUIRE(emitted == comboCount);
::Lightweight::SqlStatement stmt;
(void)stmt.ExecuteDirect(
"WITH RECURSIVE suffix(x) AS (SELECT 0 UNION ALL SELECT x + 1 FROM suffix WHERE x < " +
std::to_string(kSuffixes - 1) +
") INSERT INTO pastes (id, content, syntax, created_at_ms, expires_at_ms, burn_after_reads, "
"read_count, is_private, is_editable) SELECT c.prefix || '-' || suffix.x, 'occupied', 'text', "
"0, NULL, NULL, 0, 0, 0 FROM suffix, (" +
combos + ") c");
}
// ─────────────────────────────────────────────────────────────────────────
// Fuzz-corpus replay support (Step 8 / README "Hostile content round-trip")
// ─────────────────────────────────────────────────────────────────────────
/// @brief Every committed fuzz finding, as raw bytes.
///
/// `MORPH_LADDER_SOURCE_ROOT` is compiled in by `morph_add_rung()` — ctest
/// runs this binary from its own build directory, so a repo-relative path
/// would not resolve. The directory is walked at runtime (not a hard-coded
/// file list) for the same reason `tests/fuzz/CMakeLists.txt` globs it:
/// a newly committed reproducer must start being replayed without anyone
/// remembering to edit a list here.
[[nodiscard]] std::vector<std::pair<std::string, std::string>> fuzzFindings() {
const std::filesystem::path root = std::filesystem::path{MORPH_LADDER_SOURCE_ROOT} / "tests" / "fuzz" / "findings";
std::vector<std::pair<std::string, std::string>> inputs;
for (const auto& entry : std::filesystem::recursive_directory_iterator{root}) {
if (!entry.is_regular_file()) {
continue;
}
std::ifstream in{entry.path(), std::ios::binary};
REQUIRE(in.good());
inputs.emplace_back(entry.path().filename().string(),
std::string{std::istreambuf_iterator<char>{in}, std::istreambuf_iterator<char>{}});
}
std::ranges::sort(inputs); // stable order across filesystems, for reproducible failures
return inputs;
}
/// @brief Whether @p text is well-formed UTF-8.
///
/// The wire protocol is JSON in a WebSocket *text* frame, and the storage
/// column is `TEXT`: bytes that are not valid UTF-8 have no faithful
/// representation anywhere along that path. Which half of the corpus a given
/// finding falls into decides which guarantee the round-trip case below can
/// honestly assert — see it for the split.
[[nodiscard]] bool isValidUtf8(std::string_view text) {
std::size_t i = 0;
while (i < text.size()) {
const auto lead = static_cast<unsigned char>(text[i]);
std::size_t extra = 0;
if (lead < 0x80) {
extra = 0;
} else if ((lead & 0xE0) == 0xC0 && lead >= 0xC2) {
extra = 1;
} else if ((lead & 0xF0) == 0xE0) {
extra = 2;
} else if ((lead & 0xF8) == 0xF0 && lead <= 0xF4) {
extra = 3;
} else {
return false;
}
if (i + extra >= text.size()) {
return false;
}
for (std::size_t k = 1; k <= extra; ++k) {
if ((static_cast<unsigned char>(text[i + k]) & 0xC0) != 0x80) {
return false;
}
}
i += extra + 1;
}
return true;
}
/// @brief How many rows the `pastes` table currently holds.
///
/// Read straight from SQL rather than through `ListPastes`, so it counts
/// private pastes too and is unaffected by paging.
[[nodiscard]] std::int64_t pasteRowCount() {
::Lightweight::SqlStatement stmt;
return stmt.ExecuteDirectScalar<std::int64_t>("SELECT COUNT(*) FROM pastes").value_or(-1);
}
/// @brief Installs a short SQLite `busy_timeout` on every connection opened
/// while it is alive, and restores the default afterwards.
///
/// `Lightweight::SqlConnection::PostConnect()` unconditionally issues
/// `PRAGMA busy_timeout = 60000` on every new SQLite connection, so a write
/// that collides with `DbBusyFixture`'s held lock blocks for a real minute
/// before SQLite gives up. `test_db_busy_fixture.cpp` re-issues the PRAGMA on
/// the connection it owns — that is not available here, because the
/// connection `PasteModel` uses is acquired from
/// `Lightweight::GlobalDataMapperPool()` inside `execute(...)`, which no test
/// can reach directly. The post-connected hook is the seam that works from
/// the outside: it runs immediately after `PostConnect()` on every
/// newly-created connection. See `db_busy_fixture.hpp`'s
/// "`SetPostConnectedHook` and `GlobalDataMapperPool()`" note: this is only
/// guaranteed to fire if the pool actually creates a fresh connection for the
/// model under test's acquisition, not if it hands back an already-connected
/// idle one — the two call sites below accept that as a documented,
/// not-fully-deterministic tradeoff rather than a hard guarantee.
class ScopedShortBusyTimeout {
public:
explicit ScopedShortBusyTimeout(int milliseconds) {
::Lightweight::SqlConnection::SetPostConnectedHook([milliseconds](::Lightweight::SqlConnection& connection) {
::Lightweight::SqlStatement stmt{connection};
(void)stmt.ExecuteDirect("PRAGMA busy_timeout = " + std::to_string(milliseconds));
});
}
~ScopedShortBusyTimeout() { ::Lightweight::SqlConnection::ResetPostConnectedHook(); }
ScopedShortBusyTimeout(const ScopedShortBusyTimeout&) = delete;
ScopedShortBusyTimeout& operator=(const ScopedShortBusyTimeout&) = delete;
ScopedShortBusyTimeout(ScopedShortBusyTimeout&&) = delete;
ScopedShortBusyTimeout& operator=(ScopedShortBusyTimeout&&) = delete;
};
} // namespace
// ═════════════════════════════════════════════════════════════════════════
// Step 1 — ordinary CRUD and validation
// ═════════════════════════════════════════════════════════════════════════
TEST_CASE("CreatePaste stores a paste under a freshly allocated animal-name id", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
const auto id = model.execute(makeCreate("hello", "cpp")).id;
REQUIRE(id.hasValue());
CHECK_FALSE((*id).empty());
const auto view = model.execute(pastebin::GetPaste{.id = id});
CHECK(view.id == id);
CHECK(view.content == "hello");
CHECK(view.syntax == "cpp");
CHECK(view.visibility == pastebin::Visibility::Public);
CHECK(view.editability == pastebin::Editability::Immutable);
CHECK_FALSE(view.expiresAt.hasValue());
CHECK_FALSE(view.burnAfterReads.hasValue());
}
TEST_CASE("CreatePaste and EditPaste round-trip non-ASCII content losslessly", "[pastebin][model]") {
// `content` is stored as Light::SqlMaxDynamicWideString (paste_entity.hpp's
// file comment explains why: SqlText/std::string are char-based and render
// as VARCHAR(MAX) on the SQL Server backend, a single-byte-collation
// column). This exercises both the DataMapper-bound write path
// (CreatePaste) and the raw-prepared-statement write path (EditPaste's
// compare-and-swap, paste_model.cpp's kEditPasteSql) that binds a
// Light::SqlMaxDynamicWideString parameter by hand rather than through a
// Field<>.
DbFixture fixture;
pastebin::PasteModel model;
const std::string original = "héllo wörld — \xE4\xB8\xAD\xE6\x96\x87 \xF0\x9F\x8E\x89"; // Latin-1 + CJK + emoji
auto create = makeCreate(original, "text");
create.editability = pastebin::Editability::Editable;
const auto id = model.execute(create).id;
CHECK(model.execute(pastebin::GetPaste{.id = id}).content == original);
const std::string edited = "édité — \xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"; // Japanese
model.execute(pastebin::EditPaste{.id = id, .content = edited, .syntax = "text"});
CHECK(model.execute(pastebin::GetPaste{.id = id}).content == edited);
}
TEST_CASE("CreatePaste's validate() rejects empty content and empty syntax", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
REQUIRE_THROWS_AS(model.execute(makeCreate("", "text")), pastebin::ValidationError);
REQUIRE_THROWS_AS(model.execute(makeCreate("body", "")), pastebin::ValidationError);
REQUIRE_THROWS_AS(model.execute(makeCreate("", "")), pastebin::ValidationError);
// Nothing was stored by any of the three rejections.
CHECK(model.execute(pastebin::ListPastes{}).pastes.empty());
}
TEST_CASE("CreatePaste's validate() rejects a zero or negative burnAfterReads", "[pastebin][model]") {
// A budget of 0 is a whole number, so it clears the integrality half of
// `CreatePaste::validate()`'s burn-budget rule (the case below this one),
// but PasteModel::execute(GetPaste)'s burn check
// (`readCount >= *burnAfterReads`) is already true before the first read
// ever happens — a paste born with burnAfterReads=0 would be permanently
// Burned on its very first GetPaste, having never been read once. The two
// halves of the rule are independent and are asserted separately.
DbFixture fixture;
pastebin::PasteModel model;
auto zero = makeCreate("body", "text");
zero.burnAfterReads = pastebin::Reads::fromDouble(0.0);
REQUIRE_THROWS_AS(model.execute(zero), pastebin::ValidationError);
auto negative = makeCreate("body", "text");
negative.burnAfterReads = pastebin::Reads::fromDouble(-1.0);
REQUIRE_THROWS_AS(model.execute(negative), pastebin::ValidationError);
// A positive budget is unaffected by the new check.
auto positive = makeCreate("body", "text");
positive.burnAfterReads = pastebin::Reads::fromDouble(1.0);
REQUIRE_NOTHROW(model.execute(positive));
// Nothing was stored by either rejection — only the positive create.
CHECK(model.execute(pastebin::ListPastes{}).pastes.size() == 1);
}
TEST_CASE("CreatePaste's validate() rejects a fractional burnAfterReads", "[pastebin][model]") {
// The whole-number premise `Reads` documents (`pastebin/units.hpp`) and
// `paste_model.cpp`'s `countOf` relies on is a *DTO* obligation — the type
// cannot carry it, because `Quantity` requires `DeclaredDecimals >= 1` and
// so `Reads` represents one tenth exactly. A fractional budget therefore
// travels the whole path intact: it survives the wire codec (a `Rational`
// serialises as its own num/den pair), reaches `validate()`, and — before
// this case existed — was accepted, then floored to a *different* budget by
// `countOf`'s `math::floor` on the way into the row. `2.5` became `2`, and
// `GetPaste` reported `2` back. That is the same silent-data-loss class the
// `syntax` bound is validated to prevent (`kMaxSyntaxBytes`' own doc
// comment), arriving through an unguarded door, so the answer is the same:
// refuse the input rather than quietly rewrite it.
DbFixture fixture;
pastebin::PasteModel model;
auto fractional = makeCreate("body", "text");
fractional.burnAfterReads = pastebin::Reads::fromDouble(2.5);
// The premise this whole case rests on: 2.5 really is exactly representable
// in `Reads`, so nothing upstream of `validate()` has already rejected or
// rounded it. If `Reads` ever gains a zero declared precision this fails
// here rather than silently turning the assertions below into tautologies.
REQUIRE(fractional.burnAfterReads.hasValue());
REQUIRE_FALSE(fractional.burnAfterReads.value()->isInteger());
CHECK(fractional.burnAfterReads.value()->numerator == 5);
CHECK(fractional.burnAfterReads.value()->denominator == 2);
REQUIRE_THROWS_AS(model.execute(fractional), pastebin::ValidationError);
// A negative fraction is refused by the integrality rule too, not only by
// the sign rule — the two checks are independent.
auto negativeFraction = makeCreate("body", "text");
negativeFraction.burnAfterReads = pastebin::Reads::fromDouble(-0.5);
REQUIRE_THROWS_AS(model.execute(negativeFraction), pastebin::ValidationError);
// The neighbouring whole numbers are both still accepted, so the rejection
// above is the fractional part and not a blanket refusal of the field.
for (const double whole : {2.0, 3.0}) {
auto accepted = makeCreate("body", "text");
accepted.burnAfterReads = pastebin::Reads::fromDouble(whole);
REQUIRE_NOTHROW(model.execute(accepted));
}
// Nothing was stored by either rejection — only the two whole-number
// creates. Had 2.5 been accepted and floored, this would read three.
const auto listed = model.execute(pastebin::ListPastes{}).pastes;
REQUIRE(listed.size() == 2);
for (const auto& summary : listed) {
const auto budget = model.execute(pastebin::GetPaste{.id = summary.id}).burnAfterReads;
CHECK(budget.hasValue());
CHECK(budget.value()->isInteger());
}
}
TEST_CASE("An over-length syntax is rejected, not silently truncated into the column", "[pastebin][model]") {
// `PasteRecord::syntax` is a `Light::SqlAnsiString<32>`, whose constructor
// is `_size{std::min(N, s.size())}` — no throw, no diagnostic. Before
// `kMaxSyntaxBytes` was validated, a 33-byte label was cut to 32 on the way
// into the row and the client was told the create succeeded, and a cut
// landing mid-UTF-8-sequence put ill-formed UTF-8 into both the TEXT column
// and the JSON frame carrying the resulting PasteView back. Both halves are
// asserted here: the boundary still fits, one byte past it is refused, and
// nothing was stored by any refusal.
DbFixture fixture;
pastebin::PasteModel model;
static constexpr std::size_t kMax = pastebin::kMaxSyntaxBytes;
const std::string atLimit(kMax, 'x');
const std::string overLimit(kMax + 1, 'x');
// The boundary itself is accepted and round-trips whole — the bound is
// "<= capacity", not an off-by-one that rejects a label that would fit.
// Editable, so the EditPaste assertion below is genuinely about the syntax
// bound and not about `EditPaste: paste is not editable`.
auto create = makeCreate("at the limit", atLimit);
create.editability = pastebin::Editability::Editable;
const auto id = model.execute(create).id;
CHECK(model.execute(pastebin::GetPaste{.id = id}).syntax == atLimit);
// One byte past it is a typed rejection, on both actions that write the
// column.
REQUIRE_THROWS_AS(model.execute(makeCreate("one too many", overLimit)), pastebin::ValidationError);
REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = overLimit}),
pastebin::ValidationError);
// ... and the still-valid boundary length is accepted by EditPaste too, so
// the rejection above is the length rule, not a blanket refusal.
REQUIRE_NOTHROW(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = atLimit}));
// A multi-byte label whose truncation point falls *inside* a codepoint —
// the ill-formed-UTF-8 case specifically. Thirty-three 2-byte characters is
// 66 bytes, so a 32-byte cut would sever the 17th one.
std::string multiByte;
for (int i = 0; i < 33; ++i) {
multiByte += "é"; // U+00E9, two bytes in UTF-8
}
REQUIRE(multiByte.size() > kMax);
REQUIRE_THROWS_AS(model.execute(makeCreate("mid-codepoint", multiByte)), pastebin::ValidationError);
// Exactly one paste exists: the at-limit one. No refusal wrote a row, and
// no refused edit changed the one that did.
const auto listed = model.execute(pastebin::ListPastes{});
REQUIRE(listed.pastes.size() == 1);
CHECK(listed.pastes.front().syntax == atLimit);
}
TEST_CASE("CreatePaste round-trips visibility and editability", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
auto create = makeCreate("private and editable");
create.visibility = pastebin::Visibility::Private;
create.editability = pastebin::Editability::Editable;
const auto id = model.execute(create).id;
const auto view = model.execute(pastebin::GetPaste{.id = id});
CHECK(view.visibility == pastebin::Visibility::Private);
CHECK(view.editability == pastebin::Editability::Editable);
}
TEST_CASE("GetPaste returns a freshly created paste and counts the read", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
const auto id = model.execute(makeCreate("secret")).id;
const auto first = model.execute(pastebin::GetPaste{.id = id});
CHECK(first.content == "secret");
CHECK(countOf(first.readCount) == 1);
const auto second = model.execute(pastebin::GetPaste{.id = id});
CHECK(second.content == "secret");
CHECK(countOf(second.readCount) == 2); // the count is real state, not a per-call constant
}
TEST_CASE("GetPaste against an unknown id throws NotFound, and an empty id is a ValidationError",
"[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = pastebin::PasteId{"no-such-paste"}}), pastebin::NotFound);
REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{}), pastebin::ValidationError);
}
TEST_CASE("EditPaste replaces an editable paste's content and syntax", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
auto create = makeCreate("before", "text");
create.editability = pastebin::Editability::Editable;
const auto id = model.execute(create).id;
const auto edited = model.execute(pastebin::EditPaste{.id = id, .content = "after", .syntax = "cpp"});
CHECK(edited.content == "after");
CHECK(edited.syntax == "cpp");
// Persisted, not merely reflected back from the action.
const auto refetched = model.execute(pastebin::GetPaste{.id = id});
CHECK(refetched.content == "after");
CHECK(refetched.syntax == "cpp");
}
TEST_CASE("EditPaste refuses an immutable paste, an unknown id, and an incomplete action", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
const auto id = model.execute(makeCreate("immutable")).id; // Editability::Immutable by default
REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "nope", .syntax = "text"}),
pastebin::ValidationError);
REQUIRE_THROWS_AS(
model.execute(pastebin::EditPaste{.id = pastebin::PasteId{"ghost"}, .content = "nope", .syntax = "text"}),
pastebin::NotFound);
REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "", .syntax = "text"}),
pastebin::ValidationError);
REQUIRE_THROWS_AS(model.execute(pastebin::EditPaste{.id = id, .content = "body", .syntax = ""}),
pastebin::ValidationError);
// The refused edits left the stored paste untouched.
CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "immutable");
}
TEST_CASE("A concurrent write between EditPaste's read and its write is a Conflict, not a lost update",
"[pastebin][model]") {
// EditPaste used to be a plain read-then-write: whichever caller's
// UPDATE landed last would silently discard whatever an earlier caller
// had just written, with no error to either side. The fix makes the
// write a compare-and-swap (`kEditPasteSql`'s `content = ? AND syntax
// = ?` guard): the write only applies if the row still holds what this
// call read.
//
// Provoked deterministically — no `sleep_for` (examples/TESTING.md)
// and no guessing at thread-scheduling order. `WaitForGuardedUpdate`
// is a `Lightweight::SqlLogger` that fires `OnExecute()` on whatever
// thread runs a statement, strictly before that statement's actual
// (and here, blocking) ODBC call — a real hook Lightweight already
// exposes, not new instrumentation added to PasteModel. It lets the
// main thread wait on a condition variable for the precise moment
// `contendedModel`'s guarded UPDATE is about to run — which can only
// happen after its own `before` SELECT has already completed — before
// committing a *different* write through a lock held open on a second
// connection. `contendedModel`'s guarded UPDATE then blocks on that
// lock; when it is finally released, the guard compares against
// content that is no longer there.
class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null {
public:
void OnExecute(std::string_view const& query) override {
if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) {
return;
}
{
const std::lock_guard lock{_mutex};
_reached = true;
}
_cv.notify_all();
}
void wait() {
std::unique_lock lock{_mutex};
_cv.wait(lock, [this] { return _reached; });
}
private:
std::mutex _mutex;
std::condition_variable _cv;
bool _reached = false;
};
DbFixture fixture;
pastebin::PasteModel seedModel;
auto create = makeCreate("seed", "text");
create.editability = pastebin::Editability::Editable;
const auto id = seedModel.execute(create).id;
// `contendedModel`'s execute() below must acquire a genuinely new pooled
// connection *while* the short busy-timeout hook is installed for this
// hook to actually apply to it (see db_busy_fixture.hpp's
// GlobalDataMapperPool() note above `ScopedShortBusyTimeout`'s own doc
// comment) — same requirement as the SQLITE_BUSY cases below. Draining
// the pool's idle mappers first (see drainPoolIdleMappers's own doc
// comment) turns that into a hard guarantee rather than the "correct in
// practice, not guaranteed" caveat a shared pool would otherwise leave:
// held alive across the hook install and the racy execute() below, then
// released once this test no longer needs a forced-fresh acquisition.
const ScopedShortBusyTimeout shortTimeout{5000};
auto drained = drainPoolIdleMappers();
pastebin::PasteModel contendedModel;
::Lightweight::SqlConnection lockingConnection;
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("BEGIN IMMEDIATE");
(void)stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'");
}
WaitForGuardedUpdate probe;
::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger();
::Lightweight::SqlLogger::SetLogger(probe);
std::optional<pastebin::PasteView> succeeded;
std::exception_ptr failure;
std::thread editor{[&] {
try {
succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"});
} catch (...) {
failure = std::current_exception();
}
}};
// Blocks until `contendedModel`'s guarded UPDATE is about to execute —
// which is only reachable after its own `before` SELECT has already
// returned "seed". Only past this point is it safe to commit a
// different write through the lock: the SELECT is guaranteed done.
probe.wait();
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("UPDATE pastes SET content = 'concurrent writer' WHERE id = '" + *id + "'");
(void)stmt.ExecuteDirect("COMMIT");
}
editor.join();
// Safe to stop forcing fresh acquisitions now: contendedModel's one and
// only execute() call (and so its one pool acquisition) already
// happened, inside the joined editor thread above.
drained.clear();
// Restored only after the editor thread is done issuing statements —
// `probe` must not be touched by another thread once it goes out of
// scope below.
::Lightweight::SqlLogger::SetLogger(previousLogger);
REQUIRE_FALSE(succeeded.has_value());
REQUIRE(failure);
bool sawConflict = false;
try {
std::rethrow_exception(failure);
} catch (const pastebin::Conflict&) {
sawConflict = true;
} catch (...) {
// Falls through to the REQUIRE below with sawConflict still false.
}
REQUIRE(sawConflict);
// Not a lost update: the concurrent writer's content survived, untouched
// by the rejected edit.
CHECK(seedModel.execute(pastebin::GetPaste{.id = id}).content == "concurrent writer");
}
TEST_CASE(
"A concurrent delete between EditPaste's read and its guarded write is a NotFound, "
"not a lost update or a Conflict",
"[pastebin][model]") {
// Same forced-interleaving idiom as "A concurrent write between
// EditPaste's read and its write is a Conflict" above, but the
// concurrent writer deletes the row outright instead of editing its
// content. This exercises EditPaste's *post-CAS-miss* classification
// path (paste_model.cpp's "Zero rows matched: classify why" block) --
// distinct from the earlier, pre-CAS existing.empty() check the "refuses
// an unknown id" test above already covers, since that one never reaches
// the guarded UPDATE at all (the row was never there to begin with). This
// one has the row present and readable at EditPaste's first SELECT, and
// only disappears in the window the CAS UPDATE itself is blocked in.
class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null {
public:
void OnExecute(std::string_view const& query) override {
if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) {
return;
}
{
const std::lock_guard lock{_mutex};
_reached = true;
}
_cv.notify_all();
}
void wait() {
std::unique_lock lock{_mutex};
_cv.wait(lock, [this] { return _reached; });
}
private:
std::mutex _mutex;
std::condition_variable _cv;
bool _reached = false;
};
DbFixture fixture;
pastebin::PasteModel seedModel;
auto create = makeCreate("about to vanish", "text");
create.editability = pastebin::Editability::Editable;
const auto id = seedModel.execute(create).id;
const ScopedShortBusyTimeout shortTimeout{5000};
auto drained = drainPoolIdleMappers();
pastebin::PasteModel contendedModel;
::Lightweight::SqlConnection lockingConnection;
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("BEGIN IMMEDIATE");
(void)stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'");
}
WaitForGuardedUpdate probe;
::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger();
::Lightweight::SqlLogger::SetLogger(probe);
std::optional<pastebin::PasteView> succeeded;
std::exception_ptr failure;
std::thread editor{[&] {
try {
succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"});
} catch (...) {
failure = std::current_exception();
}
}};
probe.wait();
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("DELETE FROM pastes WHERE id = '" + *id + "'");
(void)stmt.ExecuteDirect("COMMIT");
}
editor.join();
drained.clear();
::Lightweight::SqlLogger::SetLogger(previousLogger);
REQUIRE_FALSE(succeeded.has_value());
REQUIRE(failure);
bool sawNotFound = false;
try {
std::rethrow_exception(failure);
} catch (const pastebin::NotFound&) {
sawNotFound = true;
} catch (...) {
// Falls through to the REQUIRE below with sawNotFound still false.
}
REQUIRE(sawNotFound);
}
TEST_CASE(
"A concurrent DeletePaste is not the only way to reach EditPaste's post-CAS \"not editable\" "
"classification, but flipping is_editable underneath a pending edit reaches it too",
"[pastebin][model]") {
// Mirrors the delete case above, but the concurrent writer clears
// is_editable instead of removing the row -- the other branch of the
// same "Zero rows matched: classify why" block (paste_model.cpp).
// is_editable has no ordinary action that flips it after creation (only
// CreatePaste sets it, permanently, in this rung), so this reaches into
// the row directly through the locking connection, the same way the
// Conflict/NotFound tests above simulate "some other write landed" --
// there is no in-API way to un-edit a paste, which is exactly why this
// classification branch has no other route to it.
class WaitForGuardedUpdate : public ::Lightweight::SqlLogger::Null {
public:
void OnExecute(std::string_view const& query) override {
if (query.find("SET content = ?, syntax = ?") == std::string_view::npos) {
return;
}
{
const std::lock_guard lock{_mutex};
_reached = true;
}
_cv.notify_all();
}
void wait() {
std::unique_lock lock{_mutex};
_cv.wait(lock, [this] { return _reached; });
}
private:
std::mutex _mutex;
std::condition_variable _cv;
bool _reached = false;
};
DbFixture fixture;
pastebin::PasteModel seedModel;
auto create = makeCreate("about to be locked", "text");
create.editability = pastebin::Editability::Editable;
const auto id = seedModel.execute(create).id;
const ScopedShortBusyTimeout shortTimeout{5000};
auto drained = drainPoolIdleMappers();
pastebin::PasteModel contendedModel;
::Lightweight::SqlConnection lockingConnection;
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("BEGIN IMMEDIATE");
(void)stmt.ExecuteDirect("UPDATE pastes SET id = id WHERE id = '" + *id + "'");
}
WaitForGuardedUpdate probe;
::Lightweight::SqlLogger& previousLogger = ::Lightweight::SqlLogger::GetLogger();
::Lightweight::SqlLogger::SetLogger(probe);
std::optional<pastebin::PasteView> succeeded;
std::exception_ptr failure;
std::thread editor{[&] {
try {
succeeded = contendedModel.execute(pastebin::EditPaste{.id = id, .content = "mine", .syntax = "text"});
} catch (...) {
failure = std::current_exception();
}
}};
probe.wait();
{
::Lightweight::SqlStatement stmt{lockingConnection};
(void)stmt.ExecuteDirect("UPDATE pastes SET is_editable = 0 WHERE id = '" + *id + "'");
(void)stmt.ExecuteDirect("COMMIT");
}
editor.join();
drained.clear();
::Lightweight::SqlLogger::SetLogger(previousLogger);
REQUIRE_FALSE(succeeded.has_value());
REQUIRE(failure);
bool sawValidationError = false;
try {
std::rethrow_exception(failure);
} catch (const pastebin::ValidationError&) {
sawValidationError = true;
} catch (...) {
// Falls through to the REQUIRE below with sawValidationError still false.
}
REQUIRE(sawValidationError);
}
TEST_CASE("DeletePaste removes the paste, and a follow-up GetPaste throws NotFound", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
const auto id = model.execute(makeCreate("doomed")).id;
REQUIRE_NOTHROW(model.execute(pastebin::DeletePaste{.id = id}));
REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound);
// Deleting an absent paste is a no-op acknowledgement, not an error —
// the operation is idempotent by design.
REQUIRE_NOTHROW(model.execute(pastebin::DeletePaste{.id = id}));
REQUIRE_THROWS_AS(model.execute(pastebin::DeletePaste{}), pastebin::ValidationError);
}
TEST_CASE("ListPastes returns only public pastes, one page at a time, and its cursor round-trips",
"[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
constexpr int kPublic = 25; // one full 20-row page plus a partial second one
constexpr int kPrivate = 3;
std::vector<pastebin::PasteId> publicIds;
for (int i = 0; i < kPublic; ++i) {
publicIds.push_back(model.execute(makeCreate("public " + std::to_string(i))).id);
}
std::vector<pastebin::PasteId> privateIds;
for (int i = 0; i < kPrivate; ++i) {
auto create = makeCreate("private " + std::to_string(i));
create.visibility = pastebin::Visibility::Private;
privateIds.push_back(model.execute(create).id);
}
const auto page1 = model.execute(pastebin::ListPastes{});
REQUIRE(page1.pastes.size() == 20);
REQUIRE(page1.nextCursor.hasValue());
const auto page2 = model.execute(pastebin::ListPastes{.cursor = page1.nextCursor});
REQUIRE(page2.pastes.size() == static_cast<std::size_t>(kPublic - 20));
CHECK_FALSE(page2.nextCursor.hasValue()); // exhausted — no third page
std::vector<pastebin::PasteId> walked;
for (const auto& summary : page1.pastes) {
walked.push_back(summary.id);
}
for (const auto& summary : page2.pastes) {
walked.push_back(summary.id);
}
// Every public paste exactly once, no private paste at all.
std::ranges::sort(walked);
CHECK(std::ranges::adjacent_find(walked) == walked.end()); // no overlap between the two pages
CHECK(walked.size() == static_cast<std::size_t>(kPublic));
for (const auto& id : publicIds) {
CHECK(std::ranges::find(walked, id) != walked.end());
}
for (const auto& id : privateIds) {
CHECK(std::ranges::find(walked, id) == walked.end());
}
// A summary is deliberately narrower than a view: it carries no content.
CHECK(page1.pastes.front().syntax == "text");
CHECK(page1.pastes.front().visibility == pastebin::Visibility::Public);
}
TEST_CASE("ListPastes does not consume a read budget — listing is not reading", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
auto create = makeCreate("listed but unread");
create.burnAfterReads = pastebin::Reads::fromDouble(1.0);
const auto id = model.execute(create).id;
REQUIRE(model.execute(pastebin::ListPastes{}).pastes.size() == 1);
REQUIRE(model.execute(pastebin::ListPastes{}).pastes.size() == 1);
// The one allowed read is still available.
CHECK(model.execute(pastebin::GetPaste{.id = id}).content == "listed but unread");
}
// ═════════════════════════════════════════════════════════════════════════
// Step 2 — burn-after-read semantics, single client
// ═════════════════════════════════════════════════════════════════════════
TEST_CASE("GetPaste spends the burn budget and deletes the paste on the last allowed read", "[pastebin][model]") {
DbFixture fixture;
pastebin::PasteModel model;
auto create = makeCreate("secret");
create.burnAfterReads = pastebin::Reads::fromDouble(2.0);
const auto id = model.execute(create).id;
const auto first = model.execute(pastebin::GetPaste{.id = id});
CHECK(first.content == "secret");
CHECK(countOf(first.readCount) == 1);
// Read 2 of 2 still returns the content: burn-after-read destroys the
// paste *on* the Nth read, after building the result — not before it.
const auto second = model.execute(pastebin::GetPaste{.id = id});
CHECK(second.content == "secret");
CHECK(countOf(second.readCount) == 2);
REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = id}), pastebin::NotFound);
}
TEST_CASE("GetPaste against a row already at its burn budget throws Burned, not NotFound", "[pastebin][model]") {
// Seeds the row directly at the storage layer with read_count already at
// burn_after_reads, bypassing the delete-on-last-read step that would
// normally have removed it. This is the "conditional UPDATE matched zero
// rows, and the row still exists" classification branch — reachable no
// other way from the model's own API.
//
// It is also the *only* case in this suite that pins the burn clause of
// `kConsumeReadSql`'s `WHERE` on its own: with that clause deleted, this
// read matches the row, increments past the budget, and hands back
// content that was already spent. Verified by doing exactly that. See the
// concurrent case below for why the socket race does not catch it on
// SQLite, and why the two belong together.
DbFixture fixture;
{
Lightweight::DataMapper mapper;
pastebin::db::PasteRecord rec;
rec.id = Light::SqlAnsiString<32>{"test-burned-paste"};
rec.content = Light::SqlMaxDynamicWideString{L"gone"};
rec.syntax = Light::SqlAnsiString<32>{"text"};
rec.createdAtMs = std::int64_t{0};
rec.burnAfterReads = std::optional<std::int64_t>{1};
rec.readCount = std::int64_t{1}; // already at budget
mapper.Create(rec);
}
pastebin::PasteModel model;
REQUIRE_THROWS_AS(model.execute(pastebin::GetPaste{.id = pastebin::PasteId{"test-burned-paste"}}),
pastebin::Burned);
}
TEST_CASE("GetPaste against a row that is both burn-exhausted and still-future-expiring throws Burned",
"[pastebin][model]") {
// Same "conditional UPDATE matched zero rows, and the row still exists"
// classification branch as the burned-row test above, but with
// `expiresAtMs` engaged and still in the future rather than disengaged.
// That combination is what forces the classifier's expiry check
// (`row.expiresAtMs.Value() && *row.expiresAtMs.Value() <= readAtMs`) to