Compare commits
628 Commits
7e1f4f8b09
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb69135f2c | ||
|
|
4edd2461d1 | ||
|
|
fc9a02a6a0 | ||
|
|
6607ad9104 | ||
|
|
6832300a4b | ||
|
|
9c5267e1f0 | ||
|
|
4979ae1867 | ||
|
|
29ef82f7b4 | ||
|
|
f458c11a0d | ||
|
|
e615ba1bbf | ||
|
|
1bec7dd17e | ||
|
|
a0339a5526 | ||
|
|
65cae4a5e8 | ||
|
|
c8cc0646cb | ||
|
|
e8057fe517 | ||
|
|
378023c53d | ||
|
|
ff3e863032 | ||
|
|
8fc32f18ce | ||
|
|
0cd9ea915e | ||
|
|
f0e7f73ec1 | ||
|
|
567f9267e8 | ||
|
|
1dc5bf4377 | ||
|
|
31d3ec8367 | ||
|
|
d739f58bb5 | ||
|
|
18e675a5b2 | ||
|
|
a3fc838855 | ||
|
|
d5043053e0 | ||
|
|
c932dd19d9 | ||
|
|
c532ad21bf | ||
|
|
0e95bd9160 | ||
|
|
e312cce4e1 | ||
|
|
5587722800 | ||
|
|
0451b6630c | ||
|
|
f77fb79cd2 | ||
|
|
1247b51d9e | ||
|
|
7342c60952 | ||
|
|
328bd2c3b4 | ||
|
|
db87a214fd | ||
|
|
ad95b09046 | ||
|
|
1e95ca979b | ||
|
|
1cae9ac311 | ||
|
|
72bd2e11b4 | ||
|
|
69b3c663c0 | ||
|
|
f470a39ad2 | ||
|
|
e2f287d3d8 | ||
|
|
914e438793 | ||
|
|
6266c5f721 | ||
|
|
f564c30ae2 | ||
|
|
a5ce46359a | ||
|
|
b45953e567 | ||
|
|
36d1b9c038 | ||
|
|
56bcbcdd5c | ||
|
|
9b9bfde843 | ||
|
|
164a917d95 | ||
|
|
96c0aa592c | ||
|
|
d8520d9714 | ||
|
|
873d668653 | ||
|
|
4e257a7ca4 | ||
|
|
d0bb6729cd | ||
|
|
32ede3e3ce | ||
|
|
5da78e5e30 | ||
|
|
cb108faaf8 | ||
|
|
611b82ccde | ||
|
|
64d8f9d904 | ||
|
|
6f452a9a8b | ||
|
|
20fe5637c1 | ||
|
|
9bf8cf831d | ||
|
|
9f4a1141ef | ||
|
|
cb818f4bfa | ||
|
|
9c195ff5cb | ||
|
|
54d32c9163 | ||
|
|
0b5ab73963 | ||
|
|
956387471d | ||
|
|
78fd9e026e | ||
|
|
4d6fb06e02 | ||
|
|
8944f8bb44 | ||
|
|
1b178767ab | ||
|
|
7d10653c41 | ||
|
|
b7a03614bc | ||
|
|
49c5324352 | ||
|
|
193a4d6ee6 | ||
|
|
3182da8d92 | ||
|
|
6839cf2a33 | ||
|
|
775b5c062e | ||
|
|
e31dac5c9c | ||
|
|
c2bd1b34f0 | ||
|
|
cfd49ff69e | ||
|
|
1f7b08b74f | ||
|
|
240b373f68 | ||
|
|
09a043431e | ||
|
|
9b21d6aee8 | ||
|
|
e4c8535f42 | ||
|
|
97a2dd8743 | ||
|
|
17d9328c62 | ||
|
|
e10090b9ef | ||
|
|
4f1594390e | ||
|
|
1f4e8a5958 | ||
|
|
d64139d9d1 | ||
|
|
2779502f3b | ||
|
|
9f1e2c9ff5 | ||
|
|
dd99c5dd74 | ||
|
|
b607677f30 | ||
|
|
20fe83d889 | ||
|
|
c7782d554f | ||
|
|
ea65611690 | ||
|
|
17b29edd14 | ||
|
|
3438260090 | ||
|
|
0bd00a3044 | ||
|
|
d301825e50 | ||
|
|
6193e28587 | ||
|
|
bfdf64975c | ||
|
|
ea800e5e2a | ||
|
|
cfff594732 | ||
|
|
0fa330a357 | ||
|
|
a6c85e3658 | ||
|
|
e0aca0f883 | ||
|
|
a77b0c1221 | ||
|
|
393a3c25fd | ||
|
|
8c7a2741b0 | ||
|
|
865c6ed796 | ||
|
|
14542b6e33 | ||
|
|
de7053644b | ||
|
|
f1e0b92f47 | ||
|
|
bead6f1811 | ||
|
|
7769dbc9f4 | ||
|
|
74ca5ee35f | ||
|
|
38973a014e | ||
|
|
fc8b4b164b | ||
|
|
eb63df2000 | ||
|
|
53bd574660 | ||
|
|
581ba01d8d | ||
|
|
9db42d6cc1 | ||
|
|
ab24786d2a | ||
|
|
1aca4c4a41 | ||
|
|
669eaa7c65 | ||
|
|
f15ea031d1 | ||
|
|
25a39fca9c | ||
|
|
e398133907 | ||
|
|
186535f8c9 | ||
|
|
de19d17b00 | ||
|
|
b2e31c3c1b | ||
|
|
9e23620072 | ||
|
|
af42113fca | ||
|
|
c779ec59f9 | ||
|
|
2023ea2931 | ||
|
|
59b18039ed | ||
|
|
96ea7e6815 | ||
|
|
dff81f7bfb | ||
|
|
a9c82ec481 | ||
|
|
97aa372094 | ||
|
|
e61409773e | ||
|
|
7713a03cd5 | ||
|
|
cea94ce260 | ||
|
|
45a992f5a8 | ||
|
|
bd57310bbf | ||
|
|
c2d092f435 | ||
|
|
e19bd60984 | ||
|
|
2aa0ff9e70 | ||
|
|
5dd74df293 | ||
|
|
7712180f3a | ||
|
|
c9a22945c8 | ||
|
|
9d84ebc4fe | ||
|
|
58b9204395 | ||
|
|
0d662f3a5e | ||
|
|
2e864e5b81 | ||
|
|
40d9713b79 | ||
|
|
68d07fe961 | ||
|
|
6145a25fe2 | ||
|
|
c43f45a472 | ||
|
|
134f1e2ae0 | ||
|
|
55ccd5f3c0 | ||
| 3658733003 | |||
| 0bb0a314ad | |||
| b194b565f6 | |||
|
|
6720a5aeb2 | ||
|
|
a7f60ebed8 | ||
|
|
25062be657 | ||
|
|
9662ff5f8c | ||
|
|
f5c7be932b | ||
|
|
dec0001bd1 | ||
|
|
f628ab6435 | ||
|
|
4c5ee96e36 | ||
|
|
53cf1837b2 | ||
|
|
d83ed7254d | ||
|
|
1ae4bfe325 | ||
|
|
c5139851b8 | ||
|
|
f9baf02b86 | ||
|
|
b67bd201b2 | ||
|
|
79735e23e0 | ||
|
|
df37113d38 | ||
|
|
c7d2eeb3f0 | ||
|
|
4e94d85d7e | ||
|
|
dec6b8139b | ||
|
|
7b7d0c92a8 | ||
|
|
448c3cdcdb | ||
|
|
7e52494880 | ||
|
|
1181b97f94 | ||
|
|
458968ded5 | ||
|
|
23515b8542 | ||
|
|
e4ac5f08e7 | ||
|
|
15ef079eff | ||
|
|
56c3e51657 | ||
|
|
2cc8b1174b | ||
|
|
1fc47888d5 | ||
|
|
d435b2b0e4 | ||
|
|
fed427dc4a | ||
|
|
cf78ab2f8e | ||
|
|
c8883d0e40 | ||
|
|
7154092547 | ||
|
|
ada3a3ccaf | ||
|
|
8cf3a2a726 | ||
|
|
553e2f8898 | ||
|
|
4a7349543a | ||
|
|
f15e004645 | ||
|
|
b137e3e72d | ||
|
|
4c8a23ff14 | ||
|
|
d7d225af77 | ||
|
|
4358997482 | ||
|
|
7c2e75facc | ||
|
|
7b05b9d5a0 | ||
|
|
20edc0474c | ||
|
|
fa191b5c05 | ||
|
|
2139d600f5 | ||
|
|
68e4ff4121 | ||
|
|
0a1d709c5f | ||
|
|
8a00d66435 | ||
| d2ad623bb8 | |||
|
|
00a8731cdd | ||
|
|
b4e6e4ca2a | ||
| 427c3ea537 | |||
|
|
67004737f6 | ||
|
|
3ced565aa2 | ||
|
|
cd715029eb | ||
| 84f9bbadeb | |||
|
|
457c1d3aee | ||
|
|
c99321e5cf | ||
|
|
f3f8345b03 | ||
| c3b477c609 | |||
|
|
3a67f7820e | ||
|
|
6ce6122384 | ||
|
|
b3e49a9504 | ||
| 2eff1ab14c | |||
|
|
de08ffe989 | ||
| 5ed24cb6eb | |||
|
|
c1406a32f1 | ||
|
|
22e1b25398 | ||
| 6a118589c2 | |||
|
|
0c66f6298b | ||
|
|
0c9973fdff | ||
| 52508e9dea | |||
|
|
cf8d22d81b | ||
|
|
1d42be9882 | ||
|
|
33c738db3b | ||
|
|
62c807b7fe | ||
|
|
82f0f7b82c | ||
|
|
4994d28a20 | ||
|
|
15d91da174 | ||
|
|
ae6d7a5467 | ||
|
|
24a398a0d8 | ||
|
|
e2632a556d | ||
|
|
be741ff9a2 | ||
|
|
4995c3139e | ||
|
|
0a5d4fb950 | ||
|
|
e4303baa40 | ||
|
|
46c8d4553b | ||
|
|
3fc0ec95ef | ||
|
|
510fa5e398 | ||
|
|
75453bed51 | ||
|
|
78e3acaeb7 | ||
|
|
0f4c844002 | ||
|
|
4dba268a04 | ||
|
|
b0cf35cf06 | ||
|
|
0d934a1b44 | ||
|
|
f4bda546a0 | ||
|
|
b7744667f2 | ||
|
|
3d36c26226 | ||
|
|
375fd3893c | ||
|
|
c5d482bead | ||
|
|
31eacb6d06 | ||
|
|
636900110a | ||
|
|
d78ee4397b | ||
|
|
ebdb36b7d0 | ||
|
|
93ff6cfb67 | ||
|
|
ed4c4a52eb | ||
|
|
2ca8428be4 | ||
|
|
6fffc06c28 | ||
|
|
ffcb901376 | ||
|
|
30469e74c9 | ||
|
|
5646e739c2 | ||
|
|
bbbdf8cd09 | ||
|
|
f727429699 | ||
|
|
e268e2dbca | ||
|
|
3de0d2f0fe | ||
|
|
0abbc147e2 | ||
|
|
6210480952 | ||
|
|
e17f4110f1 | ||
|
|
fa46492759 | ||
|
|
3965541879 | ||
|
|
582191d014 | ||
|
|
118100e58d | ||
|
|
2e6cc346ab | ||
|
|
7fc1295dc0 | ||
|
|
0cf4a488bb | ||
|
|
9030a7d031 | ||
|
|
feadf372a0 | ||
|
|
edde9292e6 | ||
|
|
addf5c98db | ||
|
|
c820884765 | ||
|
|
67cd56acc7 | ||
|
|
5afebde382 | ||
|
|
636d61a81b | ||
|
|
3c9e40ca71 | ||
|
|
9f1b8b4215 | ||
|
|
89860403f6 | ||
|
|
6b78557954 | ||
|
|
bc2dd3a98a | ||
|
|
3005782a75 | ||
|
|
8ccc9aba1a | ||
|
|
d21ba8fed2 | ||
|
|
23cbb6be22 | ||
|
|
9260866f47 | ||
|
|
7c8811e439 | ||
|
|
ef592ddd0c | ||
|
|
6c596babcb | ||
|
|
763e9f5708 | ||
|
|
37026bbbb8 | ||
|
|
53ecfee25e | ||
|
|
fa4f8ed661 | ||
|
|
890b811bc1 | ||
|
|
ed91c9bcf6 | ||
|
|
661e8582a2 | ||
|
|
7ee038faaf | ||
|
|
ae1688319e | ||
|
|
7f07180c71 | ||
|
|
1ead1f293f | ||
|
|
a693f07eca | ||
|
|
3ae7c9da0c | ||
|
|
729f5c66d6 | ||
|
|
d40f477397 | ||
|
|
f126634804 | ||
|
|
bdadff787c | ||
|
|
cf78957476 | ||
|
|
f8dad85020 | ||
|
|
5cd330de74 | ||
|
|
06b158bf54 | ||
|
|
3594204214 | ||
|
|
073b6cb45d | ||
|
|
a7e0a66355 | ||
|
|
538adb43a9 | ||
|
|
115476453a | ||
|
|
817ec44439 | ||
| 51e2d50dd0 | |||
|
|
9c26c00eee | ||
|
|
6d16be4669 | ||
|
|
f1032865f3 | ||
|
|
3056311c24 | ||
|
|
e9caa3a1f7 | ||
|
|
58922bee53 | ||
|
|
bbdf1c3e67 | ||
|
|
8536b2ebbd | ||
|
|
4bb988824f | ||
|
|
544b96bc9e | ||
|
|
fe2cdaae83 | ||
|
|
d29169eb39 | ||
|
|
d750d5cee2 | ||
|
|
90f52eae41 | ||
|
|
dacc7d6ff8 | ||
|
|
e9d7b6568c | ||
|
|
b67ac17eef | ||
|
|
6ba89da829 | ||
|
|
de55a4e7ab | ||
|
|
56930fb586 | ||
|
|
fec2b2ccbd | ||
|
|
d4ae74d9a5 | ||
|
|
d754e23922 | ||
|
|
6da686ccea | ||
|
|
df75a0b5f3 | ||
|
|
eb666b2eb3 | ||
|
|
b4c249c489 | ||
|
|
0e9d88eed4 | ||
|
|
dccd000d66 | ||
|
|
1035527278 | ||
|
|
910f890c75 | ||
|
|
f044e8f499 | ||
|
|
ebfa20dde5 | ||
|
|
6c7d696d56 | ||
|
|
e70511a8f8 | ||
|
|
a483c1020f | ||
|
|
29672c066b | ||
|
|
ca6342363a | ||
|
|
f3915c4878 | ||
|
|
251891fbed | ||
|
|
4045cec457 | ||
|
|
92af7d22da | ||
|
|
57dc467f26 | ||
|
|
f75f34cbff | ||
|
|
e42c7b04c1 | ||
|
|
27041a639d | ||
|
|
878bb3843b | ||
|
|
dd54ba9e74 | ||
|
|
f96a7fdb72 | ||
|
|
961727c3f2 | ||
|
|
108dc3104d | ||
|
|
f989fa00d4 | ||
|
|
a53c656077 | ||
|
|
d37473d905 | ||
|
|
b9ae5df8f4 | ||
|
|
f6554c1e53 | ||
|
|
363bc83054 | ||
|
|
2e618bfc80 | ||
|
|
e5eedc17d0 | ||
|
|
5ccc4c5e88 | ||
|
|
2bb290ebe8 | ||
|
|
aa0c91cf76 | ||
|
|
2694db3f28 | ||
|
|
6050773da5 | ||
|
|
0972f2691b | ||
|
|
c1f515ddc4 | ||
|
|
95d875e27c | ||
|
|
d82ce1a48e | ||
|
|
96f2b99dec | ||
|
|
8be1c0e55a | ||
|
|
71940fc99a | ||
|
|
57f4d12808 | ||
|
|
74b2ada2f4 | ||
|
|
31c14fd5e3 | ||
|
|
9812a2ff23 | ||
|
|
a58d283eb0 | ||
|
|
3205fab33b | ||
|
|
4c0eee8da3 | ||
|
|
b38d555791 | ||
|
|
a2d432be49 | ||
|
|
39c8413c46 | ||
|
|
12733cb699 | ||
|
|
ef88584a97 | ||
|
|
d89279842c | ||
|
|
8aedbab0c7 | ||
|
|
a09e25186f | ||
|
|
b7f2841375 | ||
|
|
c6a7e56119 | ||
|
|
52ac6b874e | ||
|
|
16f5410c6f | ||
|
|
9837d3b502 | ||
|
|
0d3b5cda7e | ||
|
|
7206439cec | ||
|
|
99ca003f66 | ||
|
|
0f9ffc4c39 | ||
|
|
a93034a8d7 | ||
|
|
c9a14b6e90 | ||
|
|
f9b62982f6 | ||
|
|
8a22eeaa16 | ||
|
|
dc4169fb90 | ||
|
|
fd83a62a1c | ||
|
|
6d45aaadf8 | ||
|
|
87c7b2f58d | ||
|
|
a25408d4d7 | ||
|
|
0926545fc4 | ||
|
|
70c2dc22cf | ||
|
|
78c01d4561 | ||
|
|
6bb520f822 | ||
|
|
d1aa0dc9f0 | ||
|
|
6b4a5ba0da | ||
|
|
7fae13ff4e | ||
|
|
1bcce359e1 | ||
|
|
41a42c77bb | ||
|
|
ac43ef2243 | ||
|
|
23bae62248 | ||
|
|
c0d0638f2b | ||
|
|
22e4b98229 | ||
|
|
a8577fabc4 | ||
|
|
cd26296969 | ||
|
|
c42585d5d8 | ||
|
|
84c9cdab2f | ||
|
|
2f700f80f7 | ||
|
|
8e6bce7d01 | ||
|
|
2beead7b71 | ||
|
|
37726a8585 | ||
|
|
a08d537fd6 | ||
|
|
63f1155966 | ||
|
|
a47fe9fbce | ||
|
|
5564d397e7 | ||
|
|
36c08fed61 | ||
|
|
1f63267193 | ||
|
|
b1ea7d0916 | ||
|
|
15a3f41765 | ||
|
|
d1e07d376f | ||
|
|
103b907f2a | ||
|
|
f2192806cd | ||
|
|
4b223df330 | ||
|
|
f684ba3a61 | ||
|
|
931c4f7134 | ||
|
|
4ea8968af4 | ||
|
|
3891cb79b4 | ||
|
|
16c97dc329 | ||
|
|
13e1a9497c | ||
|
|
2bde11c612 | ||
|
|
9fd0d7f512 | ||
|
|
ba96db968b | ||
|
|
fbff5d9bd2 | ||
|
|
bdcf813e71 | ||
|
|
8db051d99c | ||
|
|
2d5768f635 | ||
|
|
c4b90b2c12 | ||
|
|
010481e7ca | ||
|
|
be2ae4b429 | ||
|
|
950dd116df | ||
|
|
2772652bc6 | ||
|
|
c607fffacd | ||
|
|
94a9fa9034 | ||
|
|
ff8f1b4c00 | ||
|
|
4a794c8beb | ||
|
|
890f2d3051 | ||
|
|
6aed9afbe5 | ||
|
|
26611676a9 | ||
|
|
80c1bac991 | ||
|
|
2bce127065 | ||
|
|
71292635ce | ||
|
|
c6f6822781 | ||
|
|
cdf10e079d | ||
|
|
750f2463a2 | ||
|
|
f1a0076cc0 | ||
|
|
b4d25620ed | ||
|
|
a9371e4307 | ||
|
|
145ea1c53b | ||
|
|
434a6fecc9 | ||
|
|
1e0684e9b2 | ||
|
|
dce99543d2 | ||
|
|
f4e1117757 | ||
|
|
ff19e7da35 | ||
|
|
056de96159 | ||
|
|
79f995af10 | ||
|
|
2bd62b8a4f | ||
|
|
909c547e0e | ||
|
|
54a9731bdc | ||
|
|
973314774a | ||
|
|
e5256c89a1 | ||
|
|
00a8878146 | ||
|
|
7d5a34edb7 | ||
|
|
9d26ce6054 | ||
|
|
63abfdaadc | ||
|
|
54ae412f60 | ||
|
|
74747524a4 | ||
|
|
83ca262b75 | ||
|
|
79e7f9d243 | ||
|
|
1f3c18f898 | ||
|
|
fb52db1253 | ||
|
|
2e5a9bd36c | ||
|
|
f6bbb08b26 | ||
|
|
98335411af | ||
|
|
00bf2eba38 | ||
|
|
273bf5e5fa | ||
|
|
2d18de57c9 | ||
|
|
4483413abf | ||
|
|
9572b062f1 | ||
|
|
92da39ed84 | ||
|
|
3775f4cb52 | ||
|
|
c2c42706c7 | ||
|
|
9703a72e6c | ||
|
|
a40267e490 | ||
|
|
cdb5db6c68 | ||
|
|
ff20721dee | ||
|
|
4a537d6b19 | ||
|
|
5f3529439a | ||
|
|
48c8bb8a5f | ||
|
|
023810df1e | ||
|
|
ad3b571bba | ||
|
|
9686e304c2 | ||
|
|
ea0b3050e4 | ||
|
|
21343cdf23 | ||
|
|
6ba7254344 | ||
|
|
b2955fb695 | ||
| 5d2888e038 | |||
|
|
3668555421 | ||
|
|
54a8f7f8e9 | ||
|
|
f8f0951bd5 | ||
| c3c1efe5f1 | |||
|
|
e5363913ec | ||
|
|
4d4d5793bb | ||
|
|
9adde3cd89 | ||
|
|
440a191138 | ||
|
|
1873f50f7f | ||
|
|
a4f2047bcc | ||
|
|
09680557ef | ||
|
|
8fcf653cb0 | ||
|
|
a7a80f8c16 | ||
|
|
03d478840b | ||
|
|
6a6a1c4353 | ||
|
|
b57afb9ad2 | ||
|
|
59bc81d353 | ||
|
|
33300e4ad9 | ||
|
|
fe1451f570 | ||
|
|
f2ec81547b | ||
|
|
7e430998b8 | ||
|
|
156afa14a2 | ||
|
|
91f70e652d | ||
|
|
9652894aa4 | ||
|
|
e5d953dee8 | ||
|
|
ba5bd9cb11 | ||
|
|
83565c6bb5 | ||
|
|
a91a3e1f61 | ||
|
|
c523721ce8 | ||
|
|
ad69d7cb83 | ||
|
|
8d27c82e6d | ||
|
|
4eb5eba347 | ||
|
|
47c5f77c81 | ||
|
|
a36f25cfc3 | ||
|
|
c9ac83b2ba | ||
|
|
e4df17f308 | ||
|
|
2eade2b78f | ||
|
|
334b507476 | ||
|
|
59349dfe93 | ||
|
|
56e55ff488 | ||
|
|
ecb930e5f9 | ||
|
|
8b109349c2 | ||
|
|
ebd0f671f9 | ||
|
|
83f022ff4b | ||
|
|
80ccc0f3c6 | ||
|
|
eccecf35e3 | ||
|
|
16f69fff33 | ||
|
|
bb374bf2cd | ||
|
|
1a28e3114d | ||
|
|
915ad9f5c6 | ||
|
|
143622bf27 | ||
|
|
a3906976e8 | ||
|
|
b017da22c3 | ||
|
|
fea837b345 | ||
|
|
a364e3f69b | ||
|
|
7ca44d7df1 |
@@ -414,7 +414,7 @@ Never Kafka for teams under 10 or <100k events/day. Never gRPC inside a monolith
|
|||||||
|
|
||||||
| PR contains | Required doc update |
|
| PR contains | Required doc update |
|
||||||
|---|---|
|
|---|---|
|
||||||
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` |
|
| New Flyway migration adding/removing/renaming a table or column | `docs/architecture/db/db-orm.puml` and `docs/architecture/db/db-relationships.puml` — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
|
||||||
| New `@ManyToMany` join table or FK | Both DB diagrams |
|
| New `@ManyToMany` join table or FK | Both DB diagrams |
|
||||||
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
|
| New backend package or domain module | `CLAUDE.md` package table + matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||||
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
|
| New controller or service in an existing backend domain | Matching `docs/architecture/c4/l3-backend-*.puml` |
|
||||||
|
|||||||
@@ -984,7 +984,7 @@ Mark with `@pytest.mark.asyncio` so pytest runs the coroutine. Without it, the t
|
|||||||
|
|
||||||
| What changed in code | Doc(s) to update |
|
| What changed in code | Doc(s) to update |
|
||||||
|---|---|
|
|---|---|
|
||||||
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) |
|
| New Flyway migration adds/removes/renames a table or column | `docs/architecture/db/db-orm.puml` (add/remove entity or attribute) **and** `docs/architecture/db/db-relationships.puml` (add/remove relationship line) — **except** framework-owned tables (e.g. Spring Session JDBC's `spring_session*`, Flyway's `flyway_schema_history`), which are opaque to app code; reference the relevant ADR if an exclusion is load-bearing |
|
||||||
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
|
| New `@ManyToMany` join table or FK relationship | Both DB diagrams above |
|
||||||
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
|
| New backend package / domain module | `CLAUDE.md` (package structure table) **and** the matching `docs/architecture/c4/l3-backend-*.puml` diagram for that domain |
|
||||||
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
|
| New Spring Boot controller or service in an existing domain | The matching `docs/architecture/c4/l3-backend-*.puml` for that domain |
|
||||||
|
|||||||
40
.env.example
40
.env.example
@@ -26,6 +26,46 @@ PORT_MAILPIT_SMTP=1025
|
|||||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
OCR_TRAINING_TOKEN=change-me-in-production
|
OCR_TRAINING_TOKEN=change-me-in-production
|
||||||
|
|
||||||
|
# --- Observability ---
|
||||||
|
# Optional stack — start with: docker compose -f docker-compose.observability.yml up -d
|
||||||
|
# Requires the main stack to already be running (docker compose up -d creates archiv-net).
|
||||||
|
# In production the stack is managed from /opt/familienarchiv/ (see docs/DEPLOYMENT.md §4).
|
||||||
|
|
||||||
|
# Ports for host access
|
||||||
|
PORT_GRAFANA=3003
|
||||||
|
PORT_GLITCHTIP=3002
|
||||||
|
PORT_PROMETHEUS=9090
|
||||||
|
|
||||||
|
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||||
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||||
|
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||||
|
|
||||||
|
# GlitchTip secret key — Django SECRET_KEY equivalent, used to sign sessions and tokens.
|
||||||
|
# REQUIRED in production — must not be empty or 'changeme'. Fail-closed: GlitchTip will
|
||||||
|
# refuse to start with an invalid key.
|
||||||
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(50))"
|
||||||
|
GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
|
||||||
|
|
||||||
|
# PostgreSQL hostname for GlitchTip's db-init job and workers.
|
||||||
|
# Override when only the staging stack is running (container name differs from archive-db).
|
||||||
|
# Default (archive-db) is correct for production with the full stack up.
|
||||||
|
POSTGRES_HOST=archive-db
|
||||||
|
|
||||||
|
# $$ escaping note: passwords in /opt/familienarchiv/.env that contain a literal '$' must
|
||||||
|
# use '$$' so Docker Compose does not expand them as variable references.
|
||||||
|
# Example: a password 'p@$$word' should be written as 'p@$$$$word' in the .env file.
|
||||||
|
|
||||||
|
# Error reporting DSNs — leave empty to disable the SDK (safe default).
|
||||||
|
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE=
|
||||||
|
# VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite
|
||||||
|
VITE_SENTRY_DSN=
|
||||||
|
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||||
|
SENTRY_AUTH_TOKEN=
|
||||||
|
|
||||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||||
# APP_BASE_URL=https://your-domain.example.com
|
# APP_BASE_URL=https://your-domain.example.com
|
||||||
# MAIL_HOST=smtp.example.com
|
# MAIL_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -12,7 +13,7 @@ jobs:
|
|||||||
name: Unit & Component Tests
|
name: Unit & Component Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -28,33 +29,133 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Security audit (no dev deps)
|
||||||
|
run: npm audit --audit-level=high --omit=dev
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Compile Paraglide i18n
|
- name: Compile Paraglide i18n
|
||||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Sync SvelteKit
|
||||||
|
run: npx svelte-kit sync
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Run unit and component tests
|
- name: Assert no banned vi.mock patterns
|
||||||
run: npm test
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Literal pdfjs-dist (libLoader pattern — ADR 012)
|
||||||
|
if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then
|
||||||
|
echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Async factory with dynamic import in body (named mechanism — ADR 012 / #553).
|
||||||
|
# Multiline PCRE matches `vi.mock(<arg>, async ... { ... await import(...) ... })`
|
||||||
|
# across line breaks. __meta__ is excluded because it contains fixture strings
|
||||||
|
# demonstrating the very pattern this check is meant to forbid.
|
||||||
|
if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \
|
||||||
|
--include='*.spec.ts' --include='*.test.ts' \
|
||||||
|
--exclude-dir='__meta__' \
|
||||||
|
frontend/src/; then
|
||||||
|
echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Assert no (upload|download)-artifact past v3
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Self-test: verify the regex catches v4+ and does not catch v3.
|
||||||
|
tmp=$(mktemp)
|
||||||
|
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
|
||||||
|
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
|
||||||
|
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
|
||||||
|
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
|
||||||
|
rm "$tmp"
|
||||||
|
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
|
||||||
|
# Both upload-artifact and download-artifact share the same incompatibility.
|
||||||
|
# Pin to @v3. See ADR-014 / #557.
|
||||||
|
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
|
||||||
|
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run unit and component tests with coverage
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eo pipefail
|
||||||
|
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
env:
|
env:
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
# Diagnostic guard: covers the coverage run only. If `npm test` (above)
|
||||||
|
# exits 1 with a birpc error, the named pattern appears here — not there.
|
||||||
|
- name: Assert no birpc teardown race in coverage run
|
||||||
|
shell: bash
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then
|
||||||
|
echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run"
|
||||||
|
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
- name: Upload coverage reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage-reports
|
||||||
|
path: |
|
||||||
|
frontend/coverage/
|
||||||
|
/tmp/coverage-test-${{ github.run_id }}.log
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
# ── Prerender output is exactly the public help page ───────────────────
|
||||||
|
# SvelteKit prerender + crawl follows nav links and bakes "redirect to
|
||||||
|
# /login" HTML for every protected route, served BEFORE runtime hooks
|
||||||
|
# (see #514). With `crawl: false` only the explicit entry should land
|
||||||
|
# in build/prerendered/. Anything else is a regression — fail the build.
|
||||||
|
- name: Assert prerender output is only /hilfe/transkription
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
set -e
|
||||||
|
extra=$(find build/prerendered -type f \
|
||||||
|
-not -path 'build/prerendered/hilfe/*' \
|
||||||
|
-not -name '*.br' -not -name '*.gz' \
|
||||||
|
|| true)
|
||||||
|
if [ -n "$extra" ]; then
|
||||||
|
echo "FAIL: unexpected prerendered files (would shadow runtime hooks):"
|
||||||
|
echo "$extra"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# And the help page must still be there.
|
||||||
|
test -f build/prerendered/hilfe/transkription.html \
|
||||||
|
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
||||||
|
echo "PASS: only /hilfe/transkription.html prerendered."
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: unit-test-screenshots
|
name: unit-test-screenshots
|
||||||
path: frontend/test-results/screenshots/
|
path: frontend/test-results/screenshots/
|
||||||
|
|
||||||
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
# ─── OCR Service Unit Tests ───────────────────────────────────────────────────
|
||||||
# Only spell_check.py, test_confidence.py, test_sender_registry.py — no ML stack required.
|
# Only stdlib/lightweight tests — no ML stack (PyTorch/Surya/Kraken) required.
|
||||||
|
# test_tmpdir.py covers the TMPDIR env var and entrypoint mkdir behaviour (ADR-021).
|
||||||
|
# test_tmpdir_is_inside_persistent_cache_volume is skipped in CI (TMPDIR not
|
||||||
|
# set to /app/cache here); it runs inside the deployed Docker container.
|
||||||
ocr-tests:
|
ocr-tests:
|
||||||
name: OCR Service Tests
|
name: OCR Service Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -66,11 +167,11 @@ jobs:
|
|||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
run: pip install "pyspellchecker==0.9.0" pytest pytest-asyncio
|
run: pip install "pyspellchecker==0.9.0" "fastapi==0.115.6" pytest pytest-asyncio
|
||||||
working-directory: ocr-service
|
working-directory: ocr-service
|
||||||
|
|
||||||
- name: Run OCR unit tests (no ML stack required)
|
- name: Run OCR unit tests (no ML stack required)
|
||||||
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v
|
run: python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py test_tmpdir.py -v
|
||||||
working-directory: ocr-service
|
working-directory: ocr-service
|
||||||
|
|
||||||
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
# ─── Backend Unit & Slice Tests ───────────────────────────────────────────────
|
||||||
@@ -100,5 +201,155 @@ jobs:
|
|||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: |
|
run: |
|
||||||
chmod +x mvnw
|
chmod +x mvnw
|
||||||
./mvnw clean test
|
./mvnw clean verify
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
|
- name: Upload surefire reports
|
||||||
|
if: always()
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: surefire-reports
|
||||||
|
path: backend/target/surefire-reports/
|
||||||
|
|
||||||
|
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
||||||
|
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
||||||
|
# the JSON keys would silently break it (fail2ban-regex would return
|
||||||
|
# "0 matches", fail2ban would stop banning, no error surface). This job
|
||||||
|
# pins the contract against a deterministic sample line.
|
||||||
|
fail2ban-regex:
|
||||||
|
name: fail2ban Regex
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install fail2ban
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y fail2ban
|
||||||
|
|
||||||
|
- name: Matches /api/auth/login 401
|
||||||
|
run: |
|
||||||
|
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":401}' > /tmp/sample.log
|
||||||
|
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -qE '1 matched' \
|
||||||
|
|| { echo "expected 1 match for /api/auth/login 401"; exit 1; }
|
||||||
|
|
||||||
|
- name: Matches /api/auth/login 429
|
||||||
|
run: |
|
||||||
|
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":429}' > /tmp/sample.log
|
||||||
|
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -qE '1 matched' \
|
||||||
|
|| { echo "expected 1 match for /api/auth/login 429"; exit 1; }
|
||||||
|
|
||||||
|
- name: Matches /api/auth/forgot-password 401
|
||||||
|
run: |
|
||||||
|
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/forgot-password"},"status":401}' > /tmp/sample.log
|
||||||
|
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -qE '1 matched' \
|
||||||
|
|| { echo "expected 1 match for /api/auth/forgot-password 401"; exit 1; }
|
||||||
|
|
||||||
|
- name: Does not match /api/auth/login 200
|
||||||
|
run: |
|
||||||
|
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"POST","host":"archiv.raddatz.cloud","uri":"/api/auth/login"},"status":200}' > /tmp/sample.log
|
||||||
|
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -qE '0 matched' \
|
||||||
|
|| { echo "expected 0 matches for /api/auth/login 200"; exit 1; }
|
||||||
|
|
||||||
|
- name: Does not match /api/documents (unrelated 401)
|
||||||
|
run: |
|
||||||
|
echo '{"level":"info","ts":1700000000.12,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"203.0.113.42","method":"GET","host":"archiv.raddatz.cloud","uri":"/api/documents"},"status":401}' > /tmp/sample.log
|
||||||
|
out=$(fail2ban-regex /tmp/sample.log infra/fail2ban/filter.d/familienarchiv-auth.conf)
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -qE '0 matched' \
|
||||||
|
|| { echo "expected 0 matches for /api/documents 401"; exit 1; }
|
||||||
|
|
||||||
|
# ── Backend resolves to file-polling, not systemd ─────────────────────
|
||||||
|
# The Debian/Ubuntu fail2ban package ships defaults-debian.conf with
|
||||||
|
# `[DEFAULT] backend = systemd`. Without `backend = polling` in our
|
||||||
|
# jail, the daemon loads the jail but reads from journald and never
|
||||||
|
# touches /var/log/caddy/access.log — i.e. the regex above passes in
|
||||||
|
# isolation while the live jail is inert. See issue #503.
|
||||||
|
- name: Jail resolves with polling backend (not inherited systemd)
|
||||||
|
run: |
|
||||||
|
sudo ln -sfn "$PWD/infra/fail2ban/jail.d/familienarchiv.conf" /etc/fail2ban/jail.d/familienarchiv.conf
|
||||||
|
sudo ln -sfn "$PWD/infra/fail2ban/filter.d/familienarchiv-auth.conf" /etc/fail2ban/filter.d/familienarchiv-auth.conf
|
||||||
|
dump=$(sudo fail2ban-client -d 2>&1)
|
||||||
|
echo "$dump" | grep -E "add.*familienarchiv-auth" || true
|
||||||
|
echo "$dump" | grep -qE "\['add', 'familienarchiv-auth', 'polling'\]" \
|
||||||
|
|| { echo "FAIL: familienarchiv-auth jail did not resolve to 'polling' backend"; exit 1; }
|
||||||
|
|
||||||
|
# ─── Semgrep Security Scan ───────────────────────────────────────────────────
|
||||||
|
# Catches XXE-unprotected XML parser factories and similar patterns defined in
|
||||||
|
# .semgrep/security.yml. Runs in parallel with backend-unit-tests for fast feedback.
|
||||||
|
# Uses local rules only (no SEMGREP_APP_TOKEN / OIDC — act_runner does not support it).
|
||||||
|
semgrep-scan:
|
||||||
|
name: Semgrep Security Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: 'pip'
|
||||||
|
|
||||||
|
- name: Install Semgrep
|
||||||
|
run: pip install semgrep==1.163.0
|
||||||
|
|
||||||
|
- name: Run security rules
|
||||||
|
run: semgrep --config .semgrep/security.yml --error --metrics=off backend/src/
|
||||||
|
|
||||||
|
# ─── Compose Bucket-Bootstrap Idempotency ─────────────────────────────────────
|
||||||
|
# docker-compose.prod.yml's create-buckets service runs on every
|
||||||
|
# `docker compose up` (one-shot, no restart). Must be idempotent — a
|
||||||
|
# re-deploy must not fail just because the bucket / user / policy
|
||||||
|
# already exists. Validated by running create-buckets twice against a
|
||||||
|
# throwaway minio stack and asserting both invocations exit 0.
|
||||||
|
compose-idempotency:
|
||||||
|
name: Compose Bucket Idempotency
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Write stub env file
|
||||||
|
run: |
|
||||||
|
cat > .env.test <<'EOF'
|
||||||
|
TAG=test
|
||||||
|
PORT_BACKEND=18080
|
||||||
|
PORT_FRONTEND=13000
|
||||||
|
APP_DOMAIN=localhost
|
||||||
|
POSTGRES_PASSWORD=stub
|
||||||
|
MINIO_PASSWORD=stubrootpassword
|
||||||
|
MINIO_APP_PASSWORD=stubapppassword
|
||||||
|
OCR_TRAINING_TOKEN=stub
|
||||||
|
APP_ADMIN_USERNAME=admin@local
|
||||||
|
APP_ADMIN_PASSWORD=stub
|
||||||
|
MAIL_HOST=mailpit
|
||||||
|
MAIL_PORT=1025
|
||||||
|
APP_MAIL_FROM=noreply@local
|
||||||
|
IMPORT_HOST_DIR=/tmp/dummy-import
|
||||||
|
COMPOSE_NETWORK_NAME=test-idem-archiv-net
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Bring up minio
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test up -d --wait minio
|
||||||
|
|
||||||
|
- name: First create-buckets run
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets
|
||||||
|
|
||||||
|
- name: Second create-buckets run (idempotency check)
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test run --rm create-buckets
|
||||||
|
|
||||||
|
- name: Teardown
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose.prod.yml -p test-idem --env-file .env.test down -v
|
||||||
|
rm -f .env.test
|
||||||
65
.gitea/workflows/coverage-flake-probe.yml
Normal file
65
.gitea/workflows/coverage-flake-probe.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: Coverage Flake Probe
|
||||||
|
|
||||||
|
# Manually-triggered probe for the birpc teardown race documented in ADR 012
|
||||||
|
# / #553. Runs the full coverage suite 20× in parallel against a single SHA
|
||||||
|
# and asserts zero `[birpc] rpc is closed` lines across every cell. Verifies
|
||||||
|
# the acceptance criterion that the race no longer surfaces under coverage.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
coverage-flake-probe:
|
||||||
|
name: Coverage flake probe (run ${{ matrix.run }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
run: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: node-modules-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: frontend/node_modules
|
||||||
|
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Compile Paraglide i18n
|
||||||
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Run unit and component tests with coverage
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eo pipefail
|
||||||
|
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
- name: Assert no birpc teardown race
|
||||||
|
shell: bash
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log 2>/dev/null; then
|
||||||
|
echo "FAIL: [birpc] rpc is closed teardown race detected in run ${{ matrix.run }}"
|
||||||
|
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||||
|
- name: Upload coverage log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage-log-run-${{ matrix.run }}
|
||||||
|
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||||
280
.gitea/workflows/nightly.yml
Normal file
280
.gitea/workflows/nightly.yml
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
name: nightly
|
||||||
|
|
||||||
|
# Builds and deploys the staging environment from main every night.
|
||||||
|
# Runs on the self-hosted runner using Docker-out-of-Docker (the docker
|
||||||
|
# socket is mounted in), so `docker compose build` produces images on
|
||||||
|
# the host daemon and `docker compose up` consumes them directly — no
|
||||||
|
# registry hop.
|
||||||
|
#
|
||||||
|
# Operational assumptions (see docs/DEPLOYMENT.md §3 for the full setup):
|
||||||
|
#
|
||||||
|
# 1. Single-tenant self-hosted runner. The "Write staging env file" step
|
||||||
|
# writes every secret to .env.staging on the runner filesystem; the
|
||||||
|
# `if: always()` cleanup step removes it. A multi-tenant runner
|
||||||
|
# would need to switch to docker compose --env-file <(stdin) instead.
|
||||||
|
#
|
||||||
|
# 2. Host docker layer cache is authoritative. There is no
|
||||||
|
# actions/cache; we rely on the host daemon to keep Maven and npm
|
||||||
|
# layers warm between runs. A `docker system prune` on the host
|
||||||
|
# will cause the next nightly build to be cold (5–10 min slower).
|
||||||
|
#
|
||||||
|
# Staging environment isolation:
|
||||||
|
# - project name: archiv-staging
|
||||||
|
# - host ports: backend 8081, frontend 3001
|
||||||
|
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
||||||
|
#
|
||||||
|
# Required Gitea secrets:
|
||||||
|
# STAGING_POSTGRES_PASSWORD
|
||||||
|
# STAGING_MINIO_PASSWORD
|
||||||
|
# STAGING_MINIO_APP_PASSWORD
|
||||||
|
# STAGING_OCR_TRAINING_TOKEN
|
||||||
|
# STAGING_APP_ADMIN_USERNAME
|
||||||
|
# STAGING_APP_ADMIN_PASSWORD
|
||||||
|
# GRAFANA_ADMIN_PASSWORD
|
||||||
|
# GLITCHTIP_SECRET_KEY
|
||||||
|
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Ensures the backend Dockerfile's `RUN --mount=type=cache` lines are
|
||||||
|
# honoured (Maven cache survives between runs).
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-staging:
|
||||||
|
# `ubuntu-latest` matches our self-hosted runner's advertised label
|
||||||
|
# (the runner has labels: ubuntu-latest / ubuntu-24.04 / ubuntu-22.04).
|
||||||
|
# `self-hosted` would never match — no runner advertises it — so the
|
||||||
|
# job parks in the queue forever. ADR-011's "single-tenant" promise
|
||||||
|
# is at the repo level; sharing this runner between CI and deploys
|
||||||
|
# for the same repo is within that boundary.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Write staging env file
|
||||||
|
run: |
|
||||||
|
cat > .env.staging <<EOF
|
||||||
|
TAG=nightly
|
||||||
|
PORT_BACKEND=8081
|
||||||
|
PORT_FRONTEND=3001
|
||||||
|
APP_DOMAIN=staging.raddatz.cloud
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||||
|
MINIO_PASSWORD=${{ secrets.STAGING_MINIO_PASSWORD }}
|
||||||
|
MINIO_APP_PASSWORD=${{ secrets.STAGING_MINIO_APP_PASSWORD }}
|
||||||
|
OCR_TRAINING_TOKEN=${{ secrets.STAGING_OCR_TRAINING_TOKEN }}
|
||||||
|
APP_ADMIN_USERNAME=${{ secrets.STAGING_APP_ADMIN_USERNAME }}
|
||||||
|
APP_ADMIN_PASSWORD=${{ secrets.STAGING_APP_ADMIN_PASSWORD }}
|
||||||
|
MAIL_HOST=mailpit
|
||||||
|
MAIL_PORT=1025
|
||||||
|
MAIL_USERNAME=
|
||||||
|
MAIL_PASSWORD=
|
||||||
|
MAIL_SMTP_AUTH=false
|
||||||
|
MAIL_STARTTLS_ENABLE=false
|
||||||
|
APP_MAIL_FROM=noreply@staging.raddatz.cloud
|
||||||
|
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||||
|
POSTGRES_USER=archiv
|
||||||
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Verify backend /import:ro mount is wired
|
||||||
|
# Regression guard for #526: the /admin/system mass-import card
|
||||||
|
# only works when the backend service mounts the host import
|
||||||
|
# payload at /import (read-only). If a future "compose cleanup"
|
||||||
|
# PR drops the volumes block, mass import silently breaks again.
|
||||||
|
# `compose config` renders both shorthand and longform mounts as
|
||||||
|
# `target: /import` + `read_only: true`, so we assert against
|
||||||
|
# the rendered form rather than the raw source YAML.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-staging \
|
||||||
|
--env-file .env.staging \
|
||||||
|
--profile staging \
|
||||||
|
config > /tmp/compose-rendered.yml
|
||||||
|
grep -q '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|
||||||
|
|| { echo "::error::backend is missing the /import bind mount (see #526)"; exit 1; }
|
||||||
|
grep -A2 '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|
||||||
|
| grep -q 'read_only: true' \
|
||||||
|
|| { echo "::error::backend /import mount is not read-only (see #526)"; exit 1; }
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
# `--pull` forces re-fetching pinned base images so a CVE
|
||||||
|
# re-publication of the same tag (e.g. node:20.19.0-alpine3.21,
|
||||||
|
# postgres:16-alpine) is picked up instead of being served
|
||||||
|
# from the host's stale Docker layer cache.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-staging \
|
||||||
|
--env-file .env.staging \
|
||||||
|
--profile staging \
|
||||||
|
build --pull
|
||||||
|
|
||||||
|
- name: Deploy staging
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-staging \
|
||||||
|
--env-file .env.staging \
|
||||||
|
--profile staging \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Deploy observability configs
|
||||||
|
# Copies the compose file and config tree from the workspace checkout
|
||||||
|
# into /opt/familienarchiv/ — the permanent location that persists
|
||||||
|
# between CI runs. Containers started in the next step bind-mount
|
||||||
|
# from there, so a future workspace wipe cannot corrupt a running
|
||||||
|
# config file.
|
||||||
|
#
|
||||||
|
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||||
|
# Gitea is always the single source of truth for secret rotation.
|
||||||
|
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||||
|
run: |
|
||||||
|
rm -rf /opt/familienarchiv/infra/observability
|
||||||
|
mkdir -p /opt/familienarchiv/infra/observability
|
||||||
|
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||||
|
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||||
|
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||||
|
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_HOST=archiv-staging-db-1
|
||||||
|
EOF
|
||||||
|
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||||
|
# and service name (db). A project rename requires updating this value.
|
||||||
|
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||||
|
|
||||||
|
- name: Validate observability compose config
|
||||||
|
# Dry-run: resolves all variable substitutions and reports any missing
|
||||||
|
# required keys before containers start. Catches undefined variables and
|
||||||
|
# YAML errors in config files updated by the previous step.
|
||||||
|
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||||
|
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||||
|
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
config --quiet
|
||||||
|
|
||||||
|
- name: Start observability stack
|
||||||
|
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||||
|
# that survive workspace wipes between nightly runs (see ADR-016).
|
||||||
|
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||||
|
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||||
|
# obs-secrets.env second — later file wins on duplicate keys.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Assert observability stack health
|
||||||
|
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||||
|
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||||
|
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||||
|
# This step explicitly asserts the five healthchecked critical services are
|
||||||
|
# healthy before the smoke test proceeds.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
unhealthy=""
|
||||||
|
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||||
|
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||||
|
if [ "$status" != "healthy" ]; then
|
||||||
|
echo "::error::$svc is not healthy (status: $status)"
|
||||||
|
unhealthy="$unhealthy $svc"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -z "$unhealthy" ] || exit 1
|
||||||
|
echo "All critical observability services are healthy"
|
||||||
|
|
||||||
|
- name: Reload Caddy
|
||||||
|
# Apply any committed Caddyfile changes before smoke-testing the
|
||||||
|
# public surface. Without this step, a Caddyfile edit lands in the
|
||||||
|
# repo but Caddy keeps serving the previous config until someone
|
||||||
|
# reloads it manually — the smoke test would then catch a stale
|
||||||
|
# header or a still-proxied /actuator route rather than confirming
|
||||||
|
# the current config is live.
|
||||||
|
#
|
||||||
|
# The runner executes job steps inside Docker containers (DooD).
|
||||||
|
# `systemctl` is not present in container images and cannot reach
|
||||||
|
# the host's systemd directly. We use the Docker socket (mounted
|
||||||
|
# into every job container via runner-config.yaml) to spin up a
|
||||||
|
# privileged sibling container in the host PID namespace; nsenter
|
||||||
|
# then enters the host's namespaces so systemctl talks to the real
|
||||||
|
# host systemd daemon. No sudoers entry is required — the Docker
|
||||||
|
# socket already grants root-equivalent host access.
|
||||||
|
#
|
||||||
|
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
||||||
|
# tooling, and the digest is pinned so any upstream change requires
|
||||||
|
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
||||||
|
# at run time; apk add takes ~1 s on the warm VPS cache.
|
||||||
|
#
|
||||||
|
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
||||||
|
# config in-process without dropping TLS connections. `restart`
|
||||||
|
# would briefly stop the service, losing in-flight requests.
|
||||||
|
#
|
||||||
|
# If Caddy is not running this step fails fast before the smoke test
|
||||||
|
# issues a misleading "port 443 refused" error.
|
||||||
|
run: |
|
||||||
|
docker run --rm --privileged --pid=host \
|
||||||
|
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||||
|
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||||
|
|
||||||
|
- name: Smoke test deployed environment
|
||||||
|
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
||||||
|
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||||
|
# header dropped, /actuator block bypassed.
|
||||||
|
#
|
||||||
|
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
|
||||||
|
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||||
|
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||||
|
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||||
|
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||||
|
# and is therefore reachable from the container via that IP.
|
||||||
|
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||||
|
#
|
||||||
|
# Gateway detection reads /proc/net/route (always present, no package
|
||||||
|
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||||
|
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||||
|
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
HOST="staging.raddatz.cloud"
|
||||||
|
URL="https://$HOST"
|
||||||
|
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||||
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
|
# fail this check rather than pass it silently.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
|
# header now fails the smoke step.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
- name: Cleanup env file
|
||||||
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
|
# single-tenant runner trust model. Every secret in .env.staging
|
||||||
|
# is plain text on the runner filesystem until this step runs.
|
||||||
|
# If a future refactor drops `if: always()`, a failed deploy
|
||||||
|
# leaves the env-file behind. Do not remove this conditional
|
||||||
|
# without first re-evaluating ADR-011.
|
||||||
|
if: always()
|
||||||
|
run: rm -f .env.staging
|
||||||
220
.gitea/workflows/release.yml
Normal file
220
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
# Builds and deploys the production environment on `v*` tag push.
|
||||||
|
# Runs on the self-hosted runner via Docker-out-of-Docker; images are
|
||||||
|
# tagged with the actual git tag (e.g. v1.0.0) so rollback is
|
||||||
|
# `TAG=<previous> docker compose -f docker-compose.prod.yml -p archiv-production up -d --wait`
|
||||||
|
#
|
||||||
|
# Operational assumptions (see docs/DEPLOYMENT.md §3 for the full setup):
|
||||||
|
#
|
||||||
|
# 1. Single-tenant self-hosted runner. The "Write production env file"
|
||||||
|
# step writes every secret to .env.production on the runner
|
||||||
|
# filesystem; the `if: always()` cleanup step removes it. A
|
||||||
|
# multi-tenant runner would need to switch to
|
||||||
|
# `docker compose --env-file <(stdin)` instead.
|
||||||
|
#
|
||||||
|
# 2. Host docker layer cache is authoritative. There is no
|
||||||
|
# actions/cache; we rely on the host daemon to keep Maven and npm
|
||||||
|
# layers warm between runs. A `docker system prune` on the host
|
||||||
|
# will cause the next release build to be cold (5–10 min slower).
|
||||||
|
#
|
||||||
|
# Production environment:
|
||||||
|
# - project name: archiv-production
|
||||||
|
# - host ports: backend 8080, frontend 3000
|
||||||
|
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
||||||
|
#
|
||||||
|
# Required Gitea secrets:
|
||||||
|
# PROD_POSTGRES_PASSWORD
|
||||||
|
# PROD_MINIO_PASSWORD
|
||||||
|
# PROD_MINIO_APP_PASSWORD
|
||||||
|
# PROD_OCR_TRAINING_TOKEN
|
||||||
|
# PROD_APP_ADMIN_USERNAME (CRITICAL: see docs/DEPLOYMENT.md)
|
||||||
|
# PROD_APP_ADMIN_PASSWORD (CRITICAL: locked in on first deploy)
|
||||||
|
# MAIL_HOST
|
||||||
|
# MAIL_PORT
|
||||||
|
# MAIL_USERNAME
|
||||||
|
# MAIL_PASSWORD
|
||||||
|
# GRAFANA_ADMIN_PASSWORD
|
||||||
|
# GLITCHTIP_SECRET_KEY
|
||||||
|
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-production:
|
||||||
|
# See nightly.yml — same rationale: `ubuntu-latest` matches the
|
||||||
|
# advertised label of our single-tenant self-hosted runner.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Write production env file
|
||||||
|
run: |
|
||||||
|
cat > .env.production <<EOF
|
||||||
|
TAG=${{ gitea.ref_name }}
|
||||||
|
PORT_BACKEND=8080
|
||||||
|
PORT_FRONTEND=3000
|
||||||
|
APP_DOMAIN=archiv.raddatz.cloud
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
|
MINIO_PASSWORD=${{ secrets.PROD_MINIO_PASSWORD }}
|
||||||
|
MINIO_APP_PASSWORD=${{ secrets.PROD_MINIO_APP_PASSWORD }}
|
||||||
|
OCR_TRAINING_TOKEN=${{ secrets.PROD_OCR_TRAINING_TOKEN }}
|
||||||
|
APP_ADMIN_USERNAME=${{ secrets.PROD_APP_ADMIN_USERNAME }}
|
||||||
|
APP_ADMIN_PASSWORD=${{ secrets.PROD_APP_ADMIN_PASSWORD }}
|
||||||
|
MAIL_HOST=${{ secrets.MAIL_HOST }}
|
||||||
|
MAIL_PORT=${{ secrets.MAIL_PORT }}
|
||||||
|
MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}
|
||||||
|
MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}
|
||||||
|
MAIL_SMTP_AUTH=true
|
||||||
|
MAIL_STARTTLS_ENABLE=true
|
||||||
|
APP_MAIL_FROM=noreply@raddatz.cloud
|
||||||
|
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||||
|
POSTGRES_USER=archiv
|
||||||
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
# `--pull` forces re-fetching pinned base images so a CVE
|
||||||
|
# re-publication of the same tag is picked up rather than served
|
||||||
|
# from the host's stale Docker layer cache.
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-production \
|
||||||
|
--env-file .env.production \
|
||||||
|
build --pull
|
||||||
|
|
||||||
|
- name: Deploy production
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f docker-compose.prod.yml \
|
||||||
|
-p archiv-production \
|
||||||
|
--env-file .env.production \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Deploy observability configs
|
||||||
|
# Mirrors the nightly approach: copies obs compose file and config tree
|
||||||
|
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
||||||
|
# then writes obs-secrets.env fresh from Gitea secrets.
|
||||||
|
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||||
|
run: |
|
||||||
|
rm -rf /opt/familienarchiv/infra/observability
|
||||||
|
mkdir -p /opt/familienarchiv/infra/observability
|
||||||
|
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||||
|
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||||
|
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||||
|
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
|
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_HOST=archiv-production-db-1
|
||||||
|
EOF
|
||||||
|
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||||
|
# and service name (db). A project rename requires updating this value.
|
||||||
|
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||||
|
|
||||||
|
- name: Validate observability compose config
|
||||||
|
# Dry-run: resolves all variable substitutions and reports any missing
|
||||||
|
# required keys before containers start. Catches undefined variables and
|
||||||
|
# YAML errors in config files updated by the previous step.
|
||||||
|
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||||
|
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||||
|
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||||
|
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
config --quiet
|
||||||
|
|
||||||
|
- name: Start observability stack
|
||||||
|
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||||
|
# that survive workspace wipes between runs (see ADR-016).
|
||||||
|
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||||
|
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||||
|
# obs-secrets.env second — later file wins on duplicate keys.
|
||||||
|
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||||
|
run: |
|
||||||
|
docker compose \
|
||||||
|
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||||
|
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||||
|
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||||
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
|
- name: Assert observability stack health
|
||||||
|
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||||
|
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||||
|
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||||
|
# This step explicitly asserts the five healthchecked critical services are
|
||||||
|
# healthy before the smoke test proceeds.
|
||||||
|
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
unhealthy=""
|
||||||
|
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||||
|
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||||
|
if [ "$status" != "healthy" ]; then
|
||||||
|
echo "::error::$svc is not healthy (status: $status)"
|
||||||
|
unhealthy="$unhealthy $svc"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -z "$unhealthy" ] || exit 1
|
||||||
|
echo "All critical observability services are healthy"
|
||||||
|
|
||||||
|
- name: Reload Caddy
|
||||||
|
# See nightly.yml — same rationale and mechanism: DooD job containers
|
||||||
|
# cannot call systemctl directly; nsenter via a privileged sibling
|
||||||
|
# container reaches the host systemd. Must run after deploy (so the
|
||||||
|
# latest Caddyfile is on disk) and before the smoke test (so the
|
||||||
|
# public surface reflects the current config). Alpine with pinned
|
||||||
|
# digest; reload not restart — see nightly.yml for full rationale.
|
||||||
|
run: |
|
||||||
|
docker run --rm --privileged --pid=host \
|
||||||
|
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||||
|
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||||
|
|
||||||
|
- name: Smoke test deployed environment
|
||||||
|
# See nightly.yml — same three checks, against the prod vhost.
|
||||||
|
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||||
|
# separate arguments; a quoted string would pass the flag and its value
|
||||||
|
# as one token and curl would reject it as an unknown option.
|
||||||
|
# Gateway detection via /proc/net/route — no iproute2 dependency.
|
||||||
|
# See nightly.yml for the full network topology explanation.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
HOST="archiv.raddatz.cloud"
|
||||||
|
URL="https://$HOST"
|
||||||
|
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||||
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
|
# fail this check rather than pass it silently.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
|
# header now fails the smoke step.
|
||||||
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
- name: Cleanup env file
|
||||||
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
|
# single-tenant runner trust model. Every secret in
|
||||||
|
# .env.production is plain text on the runner filesystem until
|
||||||
|
# this step runs. If a future refactor drops `if: always()`, a
|
||||||
|
# failed deploy leaves the env-file behind. Do not remove this
|
||||||
|
# conditional without first re-evaluating ADR-011.
|
||||||
|
if: always()
|
||||||
|
run: rm -f .env.production
|
||||||
54
.semgrep/security.yml
Normal file
54
.semgrep/security.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Semgrep security rules for Familienarchiv backend.
|
||||||
|
# These rules catch the absence of XXE protection on XML parser factories.
|
||||||
|
# CWE-611: Improper Restriction of XML External Entity Reference.
|
||||||
|
# Run: semgrep --config .semgrep/security.yml --error backend/src/
|
||||||
|
|
||||||
|
rules:
|
||||||
|
|
||||||
|
# DocumentBuilderFactory without XXE hardening.
|
||||||
|
# All call sites must call setFeature("…disallow-doctype-decl", true) before use.
|
||||||
|
- id: dbf-xxe-default
|
||||||
|
patterns:
|
||||||
|
- pattern: $X = DocumentBuilderFactory.newInstance();
|
||||||
|
- pattern-not-inside: |
|
||||||
|
...
|
||||||
|
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
...
|
||||||
|
message: >
|
||||||
|
DocumentBuilderFactory without XXE protection (CWE-611).
|
||||||
|
Call XxeSafeXmlParser.hardenedFactory() instead of DocumentBuilderFactory.newInstance().
|
||||||
|
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||||
|
languages: [java]
|
||||||
|
severity: ERROR
|
||||||
|
|
||||||
|
# SAXParserFactory without XXE hardening.
|
||||||
|
- id: sax-xxe-default
|
||||||
|
patterns:
|
||||||
|
- pattern: $X = SAXParserFactory.newInstance();
|
||||||
|
- pattern-not-inside: |
|
||||||
|
...
|
||||||
|
$X.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
...
|
||||||
|
message: >
|
||||||
|
SAXParserFactory without XXE protection (CWE-611).
|
||||||
|
Set disallow-doctype-decl=true, external-general-entities=false, external-parameter-entities=false,
|
||||||
|
and load-external-dtd=false before use. Follow the pattern in XxeSafeXmlParser.hardenedFactory().
|
||||||
|
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||||
|
languages: [java]
|
||||||
|
severity: ERROR
|
||||||
|
|
||||||
|
# XMLInputFactory without XXE hardening (StAX parser).
|
||||||
|
- id: stax-xxe-default
|
||||||
|
patterns:
|
||||||
|
- pattern: $X = XMLInputFactory.newInstance();
|
||||||
|
- pattern-not-inside: |
|
||||||
|
...
|
||||||
|
$X.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
|
||||||
|
...
|
||||||
|
message: >
|
||||||
|
XMLInputFactory without XXE protection (CWE-611).
|
||||||
|
Set IS_SUPPORTING_EXTERNAL_ENTITIES=false and SUPPORT_DTD=false before use.
|
||||||
|
Follow the pattern in XxeSafeXmlParser.hardenedFactory().
|
||||||
|
See: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
|
||||||
|
languages: [java]
|
||||||
|
severity: ERROR
|
||||||
39
CLAUDE.md
39
CLAUDE.md
@@ -77,6 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
|||||||
```
|
```
|
||||||
backend/src/main/java/org/raddatz/familienarchiv/
|
backend/src/main/java/org/raddatz/familienarchiv/
|
||||||
├── audit/ Audit logging
|
├── audit/ Audit logging
|
||||||
|
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||||
├── config/ Infrastructure config (Minio, Async, Web)
|
├── config/ Infrastructure config (Minio, Async, Web)
|
||||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||||
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
||||||
@@ -93,7 +94,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── relationship/ PersonRelationship sub-domain
|
│ └── relationship/ PersonRelationship sub-domain
|
||||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||||
├── tag/ Tag domain
|
├── tag/ Tag domain
|
||||||
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers
|
└── user/ User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layering Rules
|
### Layering Rules
|
||||||
@@ -159,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -202,8 +203,7 @@ frontend/src/routes/
|
|||||||
├── profile/ User profile settings
|
├── profile/ User profile settings
|
||||||
├── users/[id]/ Public user profile page
|
├── users/[id]/ Public user profile page
|
||||||
├── login/ logout/ register/
|
├── login/ logout/ register/
|
||||||
├── forgot-password/ reset-password/
|
└── forgot-password/ reset-password/
|
||||||
└── demo/ Dev-only demos
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Client Pattern
|
### API Client Pattern
|
||||||
@@ -267,7 +267,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -275,6 +275,35 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
||||||
|
|
||||||
|
### Observability stack (separate compose file)
|
||||||
|
|
||||||
|
Run via `docker-compose.observability.yml` — requires the main stack to be running first. Full setup procedure: [docs/DEPLOYMENT.md §4](./docs/DEPLOYMENT.md#4-logs--observability).
|
||||||
|
|
||||||
|
| Service | Container | Default Port | Purpose |
|
||||||
|
|---------|-----------|-------------|---------|
|
||||||
|
| Grafana | `obs-grafana` | 3003 | Metrics / logs / traces dashboard |
|
||||||
|
| Prometheus | `obs-prometheus` | 9090 (dev only — `127.0.0.1` bound) | Metrics store |
|
||||||
|
| Loki | `obs-loki` | — (internal) | Log store |
|
||||||
|
| Tempo | `obs-tempo` | — (internal) | Trace store |
|
||||||
|
| GlitchTip | `obs-glitchtip` | 3002 | Error tracking (Sentry-compatible) |
|
||||||
|
|
||||||
|
### Observability env vars
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `PORT_GRAFANA` | Host port for Grafana UI (default: `3003`) |
|
||||||
|
| `PORT_GLITCHTIP` | Host port for GlitchTip UI (default: `3002`) |
|
||||||
|
| `PORT_PROMETHEUS` | Host port for Prometheus UI (default: `9090`) |
|
||||||
|
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` login password — generate with `openssl rand -hex 32` |
|
||||||
|
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` |
|
||||||
|
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (email links, CORS), e.g. `https://glitchtip.example.com` |
|
||||||
|
| `SENTRY_DSN` | GlitchTip/Sentry DSN for the backend (Spring Boot) — leave empty to disable |
|
||||||
|
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite |
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
→ See [docs/OBSERVABILITY.md](./docs/OBSERVABILITY.md) — where to look for logs, traces, metrics, and errors.
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ if (!result.response.ok) {
|
|||||||
return { person: result.data! }; // non-null assertion is safe after the ok check
|
return { person: result.data! }; // non-null assertion is safe after the ok check
|
||||||
```
|
```
|
||||||
|
|
||||||
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
|
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie.
|
||||||
|
|
||||||
### Date handling
|
### Date handling
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
|
|||||||
```
|
```
|
||||||
src/main/java/org/raddatz/familienarchiv/
|
src/main/java/org/raddatz/familienarchiv/
|
||||||
├── audit/ # Audit logging (AuditService, AuditLogQueryService)
|
├── audit/ # Audit logging (AuditService, AuditLogQueryService)
|
||||||
|
├── auth/ # AuthService, AuthSessionController, LoginRequest (Spring Session JDBC — ADR-020)
|
||||||
├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig)
|
├── config/ # Infrastructure config (MinioConfig, AsyncConfig, WebConfig)
|
||||||
├── dashboard/ # Dashboard analytics + StatsController/StatsService
|
├── dashboard/ # Dashboard analytics + StatsController/StatsService
|
||||||
├── document/ # Document domain — entities, controller, service, repository, DTOs
|
├── document/ # Document domain — entities, controller, service, repository, DTOs
|
||||||
@@ -40,7 +41,7 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── relationship/ # PersonRelationship sub-domain
|
│ └── relationship/ # PersonRelationship sub-domain
|
||||||
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||||
├── tag/ # Tag domain — Tag, TagService, TagController
|
├── tag/ # Tag domain — Tag, TagService, TagController
|
||||||
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
|
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||||
```
|
```
|
||||||
|
|
||||||
For per-domain ownership and public surface, see each domain's `README.md`.
|
For per-domain ownership and public surface, see each domain's `README.md`.
|
||||||
@@ -96,7 +97,10 @@ public class MyEntity {
|
|||||||
|
|
||||||
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
|
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
|
||||||
- Write methods: `@Transactional`.
|
- Write methods: `@Transactional`.
|
||||||
- Read methods: no annotation (default non-transactional).
|
- Read methods: no annotation (default non-transactional) — **except** when the method returns
|
||||||
|
an entity whose lazy associations must remain accessible to the caller after the method
|
||||||
|
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
|
||||||
|
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
|
||||||
- Cross-domain access goes through the other domain's service, never its repository.
|
- Cross-domain access goes through the other domain's service, never its repository.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>4.0.0</version>
|
<version>4.0.6</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>org.raddatz</groupId>
|
<groupId>org.raddatz</groupId>
|
||||||
@@ -29,11 +29,30 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- opentelemetry-spring-boot-starter:2.27.0 was built against opentelemetry-api:1.61.0,
|
||||||
|
but Spring Boot 4.0.0 BOM only manages 1.55.0 (missing GlobalOpenTelemetry.getOrNoop()).
|
||||||
|
Import the core OTel BOM here to override it before the Spring Boot BOM applies. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.opentelemetry</groupId>
|
||||||
|
<artifactId>opentelemetry-bom</artifactId>
|
||||||
|
<version>1.61.0</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Spring Boot 4.0 splits Micrometer metrics export (incl. Prometheus scrape endpoint) into its own starter -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-micrometer-metrics</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
@@ -50,6 +69,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-session-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
@@ -157,11 +180,16 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache for in-memory rate limiting -->
|
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.bucket4j</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>8.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -188,7 +216,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
<groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
|
||||||
<artifactId>owasp-java-html-sanitizer</artifactId>
|
<artifactId>owasp-java-html-sanitizer</artifactId>
|
||||||
<version>20240325.1</version>
|
<version>20260101.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- HTML → plain-text extraction for comment previews -->
|
<!-- HTML → plain-text extraction for comment previews -->
|
||||||
@@ -197,6 +225,42 @@
|
|||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.18.1</version>
|
<version>1.18.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Observability: Prometheus metrics scrape endpoint (version managed by Spring Boot BOM) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Observability: Micrometer → OpenTelemetry tracing bridge (version managed by Spring Boot BOM) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-tracing-bridge-otel</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Observability: OTel Spring Boot auto-instrumentation — NOT in Spring Boot BOM, pinned explicitly -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||||
|
<artifactId>opentelemetry-spring-boot-starter</artifactId>
|
||||||
|
<version>2.27.0</version>
|
||||||
|
<exclusions>
|
||||||
|
<!-- Excludes AzureAppServiceResourceProvider which references ServiceAttributes.SERVICE_INSTANCE_ID
|
||||||
|
that does not exist in the semconv version pulled by this project. -->
|
||||||
|
<exclusion>
|
||||||
|
<groupId>io.opentelemetry.contrib</groupId>
|
||||||
|
<artifactId>opentelemetry-azure-resources</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Sentry error reporting (GlitchTip-compatible) — sentry-spring-boot-4 is the
|
||||||
|
Spring Boot 4 / Spring Framework 7 compatible module (replaces the jakarta starter
|
||||||
|
which crashes with SF7 due to bean-name generation for triply-nested @Import classes) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.sentry</groupId>
|
||||||
|
<artifactId>sentry-spring-boot-4</artifactId>
|
||||||
|
<version>8.41.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +306,7 @@
|
|||||||
<phase>verify</phase>
|
<phase>verify</phase>
|
||||||
<goals><goal>report</goal></goals>
|
<goals><goal>report</goal></goals>
|
||||||
</execution>
|
</execution>
|
||||||
<!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
|
<!-- Gate: ratchet at 0.77 — actual measured coverage after drift; raise via #496 -->
|
||||||
<execution>
|
<execution>
|
||||||
<id>check</id>
|
<id>check</id>
|
||||||
<phase>verify</phase>
|
<phase>verify</phase>
|
||||||
@@ -255,7 +319,7 @@
|
|||||||
<limit>
|
<limit>
|
||||||
<counter>BRANCH</counter>
|
<counter>BRANCH</counter>
|
||||||
<value>COVEREDRATIO</value>
|
<value>COVEREDRATIO</value>
|
||||||
<minimum>0.88</minimum>
|
<minimum>0.77</minimum>
|
||||||
</limit>
|
</limit>
|
||||||
</limits>
|
</limits>
|
||||||
</rule>
|
</rule>
|
||||||
@@ -273,6 +337,16 @@
|
|||||||
</profiles>
|
</profiles>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||||
|
<systemPropertyVariables>
|
||||||
|
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||||
|
</systemPropertyVariables>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,22 @@ public enum AuditKind {
|
|||||||
USER_DELETED,
|
USER_DELETED,
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||||
GROUP_MEMBERSHIP_CHANGED;
|
GROUP_MEMBERSHIP_CHANGED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
||||||
|
LOGIN_SUCCESS,
|
||||||
|
|
||||||
|
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||||
|
LOGIN_FAILED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
|
||||||
|
LOGOUT,
|
||||||
|
|
||||||
|
/** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */
|
||||||
|
ADMIN_FORCE_LOGOUT,
|
||||||
|
|
||||||
|
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||||
|
LOGIN_RATE_LIMITED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
private final UserService userService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final LoginRateLimiter loginRateLimiter;
|
||||||
|
private final SessionRevocationPort sessionRevocationPort;
|
||||||
|
|
||||||
|
public LoginResult login(String email, String password, String ip, String ua) {
|
||||||
|
try {
|
||||||
|
loginRateLimiter.checkAndConsume(ip, email);
|
||||||
|
} catch (DomainException ex) {
|
||||||
|
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||||
|
"ip", ip,
|
||||||
|
"email", email));
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Authentication auth = authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(email, password));
|
||||||
|
|
||||||
|
AppUser user = userService.findByEmail(email);
|
||||||
|
auditService.log(AuditKind.LOGIN_SUCCESS, user.getId(), null, Map.of(
|
||||||
|
"userId", user.getId().toString(),
|
||||||
|
"ip", ip,
|
||||||
|
"ua", truncateUa(ua)));
|
||||||
|
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||||
|
return new LoginResult(user, auth);
|
||||||
|
} catch (AuthenticationException ex) {
|
||||||
|
// Audit login failure — intentionally does NOT log the attempted password.
|
||||||
|
// DaoAuthenticationProvider already runs a dummy BCrypt on unknown users to
|
||||||
|
// equalise timing between "user not found" and "wrong password" paths.
|
||||||
|
auditService.log(AuditKind.LOGIN_FAILED, null, null, Map.of(
|
||||||
|
"email", email,
|
||||||
|
"ip", ip,
|
||||||
|
"ua", truncateUa(ua)));
|
||||||
|
throw DomainException.invalidCredentials();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logout(String email, String ip, String ua) {
|
||||||
|
AppUser user = userService.findByEmail(email);
|
||||||
|
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||||
|
"userId", user.getId().toString(),
|
||||||
|
"ip", ip,
|
||||||
|
"ua", truncateUa(ua)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String truncateUa(String ua) {
|
||||||
|
if (ua == null) return "";
|
||||||
|
return ua.length() > 200 ? ua.substring(0, 200) : ua;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginResult(AppUser user, Authentication authentication) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||||
|
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
// @RequirePermission is intentionally absent: login is unauthenticated by design;
|
||||||
|
// logout requires an authenticated session (enforced by SecurityConfig), not a specific permission.
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AuthSessionController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final SessionAuthenticationStrategy sessionAuthenticationStrategy;
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<AppUser> login(
|
||||||
|
@RequestBody LoginRequest request,
|
||||||
|
HttpServletRequest httpRequest,
|
||||||
|
HttpServletResponse httpResponse) {
|
||||||
|
|
||||||
|
String ip = resolveClientIp(httpRequest);
|
||||||
|
String ua = resolveUserAgent(httpRequest);
|
||||||
|
|
||||||
|
AuthService.LoginResult result = authService.login(request.email(), request.password(), ip, ua);
|
||||||
|
|
||||||
|
// Session-fixation defense (CWE-384): rotate the session ID at the authentication
|
||||||
|
// boundary. ChangeSessionIdAuthenticationStrategy invalidates any pre-auth session ID
|
||||||
|
// an attacker may have planted and mints a fresh one before we attach the SecurityContext.
|
||||||
|
httpRequest.getSession(true);
|
||||||
|
sessionAuthenticationStrategy.onAuthentication(result.authentication(), httpRequest, httpResponse);
|
||||||
|
|
||||||
|
// Spring Session JDBC intercepts setAttribute() and persists the record under the
|
||||||
|
// (now rotated) opaque ID; the Set-Cookie: fa_session=<opaque-id> is added automatically.
|
||||||
|
SecurityContext context = SecurityContextHolder.createEmptyContext();
|
||||||
|
context.setAuthentication(result.authentication());
|
||||||
|
SecurityContextHolder.setContext(context);
|
||||||
|
httpRequest.getSession()
|
||||||
|
.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(result.user());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public ResponseEntity<Void> logout(Authentication authentication, HttpServletRequest httpRequest) {
|
||||||
|
String email = authentication.getName();
|
||||||
|
String ip = resolveClientIp(httpRequest);
|
||||||
|
String ua = resolveUserAgent(httpRequest);
|
||||||
|
|
||||||
|
// CWE-613 defense: invalidate the session first — that is the contract the user
|
||||||
|
// is relying on when they click "Log out." Audit is best-effort and must not
|
||||||
|
// bubble up: if the user record was deleted while the session was live, the
|
||||||
|
// audit lookup throws, but the session row in spring_session must still die.
|
||||||
|
HttpSession session = httpRequest.getSession(false);
|
||||||
|
if (session != null) {
|
||||||
|
session.invalidate();
|
||||||
|
}
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
|
||||||
|
try {
|
||||||
|
authService.logout(email, ip, ua);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Audit logout failed for {}; session was already invalidated", email, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the client IP for audit-log purposes.
|
||||||
|
*
|
||||||
|
* <p>Trust model: the leftmost {@code X-Forwarded-For} value is taken at face value.
|
||||||
|
* This is correct <em>only</em> if the ingress (Caddy in production) strips any
|
||||||
|
* client-supplied XFF before forwarding — otherwise an attacker can pin audit-log
|
||||||
|
* IPs to whatever they want. Verify the reverse-proxy config before exposing this
|
||||||
|
* service behind a different ingress.
|
||||||
|
*/
|
||||||
|
private static String resolveClientIp(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwarded != null && !forwarded.isBlank()) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolveUserAgent(HttpServletRequest request) {
|
||||||
|
String ua = request.getHeader("User-Agent");
|
||||||
|
return ua != null ? ua : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
private final JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
int count = 0;
|
||||||
|
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
||||||
|
if (!id.equals(currentSessionId)) {
|
||||||
|
sessionRepository.deleteById(id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||||
|
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||||
|
return sessions.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
private final LoadingCache<String, Bucket> byIpEmail;
|
||||||
|
private final LoadingCache<String, Bucket> byIp;
|
||||||
|
private final int maxPerIpEmail;
|
||||||
|
private final int maxPerIp;
|
||||||
|
private final int windowMinutes;
|
||||||
|
|
||||||
|
public LoginRateLimiter(RateLimitProperties props) {
|
||||||
|
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
|
||||||
|
this.maxPerIp = props.getMaxAttemptsPerIp();
|
||||||
|
this.windowMinutes = props.getWindowMinutes();
|
||||||
|
|
||||||
|
this.byIpEmail = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
|
||||||
|
|
||||||
|
this.byIp = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||||
|
// effective limits would be multiplied by replica count.
|
||||||
|
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||||
|
|
||||||
|
public void checkAndConsume(String ip, String email) {
|
||||||
|
long retryAfterSeconds = windowMinutes * 60L;
|
||||||
|
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
|
||||||
|
if (!byIpEmail.get(key).tryConsume(1)) {
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
if (!byIp.get(ip).tryConsume(1)) {
|
||||||
|
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||||
|
byIpEmail.get(key).addTokens(1);
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidateOnSuccess(String ip, String email) {
|
||||||
|
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
|
||||||
|
byIp.invalidate(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bucket newBucket(int limit, int minutes) {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(limit)
|
||||||
|
.refillGreedy(limit, Duration.ofMinutes(minutes))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
public record LoginRequest(String email, String password) {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("rate-limit.login")
|
||||||
|
@Data
|
||||||
|
public class RateLimitProperties {
|
||||||
|
private int maxAttemptsPerIpEmail = 10;
|
||||||
|
private int maxAttemptsPerIp = 20;
|
||||||
|
private int windowMinutes = 15;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class SessionRevocationConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SessionRevocationPort sessionRevocationPort(
|
||||||
|
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
||||||
|
if (sessionRepository != null) {
|
||||||
|
return new JdbcSessionRevocationAdapter(sessionRepository);
|
||||||
|
}
|
||||||
|
return new NoOpSessionRevocationAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
public interface SessionRevocationPort {
|
||||||
|
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||||
|
int revokeAllSessions(String principalName);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||||||
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
||||||
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
||||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||||
|
response.setHeader("Retry-After", "60");
|
||||||
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.session.web.http.CookieSerializer;
|
||||||
|
import org.springframework.session.web.http.DefaultCookieSerializer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SpringSessionConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CookieSerializer cookieSerializer() {
|
||||||
|
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
|
||||||
|
serializer.setCookieName("fa_session");
|
||||||
|
serializer.setSameSite("Strict");
|
||||||
|
// cookieHttpOnly: true is the DefaultCookieSerializer default
|
||||||
|
// useSecureCookie not set: auto-detects from request.isSecure().
|
||||||
|
// With forward-headers-strategy: native, Caddy's X-Forwarded-Proto: https
|
||||||
|
// causes isSecure() → true in production; direct HTTP in dev/tests → false.
|
||||||
|
return serializer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.document;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.BatchSize;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@@ -21,6 +22,15 @@ import java.util.HashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||||
|
@NamedAttributeNode("sender"),
|
||||||
|
@NamedAttributeNode("receivers"),
|
||||||
|
@NamedAttributeNode("tags")
|
||||||
|
})
|
||||||
|
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||||
|
@NamedAttributeNode("sender"),
|
||||||
|
@NamedAttributeNode("tags")
|
||||||
|
})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "documents")
|
@Table(name = "documents")
|
||||||
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
|
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
|
||||||
@@ -118,24 +128,27 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private ScriptType scriptType = ScriptType.UNKNOWN;
|
private ScriptType scriptType = ScriptType.UNKNOWN;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> receivers = new HashSet<>();
|
private Set<Person> receivers = new HashSet<>();
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "sender_id")
|
@JoinColumn(name = "sender_id")
|
||||||
private Person sender;
|
private Person sender;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Tag> tags = new HashSet<>();
|
private Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.LAZY)
|
||||||
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
||||||
@Column(name = "label")
|
@Column(name = "label")
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
@@ -23,6 +25,18 @@ import java.util.UUID;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
|
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
|
Optional<Document> findById(UUID id);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
Page<Document> findAll(Specification<Document> spec, Pageable pageable);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
List<Document> findAll(Specification<Document> spec);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
Page<Document> findAll(Pageable pageable);
|
||||||
|
|
||||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
@@ -30,17 +44,21 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||||
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
// Findet alle Dokumente mit einem bestimmten Status
|
// Callers access only status/id scalar fields — no graph needed.
|
||||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
|
||||||
List<Document> findByStatus(DocumentStatus status);
|
List<Document> findByStatus(DocumentStatus status);
|
||||||
|
|
||||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||||
boolean existsByOriginalFilename(String originalFilename);
|
boolean existsByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
|
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||||
|
@EntityGraph("Document.full")
|
||||||
List<Document> findBySenderId(UUID senderId);
|
List<Document> findBySenderId(UUID senderId);
|
||||||
|
|
||||||
|
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||||
|
@EntityGraph("Document.full")
|
||||||
List<Document> findByReceiversId(UUID receiverId);
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
|
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
@@ -55,12 +73,15 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
long countByMetadataCompleteFalse();
|
long countByMetadataCompleteFalse();
|
||||||
|
|
||||||
|
// No production callers — only used if a future export path iterates the full list; no graph needed.
|
||||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||||
|
|
||||||
|
// Callers map to IncompleteDocumentDTO using only scalar fields (id, title, createdAt) — no graph needed.
|
||||||
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
||||||
|
|
||||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
@@ -75,6 +96,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Param("to") LocalDate to,
|
@Param("to") LocalDate to,
|
||||||
Sort sort);
|
Sort sort);
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"LEFT JOIN d.receivers r " +
|
"LEFT JOIN d.receivers r " +
|
||||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||||
@@ -100,7 +122,45 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
|
ORDER BY ts_rank(d.search_vector, q.pq) DESC,
|
||||||
d.meta_date DESC NULLS LAST
|
d.meta_date DESC NULLS LAST
|
||||||
""")
|
""")
|
||||||
List<UUID> findRankedIdsByFts(@Param("query") String query);
|
// Unpaged path — for bulk-edit "select all" and density chart
|
||||||
|
List<UUID> findAllMatchingIdsByFts(@Param("query") String query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns one page of FTS-ranked document IDs with the total match count.
|
||||||
|
*
|
||||||
|
* <p>Each row contains (in column order):
|
||||||
|
* <ol>
|
||||||
|
* <li>UUID — document id</li>
|
||||||
|
* <li>double — ts_rank score</li>
|
||||||
|
* <li>long — COUNT(*) OVER () — full match count, not page count</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Returns an empty list when the query matches no documents (including
|
||||||
|
* stopword-only queries where websearch_to_tsquery returns an empty tsquery).
|
||||||
|
* Use findAllMatchingIdsByFts for the unpaged bulk-edit path.
|
||||||
|
*/
|
||||||
|
@Query(nativeQuery = true, value = """
|
||||||
|
WITH q AS (
|
||||||
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
|
websearch_to_tsquery('german', :query)::text,
|
||||||
|
'''([^'']+)''',
|
||||||
|
'''\\1'':*',
|
||||||
|
'g'))
|
||||||
|
END AS pq
|
||||||
|
), matches AS (
|
||||||
|
SELECT d.id, ts_rank(d.search_vector, q.pq) AS rank
|
||||||
|
FROM documents d, q
|
||||||
|
WHERE d.search_vector @@ q.pq
|
||||||
|
)
|
||||||
|
SELECT id, rank, COUNT(*) OVER () AS total
|
||||||
|
FROM matches
|
||||||
|
ORDER BY rank DESC, id
|
||||||
|
OFFSET :offset LIMIT :limit
|
||||||
|
""")
|
||||||
|
List<Object[]> findFtsPageRaw(@Param("query") String query,
|
||||||
|
@Param("offset") int offset,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns match-enrichment data for a set of documents identified by their IDs.
|
* Returns match-enrichment data for a set of documents identified by their IDs.
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ public class DocumentService {
|
|||||||
*/
|
*/
|
||||||
private List<UUID> resolveFtsIds(String text) {
|
private List<UUID> resolveFtsIds(String text) {
|
||||||
if (!StringUtils.hasText(text)) return null;
|
if (!StringUtils.hasText(text)) return null;
|
||||||
return documentRepository.findRankedIdsByFts(text);
|
return documentRepository.findAllMatchingIdsByFts(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||||
@@ -447,6 +447,7 @@ public class DocumentService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||||
Document doc = documentRepository.findById(docId)
|
Document doc = documentRepository.findById(docId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||||
@@ -485,7 +486,7 @@ public class DocumentService {
|
|||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||||
if (rankedIds.isEmpty()) return List.of();
|
if (rankedIds.isEmpty()) return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +636,7 @@ public class DocumentService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
@Transactional(readOnly = true)
|
||||||
public List<Document> getRecentActivity(int size) {
|
public List<Document> getRecentActivity(int size) {
|
||||||
return documentRepository.findAll(
|
return documentRepository.findAll(
|
||||||
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||||
@@ -645,39 +646,43 @@ public class DocumentService {
|
|||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
|
||||||
|
|
||||||
|
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
|
||||||
|
if (isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
||||||
|
return relevanceSortedPageFromSql(text, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||||
|
|
||||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
||||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
// documents with null sender/receivers. Cost scales with match count —
|
||||||
// rank list. Cost scales linearly with match count — acceptable while documents
|
// acceptable while documents stays under ~10k rows. (ADR-008)
|
||||||
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
|
// In-memory sort on page slice (≤ page size rows) — acceptable
|
||||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
|
// In-memory sort on page slice (≤ page size rows) — acceptable
|
||||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE: default when text present and no explicit sort given
|
// RELEVANCE with active filters: load filtered subset and sort in-memory by rank.
|
||||||
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
|
boolean useRankOrder = hasText && (sort == null || sort == DocumentSort.RELEVANCE);
|
||||||
if (useRankOrder) {
|
if (useRankOrder) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
|
for (int i = 0; i < rankedIds.size(); i++) rankMap.put(rankedIds.get(i), i);
|
||||||
List<Document> sorted = results.stream()
|
List<Document> sorted = documentRepository.findAll(spec).stream()
|
||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
|
||||||
.toList();
|
.toList();
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
@@ -688,6 +693,39 @@ public class DocumentService {
|
|||||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
|
||||||
|
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||||
|
List<String> tags, String tagQ, DocumentStatus status) {
|
||||||
|
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
||||||
|
&& from == null && to == null && sender == null && receiver == null
|
||||||
|
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-text RELEVANCE path — pagination and ts_rank ordering pushed into SQL.
|
||||||
|
* Called when no non-text filters are active (ADR-008).
|
||||||
|
*/
|
||||||
|
private DocumentSearchResult relevanceSortedPageFromSql(String text, Pageable pageable) {
|
||||||
|
long rawOffset = pageable.getOffset();
|
||||||
|
if (rawOffset > Integer.MAX_VALUE) return DocumentSearchResult.of(List.of());
|
||||||
|
int offset = (int) rawOffset;
|
||||||
|
int limit = pageable.getPageSize();
|
||||||
|
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||||
|
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
|
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
||||||
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
|
List<UUID> pageIds = new ArrayList<>();
|
||||||
|
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||||
|
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||||
|
pageIds.add(ftsPage.hits().get(i).id());
|
||||||
|
}
|
||||||
|
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||||
|
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||||
|
.toList();
|
||||||
|
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||||
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||||
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||||
@@ -806,6 +844,7 @@ public class DocumentService {
|
|||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public Document getDocumentById(UUID id) {
|
public Document getDocumentById(UUID id) {
|
||||||
Document doc = documentRepository.findById(id)
|
Document doc = documentRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
@@ -1013,6 +1052,28 @@ public class DocumentService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final int COL_ID = 0;
|
||||||
|
private static final int COL_RANK = 1;
|
||||||
|
private static final int COL_TOTAL = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps raw Object[] rows from {@link DocumentRepository#findFtsPageRaw} to an
|
||||||
|
* {@link FtsPage}. Uses pattern-matching UUID cast to guard against driver-level
|
||||||
|
* type variance (some JDBC drivers return UUID as String).
|
||||||
|
*/
|
||||||
|
private static FtsPage toFtsPage(List<Object[]> rows) {
|
||||||
|
if (rows.isEmpty()) return new FtsPage(List.of(), 0);
|
||||||
|
long total = ((Number) rows.get(0)[COL_TOTAL]).longValue();
|
||||||
|
List<FtsHit> hits = rows.stream()
|
||||||
|
.map(r -> {
|
||||||
|
UUID id = r[COL_ID] instanceof UUID u ? u : UUID.fromString(r[COL_ID].toString());
|
||||||
|
double rank = ((Number) r[COL_RANK]).doubleValue();
|
||||||
|
return new FtsHit(id, rank);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
return new FtsPage(hits, total);
|
||||||
|
}
|
||||||
|
|
||||||
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
|
/** Clean text + highlight offsets parsed from a {@code ts_headline} sentinel-delimited string. */
|
||||||
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
|
public record ParsedHighlight(String cleanText, List<MatchOffset> offsets) {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** A single document hit from a paginated FTS query — id and its ts_rank score. */
|
||||||
|
record FtsHit(UUID id, double rank) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** One page of FTS results — the ranked hit list for this page and the total match count. */
|
||||||
|
record FtsPage(List<FtsHit> hits, long total) {}
|
||||||
@@ -10,11 +10,21 @@ public class DomainException extends RuntimeException {
|
|||||||
|
|
||||||
private final ErrorCode code;
|
private final ErrorCode code;
|
||||||
private final HttpStatus status;
|
private final HttpStatus status;
|
||||||
|
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
|
||||||
|
private final Long retryAfterSeconds;
|
||||||
|
|
||||||
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
|
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
|
||||||
super(developerMessage);
|
super(developerMessage);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.retryAfterSeconds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
|
||||||
|
super(developerMessage);
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ErrorCode getCode() {
|
public ErrorCode getCode() {
|
||||||
@@ -25,6 +35,11 @@ public class DomainException extends RuntimeException {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
|
||||||
|
public Long getRetryAfterSeconds() {
|
||||||
|
return retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Static factories for common cases ---
|
// --- Static factories for common cases ---
|
||||||
|
|
||||||
public static DomainException notFound(ErrorCode code, String message) {
|
public static DomainException notFound(ErrorCode code, String message) {
|
||||||
@@ -39,6 +54,11 @@ public class DomainException extends RuntimeException {
|
|||||||
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
|
return new DomainException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DomainException invalidCredentials() {
|
||||||
|
return new DomainException(ErrorCode.INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED,
|
||||||
|
"Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
public static DomainException conflict(ErrorCode code, String message) {
|
public static DomainException conflict(ErrorCode code, String message) {
|
||||||
return new DomainException(code, HttpStatus.CONFLICT, message);
|
return new DomainException(code, HttpStatus.CONFLICT, message);
|
||||||
}
|
}
|
||||||
@@ -50,4 +70,12 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException internal(ErrorCode code, String message) {
|
public static DomainException internal(ErrorCode code, String message) {
|
||||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DomainException tooManyRequests(ErrorCode code, String message) {
|
||||||
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public enum ErrorCode {
|
|||||||
// --- Users ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
|
/** A group with the given ID does not exist. 404 */
|
||||||
|
GROUP_NOT_FOUND,
|
||||||
/** The supplied email address is already used by another account. 409 */
|
/** The supplied email address is already used by another account. 409 */
|
||||||
EMAIL_ALREADY_IN_USE,
|
EMAIL_ALREADY_IN_USE,
|
||||||
/** The supplied current password does not match the stored hash. 400 */
|
/** The supplied current password does not match the stored hash. 400 */
|
||||||
@@ -52,14 +54,24 @@ public enum ErrorCode {
|
|||||||
INVITE_REVOKED,
|
INVITE_REVOKED,
|
||||||
/** The invite has passed its expiry date. 410 */
|
/** The invite has passed its expiry date. 410 */
|
||||||
INVITE_EXPIRED,
|
INVITE_EXPIRED,
|
||||||
|
/** A group cannot be deleted because one or more active invites reference it. 409 */
|
||||||
|
GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
/** The request is not authenticated. 401 */
|
/** The request is not authenticated. 401 */
|
||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
/** The authenticated user lacks the required permission. 403 */
|
/** The authenticated user lacks the required permission. 403 */
|
||||||
FORBIDDEN,
|
FORBIDDEN,
|
||||||
|
/** The supplied email/password combination does not match any active account. 401 */
|
||||||
|
INVALID_CREDENTIALS,
|
||||||
|
/** The session has expired or been invalidated. 401 */
|
||||||
|
SESSION_EXPIRED,
|
||||||
/** The password-reset token is missing, expired, or already used. 400 */
|
/** The password-reset token is missing, expired, or already used. 400 */
|
||||||
INVALID_RESET_TOKEN,
|
INVALID_RESET_TOKEN,
|
||||||
|
/** CSRF token is missing or does not match the expected value. 403 */
|
||||||
|
CSRF_TOKEN_MISSING,
|
||||||
|
/** The login rate limit has been exceeded for this IP/email combination. 429 */
|
||||||
|
TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
|
||||||
// --- Annotations ---
|
// --- Annotations ---
|
||||||
/** The annotation with the given ID does not exist. 404 */
|
/** The annotation with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.exception;
|
|||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import io.sentry.Sentry;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -22,9 +23,11 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(DomainException.class)
|
@ExceptionHandler(DomainException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
||||||
return ResponseEntity
|
var builder = ResponseEntity.status(ex.getStatus());
|
||||||
.status(ex.getStatus())
|
if (ex.getRetryAfterSeconds() != null) {
|
||||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
|
||||||
|
}
|
||||||
|
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
@@ -63,6 +66,7 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||||
|
Sentry.captureException(ex);
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
return ResponseEntity.internalServerError()
|
return ResponseEntity.internalServerError()
|
||||||
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
|
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.poi.ss.usermodel.*;
|
import org.apache.poi.ss.usermodel.*;
|
||||||
@@ -30,6 +33,7 @@ import javax.xml.parsers.DocumentBuilderFactory;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@@ -52,9 +56,33 @@ public class MassImportService {
|
|||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {}
|
public record SkippedFile(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||||
|
) {}
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
|
public record ImportStatus(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||||
|
@JsonIgnore String message,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||||
|
LocalDateTime startedAt
|
||||||
|
) {
|
||||||
|
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
|
||||||
|
// "skipped" count is a computed convenience field derived from skippedFiles.size().
|
||||||
|
@JsonProperty("skipped")
|
||||||
|
public int skipped() { return skippedFiles.size(); }
|
||||||
|
|
||||||
|
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
|
||||||
|
public ImportStatus {
|
||||||
|
skippedFiles = List.copyOf(skippedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record ProcessResult(int processed, List<SkippedFile> skippedFiles) {}
|
||||||
|
|
||||||
|
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
public ImportStatus getStatus() {
|
||||||
return currentStatus;
|
return currentStatus;
|
||||||
@@ -99,7 +127,9 @@ public class MassImportService {
|
|||||||
@Value("${app.import.col.transcription:13}")
|
@Value("${app.import.col.transcription:13}")
|
||||||
private int colTranscription;
|
private int colTranscription;
|
||||||
|
|
||||||
private static final String IMPORT_DIR = "/import";
|
@Value("${app.import.dir:/import}")
|
||||||
|
private String importDir;
|
||||||
|
|
||||||
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
||||||
|
|
||||||
// ODS XML namespaces
|
// ODS XML namespaces
|
||||||
@@ -114,30 +144,39 @@ public class MassImportService {
|
|||||||
if (currentStatus.state() == State.RUNNING) {
|
if (currentStatus.state() == State.RUNNING) {
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
}
|
}
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
|
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||||
try {
|
try {
|
||||||
File spreadsheet = findSpreadsheetFile();
|
File spreadsheet = findSpreadsheetFile();
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
ProcessResult result = processRows(readSpreadsheet(spreadsheet));
|
||||||
currentStatus = new ImportStatus(State.DONE,
|
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||||
processed, currentStatus.startedAt());
|
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||||
|
} catch (NoSpreadsheetException e) {
|
||||||
|
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||||
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||||
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Massenimport fehlgeschlagen", e);
|
log.error("Massenimport fehlgeschlagen", e);
|
||||||
currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||||
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class NoSpreadsheetException extends RuntimeException {
|
||||||
|
NoSpreadsheetException(String message) { super(message); }
|
||||||
|
}
|
||||||
|
|
||||||
private File findSpreadsheetFile() throws IOException {
|
private File findSpreadsheetFile() throws IOException {
|
||||||
try (Stream<Path> files = Files.list(Paths.get(IMPORT_DIR))) {
|
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
|
||||||
return files
|
return files
|
||||||
.filter(p -> {
|
.filter(p -> {
|
||||||
String name = p.toString().toLowerCase();
|
String name = p.toString().toLowerCase();
|
||||||
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
||||||
})
|
})
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElseThrow(() -> new RuntimeException(
|
.orElseThrow(() -> new NoSpreadsheetException(
|
||||||
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + IMPORT_DIR + " gefunden!"))
|
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
|
||||||
.toFile();
|
.toFile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,14 +195,14 @@ public class MassImportService {
|
|||||||
* Reads an ODS file by parsing its content.xml directly (no extra library needed).
|
* Reads an ODS file by parsing its content.xml directly (no extra library needed).
|
||||||
* ODS is a ZIP archive; content.xml holds the spreadsheet data as XML.
|
* ODS is a ZIP archive; content.xml holds the spreadsheet data as XML.
|
||||||
*/
|
*/
|
||||||
private List<List<String>> readOds(File file) throws Exception {
|
List<List<String>> readOds(File file) throws Exception {
|
||||||
List<List<String>> result = new ArrayList<>();
|
List<List<String>> result = new ArrayList<>();
|
||||||
|
|
||||||
try (ZipFile zip = new ZipFile(file)) {
|
try (ZipFile zip = new ZipFile(file)) {
|
||||||
var entry = zip.getEntry("content.xml");
|
var entry = zip.getEntry("content.xml");
|
||||||
if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt");
|
if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt");
|
||||||
|
|
||||||
var factory = DocumentBuilderFactory.newInstance();
|
var factory = XxeSafeXmlParser.hardenedFactory();
|
||||||
factory.setNamespaceAware(true);
|
factory.setNamespaceAware(true);
|
||||||
var builder = factory.newDocumentBuilder();
|
var builder = factory.newDocumentBuilder();
|
||||||
var doc = builder.parse(zip.getInputStream(entry));
|
var doc = builder.parse(zip.getInputStream(entry));
|
||||||
@@ -242,8 +281,10 @@ public class MassImportService {
|
|||||||
|
|
||||||
// --- Import logic (works on neutral List<String> rows) ---
|
// --- Import logic (works on neutral List<String> rows) ---
|
||||||
|
|
||||||
private int processRows(List<List<String>> rows) {
|
private ProcessResult processRows(List<List<String>> rows) {
|
||||||
int count = 0;
|
int processed = 0;
|
||||||
|
List<SkippedFile> skippedFiles = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
for (int i = 1; i < rows.size(); i++) { // skip header row
|
||||||
List<String> cells = rows.get(i);
|
List<String> cells = rows.get(i);
|
||||||
String index = getCell(cells, colIndex);
|
String index = getCell(cells, colIndex);
|
||||||
@@ -254,18 +295,58 @@ public class MassImportService {
|
|||||||
if (fileOnDisk.isEmpty()) {
|
if (fileOnDisk.isEmpty()) {
|
||||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||||
}
|
}
|
||||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
|
||||||
count++;
|
if (fileOnDisk.isPresent()) {
|
||||||
|
try {
|
||||||
|
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||||
|
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||||
|
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||||
|
if (skipReason.isPresent()) {
|
||||||
|
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||||
|
} else {
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ProcessResult(processed, skippedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// package-private: Mockito spy in tests can override to inject IOException
|
||||||
|
InputStream openFileStream(File file) throws IOException {
|
||||||
|
return new FileInputStream(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
|
try (InputStream is = openFileStream(file)) {
|
||||||
|
byte[] header = is.readNBytes(4);
|
||||||
|
return header.length == 4
|
||||||
|
&& header[0] == 0x25 // %
|
||||||
|
&& header[1] == 0x50 // P
|
||||||
|
&& header[2] == 0x44 // D
|
||||||
|
&& header[3] == 0x46; // F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports a single document row.
|
||||||
|
*
|
||||||
|
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||||
return;
|
return Optional.of("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
String archiveBox = getCell(cells, colBox);
|
String archiveBox = getCell(cells, colBox);
|
||||||
@@ -301,7 +382,7 @@ public class MassImportService {
|
|||||||
status = DocumentStatus.UPLOADED;
|
status = DocumentStatus.UPLOADED;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||||
return;
|
return Optional.of("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +424,7 @@ public class MassImportService {
|
|||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||||
}
|
}
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
@@ -378,7 +460,7 @@ public class MassImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Optional<File> findFileRecursive(String filename) {
|
private Optional<File> findFileRecursive(String filename) {
|
||||||
try (Stream<Path> walk = Files.walk(Paths.get(IMPORT_DIR))) {
|
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
||||||
return walk.filter(p -> !Files.isDirectory(p))
|
return walk.filter(p -> !Files.isDirectory(p))
|
||||||
.filter(p -> p.getFileName().toString().equals(filename))
|
.filter(p -> p.getFileName().toString().equals(filename))
|
||||||
.map(Path::toFile)
|
.map(Path::toFile)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import javax.xml.parsers.ParserConfigurationException;
|
||||||
|
|
||||||
|
class XxeSafeXmlParser {
|
||||||
|
|
||||||
|
private XxeSafeXmlParser() {}
|
||||||
|
|
||||||
|
static DocumentBuilderFactory hardenedFactory() throws ParserConfigurationException {
|
||||||
|
var factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||||
|
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||||
|
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||||
|
factory.setXIncludeAware(false);
|
||||||
|
factory.setExpandEntityReferences(false);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@@ -9,6 +10,9 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "persons")
|
@Table(name = "persons")
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
package org.raddatz.familienarchiv.security;
|
package org.raddatz.familienarchiv.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
||||||
|
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
import org.springframework.security.web.csrf.CsrfException;
|
||||||
|
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||||
|
// cannot be injected here. A static instance is safe because the response
|
||||||
|
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||||
|
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||||
|
|
||||||
private final CustomUserDetailsService userDetailsService;
|
private final CustomUserDetailsService userDetailsService;
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
|
|
||||||
@@ -34,20 +52,57 @@ public class SecurityConfig {
|
|||||||
return authProvider;
|
return authProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SessionAuthenticationStrategy sessionAuthenticationStrategy() {
|
||||||
|
// ChangeSessionIdAuthenticationStrategy rotates the session ID via the Servlet 3.1+
|
||||||
|
// HttpServletRequest.changeSessionId() — preserves attributes, mints a fresh ID.
|
||||||
|
// Used by AuthSessionController.login to defend against session fixation (CWE-384).
|
||||||
|
return new ChangeSessionIdAuthenticationStrategy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Order(1)
|
||||||
|
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatcher("/actuator/**")
|
||||||
|
.authorizeHttpRequests(auth -> {
|
||||||
|
// Health and Prometheus are open — Docker health checks and Prometheus scraping need no credentials.
|
||||||
|
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||||
|
// All other actuator endpoints (metrics, info, env, heapdump…) require authentication.
|
||||||
|
auth.anyRequest().authenticated();
|
||||||
|
})
|
||||||
|
// Explicitly return 401 for any unauthenticated actuator request.
|
||||||
|
// Without this override, Spring Security's DelegatingAuthenticationEntryPoint
|
||||||
|
// would redirect browser-like clients to the form-login page (302 → /login),
|
||||||
|
// making it impossible to distinguish "not authenticated" from "not found" in tests.
|
||||||
|
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||||
|
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
|
||||||
|
.formLogin(AbstractHttpConfigurer::disable)
|
||||||
|
.csrf(AbstractHttpConfigurer::disable);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
// CSRF is intentionally disabled: every request from the SvelteKit frontend
|
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||||
// carries an explicit Authorization header (Basic Auth token injected by
|
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||||
// hooks.server.ts). Browsers block cross-origin requests from setting custom
|
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||||
// headers, so cross-site request forgery via a third-party page is not
|
// See ADR-022 and issue #524 for the full security rationale.
|
||||||
// possible with this auth scheme. If the auth model ever changes to
|
.csrf(csrf -> csrf
|
||||||
// cookie-based sessions, CSRF protection must be re-enabled.
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||||
|
|
||||||
.authorizeHttpRequests(auth -> {
|
.authorizeHttpRequests(auth -> {
|
||||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||||
auth.requestMatchers("/actuator/health").permitAll();
|
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||||
|
// Login is unauthenticated by definition
|
||||||
|
auth.requestMatchers("/api/auth/login").permitAll();
|
||||||
// Password reset endpoints are unauthenticated by nature
|
// Password reset endpoints are unauthenticated by nature
|
||||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||||
// Invite-based registration endpoints are public
|
// Invite-based registration endpoints are public
|
||||||
@@ -67,9 +122,18 @@ public class SecurityConfig {
|
|||||||
// erlaubt pdf im Iframe
|
// erlaubt pdf im Iframe
|
||||||
.headers(headers -> headers
|
.headers(headers -> headers
|
||||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||||
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
|
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
|
||||||
.httpBasic(Customizer.withDefaults())
|
.exceptionHandling(ex -> ex
|
||||||
.formLogin(form -> form.usernameParameter("email"));
|
.authenticationEntryPoint(
|
||||||
|
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler((req, res, e) -> {
|
||||||
|
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
res.setContentType("application/json;charset=UTF-8");
|
||||||
|
ErrorCode code = (e instanceof CsrfException)
|
||||||
|
? ErrorCode.CSRF_TOKEN_MISSING
|
||||||
|
: ErrorCode.FORBIDDEN;
|
||||||
|
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||||
|
}));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package org.raddatz.familienarchiv.tag;
|
|||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ public class InviteListItemDTO {
|
|||||||
private String status;
|
private String status;
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String shareableUrl;
|
private String shareableUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ public class InviteService {
|
|||||||
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
||||||
Set<UUID> groupIds = new HashSet<>();
|
Set<UUID> groupIds = new HashSet<>();
|
||||||
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
||||||
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
|
Set<UUID> uniqueIds = new HashSet<>(dto.getGroupIds());
|
||||||
|
List<UserGroup> groups = userService.findGroupsByIds(new ArrayList<>(uniqueIds));
|
||||||
|
if (groups.size() != uniqueIds.size()) {
|
||||||
|
throw DomainException.notFound(ErrorCode.GROUP_NOT_FOUND, "One or more group IDs do not exist");
|
||||||
|
}
|
||||||
groups.forEach(g -> groupIds.add(g.getId()));
|
groups.forEach(g -> groupIds.add(g.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
|
|||||||
|
|
||||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||||
List<InviteToken> findAllOrderedByCreatedAt();
|
List<InviteToken> findAllOrderedByCreatedAt();
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
|
||||||
|
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -32,6 +33,7 @@ public class PasswordResetService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PasswordResetTokenRepository tokenRepository;
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private JavaMailSender mailSender;
|
private JavaMailSender mailSender;
|
||||||
@@ -85,6 +87,8 @@ public class PasswordResetService {
|
|||||||
|
|
||||||
resetToken.setUsed(true);
|
resetToken.setUsed(true);
|
||||||
tokenRepository.save(resetToken);
|
tokenRepository.save(resetToken);
|
||||||
|
|
||||||
|
authService.revokeAllSessions(user.getEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
||||||
@@ -26,13 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/")
|
@RequestMapping("/api/")
|
||||||
@AllArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private UserService userService;
|
private final UserService userService;
|
||||||
|
private final AuthService authService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
@GetMapping("users/me")
|
@GetMapping("users/me")
|
||||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||||
@@ -56,9 +62,14 @@ public class UserController {
|
|||||||
@PostMapping("users/me/password")
|
@PostMapping("users/me/password")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void changePassword(Authentication authentication,
|
public void changePassword(Authentication authentication,
|
||||||
|
HttpSession session,
|
||||||
@RequestBody ChangePasswordDTO dto) {
|
@RequestBody ChangePasswordDTO dto) {
|
||||||
AppUser current = userService.findByEmail(authentication.getName());
|
AppUser current = userService.findByEmail(authentication.getName());
|
||||||
userService.changePassword(current.getId(), dto);
|
userService.changePassword(current.getId(), dto);
|
||||||
|
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
|
||||||
|
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
|
||||||
|
"reason", "password_change",
|
||||||
|
"revokedCount", revoked));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("users/{id}")
|
@GetMapping("users/{id}")
|
||||||
@@ -101,6 +112,18 @@ public class UserController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/force-logout")
|
||||||
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
|
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
|
||||||
|
@PathVariable UUID id) {
|
||||||
|
AppUser target = userService.getById(id);
|
||||||
|
int revoked = authService.revokeAllSessions(target.getEmail());
|
||||||
|
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
|
||||||
|
"targetUserId", target.getId().toString(),
|
||||||
|
"revokedCount", revoked));
|
||||||
|
return ResponseEntity.ok(Map.of("revokedCount", revoked));
|
||||||
|
}
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
private UUID actorId(Authentication auth) {
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
return userService.findByEmail(auth.getName()).getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import org.springframework.boot.CommandLineRunner;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -31,26 +32,51 @@ import java.util.Set;
|
|||||||
@DependsOn("flyway")
|
@DependsOn("flyway")
|
||||||
public class UserDataInitializer {
|
public class UserDataInitializer {
|
||||||
|
|
||||||
@Value("${app.admin.email:admin@familyarchive.local}")
|
static final String DEFAULT_ADMIN_EMAIL = "admin@familienarchiv.local";
|
||||||
|
static final String DEFAULT_ADMIN_PASSWORD = "admin123";
|
||||||
|
|
||||||
|
@Value("${app.admin.email:" + DEFAULT_ADMIN_EMAIL + "}")
|
||||||
private String adminEmail;
|
private String adminEmail;
|
||||||
|
|
||||||
@Value("${app.admin.password:admin123}")
|
@Value("${app.admin.password:" + DEFAULT_ADMIN_PASSWORD + "}")
|
||||||
private String adminPassword;
|
private String adminPassword;
|
||||||
|
|
||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
if (userRepository.findByEmail(adminEmail).isEmpty()) {
|
if (userRepository.findByEmail(adminEmail).isEmpty()) {
|
||||||
|
// Fail-closed in production: refuse to seed with the well-known
|
||||||
|
// defaults. Otherwise an operator who forgets APP_ADMIN_USERNAME
|
||||||
|
// / APP_ADMIN_PASSWORD locks production to admin@…/admin123 PERMANENTLY
|
||||||
|
// (UserDataInitializer only seeds when the row is missing — see #513).
|
||||||
|
// Allowed in dev/test/e2e because those run without secrets configured.
|
||||||
|
boolean isLocalProfile = environment.matchesProfiles("dev", "test", "e2e");
|
||||||
|
if (!isLocalProfile
|
||||||
|
&& (DEFAULT_ADMIN_EMAIL.equals(adminEmail)
|
||||||
|
|| DEFAULT_ADMIN_PASSWORD.equals(adminPassword))) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Refusing to seed admin user with default credentials outside "
|
||||||
|
+ "the dev/test/e2e profiles. Set APP_ADMIN_USERNAME and "
|
||||||
|
+ "APP_ADMIN_PASSWORD to non-default values before first boot — "
|
||||||
|
+ "this lock-in is permanent."
|
||||||
|
);
|
||||||
|
}
|
||||||
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail);
|
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail);
|
||||||
|
|
||||||
UserGroup adminGroup = UserGroup.builder()
|
// Reuse the Administrators group if it already exists (e.g. a
|
||||||
|
// previous boot seeded the group but failed before creating
|
||||||
|
// the admin user, or the operator deleted just the user row
|
||||||
|
// to retry the seed with a new email). Blind-INSERTing would
|
||||||
|
// violate user_groups_name_key and abort the context. See #518.
|
||||||
|
UserGroup adminGroup = groupRepository.findByName("Administrators")
|
||||||
|
.orElseGet(() -> groupRepository.save(UserGroup.builder()
|
||||||
.name("Administrators")
|
.name("Administrators")
|
||||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||||
.build();
|
.build()));
|
||||||
groupRepository.save(adminGroup);
|
|
||||||
|
|
||||||
AppUser admin = AppUser.builder()
|
AppUser admin = AppUser.builder()
|
||||||
.email(adminEmail)
|
.email(adminEmail)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class UserService {
|
|||||||
|
|
||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
|
// Injected directly (not via InviteService) to avoid a constructor injection cycle:
|
||||||
|
// InviteService → UserService → InviteService. Spring Framework 7 forbids such cycles.
|
||||||
|
private final InviteTokenRepository inviteTokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
|
||||||
@@ -288,6 +291,10 @@ public class UserService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteGroup(UUID id) {
|
public void deleteGroup(UUID id) {
|
||||||
|
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
|
||||||
|
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
|
||||||
|
"Cannot delete group " + id + " — referenced by one or more active invites");
|
||||||
|
}
|
||||||
groupRepository.deleteById(id);
|
groupRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
spring:
|
spring:
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
|
# spring.session.cookie.secure is no longer a supported Boot 4.x property.
|
||||||
|
# DefaultCookieSerializer auto-detects Secure from request.isSecure().
|
||||||
|
# Direct HTTP in dev → isSecure()=false → cookie sent without Secure attribute.
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
|
|||||||
@@ -38,10 +38,64 @@ spring:
|
|||||||
starttls:
|
starttls:
|
||||||
enable: true
|
enable: true
|
||||||
|
|
||||||
|
session:
|
||||||
|
timeout: 28800s # 8 h idle timeout (MaxInactiveIntervalInSeconds)
|
||||||
|
jdbc:
|
||||||
|
initialize-schema: never # Flyway owns schema creation (V67)
|
||||||
|
# Cookie name, SameSite, and Secure are configured via SpringSessionConfig#cookieSerializer
|
||||||
|
# (spring.session.cookie.* is not supported in Spring Boot 4.x).
|
||||||
|
|
||||||
|
server:
|
||||||
|
# Behind Caddy/reverse proxy: trust X-Forwarded-{Proto,For,Host} so that
|
||||||
|
# request.getScheme(), redirect URLs, and Spring Session "Secure" cookies
|
||||||
|
# reflect the original https client request, not the http hop from Caddy.
|
||||||
|
forward-headers-strategy: native
|
||||||
|
|
||||||
management:
|
management:
|
||||||
|
server:
|
||||||
|
# Management port is separate from the app port so that:
|
||||||
|
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
|
||||||
|
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
|
||||||
|
# Note: in Spring Boot 4.0 the management port shares the security filter chain; /actuator/health
|
||||||
|
# and /actuator/prometheus must be explicitly permitted in SecurityConfig — see SecurityConfig.java.
|
||||||
|
port: 8081
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,prometheus,metrics
|
||||||
|
endpoint:
|
||||||
|
prometheus:
|
||||||
|
enabled: true
|
||||||
|
# Spring Boot 4.0: metrics export is disabled by default — explicitly opt in for Prometheus
|
||||||
|
prometheus:
|
||||||
|
metrics:
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
|
metrics:
|
||||||
|
tags:
|
||||||
|
# Common tag applied to every metric so Grafana's Spring Boot dashboard can filter by application name.
|
||||||
|
# Override via MANAGEMENT_METRICS_TAGS_APPLICATION env var.
|
||||||
|
application: ${spring.application.name}
|
||||||
health:
|
health:
|
||||||
mail:
|
mail:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
tracing:
|
||||||
|
sampling:
|
||||||
|
probability: 1.0 # 100% in dev; override via MANAGEMENT_TRACING_SAMPLING_PROBABILITY in prod compose
|
||||||
|
|
||||||
|
# OpenTelemetry trace export — failures are non-fatal (app starts cleanly without Tempo running)
|
||||||
|
# Port 4318 = OTLP HTTP (the default transport for Spring Boot's HttpExporter).
|
||||||
|
# Port 4317 is gRPC-only; sending HTTP/1.1 to it produces "Connection reset".
|
||||||
|
otel:
|
||||||
|
service:
|
||||||
|
name: familienarchiv-backend
|
||||||
|
exporter:
|
||||||
|
otlp:
|
||||||
|
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}
|
||||||
|
logs:
|
||||||
|
exporter: none # Promtail captures Docker logs; disable OTLP log export (Tempo only accepts traces)
|
||||||
|
metrics:
|
||||||
|
exporter: none # Prometheus scrapes /actuator/prometheus; disable OTLP metric export to Tempo
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
@@ -63,7 +117,11 @@ app:
|
|||||||
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
username: ${APP_ADMIN_USERNAME:admin}
|
# Key must be `email`, not `username` — UserDataInitializer reads
|
||||||
|
# `${app.admin.email:...}`. The env-var name stays APP_ADMIN_USERNAME
|
||||||
|
# to match the existing Gitea secrets and DEPLOYMENT.md §3.3.
|
||||||
|
# See #513.
|
||||||
|
email: ${APP_ADMIN_USERNAME:admin@familienarchiv.local}
|
||||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||||
|
|
||||||
import:
|
import:
|
||||||
@@ -83,3 +141,18 @@ ocr:
|
|||||||
sender-model:
|
sender-model:
|
||||||
activation-threshold: 100
|
activation-threshold: 100
|
||||||
retrain-delta: 50
|
retrain-delta: 50
|
||||||
|
|
||||||
|
sentry:
|
||||||
|
dsn: ${SENTRY_DSN:}
|
||||||
|
environment: ${SPRING_PROFILES_ACTIVE:dev}
|
||||||
|
traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0}
|
||||||
|
send-default-pii: false
|
||||||
|
enable-tracing: true
|
||||||
|
ignored-exceptions-for-type:
|
||||||
|
- org.raddatz.familienarchiv.exception.DomainException
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
login:
|
||||||
|
max-attempts-per-ip-email: 10
|
||||||
|
max-attempts-per-ip: 20
|
||||||
|
window-minutes: 15
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Speeds up "documents by sender" queries used on /persons/[id] Korrespondenz-Überblick (#306),
|
||||||
|
-- /briefwechsel, and bulk-edit flows.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_sender_id
|
||||||
|
ON documents(sender_id);
|
||||||
|
|
||||||
|
-- Speeds up "comments by author" queries on admin user detail and (future) contributor profile.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_author_id
|
||||||
|
ON document_comments(author_id);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- The composite PK (invite_token_id, group_id) does not support efficient lookups by group_id alone.
|
||||||
|
-- Add a dedicated index to support existsActiveWithGroupId queries.
|
||||||
|
CREATE INDEX idx_itg_group_id ON invite_token_group_ids (group_id);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Re-introduces the Spring Session JDBC tables that were dropped by V2 as unused.
|
||||||
|
-- DDL copied verbatim from Spring Session 3.x schema-postgresql.sql.
|
||||||
|
-- See ADR-020 and issue #523.
|
||||||
|
|
||||||
|
CREATE TABLE spring_session (
|
||||||
|
PRIMARY_ID CHAR(36) NOT NULL,
|
||||||
|
SESSION_ID CHAR(36) NOT NULL,
|
||||||
|
CREATION_TIME BIGINT NOT NULL,
|
||||||
|
LAST_ACCESS_TIME BIGINT NOT NULL,
|
||||||
|
MAX_INACTIVE_INTERVAL INT NOT NULL,
|
||||||
|
EXPIRY_TIME BIGINT NOT NULL,
|
||||||
|
PRINCIPAL_NAME VARCHAR(100),
|
||||||
|
CONSTRAINT spring_session_pk PRIMARY KEY (PRIMARY_ID)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX spring_session_ix1 ON spring_session (SESSION_ID);
|
||||||
|
CREATE INDEX spring_session_ix2 ON spring_session (EXPIRY_TIME);
|
||||||
|
CREATE INDEX spring_session_ix3 ON spring_session (PRINCIPAL_NAME);
|
||||||
|
|
||||||
|
CREATE TABLE spring_session_attributes (
|
||||||
|
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
|
||||||
|
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
|
||||||
|
ATTRIBUTE_BYTES BYTEA NOT NULL,
|
||||||
|
CONSTRAINT spring_session_attributes_pk PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
|
||||||
|
CONSTRAINT spring_session_attributes_fk FOREIGN KEY (SESSION_PRIMARY_ID)
|
||||||
|
REFERENCES spring_session (PRIMARY_ID) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.raddatz.familienarchiv;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalManagementPort;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class ActuatorPrometheusIT {
|
||||||
|
|
||||||
|
@LocalManagementPort
|
||||||
|
private int managementPort;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prometheus_endpoint_returns_200_without_credentials() {
|
||||||
|
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||||
|
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prometheus_endpoint_returns_jvm_metrics() {
|
||||||
|
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||||
|
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||||
|
|
||||||
|
assertThat(response.getBody()).contains("jvm_memory_used_bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actuator_metrics_requires_authentication() {
|
||||||
|
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||||
|
"http://localhost:" + managementPort + "/actuator/metrics", String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RestTemplate noThrowTemplate() {
|
||||||
|
RestTemplate template = new RestTemplate();
|
||||||
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
@Override
|
||||||
|
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.raddatz.familienarchiv;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalManagementPort;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class ActuatorSecurityTest {
|
||||||
|
|
||||||
|
@LocalManagementPort
|
||||||
|
private int managementPort;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actuator_health_is_accessible_without_authentication() {
|
||||||
|
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||||
|
"http://localhost:" + managementPort + "/actuator/health", String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actuator_env_requires_authentication() {
|
||||||
|
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||||
|
"http://localhost:" + managementPort + "/actuator/env", String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RestTemplate noThrowTemplate() {
|
||||||
|
RestTemplate template = new RestTemplate();
|
||||||
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
@Override
|
||||||
|
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
package org.raddatz.familienarchiv;
|
package org.raddatz.familienarchiv;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
@@ -17,9 +21,18 @@ class ApplicationContextTest {
|
|||||||
@MockitoBean
|
@MockitoBean
|
||||||
S3Client s3Client;
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ApplicationContext ctx;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {
|
||||||
// verifies that the Spring context starts successfully with all beans wired,
|
// verifies that the Spring context starts successfully with all beans wired,
|
||||||
// Flyway migrations applied, and no configuration errors
|
// Flyway migrations applied, and no configuration errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sentry_is_disabled_when_no_dsn_is_configured() {
|
||||||
|
// application-test.yaml has no sentry.dsn — SDK must stay inactive so tests are clean
|
||||||
|
assertThat(io.sentry.Sentry.isEnabled()).isFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,6 +399,24 @@ class MigrationIntegrationTest {
|
|||||||
AND dc.annotation_id IS NOT NULL
|
AND dc.annotation_id IS NOT NULL
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
// ─── V62: indexes on FK columns ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v62_idx_documents_sender_id_exists() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'documents' AND indexname = 'idx_documents_sender_id'",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v62_idx_comments_author_id_exists() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM pg_catalog.pg_indexes WHERE tablename = 'document_comments' AND indexname = 'idx_comments_author_id'",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
|
// ─── V63+V64: group_permissions dedup + primary key ──────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
@@ -18,7 +18,6 @@ import static org.awaitility.Awaitility.await;
|
|||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
|
||||||
class AuditServiceIntegrationTest {
|
class AuditServiceIntegrationTest {
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
@MockitoBean S3Client s3Client;
|
||||||
@@ -26,6 +25,11 @@ class AuditServiceIntegrationTest {
|
|||||||
@Autowired AuditLogRepository auditLogRepository;
|
@Autowired AuditLogRepository auditLogRepository;
|
||||||
@Autowired TransactionTemplate transactionTemplate;
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetAuditLog() {
|
||||||
|
auditLogRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
|
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
|
||||||
transactionTemplate.execute(status -> {
|
transactionTemplate.execute(status -> {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthServiceTest {
|
||||||
|
|
||||||
|
@Mock AuthenticationManager authenticationManager;
|
||||||
|
@Mock UserService userService;
|
||||||
|
@Mock AuditService auditService;
|
||||||
|
@Mock LoginRateLimiter loginRateLimiter;
|
||||||
|
@Mock SessionRevocationPort sessionRevocationPort;
|
||||||
|
@InjectMocks AuthService authService;
|
||||||
|
|
||||||
|
private static final String IP = "127.0.0.1";
|
||||||
|
private static final String UA = "Mozilla/5.0 (Test)";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_returns_user_on_valid_credentials() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||||
|
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
AuthService.LoginResult result = authService.login("user@test.de", "pass123", IP, UA);
|
||||||
|
|
||||||
|
assertThat(result.user()).isEqualTo(user);
|
||||||
|
assertThat(result.authentication()).isEqualTo(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_fires_LOGIN_SUCCESS_audit_on_valid_credentials() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||||
|
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
authService.login("user@test.de", "pass123", IP, UA);
|
||||||
|
|
||||||
|
verify(auditService).log(
|
||||||
|
eq(AuditKind.LOGIN_SUCCESS),
|
||||||
|
eq(userId),
|
||||||
|
isNull(),
|
||||||
|
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
|
||||||
|
&& IP.equals(payload.get("ip"))
|
||||||
|
&& !payload.containsKey("password"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_throws_INVALID_CREDENTIALS_on_bad_password() {
|
||||||
|
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_fires_LOGIN_FAILED_audit_on_bad_credentials_without_password_in_payload() {
|
||||||
|
when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("bad"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "wrong", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
verify(auditService).log(
|
||||||
|
eq(AuditKind.LOGIN_FAILED),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
argThat(payload -> "user@test.de".equals(payload.get("email"))
|
||||||
|
&& IP.equals(payload.get("ip"))
|
||||||
|
&& !payload.containsKey("password")
|
||||||
|
&& !payload.containsKey("pwd")
|
||||||
|
&& !payload.containsKey("passwordAttempt"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_treats_unknown_user_identically_to_bad_password() {
|
||||||
|
when(authenticationManager.authenticate(any()))
|
||||||
|
.thenThrow(new BadCredentialsException("unknown user hidden as bad creds"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("unknown@test.de", "any", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.INVALID_CREDENTIALS));
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.LOGIN_FAILED), isNull(), isNull(), anyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_fires_LOGOUT_audit() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
authService.logout("user@test.de", IP, UA);
|
||||||
|
|
||||||
|
verify(auditService).log(
|
||||||
|
eq(AuditKind.LOGOUT),
|
||||||
|
eq(userId),
|
||||||
|
isNull(),
|
||||||
|
argThat(payload -> userId.toString().equals(payload.get("userId").toString())
|
||||||
|
&& IP.equals(payload.get("ip"))
|
||||||
|
&& !payload.containsKey("password"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_checks_rate_limit_before_authenticating() {
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
|
||||||
|
verify(authenticationManager, never()).authenticate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
||||||
|
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_invalidates_rate_limit_on_success() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||||
|
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
authService.login("user@test.de", "pass123", IP, UA);
|
||||||
|
|
||||||
|
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_delegates_to_port() {
|
||||||
|
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
||||||
|
|
||||||
|
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_delegates_to_port() {
|
||||||
|
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||||
|
|
||||||
|
int count = authService.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(3);
|
||||||
|
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService.LoginResult;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(AuthSessionController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class AuthSessionControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean AuthService authService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
@MockitoBean SessionAuthenticationStrategy sessionAuthenticationStrategy;
|
||||||
|
|
||||||
|
// ─── POST /api/auth/login ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_returns_200_with_user_on_valid_credentials() throws Exception {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser appUser = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = mock(Authentication.class);
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.email").value("user@test.de"))
|
||||||
|
.andExpect(jsonPath("$.id").value(userId.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_returns_401_with_INVALID_CREDENTIALS_on_bad_credentials() throws Exception {
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.INVALID_CREDENTIALS.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_is_public_no_session_required() throws Exception {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser appUser = AppUser.builder().id(userId).email("pub@test.de").build();
|
||||||
|
Authentication auth = mock(Authentication.class);
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
|
// No WithMockUser — must be reachable without an active session
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_delegates_to_SessionAuthenticationStrategy_for_fixation_protection() throws Exception {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser appUser = AppUser.builder().id(userId).email("fix@test.de").build();
|
||||||
|
Authentication auth = mock(Authentication.class);
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
// Session-fixation defense (CWE-384): the controller must hand the new
|
||||||
|
// Authentication to Spring Security's strategy, which rotates the session ID.
|
||||||
|
verify(sessionAuthenticationStrategy).onAuthentication(eq(auth), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_response_body_does_not_contain_password_field() throws Exception {
|
||||||
|
// Regression guard: AppUser.password is @JsonProperty(WRITE_ONLY). If anyone
|
||||||
|
// ever drops that annotation, this assertion catches the credential leak on
|
||||||
|
// the very next CI run.
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser appUser = AppUser.builder()
|
||||||
|
.id(userId)
|
||||||
|
.email("leak@test.de")
|
||||||
|
.password("$2a$10$shouldnotappearinresponse")
|
||||||
|
.build();
|
||||||
|
Authentication auth = mock(Authentication.class);
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.password").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.pwd").doesNotExist())
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(
|
||||||
|
org.hamcrest.Matchers.containsString("$2a$10$shouldnotappearinresponse"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_does_not_set_cookie_on_failure() throws Exception {
|
||||||
|
when(authService.login(anyString(), anyString(), anyString(), anyString()))
|
||||||
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
// Red test: CSRF disabled → returns 204; after re-enabling returns 403.
|
||||||
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
|
.with(user("user@test.de"))) // authenticated but no CSRF token
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_returns_204_when_authenticated() throws Exception {
|
||||||
|
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
|
.with(user("user@test.de"))
|
||||||
|
.with(csrf()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_without_session_returns_403() throws Exception {
|
||||||
|
// CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
|
||||||
|
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
|
||||||
|
mockMvc.perform(post("/api/auth/logout"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_returns_204_even_when_audit_throws() throws Exception {
|
||||||
|
// CWE-613 defense: the session MUST be invalidated even if the audit lookup
|
||||||
|
// explodes (e.g. user deleted between login and logout). Audit is best-effort.
|
||||||
|
doThrow(new RuntimeException("audit DB down"))
|
||||||
|
.when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
|
.with(user("ghost@test.de"))
|
||||||
|
.with(csrf()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class AuthSessionIntegrationTest {
|
||||||
|
|
||||||
|
@LocalServerPort int port;
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
@Autowired AppUserRepository userRepository;
|
||||||
|
@Autowired PasswordEncoder passwordEncoder;
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
private RestTemplate http;
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
private static final String TEST_EMAIL = "session-it@test.de";
|
||||||
|
private static final String TEST_PASSWORD = "pass4Session!";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
http = noThrowRestTemplate();
|
||||||
|
baseUrl = "http://localhost:" + port;
|
||||||
|
// spring_session_attributes cascades on delete — removing the parent row is enough
|
||||||
|
jdbcTemplate.update("DELETE FROM spring_session");
|
||||||
|
jdbcTemplate.update("DELETE FROM app_users WHERE email = ?", TEST_EMAIL);
|
||||||
|
userRepository.save(AppUser.builder()
|
||||||
|
.email(TEST_EMAIL)
|
||||||
|
.password(passwordEncoder.encode(TEST_PASSWORD))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task 13: full session lifecycle ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_sets_opaque_fa_session_cookie() {
|
||||||
|
String xsrf = fetchXsrfToken();
|
||||||
|
ResponseEntity<String> response = doLogin(xsrf);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
String cookie = extractFaSessionCookie(response);
|
||||||
|
assertThat(cookie).isNotBlank();
|
||||||
|
// Opaque token — must not look like Basic-auth credentials (email:password)
|
||||||
|
assertThat(cookie).doesNotContain(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void session_cookie_authenticates_subsequent_request() {
|
||||||
|
String xsrf = fetchXsrfToken();
|
||||||
|
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
|
ResponseEntity<String> me = http.exchange(
|
||||||
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||||
|
|
||||||
|
assertThat(me.getStatusCode().value()).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||||
|
String xsrf = fetchXsrfToken();
|
||||||
|
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
|
ResponseEntity<Void> logout = http.postForEntity(
|
||||||
|
baseUrl + "/api/auth/logout",
|
||||||
|
new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
|
||||||
|
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||||
|
|
||||||
|
ResponseEntity<String> me = http.exchange(
|
||||||
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
|
||||||
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task 14: idle-timeout ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void session_expired_by_idle_timeout_returns_401() {
|
||||||
|
String xsrf = fetchXsrfToken();
|
||||||
|
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
|
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||||
|
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE spring_session SET LAST_ACCESS_TIME = ?, EXPIRY_TIME = ?",
|
||||||
|
nineHoursAgoMs, nineHoursAgoMs);
|
||||||
|
|
||||||
|
ResponseEntity<String> me = http.exchange(
|
||||||
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
|
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
||||||
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||||
|
ResponseEntity<String> response = http.postForEntity(
|
||||||
|
baseUrl + "/api/auth/logout",
|
||||||
|
new HttpEntity<>("{}", headers), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||||
|
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an XSRF token for use in integration tests.
|
||||||
|
* CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X.
|
||||||
|
* By supplying both with the same value we simulate exactly what a browser does.
|
||||||
|
*/
|
||||||
|
private String fetchXsrfToken() {
|
||||||
|
return java.util.UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> doLogin(String xsrfToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||||
|
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||||
|
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||||
|
new HttpEntity<>(body, headers), String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders cookieHeaders(String sessionId) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Cookie", "fa_session=" + sessionId);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||||
|
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||||
|
if (setCookieHeader == null) return "";
|
||||||
|
return setCookieHeader.stream()
|
||||||
|
.filter(c -> c.startsWith("fa_session="))
|
||||||
|
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||||
|
.findFirst()
|
||||||
|
.orElse("");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private RestTemplate noThrowRestTemplate() {
|
||||||
|
RestTemplate template = new RestTemplate();
|
||||||
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
@Override
|
||||||
|
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for {@link JdbcSessionRevocationAdapter} that verifies
|
||||||
|
* session rows are actually written to / removed from the {@code spring_session}
|
||||||
|
* table backed by a real PostgreSQL container.
|
||||||
|
*
|
||||||
|
* <p>Sessions are inserted via raw JDBC to avoid the module-access restriction on
|
||||||
|
* {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort}
|
||||||
|
* bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JdbcSessionRevocationAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired SessionRevocationPort adapter;
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
private static final String PRINCIPAL = "revocation-it@test.de";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearSessions() {
|
||||||
|
// spring_session_attributes cascades on delete
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("DELETE FROM spring_session");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL}
|
||||||
|
* and returns its opaque primary-key ID (the value the repository uses as the
|
||||||
|
* session identifier, not the {@code SESSION_ID} column which holds the public token).
|
||||||
|
*
|
||||||
|
* <p>Column layout mirrors the Flyway-managed schema shipped with the app:
|
||||||
|
* PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL,
|
||||||
|
* EXPIRY_TIME, PRINCIPAL_NAME.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Inserts a persisted session row for {@value #PRINCIPAL} and returns the
|
||||||
|
* {@code SESSION_ID} column value — this is the opaque identifier that
|
||||||
|
* {@link JdbcIndexedSessionRepository} uses as the session's public key
|
||||||
|
* (returned by {@code JdbcSession.getId()} and expected by
|
||||||
|
* {@link JdbcIndexedSessionRepository#deleteById}).
|
||||||
|
*
|
||||||
|
* <p>The inserts run inside a {@link TransactionTemplate} so the rows are
|
||||||
|
* committed before {@code findByPrincipalName} opens its own transaction and
|
||||||
|
* can see the data via Read Committed isolation.
|
||||||
|
*/
|
||||||
|
private String insertSession() {
|
||||||
|
String primaryId = UUID.randomUUID().toString();
|
||||||
|
// SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys.
|
||||||
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
long now = Instant.now().toEpochMilli();
|
||||||
|
long expiry = now + 8L * 3600 * 1000; // 8-hour TTL
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session
|
||||||
|
(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME,
|
||||||
|
MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL);
|
||||||
|
// Spring Session's listSessionsByPrincipalName query joins spring_session_attributes;
|
||||||
|
// insert a minimal attribute row so the session appears in the result set.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session_attributes
|
||||||
|
(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, "test_attr", new byte[]{0});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return sessionId; // the public key used by JdbcSession.getId() and deleteById()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_removes_every_row_from_spring_session_table() {
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions(PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() {
|
||||||
|
String keepId = insertSession();
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions(keepId, PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
// The current session row must still be present (keyed by SESSION_ID)
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?",
|
||||||
|
Long.class, keepId))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
// The total for this principal is now exactly 1
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class JdbcSessionRevocationAdapterTest {
|
||||||
|
|
||||||
|
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-keep", null);
|
||||||
|
sessions.put("session-del-1", null);
|
||||||
|
sessions.put("session-del-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository, never()).deleteById("session-keep");
|
||||||
|
verify(sessionRepository).deleteById("session-del-1");
|
||||||
|
verify(sessionRepository).deleteById("session-del-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_deletes_all_sessions_for_principal() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-1", null);
|
||||||
|
sessions.put("session-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository).deleteById("session-1");
|
||||||
|
verify(sessionRepository).deleteById("session-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class LoginRateLimiterTest {
|
||||||
|
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(10);
|
||||||
|
props.setMaxAttemptsPerIp(20);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
rateLimiter = new LoginRateLimiter(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
|
||||||
|
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_after_10_failures_resets_ip_email_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||||
|
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||||
|
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(3);
|
||||||
|
props.setMaxAttemptsPerIp(3);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||||
|
|
||||||
|
// Exhaust the per-IP bucket using "user@"
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three blocked attempts for "target@" while IP is exhausted
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||||
|
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||||
|
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||||
|
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
|
||||||
|
import org.springframework.boot.web.server.autoconfigure.ServerProperties.ForwardHeadersStrategy;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds {@code server.forward-headers-strategy} from {@code application.yaml} into
|
||||||
|
* Spring Boot's typed {@link ForwardHeadersStrategy} enum. The binder rejects any
|
||||||
|
* value that is not a valid enum constant ({@code BindException}), so a typo
|
||||||
|
* ({@code "nativ"}, {@code "Native"}, {@code "framework "}) or a future Spring
|
||||||
|
* rename of the property fails the test, not silently degrades to {@code NONE}.
|
||||||
|
*
|
||||||
|
* <p>No Spring context, no embedded server, no Testcontainers — this is the
|
||||||
|
* cheapest test that pins the contract "Caddy's X-Forwarded-Proto is trusted".
|
||||||
|
*/
|
||||||
|
class ForwardHeadersConfigurationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forward_headers_strategy_binds_to_NATIVE() {
|
||||||
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
|
yaml.setResources(new ClassPathResource("application.yaml"));
|
||||||
|
Properties props = yaml.getObject();
|
||||||
|
assertThat(props).as("application.yaml must be on the classpath").isNotNull();
|
||||||
|
|
||||||
|
Binder binder = new Binder(ConfigurationPropertySources.from(
|
||||||
|
new PropertiesPropertySource("application", props)));
|
||||||
|
|
||||||
|
ForwardHeadersStrategy strategy = binder
|
||||||
|
.bind("server.forward-headers-strategy", ForwardHeadersStrategy.class)
|
||||||
|
.orElseThrow(() -> new AssertionError(
|
||||||
|
"server.forward-headers-strategy is missing from application.yaml"));
|
||||||
|
|
||||||
|
assertThat(strategy)
|
||||||
|
.as("Spring must trust X-Forwarded-Proto from Caddy so that "
|
||||||
|
+ "request.getScheme(), redirect URLs, and the Spring Session "
|
||||||
|
+ "'Secure' cookie reflect the original https client request.")
|
||||||
|
.isEqualTo(ForwardHeadersStrategy.NATIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ class RateLimitInterceptorTest {
|
|||||||
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_response_includes_retry_after_header() throws Exception {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
}
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
verify(response).setHeader("Retry-After", "60");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void different_ips_have_independent_limits() throws Exception {
|
void different_ips_have_independent_limits() throws Exception {
|
||||||
HttpServletRequest other = mock(HttpServletRequest.class);
|
HttpServletRequest other = mock(HttpServletRequest.class);
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(DocumentController.class)
|
@WebMvcTest(DocumentController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -214,14 +216,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +246,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +254,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +271,7 @@ class DocumentControllerTest {
|
|||||||
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id)
|
mockMvc.perform(multipart("/api/documents/" + id)
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + UUID.randomUUID()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +288,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + UUID.randomUUID()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +297,7 @@ class DocumentControllerTest {
|
|||||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + id))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,14 +305,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +328,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
@@ -345,7 +347,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||||
@@ -360,7 +362,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||||
@@ -490,7 +492,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
@@ -640,7 +642,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -649,7 +651,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -659,7 +661,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -671,7 +673,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -682,7 +684,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -696,7 +698,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +715,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.id").value(id.toString()))
|
.andExpect(jsonPath("$.id").value(id.toString()))
|
||||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||||
@@ -726,7 +728,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile(
|
new org.springframework.mock.web.MockMultipartFile(
|
||||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +745,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +802,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created.length()").value(3))
|
.andExpect(jsonPath("$.created.length()").value(3))
|
||||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||||
@@ -827,7 +829,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||||
@@ -859,7 +861,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||||
@@ -883,7 +885,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +906,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||||
@@ -926,7 +928,7 @@ class DocumentControllerTest {
|
|||||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||||
}
|
}
|
||||||
|
|
||||||
mockMvc.perform(builder)
|
mockMvc.perform(builder.with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
}
|
}
|
||||||
@@ -945,7 +947,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -954,7 +956,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -965,7 +967,7 @@ class DocumentControllerTest {
|
|||||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"documentIds\":[]}"))
|
.content("{\"documentIds\":[]}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -976,7 +978,7 @@ class DocumentControllerTest {
|
|||||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -990,7 +992,7 @@ class DocumentControllerTest {
|
|||||||
String[] ids = new String[501];
|
String[] ids = new String[501];
|
||||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -1009,7 +1011,7 @@ class DocumentControllerTest {
|
|||||||
String tooLong = "x".repeat(256);
|
String tooLong = "x".repeat(256);
|
||||||
|
|
||||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -1025,7 +1027,7 @@ class DocumentControllerTest {
|
|||||||
String[] ids = new String[500];
|
String[] ids = new String[500];
|
||||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1042,7 +1044,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
// Same id sent three times — controller should dedupe and call the
|
// Same id sent three times — controller should dedupe and call the
|
||||||
// service exactly once, returning updated=1, not 3.
|
// service exactly once, returning updated=1, not 3.
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1061,7 +1063,7 @@ class DocumentControllerTest {
|
|||||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(id1.toString(), id2.toString())))
|
.content(bulkBody(id1.toString(), id2.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1137,7 +1139,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,7 +1148,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,7 +1157,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[]}"))
|
.content("{\"ids\":[]}").with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1174,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(sb.toString()))
|
.content(sb.toString()).with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1189,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
.content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||||
@@ -1208,7 +1210,7 @@ class DocumentControllerTest {
|
|||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(badId.toString())))
|
.content(bulkBody(badId.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1232,7 +1234,7 @@ class DocumentControllerTest {
|
|||||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(okId.toString(), badId.toString())))
|
.content(bulkBody(okId.toString(), badId.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1337,4 +1339,16 @@ class DocumentControllerTest {
|
|||||||
DocumentStatus.REVIEWED,
|
DocumentStatus.REVIEWED,
|
||||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository-level integration tests for {@code findFtsPageRaw}: verifies that the
|
||||||
|
* paginated FTS query returns exactly page-size rows and that the window-function
|
||||||
|
* total reflects the full match count, not just the page count.
|
||||||
|
*
|
||||||
|
* <p>Uses real Postgres via Testcontainers so the GIN index, tsvector trigger, and
|
||||||
|
* {@code websearch_to_tsquery} semantics are identical to production.
|
||||||
|
*
|
||||||
|
* <p>{@code AFTER_CLASS} dirty-context keeps the Spring context alive for all tests
|
||||||
|
* in this class and rebuilds it once at the end, rather than after every test.
|
||||||
|
*/
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||||
|
class DocumentFtsPagedIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
|
// 60 docs match "Walter"; 10 docs with "Hans" do not.
|
||||||
|
private static final int WALTER_COUNT = 60;
|
||||||
|
private static final int PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
em.flush();
|
||||||
|
for (int i = 0; i < WALTER_COUNT; i++) {
|
||||||
|
documentRepository.saveAndFlush(doc("Brief von Walter Nr. " + i));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
documentRepository.saveAndFlush(doc("Brief von Hans Nr. " + i));
|
||||||
|
}
|
||||||
|
em.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFtsPageRaw_firstPage_returnsPageSizeRows() {
|
||||||
|
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFtsPageRaw_windowTotal_equalsFullMatchCount_notPageSize() {
|
||||||
|
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", 0, PAGE_SIZE);
|
||||||
|
|
||||||
|
long total = ((Number) rows.get(0)[2]).longValue();
|
||||||
|
assertThat(total).isEqualTo(WALTER_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFtsPageRaw_lastPage_returnsRemainder() {
|
||||||
|
int remainder = WALTER_COUNT % PAGE_SIZE; // 60 % 50 = 10
|
||||||
|
List<Object[]> rows = documentRepository.findFtsPageRaw("Walter", PAGE_SIZE, PAGE_SIZE);
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(remainder);
|
||||||
|
long total = ((Number) rows.get(0)[2]).longValue();
|
||||||
|
assertThat(total).isEqualTo(WALTER_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFtsPageRaw_noMatches_returnsEmptyList() {
|
||||||
|
List<Object[]> rows = documentRepository.findFtsPageRaw("XYZ_KEIN_TREFFER", 0, PAGE_SIZE);
|
||||||
|
|
||||||
|
assertThat(rows).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFtsPageRaw_stopwordOnlyQuery_returnsEmptyList_noException() {
|
||||||
|
assertThatNoException().isThrownBy(() -> {
|
||||||
|
List<Object[]> rows = documentRepository.findFtsPageRaw("der die das und", 0, PAGE_SIZE);
|
||||||
|
assertThat(rows).isEmpty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Document doc(String title) {
|
||||||
|
return Document.builder()
|
||||||
|
.title(title)
|
||||||
|
.originalFilename(title.replace(" ", "_") + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Alter Brief"));
|
documentRepository.saveAndFlush(document("Alter Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Alter Brief"));
|
documentRepository.saveAndFlush(document("Alter Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Briefe");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Briefe");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
|
documentRepository.saveAndFlush(document("Ein furchtbarer Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("furchtb");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("furchtb");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Familienfoto"));
|
documentRepository.saveAndFlush(document("Familienfoto"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Brief");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Brief");
|
||||||
|
|
||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("schreiben");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("schreiben");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -125,14 +125,14 @@ class DocumentFtsTest {
|
|||||||
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
|
Document doc = documentRepository.saveAndFlush(document("Leeres Dokument"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).isEmpty();
|
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).isEmpty();
|
||||||
|
|
||||||
UUID annotationId = annotation(doc.getId());
|
UUID annotationId = annotation(doc.getId());
|
||||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
|
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Grundbuch Eintrag 1923", 0));
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
|
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,13 +144,13 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).contains(doc.getId());
|
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).contains(doc.getId());
|
||||||
|
|
||||||
blockRepository.deleteById(block.getId());
|
blockRepository.deleteById(block.getId());
|
||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThat(documentRepository.findRankedIdsByFts("Grundbuch")).doesNotContain(doc.getId());
|
assertThat(documentRepository.findAllMatchingIdsByFts("Grundbuch")).doesNotContain(doc.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ranking ───────────────────────────────────────────────────────────────
|
// ─── Ranking ───────────────────────────────────────────────────────────────
|
||||||
@@ -166,7 +166,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Grundbuch");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Grundbuch");
|
||||||
|
|
||||||
assertThat(ids).hasSize(2);
|
assertThat(ids).hasSize(2);
|
||||||
assertThat(ids.get(0)).isEqualTo(docA.getId());
|
assertThat(ids.get(0)).isEqualTo(docA.getId());
|
||||||
@@ -179,7 +179,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
|
documentRepository.saveAndFlush(document("Ein Brief von der Oma"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("der die das und");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("der die das und");
|
||||||
|
|
||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Wille");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ class DocumentFtsTest {
|
|||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
assertThatNoException().isThrownBy(() -> documentRepository.findRankedIdsByFts("((("));
|
assertThatNoException().isThrownBy(() -> documentRepository.findAllMatchingIdsByFts("((("));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Weight C: sender/receiver names ───────────────────────────────────────
|
// ─── Weight C: sender/receiver names ───────────────────────────────────────
|
||||||
@@ -223,7 +223,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Schmidt");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Schmidt");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Raddatz");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Raddatz");
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
assertThat(ids).contains(doc.getId());
|
||||||
}
|
}
|
||||||
@@ -260,7 +260,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Familiengeschichte");
|
List<UUID> ids = documentRepository.findAllMatchingIdsByFts("Familiengeschichte");
|
||||||
|
|
||||||
assertThat(ids).hasSize(1);
|
assertThat(ids).hasSize(1);
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,7 @@ class DocumentFtsTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
em.clear();
|
em.clear();
|
||||||
|
|
||||||
List<UUID> rankedIds = documentRepository.findRankedIdsByFts("Grundbuch");
|
List<UUID> rankedIds = documentRepository.findAllMatchingIdsByFts("Grundbuch");
|
||||||
Specification<Document> spec = Specification.where(hasIds(rankedIds))
|
Specification<Document> spec = Specification.where(hasIds(rankedIds))
|
||||||
.and(hasStatus(DocumentStatus.UPLOADED));
|
.and(hasStatus(DocumentStatus.UPLOADED));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
|
import org.raddatz.familienarchiv.dashboard.DashboardService;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that lazy-loaded associations on {@link Document} are accessible after a service
|
||||||
|
* method returns — i.e. no {@link org.hibernate.LazyInitializationException} is thrown outside
|
||||||
|
* the Hibernate session that loaded the entity.
|
||||||
|
*
|
||||||
|
* <p><b>Known limitation:</b> calling {@code getDocumentById} (or any other service method) from
|
||||||
|
* within an already-open transaction is not covered here. When an outer transaction is active,
|
||||||
|
* the service's own {@code @Transactional} merges into it and Hibernate keeps the same session
|
||||||
|
* open, so the lazy-init guard behaves differently than in a non-transactional caller. This is a
|
||||||
|
* known constraint of the test setup, not a bug in the production code.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class DocumentLazyLoadingTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TagRepository tagRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentService documentService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DashboardService dashboardService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditLogQueryService auditLogQueryService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentById_tagsAndReceiversAccessible_afterReturnFromService() {
|
||||||
|
Person sender = savedPerson("Max", "LzSender");
|
||||||
|
Person receiver = savedPerson("Anna", "LzReceiver");
|
||||||
|
Tag tag = savedTag("LzTag");
|
||||||
|
Document doc = savedDocument("LazyTest", "lazy_test.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
|
Document result = documentService.getDocumentById(doc.getId());
|
||||||
|
|
||||||
|
// Only the collection access itself is in assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
result.getTags().size();
|
||||||
|
result.getReceivers().size();
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
assertThat(result.getTags()).isNotEmpty();
|
||||||
|
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
|
||||||
|
assertThat(result.getReceivers()).isNotEmpty();
|
||||||
|
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_collectionsAccessibleAfterReturn() {
|
||||||
|
Person sender = savedPerson("Hans", "RaSender");
|
||||||
|
Tag tag = savedTag("RaTag");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
savedDocument("RaDoc " + i, "ra_doc" + i + ".pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Document> results = documentService.getRecentActivity(3);
|
||||||
|
|
||||||
|
// Access lazy fields inside assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
results.forEach(d -> d.getSender().getLastName());
|
||||||
|
results.forEach(d -> d.getTags().size());
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
results.forEach(d -> assertThat(d.getSender()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getTags()).isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_receiverSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SrSender");
|
||||||
|
Person receiver = savedPerson("Anna", "SrReceiver");
|
||||||
|
Tag tag = savedTag("SrTag");
|
||||||
|
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.RECEIVER, "asc", null,
|
||||||
|
PageRequest.of(0, 20));
|
||||||
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
|
assertThatCode(() ->
|
||||||
|
result.items().forEach(i -> i.document().getSender().getLastName()))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SsSender");
|
||||||
|
Tag tag = savedTag("SsTag");
|
||||||
|
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
|
||||||
|
assertThatCode(() -> documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.SENDER, "asc", null,
|
||||||
|
PageRequest.of(0, 20)))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException() {
|
||||||
|
Person sender = savedPerson("Max", "DsSender");
|
||||||
|
Person receiver = savedPerson("Anna", "DsReceiver");
|
||||||
|
Document doc = savedDocument("DashboardTest", "dashboard_test.pdf", sender, Set.of(receiver), Set.of());
|
||||||
|
UUID fakeUserId = UUID.randomUUID();
|
||||||
|
when(auditLogQueryService.findMostRecentDocumentForUser(any())).thenReturn(Optional.of(doc.getId()));
|
||||||
|
when(auditLogQueryService.findRecentContributorsPerDocument(any())).thenReturn(java.util.Map.of());
|
||||||
|
|
||||||
|
assertThatCode(() -> dashboardService.getResume(fakeUserId))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person savedPerson(String firstName, String lastName) {
|
||||||
|
return personRepository.save(Person.builder().firstName(firstName).lastName(lastName).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tag savedTag(String name) {
|
||||||
|
return tagRepository.save(Tag.builder().name(name).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document savedDocument(String title, String filename, Person sender,
|
||||||
|
Set<Person> receivers, Set<Tag> tags) {
|
||||||
|
return documentRepository.save(Document.builder()
|
||||||
|
.title(title).originalFilename(filename)
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(receivers))
|
||||||
|
.tags(new HashSet<>(tags))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.hibernate.stat.Statistics;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
@@ -21,6 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -55,6 +60,12 @@ class DocumentRepositoryTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TranscriptionBlockRepository transcriptionBlockRepository;
|
private TranscriptionBlockRepository transcriptionBlockRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManagerFactory entityManagerFactory;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
// ─── save and findById ────────────────────────────────────────────────────
|
// ─── save and findById ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -490,6 +501,117 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── query-count — entity-graph assertions ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
|
||||||
|
Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver)))
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs, PageRequest.of(0, 10));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
|
||||||
|
Set<Person> receivers = new HashSet<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
receivers.add(personRepository.save(
|
||||||
|
Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
|
||||||
|
}
|
||||||
|
Set<Tag> tags = new HashSet<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
|
||||||
|
}
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("FindByIdQc").originalFilename("findbyid_qc.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(receivers).tags(tags)
|
||||||
|
.build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findById(doc.getId());
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
|
||||||
|
.isLessThanOrEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withPageable_loadsSenderWithoutNPlusOne() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs);
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── seeding helpers ─────────────────────────────────────────────────────
|
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Document uploaded(String title) {
|
private Document uploaded(String title) {
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
@Transactional
|
||||||
class DocumentSearchPagedIntegrationTest {
|
class DocumentSearchPagedIntegrationTest {
|
||||||
|
|
||||||
private static final int FIXTURE_SIZE = 120;
|
private static final int FIXTURE_SIZE = 120;
|
||||||
|
|||||||
@@ -21,17 +21,22 @@ import org.springframework.data.domain.Pageable;
|
|||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
private static final Pageable PAGE = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@@ -43,12 +48,12 @@ class DocumentServiceSortTest {
|
|||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
// ─── DATE sort ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
|
void searchDocuments_with_DATE_sort_and_text_sorts_chronologically_not_by_relevance() {
|
||||||
UUID id1 = UUID.randomUUID(); // rank position 0 (higher relevance, older doc)
|
UUID id1 = UUID.randomUUID(); // higher relevance, older doc
|
||||||
UUID id2 = UUID.randomUUID(); // rank position 1 (lower relevance, newer doc)
|
UUID id2 = UUID.randomUUID(); // lower relevance, newer doc
|
||||||
|
|
||||||
Document older = Document.builder().id(id1)
|
Document older = Document.builder().id(id1)
|
||||||
.title("Brief").status(DocumentStatus.UPLOADED)
|
.title("Brief").status(DocumentStatus.UPLOADED)
|
||||||
@@ -57,38 +62,48 @@ class DocumentServiceSortTest {
|
|||||||
.title("Brief").status(DocumentStatus.UPLOADED)
|
.title("Brief").status(DocumentStatus.UPLOADED)
|
||||||
.documentDate(LocalDate.of(1960, 1, 1)).build();
|
.documentDate(LocalDate.of(1960, 1, 1)).build();
|
||||||
|
|
||||||
// FTS returns id1 first (higher rank), id2 second
|
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
|
||||||
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
||||||
|
|
||||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_relevance_pureText_calls_findFtsPageRaw_not_findAllMatchingIds() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||||
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
|
when(documentRepository.findAllById(any()))
|
||||||
|
.thenReturn(List.of(doc(id1)));
|
||||||
|
|
||||||
|
documentService.searchDocuments(
|
||||||
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
|
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
|
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
|
void searchDocuments_with_RELEVANCE_sort_and_text_preserves_fts_rank_order() {
|
||||||
UUID id1 = UUID.randomUUID(); // rank position 0
|
UUID id1 = UUID.randomUUID(); // higher rank — must appear first
|
||||||
UUID id2 = UUID.randomUUID(); // rank position 1
|
UUID id2 = UUID.randomUUID(); // lower rank
|
||||||
|
|
||||||
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
|
List<Object[]> ftsRows = new ArrayList<>();
|
||||||
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAll(any(Specification.class)))
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
// Expect: rank order restored (id1 first)
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,16 +112,82 @@ class DocumentServiceSortTest {
|
|||||||
UUID id1 = UUID.randomUUID();
|
UUID id1 = UUID.randomUUID();
|
||||||
UUID id2 = UUID.randomUUID();
|
UUID id2 = UUID.randomUUID();
|
||||||
|
|
||||||
Document doc1 = Document.builder().id(id1).title("Brief").status(DocumentStatus.UPLOADED).build();
|
List<Object[]> ftsRows = new ArrayList<>();
|
||||||
Document doc2 = Document.builder().id(id2).title("Brief").status(DocumentStatus.UPLOADED).build();
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAll(any(Specification.class)))
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||||
.thenReturn(List.of(doc2, doc1));
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt() {
|
||||||
|
// offset = pageNumber * pageSize; choose values so offset > Integer.MAX_VALUE
|
||||||
|
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
"Brief", null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.RELEVANCE, null, null, hugePage);
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── toFtsPage — UUID-as-String JDBC driver variance ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_relevance_handles_string_uuid_from_jdbc_driver() {
|
||||||
|
String stringId = "11111111-1111-1111-1111-111111111111";
|
||||||
|
UUID uuidId = UUID.fromString(stringId);
|
||||||
|
// Simulate a JDBC driver that returns the id column as String instead of UUID
|
||||||
|
List<Object[]> ftsRows = new ArrayList<>();
|
||||||
|
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||||
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
"Brief", null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_relevance_with_active_filter_uses_inMemory_path() {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(documentRepository.findAllMatchingIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
|
when(documentRepository.findAll(any(Specification.class)))
|
||||||
|
.thenReturn(List.of(doc(id2), doc(id1)));
|
||||||
|
|
||||||
|
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
||||||
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
|
documentService.searchDocuments(
|
||||||
|
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
|
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
|
verify(documentRepository).findAllMatchingIdsByFts("Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static Document doc(UUID id) {
|
||||||
|
return Document.builder().id(id).title("Brief").status(DocumentStatus.UPLOADED).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Object[]> ftsRows(UUID id, double rank, long total) {
|
||||||
|
List<Object[]> rows = new ArrayList<>();
|
||||||
|
rows.add(new Object[]{id, rank, total});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1620,9 +1620,10 @@ class DocumentServiceTest {
|
|||||||
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
|
// chr(1)=\u0001 marks start, chr(2)=\u0002 marks end of highlighted term
|
||||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
|
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "\u0001Brief\u0002 an Anna", null, false, null, null, null});
|
||||||
|
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
.thenReturn(List.of(doc));
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
@@ -1654,9 +1655,10 @@ class DocumentServiceTest {
|
|||||||
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
|
String snippetHeadline = "Hier ist der \u0001Brief\u0002 aus Berlin";
|
||||||
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
|
List<Object[]> rows = Collections.singletonList(new Object[]{docId, "Dok", snippetHeadline, false, null, null, null});
|
||||||
|
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(docId));
|
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
.thenReturn(List.of(doc));
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||||
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
"xyz", null, null, null, null, null, null, null, null);
|
"xyz", null, null, null, null, null, null, null, null);
|
||||||
@@ -2386,7 +2388,7 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
void getDensity_shortCircuits_whenFtsReturnsNoMatches() {
|
||||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
DocumentDensityResult result = documentService.getDensity(
|
DocumentDensityResult result = documentService.getDensity(
|
||||||
new DensityFilters("xyz", null, null, null, null, null, null));
|
new DensityFilters("xyz", null, null, null, null, null, null));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
@@ -41,6 +42,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
* test pyramid mocks at the FileService boundary.
|
* test pyramid mocks at the FileService boundary.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
class ThumbnailServiceIntegrationTest {
|
class ThumbnailServiceIntegrationTest {
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(AnnotationController.class)
|
@WebMvcTest(AnnotationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -67,7 +68,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -76,7 +77,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -92,7 +93,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -101,7 +102,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -133,7 +134,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -143,28 +144,28 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -183,7 +184,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -199,7 +200,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -217,7 +218,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -229,7 +230,7 @@ class AnnotationControllerTest {
|
|||||||
when(annotationService.updateAnnotation(any(), any(), any()))
|
when(annotationService.updateAnnotation(any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -238,7 +239,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":-0.1,\"y\":0.3}"))
|
.content("{\"x\":-0.1,\"y\":0.3}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -247,7 +248,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"width\":0.005}"))
|
.content("{\"width\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -256,7 +257,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"height\":0.005}"))
|
.content("{\"height\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -265,7 +266,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":1.1}"))
|
.content("{\"x\":1.1}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -276,7 +277,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||||
// authentication == null → resolveUserId returns null
|
// authentication == null → resolveUserId returns null
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -294,7 +295,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -312,7 +313,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(CommentController.class)
|
@WebMvcTest(CommentController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -70,7 +71,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||||
@@ -79,7 +80,7 @@ class CommentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,7 @@ class CommentControllerTest {
|
|||||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -208,14 +209,14 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(TranscriptionBlockController.class)
|
@WebMvcTest(TranscriptionBlockController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
||||||
.thenReturn(updated);
|
.thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
||||||
.when(transcriptionService).deleteBlock(any(), any());
|
.when(transcriptionService).deleteBlock(any(), any());
|
||||||
|
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||||
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||||
DOC_ID, BLOCK_ID))
|
DOC_ID, BLOCK_ID).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
@@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of(b1, b2));
|
.thenReturn(List.of(b1, b2));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
@@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$").isEmpty());
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
@@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.raddatz.familienarchiv.exception;
|
||||||
|
|
||||||
|
import io.sentry.Sentry;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class GlobalExceptionHandlerTest {
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private GlobalExceptionHandler handler;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleGeneric_captures_exception_in_sentry_and_returns_500() {
|
||||||
|
RuntimeException ex = new RuntimeException("unexpected failure");
|
||||||
|
|
||||||
|
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||||
|
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
|
||||||
|
|
||||||
|
sentryMock.verify(() -> Sentry.captureException(ex));
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(500);
|
||||||
|
assertThat(response.getBody()).isNotNull();
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
@WebMvcTest(GeschichteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -130,7 +131,7 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
void create_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -139,7 +140,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -155,7 +156,7 @@ class GeschichteControllerTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -167,7 +168,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -180,7 +181,7 @@ class GeschichteControllerTest {
|
|||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(published(id, "Updated"));
|
.thenReturn(published(id, "Updated"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"status\":\"PUBLISHED\"}"))
|
.content("{\"status\":\"PUBLISHED\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -192,7 +193,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ class GeschichteControllerTest {
|
|||||||
void delete_returns204_withBlogWrite() throws Exception {
|
void delete_returns204_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(geschichteService).delete(id);
|
verify(geschichteService).delete(id);
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import org.springframework.context.annotation.Import;
|
|||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -32,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
@Transactional
|
||||||
class GeschichteServiceIntegrationTest {
|
class GeschichteServiceIntegrationTest {
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ import software.amazon.awssdk.core.sync.RequestBody;
|
|||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.xml.sax.SAXParseException;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -29,6 +35,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -50,6 +58,7 @@ class MassImportServiceTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
||||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", "/import");
|
||||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||||
ReflectionTestUtils.setField(service, "colFolder", 2);
|
ReflectionTestUtils.setField(service, "colFolder", 2);
|
||||||
@@ -69,20 +78,64 @@ class MassImportServiceTest {
|
|||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── runImportAsync ───────────────────────────────────────────────────────
|
// ─── runImportAsync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
||||||
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
|
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
|
||||||
service.runImportAsync();
|
service.runImportAsync();
|
||||||
|
|
||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
|
||||||
|
// Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
|
||||||
|
// configured path in the message. Proves the field, not a constant,
|
||||||
|
// drives the lookup.
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||||
|
assertThat(service.getStatus().message()).contains(tempDir.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path xlsx = tempDir.resolve("import.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
wb.createSheet("Sheet1");
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
|
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, List.of(), LocalDateTime.now());
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
@@ -101,9 +154,76 @@ class MassImportServiceTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_skipsWithAlreadyExists_whenDocumentUploadedAndFileIsPresent(@TempDir Path tempDir) throws Exception {
|
||||||
|
// Document already exists with status UPLOADED (not PLACEHOLDER).
|
||||||
|
// A physical PDF file is also present on disk (valid magic bytes).
|
||||||
|
// Expected: ALREADY_EXISTS is returned and no S3 upload is attempted —
|
||||||
|
// the guard fires before any file I/O, so no partial processing occurs.
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("present.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
when(documentService.findByOriginalFilename("present.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
Path physicalFile = tempDir.resolve("present.pdf");
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(physicalFile, pdfHeader);
|
||||||
|
|
||||||
|
Optional<String> result = service.importSingleDocument(
|
||||||
|
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
||||||
|
|
||||||
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
verify(documentService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — S3 failure surfaced in skippedFiles ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_addsS3UploadFailed_toSkippedFiles_whenS3Throws(@TempDir Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("upload_fail.pdf"), pdfHeader);
|
||||||
|
buildMinimalImportXlsx(tempDir, "upload_fail.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("upload_fail.pdf")).thenReturn(Optional.empty());
|
||||||
|
doThrow(new RuntimeException("S3 unavailable"))
|
||||||
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_addsAlreadyExists_toSkippedFiles_whenDocumentAlreadyUploaded(@TempDir Path tempDir) throws Exception {
|
||||||
|
buildMinimalImportXlsx(tempDir, "existing.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("existing.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||||
@@ -155,7 +275,7 @@ class MassImportServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
void importSingleDocument_returnsS3UploadFailed_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||||
Path tempFile = tempDir.resolve("fail.pdf");
|
Path tempFile = tempDir.resolve("fail.pdf");
|
||||||
Files.write(tempFile, "data".getBytes());
|
Files.write(tempFile, "data".getBytes());
|
||||||
|
|
||||||
@@ -163,10 +283,11 @@ class MassImportServiceTest {
|
|||||||
doThrow(new RuntimeException("S3 error"))
|
doThrow(new RuntimeException("S3 error"))
|
||||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
service.importSingleDocument(
|
Optional<String> result = service.importSingleDocument(
|
||||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
|
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||||
@@ -272,8 +393,8 @@ class MassImportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -282,8 +403,8 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("") // blank index
|
minimalCells("") // blank index
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
assertThat(result).isEqualTo(0);
|
assertThat(result.processed()).isEqualTo(0);
|
||||||
verify(documentService, never()).findByOriginalFilename(any());
|
verify(documentService, never()).findByOriginalFilename(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,9 +417,9 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("doc001") // no dot → appends ".pdf"
|
minimalCells("doc001") // no dot → appends ".pdf"
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
verify(documentService).findByOriginalFilename("doc001.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,9 +432,9 @@ class MassImportServiceTest {
|
|||||||
List.of("header"),
|
List.of("header"),
|
||||||
minimalCells("doc002.pdf") // has dot → used as-is
|
minimalCells("doc002.pdf") // has dot → used as-is
|
||||||
);
|
);
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
assertThat(result.processed()).isEqualTo(1);
|
||||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +593,86 @@ class MassImportServiceTest {
|
|||||||
assertThat(result).isEqualTo("hello");
|
assertThat(result).isEqualTo("hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PDF magic byte validation regression ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_uploadsValidPdf_andSkipsFakeOne(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsSkippedCount_toOne_whenOneFakeFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_includesRejectedFilename_inSkippedFiles(@TempDir Path tempDir) throws Exception {
|
||||||
|
setupOneValidOneFakeImport(tempDir);
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::filename)
|
||||||
|
.contains("fake.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_skipsFile_whenShorterThanFourBytes(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.write(tempDir.resolve("tiny.pdf"), new byte[]{0x25, 0x50, 0x44}); // only 3 bytes
|
||||||
|
buildMinimalImportXlsx(tempDir, "tiny.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_skipsFile_whenMagicBytesCheckThrowsIOException(@TempDir Path tempDir) throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("unreadable.pdf"), "some content");
|
||||||
|
buildMinimalImportXlsx(tempDir, "unreadable.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
MassImportService spyService = spy(service);
|
||||||
|
doThrow(new java.io.IOException("simulated read error")).when(spyService).openFileStream(any(File.class));
|
||||||
|
|
||||||
|
spyService.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(spyService.getStatus().skippedFiles())
|
||||||
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
|
.containsExactly("FILE_READ_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||||
|
|
||||||
|
// Security regression — do not remove.
|
||||||
|
@Test
|
||||||
|
void readOds_rejects_xxe_doctype_payload(@TempDir Path tempDir) throws Exception {
|
||||||
|
File malicious = buildXxeOds(tempDir, "file:///etc/hostname");
|
||||||
|
assertThatThrownBy(() -> service.readOds(malicious))
|
||||||
|
.isInstanceOf(SAXParseException.class)
|
||||||
|
.hasMessageContaining("DOCTYPE is disallowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readOds_parses_valid_ods_correctly(@TempDir Path tempDir) throws Exception {
|
||||||
|
File valid = buildValidOds(tempDir, "Mustermann");
|
||||||
|
List<List<String>> rows = service.readOds(valid);
|
||||||
|
assertThat(rows).isNotEmpty();
|
||||||
|
assertThat(rows.get(0)).contains("Mustermann");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -505,4 +706,72 @@ class MassImportServiceTest {
|
|||||||
"" // 13: transcription
|
"" // 13: transcription
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates a minimal ODS ZIP containing a content.xml with an XXE payload. */
|
||||||
|
private File buildXxeOds(Path dir, String entityTarget) throws Exception {
|
||||||
|
String xml = "<?xml version=\"1.0\"?>"
|
||||||
|
+ "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"" + entityTarget + "\">]>"
|
||||||
|
+ "<office:document-content"
|
||||||
|
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||||
|
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
||||||
|
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
||||||
|
+ "<office:body><office:spreadsheet>"
|
||||||
|
+ "<table:table><table:table-row><table:table-cell>"
|
||||||
|
+ "<text:p>&xxe;</text:p>"
|
||||||
|
+ "</table:table-cell></table:table-row></table:table>"
|
||||||
|
+ "</office:spreadsheet></office:body>"
|
||||||
|
+ "</office:document-content>";
|
||||||
|
return writeOdsZip(dir.resolve("malicious.ods"), xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a minimal valid ODS ZIP containing a content.xml with the given cell value.
|
||||||
|
* cellValue must not contain XML metacharacters ({@code < > &}). */
|
||||||
|
private File buildValidOds(Path dir, String cellValue) throws Exception {
|
||||||
|
String xml = "<?xml version=\"1.0\"?>"
|
||||||
|
+ "<office:document-content"
|
||||||
|
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
||||||
|
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
||||||
|
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
||||||
|
+ "<office:body><office:spreadsheet>"
|
||||||
|
+ "<table:table><table:table-row><table:table-cell>"
|
||||||
|
+ "<text:p>" + cellValue + "</text:p>"
|
||||||
|
+ "</table:table-cell></table:table-row></table:table>"
|
||||||
|
+ "</office:spreadsheet></office:body>"
|
||||||
|
+ "</office:document-content>";
|
||||||
|
return writeOdsZip(dir.resolve("valid.ods"), xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private File writeOdsZip(Path destination, String contentXml) throws Exception {
|
||||||
|
try (OutputStream fos = Files.newOutputStream(destination);
|
||||||
|
ZipOutputStream zip = new ZipOutputStream(fos)) {
|
||||||
|
zip.putNextEntry(new ZipEntry("content.xml"));
|
||||||
|
zip.write(contentXml.getBytes(StandardCharsets.UTF_8));
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
return destination.toFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupOneValidOneFakeImport(Path tempDir) throws Exception {
|
||||||
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
|
Files.write(tempDir.resolve("real.pdf"), pdfHeader);
|
||||||
|
Files.writeString(tempDir.resolve("fake.pdf"), "not a pdf");
|
||||||
|
buildMinimalImportXlsx(tempDir, "real.pdf", "fake.pdf");
|
||||||
|
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildMinimalImportXlsx(Path dir, String... filenames) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("import.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
sheet.createRow(0).createCell(0).setCellValue("Index");
|
||||||
|
for (int i = 0; i < filenames.length; i++) {
|
||||||
|
sheet.createRow(i + 1).createCell(0).setCellValue(filenames[i]);
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(NotificationController.class)
|
@WebMvcTest(NotificationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -141,7 +142,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/notifications/read-all"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ class NotificationControllerTest {
|
|||||||
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
||||||
when(userService.findByEmail("testuser")).thenReturn(user);
|
when(userService.findByEmail("testuser")).thenReturn(user);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/notifications/read-all"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(notificationService).markAllRead(USER_ID);
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
@@ -161,7 +162,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ class NotificationControllerTest {
|
|||||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(true).build();
|
.notifyOnReply(true).notifyOnMention(true).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -275,7 +276,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(false).build();
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -337,7 +338,7 @@ class NotificationControllerTest {
|
|||||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(OcrController.class)
|
@WebMvcTest(OcrController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -66,7 +67,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -80,7 +81,7 @@ class OcrControllerTest {
|
|||||||
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
||||||
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -127,7 +128,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/batch")
|
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -179,14 +180,14 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ class OcrControllerTest {
|
|||||||
when(ocrTrainingService.triggerTraining(any()))
|
when(ocrTrainingService.triggerTraining(any()))
|
||||||
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ class OcrControllerTest {
|
|||||||
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
||||||
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.status").value("DONE"))
|
.andExpect(jsonPath("$.status").value("DONE"))
|
||||||
.andExpect(jsonPath("$.blockCount").value(10));
|
.andExpect(jsonPath("$.blockCount").value(10));
|
||||||
@@ -365,7 +366,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":null}"))
|
.content("{\"personId\":null}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -373,7 +374,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -382,7 +383,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -395,7 +396,7 @@ class OcrControllerTest {
|
|||||||
when(senderModelService.triggerManualSenderTraining(unknownId))
|
when(senderModelService.triggerManualSenderTraining(unknownId))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -410,7 +411,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -426,7 +427,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -442,7 +443,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted());
|
.andExpect(status().isAccepted());
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(PersonController.class)
|
@WebMvcTest(PersonController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -217,7 +218,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -226,7 +227,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -235,7 +236,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -244,7 +245,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -253,7 +254,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -265,7 +266,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -278,7 +279,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -293,7 +294,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -307,7 +308,7 @@ class PersonControllerTest {
|
|||||||
when(personService.createPerson(any())).thenThrow(
|
when(personService.createPerson(any())).thenThrow(
|
||||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -318,7 +319,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -327,7 +328,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -336,7 +337,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -349,7 +350,7 @@ class PersonControllerTest {
|
|||||||
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -360,7 +361,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -369,7 +370,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -378,7 +379,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\" \"}"))
|
.content("{\"targetPersonId\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -390,7 +391,7 @@ class PersonControllerTest {
|
|||||||
UUID sourceId = UUID.randomUUID();
|
UUID sourceId = UUID.randomUUID();
|
||||||
UUID targetId = UUID.randomUUID();
|
UUID targetId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -402,7 +403,7 @@ class PersonControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -418,7 +419,7 @@ class PersonControllerTest {
|
|||||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
@@ -436,7 +437,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||||
String oversizedNotes = "x".repeat(5001);
|
String oversizedNotes = "x".repeat(5001);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -447,7 +448,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||||
String oversizedFirstName = "x".repeat(101);
|
String oversizedFirstName = "x".repeat(101);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -458,7 +459,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -467,7 +468,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -476,7 +477,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -507,7 +508,7 @@ class PersonControllerTest {
|
|||||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -517,7 +518,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -531,7 +532,7 @@ class PersonControllerTest {
|
|||||||
UUID personId = UUID.randomUUID();
|
UUID personId = UUID.randomUUID();
|
||||||
UUID aliasId = UUID.randomUUID();
|
UUID aliasId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(personService).removeAlias(personId, aliasId);
|
verify(personService).removeAlias(personId, aliasId);
|
||||||
@@ -540,14 +541,14 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -556,7 +557,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import org.raddatz.familienarchiv.person.PersonRepository;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(PostgresContainerConfig.class)
|
@Import(PostgresContainerConfig.class)
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
@Transactional
|
||||||
class PersonServiceIntegrationTest {
|
class PersonServiceIntegrationTest {
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
@MockitoBean S3Client s3Client;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import static org.mockito.Mockito.doNothing;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(RelationshipController.class)
|
@WebMvcTest(RelationshipController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -67,7 +68,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -76,14 +77,14 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
|
||||||
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
|
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"familyMember\":true}"))
|
.content("{\"familyMember\":true}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -125,7 +126,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -141,7 +142,7 @@ class RelationshipControllerTest {
|
|||||||
RelationType.PARENT_OF, null, null, null);
|
RelationType.PARENT_OF, null, null, null);
|
||||||
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
|
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -154,7 +155,7 @@ class RelationshipControllerTest {
|
|||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
doNothing().when(relationshipService).deleteRelationship(any(), any());
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
|
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.doThrow;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(TagController.class)
|
@WebMvcTest(TagController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -61,7 +62,7 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
void updateTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -70,7 +71,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void updateTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -82,7 +83,7 @@ class TagControllerTest {
|
|||||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("New").build();
|
||||||
when(tagService.update(any(), any())).thenReturn(tag);
|
when(tagService.update(any(), any())).thenReturn(tag);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/tags/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/tags/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"name\": \"New\"}"))
|
.content("{\"name\": \"New\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -116,7 +117,7 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
void mergeTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -125,7 +126,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void mergeTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -134,7 +135,7 @@ class TagControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
void mergeTag_returns400_whenTargetIdIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -146,7 +147,7 @@ class TagControllerTest {
|
|||||||
when(tagService.mergeTags(any(), any()))
|
when(tagService.mergeTags(any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetId\": \"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -159,7 +160,7 @@ class TagControllerTest {
|
|||||||
Tag target = Tag.builder().id(targetId).name("Target").build();
|
Tag target = Tag.builder().id(targetId).name("Target").build();
|
||||||
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
when(tagService.mergeTags(any(), any())).thenReturn(target);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge")
|
mockMvc.perform(post("/api/tags/" + UUID.randomUUID() + "/merge").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetId\": \"" + targetId + "\"}"))
|
.content("{\"targetId\": \"" + targetId + "\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -171,21 +172,21 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
void deleteSubtree_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
void deleteSubtree_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
void deleteSubtree_returns204_whenHasAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree"))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID() + "/subtree").with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,21 +194,21 @@ class TagControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
void deleteTag_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
void deleteTag_returns403_whenMissingAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN_TAG")
|
@WithMockUser(authorities = "ADMIN_TAG")
|
||||||
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
void deleteTag_returns200_whenHasAdminTagPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/tags/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(AdminController.class)
|
@WebMvcTest(AdminController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -40,16 +41,57 @@ class AdminControllerTest {
|
|||||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/admin/import-status ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||||
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.state").value("IDLE"))
|
||||||
|
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
|
||||||
|
.andExpect(jsonPath("$.processed").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||||
|
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||||
|
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
when(massImportService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importStatus_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin/import-status"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(roles = "USER")
|
@WithMockUser(roles = "USER")
|
||||||
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +101,7 @@ class AdminControllerTest {
|
|||||||
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||||
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
mockMvc.perform(post("/api/admin/backfill-versions").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(1));
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
}
|
}
|
||||||
@@ -68,14 +110,14 @@ class AdminControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(roles = "USER")
|
@WithMockUser(roles = "USER")
|
||||||
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +126,7 @@ class AdminControllerTest {
|
|||||||
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||||
when(documentService.backfillFileHashes()).thenReturn(3);
|
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
mockMvc.perform(post("/api/admin/backfill-file-hashes").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(3));
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
}
|
}
|
||||||
@@ -93,14 +135,14 @@ class AdminControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(roles = "USER")
|
@WithMockUser(roles = "USER")
|
||||||
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +153,7 @@ class AdminControllerTest {
|
|||||||
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
|
||||||
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
when(thumbnailBackfillService.getStatus()).thenReturn(status);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/admin/generate-thumbnails"))
|
mockMvc.perform(post("/api/admin/generate-thumbnails").with(csrf()))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
.andExpect(jsonPath("$.state").value("RUNNING"))
|
.andExpect(jsonPath("$.state").value("RUNNING"))
|
||||||
.andExpect(jsonPath("$.total").value(10));
|
.andExpect(jsonPath("$.total").value(10));
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserDataInitializer must refuse to seed the admin user with the hardcoded
|
||||||
|
* dev defaults when running outside the {@code dev} profile.
|
||||||
|
*
|
||||||
|
* <p>Why this matters: per DEPLOYMENT.md §3.5 and ADR-011, the admin password
|
||||||
|
* is permanently locked on first deploy (UserDataInitializer only seeds when
|
||||||
|
* the row is missing). If an operator forgets to set {@code APP_ADMIN_USERNAME}
|
||||||
|
* / {@code APP_ADMIN_PASSWORD}, prod silently boots with the well-known dev
|
||||||
|
* defaults — a credential-disclosure foot-gun, not a config typo. See #513.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AdminSeedFailClosedTest {
|
||||||
|
|
||||||
|
@Mock AppUserRepository userRepository;
|
||||||
|
@Mock UserGroupRepository groupRepository;
|
||||||
|
@Mock Environment environment;
|
||||||
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
UserDataInitializer initializer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
initializer = new UserDataInitializer(userRepository, groupRepository, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refuses_to_seed_when_email_is_default_and_profile_is_not_dev() throws Exception {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false);
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL);
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", "operator-set-this-one");
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> runner.run())
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("default credentials")
|
||||||
|
.hasMessageContaining("permanent");
|
||||||
|
|
||||||
|
verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refuses_to_seed_when_password_is_default_and_profile_is_not_dev() throws Exception {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false);
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> runner.run())
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("default credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allows_seed_when_both_values_are_set_and_profile_is_not_dev() throws Exception {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password");
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
runner.run();
|
||||||
|
|
||||||
|
verify(userRepository).save(any(AppUser.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allows_seed_with_defaults_when_profile_is_dev() throws Exception {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(true);
|
||||||
|
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL);
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
runner.run();
|
||||||
|
|
||||||
|
verify(userRepository).save(any(AppUser.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void does_not_check_defaults_when_admin_already_exists() throws Exception {
|
||||||
|
AppUser existing = AppUser.builder()
|
||||||
|
.email("someone@example.com")
|
||||||
|
.password("$2a$10$stub")
|
||||||
|
.build();
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.of(existing));
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", UserDataInitializer.DEFAULT_ADMIN_EMAIL);
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", UserDataInitializer.DEFAULT_ADMIN_PASSWORD);
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
runner.run();
|
||||||
|
|
||||||
|
verify(userRepository, never()).save(org.mockito.ArgumentMatchers.any());
|
||||||
|
// Importantly, no IllegalStateException — re-deploys must not panic over
|
||||||
|
// historical default-seeded data they cannot retroactively fix.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reuses_existing_Administrators_group_when_seeding_a_new_admin() throws Exception {
|
||||||
|
// Setup: admin user does not exist, but the Administrators group does
|
||||||
|
// (e.g. previous boot seeded the group then failed; operator deleted
|
||||||
|
// the bad user row to retry with a corrected APP_ADMIN_USERNAME). The
|
||||||
|
// re-seed must reuse the group, not blind-INSERT a duplicate. See #518.
|
||||||
|
UserGroup existingGroup = UserGroup.builder()
|
||||||
|
.name("Administrators")
|
||||||
|
.build();
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.findByName("Administrators")).thenReturn(Optional.of(existingGroup));
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password");
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
runner.run();
|
||||||
|
|
||||||
|
// Group must not be re-inserted — that would violate user_groups_name_key.
|
||||||
|
verify(groupRepository, never()).save(any(UserGroup.class));
|
||||||
|
// But the admin user IS created, with the existing group attached.
|
||||||
|
org.mockito.ArgumentCaptor<AppUser> captor = org.mockito.ArgumentCaptor.forClass(AppUser.class);
|
||||||
|
verify(userRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getGroups()).containsExactly(existingGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void creates_Administrators_group_when_seeding_admin_on_a_fresh_database() throws Exception {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.findByName("Administrators")).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.save(any(UserGroup.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(environment.matchesProfiles("dev", "test", "e2e")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode(anyString())).thenReturn("$2a$10$stub");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminEmail", "admin@archiv.raddatz.cloud");
|
||||||
|
ReflectionTestUtils.setField(initializer, "adminPassword", "a-real-strong-password");
|
||||||
|
|
||||||
|
CommandLineRunner runner = initializer.initAdminUser(passwordEncoder);
|
||||||
|
runner.run();
|
||||||
|
|
||||||
|
// Group should be inserted exactly once.
|
||||||
|
verify(groupRepository).save(any(UserGroup.class));
|
||||||
|
verify(userRepository).save(any(AppUser.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the admin-seed property key contract. {@code UserDataInitializer} reads
|
||||||
|
* {@code @Value("${app.admin.email:...}")} and {@code @Value("${app.admin.password:...}")}.
|
||||||
|
* The yaml MUST expose those exact keys, not e.g. {@code app.admin.username}, or
|
||||||
|
* the env vars {@code APP_ADMIN_USERNAME} / {@code APP_ADMIN_PASSWORD} are
|
||||||
|
* silently ignored and the admin user gets seeded with the hardcoded defaults.
|
||||||
|
*
|
||||||
|
* <p>Discovered as a HIGH bug during the production-deploy bootstrap (#513): on
|
||||||
|
* first deploy the prod admin password is permanently locked to whatever ends
|
||||||
|
* up in the database, so a key-name mismatch would lock prod to the dev defaults
|
||||||
|
* {@code admin@familyarchive.local} / {@code admin123}.
|
||||||
|
*
|
||||||
|
* <p>No Spring context — Binder reads application.yaml directly.
|
||||||
|
*/
|
||||||
|
class AdminSeedPropertyKeyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void admin_email_key_binds_from_yaml() {
|
||||||
|
Binder binder = binderFromApplicationYaml();
|
||||||
|
|
||||||
|
String email = binder.bind("app.admin.email", String.class)
|
||||||
|
.orElseThrow(() -> new AssertionError(
|
||||||
|
"app.admin.email is missing from application.yaml. "
|
||||||
|
+ "UserDataInitializer reads this exact key; if the yaml uses "
|
||||||
|
+ "a different name (e.g. 'username'), the env var "
|
||||||
|
+ "APP_ADMIN_USERNAME is silently ignored."));
|
||||||
|
|
||||||
|
assertThat(email)
|
||||||
|
.as("app.admin.email must resolve from APP_ADMIN_USERNAME or its default")
|
||||||
|
.isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void admin_password_key_binds_from_yaml() {
|
||||||
|
Binder binder = binderFromApplicationYaml();
|
||||||
|
|
||||||
|
String password = binder.bind("app.admin.password", String.class)
|
||||||
|
.orElseThrow(() -> new AssertionError(
|
||||||
|
"app.admin.password is missing from application.yaml. "
|
||||||
|
+ "UserDataInitializer reads this exact key."));
|
||||||
|
|
||||||
|
assertThat(password)
|
||||||
|
.as("app.admin.password must resolve from APP_ADMIN_PASSWORD or its default")
|
||||||
|
.isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userDataInitializer_reads_app_admin_email_not_username() throws NoSuchFieldException {
|
||||||
|
// Pin the Java side too: a future rename of the @Value placeholder
|
||||||
|
// (e.g. back to `${app.admin.username:...}`) would silently break the
|
||||||
|
// binding while the yaml-side assertions above still pass. See #513.
|
||||||
|
Field field = UserDataInitializer.class.getDeclaredField("adminEmail");
|
||||||
|
Value annotation = field.getAnnotation(Value.class);
|
||||||
|
assertThat(annotation)
|
||||||
|
.as("UserDataInitializer.adminEmail must be @Value-annotated")
|
||||||
|
.isNotNull();
|
||||||
|
assertThat(annotation.value())
|
||||||
|
.as("UserDataInitializer must read app.admin.email — not username or any other key")
|
||||||
|
.startsWith("${app.admin.email:");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userDataInitializer_reads_app_admin_password() throws NoSuchFieldException {
|
||||||
|
Field field = UserDataInitializer.class.getDeclaredField("adminPassword");
|
||||||
|
Value annotation = field.getAnnotation(Value.class);
|
||||||
|
assertThat(annotation).isNotNull();
|
||||||
|
assertThat(annotation.value())
|
||||||
|
.as("UserDataInitializer must read app.admin.password")
|
||||||
|
.startsWith("${app.admin.password:");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Binder binderFromApplicationYaml() {
|
||||||
|
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
||||||
|
yaml.setResources(new ClassPathResource("application.yaml"));
|
||||||
|
Properties props = yaml.getObject();
|
||||||
|
assertThat(props).as("application.yaml must be on the classpath").isNotNull();
|
||||||
|
return new Binder(ConfigurationPropertySources.from(
|
||||||
|
new PropertiesPropertySource("application", props)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(AuthController.class)
|
@WebMvcTest(AuthController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -117,7 +118,7 @@ class AuthControllerTest {
|
|||||||
req.setFirstName("Max");
|
req.setFirstName("Max");
|
||||||
req.setLastName("Muster");
|
req.setLastName("Muster");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -134,7 +135,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("dupe@test.com");
|
req.setEmail("dupe@test.com");
|
||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
@@ -150,7 +151,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("new@test.com");
|
req.setEmail("new@test.com");
|
||||||
req.setPassword("abc");
|
req.setPassword("abc");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -166,7 +167,7 @@ class AuthControllerTest {
|
|||||||
req.setEmail("new@test.com");
|
req.setEmail("new@test.com");
|
||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -183,7 +184,7 @@ class AuthControllerTest {
|
|||||||
req.setPassword("password123");
|
req.setPassword("password123");
|
||||||
|
|
||||||
// No WithMockUser — must still succeed (no auth challenge)
|
// No WithMockUser — must still succeed (no auth challenge)
|
||||||
mockMvc.perform(post("/api/auth/register")
|
mockMvc.perform(post("/api/auth/register").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -20,16 +20,20 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(InviteController.class)
|
@WebMvcTest(InviteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -100,7 +104,7 @@ class InviteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
void createInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -109,7 +113,7 @@ class InviteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "user@test.com")
|
@WithMockUser(username = "user@test.com")
|
||||||
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -139,7 +143,7 @@ class InviteControllerTest {
|
|||||||
req.setLabel("Für Familie");
|
req.setLabel("Für Familie");
|
||||||
req.setMaxUses(1);
|
req.setMaxUses(1);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/invites")
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -147,18 +151,42 @@ class InviteControllerTest {
|
|||||||
.andExpect(jsonPath("$.label").value("Für Familie"));
|
.andExpect(jsonPath("$.label").value("Für Familie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||||
|
void createInvite_forwardsGroupIdsToService() throws Exception {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
|
||||||
|
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
|
||||||
|
|
||||||
|
InviteToken savedToken = InviteToken.builder()
|
||||||
|
.id(UUID.randomUUID()).code("ABCDE12345").useCount(0).build();
|
||||||
|
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
|
||||||
|
when(inviteService.toListItemDTO(any(), anyString()))
|
||||||
|
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||||
|
|
||||||
|
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||||
|
mockMvc.perform(post("/api/invites").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateInviteRequest> captor = ArgumentCaptor.forClass(CreateInviteRequest.class);
|
||||||
|
verify(inviteService).createInvite(captor.capture(), eq(admin));
|
||||||
|
assertThat(captor.getValue().getGroupIds()).containsExactly(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
void revokeInvite_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "user@test.com")
|
@WithMockUser(username = "user@test.com")
|
||||||
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/invites/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +195,7 @@ class InviteControllerTest {
|
|||||||
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
void revokeInvite_returns204_whenSuccessful() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/invites/" + id))
|
mockMvc.perform(delete("/api/invites/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(inviteService).revokeInvite(id);
|
verify(inviteService).revokeInvite(id);
|
||||||
|
|||||||
@@ -156,6 +156,35 @@ class InviteServiceTest {
|
|||||||
assertThat(result.getGroupIds()).contains(g.getId());
|
assertThat(result.getGroupIds()).contains(g.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
|
||||||
|
UUID unknownGroupId = UUID.randomUUID();
|
||||||
|
when(userService.findGroupsByIds(anyList())).thenReturn(List.of());
|
||||||
|
|
||||||
|
CreateInviteRequest req = new CreateInviteRequest();
|
||||||
|
req.setGroupIds(List.of(unknownGroupId));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> inviteService.createInvite(req, admin))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GROUP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
|
||||||
|
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||||
|
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
|
||||||
|
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
CreateInviteRequest req = new CreateInviteRequest();
|
||||||
|
req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice
|
||||||
|
|
||||||
|
// before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
|
||||||
|
assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class InviteTokenRepositoryIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired InviteTokenRepository inviteTokenRepository;
|
||||||
|
@Autowired UserGroupRepository userGroupRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
private UserGroup group;
|
||||||
|
private AppUser admin;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
inviteTokenRepository.deleteAll();
|
||||||
|
userGroupRepository.deleteAll();
|
||||||
|
appUserRepository.deleteAll();
|
||||||
|
admin = appUserRepository.save(AppUser.builder().email("admin@test.com").password("pw").build());
|
||||||
|
group = userGroupRepository.save(UserGroup.builder().name("Familie").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── existsActiveWithGroupId ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsTrueForActiveInviteLinkedToGroup() {
|
||||||
|
inviteTokenRepository.save(token(t -> t));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsRevoked() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.revoked(true)));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsExpired() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.expiresAt(LocalDateTime.now().minusDays(1))));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsActiveWithGroupId_returnsFalseWhenInviteIsExhausted() {
|
||||||
|
inviteTokenRepository.save(token(t -> t.maxUses(1).useCount(1)));
|
||||||
|
|
||||||
|
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private InviteToken token(java.util.function.UnaryOperator<InviteToken.InviteTokenBuilder> customizer) {
|
||||||
|
InviteToken.InviteTokenBuilder builder = InviteToken.builder()
|
||||||
|
.code(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
|
||||||
|
.groupIds(new java.util.HashSet<>(Set.of(group.getId())))
|
||||||
|
.createdBy(admin);
|
||||||
|
return customizer.apply(builder).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import org.springframework.mail.MailSendException;
|
|||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -36,8 +37,10 @@ class PasswordResetServiceTest {
|
|||||||
@Mock PasswordResetTokenRepository tokenRepository;
|
@Mock PasswordResetTokenRepository tokenRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock JavaMailSender mailSender;
|
@Mock JavaMailSender mailSender;
|
||||||
|
@Mock AuthService authService;
|
||||||
@InjectMocks PasswordResetService service;
|
@InjectMocks PasswordResetService service;
|
||||||
|
|
||||||
|
|
||||||
private AppUser makeUser(String email) {
|
private AppUser makeUser(String email) {
|
||||||
return AppUser.builder()
|
return AppUser.builder()
|
||||||
.id(UUID.randomUUID())
|
.id(UUID.randomUUID())
|
||||||
@@ -176,6 +179,27 @@ class PasswordResetServiceTest {
|
|||||||
verify(mailSender).send(any(SimpleMailMessage.class));
|
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPassword_revokes_all_sessions_after_password_reset() {
|
||||||
|
AppUser user = makeUser("user@example.com");
|
||||||
|
PasswordResetToken token = PasswordResetToken.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.token("validtoken123")
|
||||||
|
.user(user)
|
||||||
|
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||||
|
.used(false)
|
||||||
|
.build();
|
||||||
|
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||||
|
|
||||||
|
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||||
|
req.setToken("validtoken123");
|
||||||
|
req.setNewPassword("newpass");
|
||||||
|
service.resetPassword(req);
|
||||||
|
|
||||||
|
verify(authService).revokeAllSessions("user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.user;
|
package org.raddatz.familienarchiv.user;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
@@ -10,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
@@ -17,6 +20,8 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -24,6 +29,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(UserController.class)
|
@WebMvcTest(UserController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -32,6 +38,8 @@ class UserControllerTest {
|
|||||||
@Autowired MockMvc mockMvc;
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
@MockitoBean UserService userService;
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean AuthService authService;
|
||||||
|
@MockitoBean AuditService auditService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
// ─── GET /api/users/me ────────────────────────────────────────────────────────
|
||||||
@@ -83,7 +91,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -92,7 +100,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
void createUser_returns400_whenEmailContainsColon() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -101,7 +109,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
|
||||||
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
void createUser_returns400_whenEmailIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -112,7 +120,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -121,7 +129,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -130,7 +138,7 @@ class UserControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "reader@example.com")
|
@WithMockUser(username = "reader@example.com")
|
||||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +146,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/users")
|
mockMvc.perform(post("/api/users").with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -146,7 +154,7 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -154,7 +162,92 @@ class UserControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/users/me/password (changePassword + session revocation) ────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@example.com")
|
||||||
|
void changePassword_returns204_and_calls_revokeOtherSessions() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
|
||||||
|
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
||||||
|
when(authService.revokeOtherSessions(any(), any())).thenReturn(1);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(authService).revokeOtherSessions(any(), eq("user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changePassword_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users/me/password").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@example.com")
|
||||||
|
void changePassword_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users/me/password")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"currentPassword\":\"old\",\"newPassword\":\"new123!\"}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/users/{id}/force-logout ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||||
|
void forceLogout_returns200_and_revokes_target_sessions() throws Exception {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
AppUser actor = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
|
||||||
|
AppUser target = AppUser.builder().id(targetId).email("target@example.com").build();
|
||||||
|
when(userService.findByEmail("admin@example.com")).thenReturn(actor);
|
||||||
|
when(userService.getById(targetId)).thenReturn(target);
|
||||||
|
when(authService.revokeAllSessions("target@example.com")).thenReturn(2);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.revokedCount").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forceLogout_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void forceLogout_returns403_whenMissingPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout").with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN_USER")
|
||||||
|
void forceLogout_returns404_whenUserNotFound() throws Exception {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
when(userService.getById(targetId)).thenThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.USER_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/users/" + targetId + "/force-logout").with(csrf()))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin@example.com", authorities = "ADMIN_USER")
|
||||||
|
void forceLogout_without_csrf_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users/" + UUID.randomUUID() + "/force-logout"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value("CSRF_TOKEN_MISSING"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class UserServiceTest {
|
|||||||
|
|
||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
|
@Mock InviteTokenRepository inviteTokenRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
@@ -903,6 +904,29 @@ class UserServiceTest {
|
|||||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── deleteGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> userService.deleteGroup(groupId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
|
||||||
|
UUID groupId = UUID.randomUUID();
|
||||||
|
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
|
||||||
|
|
||||||
|
userService.deleteGroup(groupId);
|
||||||
|
|
||||||
|
verify(groupRepository).deleteById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||||
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||||
|
|||||||
@@ -13,3 +13,18 @@ spring:
|
|||||||
password: test
|
password: test
|
||||||
mail:
|
mail:
|
||||||
host: localhost
|
host: localhost
|
||||||
|
|
||||||
|
# Disable OTel SDK entirely in tests — prevents auto-configuration from loading resource providers
|
||||||
|
# (e.g. AzureAppServiceResourceProvider) that fail against the semconv version used here.
|
||||||
|
otel:
|
||||||
|
sdk:
|
||||||
|
disabled: true
|
||||||
|
|
||||||
|
# Disable trace export in tests — prevents OTLP connection attempts when no Tempo is running.
|
||||||
|
# Sampling probability 0.0 means no spans are created, so no export is attempted.
|
||||||
|
management:
|
||||||
|
server:
|
||||||
|
port: 0 # random port per context — prevents TIME_WAIT conflicts when @DirtiesContext restarts the context
|
||||||
|
tracing:
|
||||||
|
sampling:
|
||||||
|
probability: 0.0
|
||||||
|
|||||||
2
backend/src/test/resources/application.properties
Normal file
2
backend/src/test/resources/application.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
logging.level.root=WARN
|
||||||
|
logging.level.org.raddatz=INFO
|
||||||
266
docker-compose.observability.yml
Normal file
266
docker-compose.observability.yml
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Observability stack — Grafana LGTM + GlitchTip
|
||||||
|
#
|
||||||
|
# Requires the main stack to be running first:
|
||||||
|
# docker compose up -d # creates archiv-net
|
||||||
|
# docker compose -f docker-compose.observability.yml up -d
|
||||||
|
#
|
||||||
|
# To validate without starting:
|
||||||
|
# docker compose -f docker-compose.observability.yml config
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# --- Metrics: Prometheus ---
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.4.0
|
||||||
|
container_name: obs-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--storage.tsdb.retention.time=30d'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PORT_PROMETHEUS:-9090}:9090"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.9.0
|
||||||
|
container_name: obs-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
# pid: host — required for process-level CPU/memory metrics; cgroup isolation applies
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
command:
|
||||||
|
- '--path.procfs=/host/proc'
|
||||||
|
- '--path.sysfs=/host/sys'
|
||||||
|
# $$ is YAML Compose escaping for a literal $ in the regex alternation
|
||||||
|
- '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($$|/)'
|
||||||
|
expose:
|
||||||
|
- "9100"
|
||||||
|
networks:
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
||||||
|
container_name: obs-cadvisor
|
||||||
|
restart: unless-stopped
|
||||||
|
# privileged: true — required for cgroup and namespace metrics, see cAdvisor docs.
|
||||||
|
# Accepted risk: cAdvisor is pinned, on Renovate, and not exposed outside obs-net.
|
||||||
|
privileged: true
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
# /var/run/docker.sock mounted read-only — sufficient for container metadata discovery
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker:/var/lib/docker:ro
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
networks:
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
# --- Logs: Loki + Promtail ---
|
||||||
|
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.4.2
|
||||||
|
container_name: obs-loki
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./infra/observability/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
|
||||||
|
- loki_data:/loki
|
||||||
|
command: -config.file=/etc/loki/loki-config.yml
|
||||||
|
expose:
|
||||||
|
- "3100"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready | grep -q ready || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
promtail:
|
||||||
|
image: grafana/promtail:3.4.2
|
||||||
|
container_name: obs-promtail
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./infra/observability/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
||||||
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
|
# :ro restricts file-system access but NOT Docker API permissions — a compromised Promtail has full daemon access. Accepted risk on single-operator self-hosted archive.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- promtail_positions:/tmp # persists positions.yaml across restarts — avoids duplicate log ingestion
|
||||||
|
command: -config.file=/etc/promtail/promtail-config.yml
|
||||||
|
networks:
|
||||||
|
- archiv-net # label discovery from application containers via Docker socket
|
||||||
|
- obs-net # log shipping to Loki
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# --- Traces: Tempo ---
|
||||||
|
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:2.7.2
|
||||||
|
container_name: obs-tempo
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./infra/observability/tempo/tempo.yml:/etc/tempo.yml:ro
|
||||||
|
- tempo_data:/var/tempo
|
||||||
|
command: -config.file=/etc/tempo.yml
|
||||||
|
expose:
|
||||||
|
- "3200" # Grafana queries Tempo on this port (obs-net only)
|
||||||
|
- "4317" # OTLP gRPC — backend sends traces here (archiv-net)
|
||||||
|
- "4318" # OTLP HTTP — alternative transport (archiv-net)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3200/ready | grep -q ready || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- archiv-net # backend (archive-backend) reaches tempo:4317 over this network
|
||||||
|
- obs-net # Grafana reaches tempo:3200 over this network
|
||||||
|
|
||||||
|
# --- Dashboards: Grafana ---
|
||||||
|
|
||||||
|
obs-grafana:
|
||||||
|
image: grafana/grafana-oss:11.6.1
|
||||||
|
container_name: obs-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PORT_GRAFANA:-3003}:3000"
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
|
||||||
|
volumes:
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q ok || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
depends_on:
|
||||||
|
prometheus:
|
||||||
|
condition: service_healthy
|
||||||
|
loki:
|
||||||
|
condition: service_healthy
|
||||||
|
tempo:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
# --- Error Tracking: GlitchTip ---
|
||||||
|
|
||||||
|
obs-redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: obs-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- glitchtip_data:/data
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
obs-glitchtip:
|
||||||
|
image: glitchtip/glitchtip:6.1.6
|
||||||
|
container_name: obs-glitchtip
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
obs-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
obs-glitchtip-db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||||
|
REDIS_URL: redis://obs-redis:6379/0
|
||||||
|
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||||
|
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:-http://localhost:3002}
|
||||||
|
DEFAULT_FROM_EMAIL: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
|
||||||
|
EMAIL_URL: smtp://mailpit:1025
|
||||||
|
GLITCHTIP_MAX_EVENT_LIFE_DAYS: 90
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PORT_GLITCHTIP:-3002}:8000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/8000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
obs-glitchtip-worker:
|
||||||
|
image: glitchtip/glitchtip:6.1.6
|
||||||
|
container_name: obs-glitchtip-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ./bin/run-celery-with-beat.sh
|
||||||
|
depends_on:
|
||||||
|
obs-redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||||
|
REDIS_URL: redis://obs-redis:6379/0
|
||||||
|
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
- obs-net
|
||||||
|
|
||||||
|
obs-glitchtip-db-init:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: obs-glitchtip-db-init
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
command: >
|
||||||
|
sh -c "psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -tc
|
||||||
|
\"SELECT 1 FROM pg_database WHERE datname = 'glitchtip'\" |
|
||||||
|
grep -q 1 ||
|
||||||
|
psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -c \"CREATE DATABASE glitchtip;\""
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Shared network created by the main docker-compose.yml.
|
||||||
|
# The observability stack joins as a peer so Prometheus can scrape
|
||||||
|
# archive-backend by container name. The observability stack must NOT
|
||||||
|
# attempt to create this network — it will fail with a clear error if
|
||||||
|
# the main stack is not running yet.
|
||||||
|
archiv-net:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
# Internal network for observability-service-to-service traffic
|
||||||
|
# (e.g. Grafana → Prometheus, Grafana → Loki, Grafana → Tempo).
|
||||||
|
obs-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus_data:
|
||||||
|
loki_data:
|
||||||
|
promtail_positions:
|
||||||
|
tempo_data:
|
||||||
|
grafana_data:
|
||||||
|
glitchtip_data:
|
||||||
289
docker-compose.prod.yml
Normal file
289
docker-compose.prod.yml
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# Production / staging Docker Compose for Familienarchiv.
|
||||||
|
#
|
||||||
|
# This is a self-contained file (not an overlay over docker-compose.yml).
|
||||||
|
# All services for the prod stack live here. Environment isolation is
|
||||||
|
# achieved via the docker compose project name:
|
||||||
|
#
|
||||||
|
# production: docker compose -f docker-compose.prod.yml -p archiv-production ...
|
||||||
|
# staging: docker compose -f docker-compose.prod.yml -p archiv-staging --profile staging ...
|
||||||
|
#
|
||||||
|
# Volumes, networks and containers are namespaced by the project name,
|
||||||
|
# so the two environments cohabit cleanly on the same host.
|
||||||
|
#
|
||||||
|
# Required env vars (provided by .env.production / .env.staging in CI):
|
||||||
|
# TAG image tag (release tag or "nightly")
|
||||||
|
# PORT_BACKEND, PORT_FRONTEND host-side ports (bound to 127.0.0.1 only)
|
||||||
|
# APP_DOMAIN e.g. archiv.raddatz.cloud / staging.raddatz.cloud
|
||||||
|
# POSTGRES_PASSWORD Postgres password
|
||||||
|
# MINIO_PASSWORD MinIO root password (admin operations only)
|
||||||
|
# MINIO_APP_PASSWORD MinIO application service-account password
|
||||||
|
# (least-privilege scope: archive bucket only)
|
||||||
|
# OCR_TRAINING_TOKEN token guarding ocr-service /train endpoint
|
||||||
|
# APP_ADMIN_USERNAME seeded admin email (e.g. admin@archiv.raddatz.cloud)
|
||||||
|
# APP_ADMIN_PASSWORD seeded admin password — CRITICAL: locked in on
|
||||||
|
# first deploy because UserDataInitializer only
|
||||||
|
# creates the account if the email does not exist
|
||||||
|
# MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit)
|
||||||
|
# MAIL_USERNAME, MAIL_PASSWORD
|
||||||
|
# APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud)
|
||||||
|
# IMPORT_HOST_DIR absolute host path holding ONLY the ODS
|
||||||
|
# spreadsheet and PDFs for /admin/system mass
|
||||||
|
# import — mounted read-only at /import inside
|
||||||
|
# the backend. Compose refuses to start when
|
||||||
|
# this var is unset, so staging and prod cannot
|
||||||
|
# accidentally share an import source. Must be
|
||||||
|
# readable by the backend container's UID
|
||||||
|
# (currently root via the OpenJDK image — any
|
||||||
|
# world-readable directory works).
|
||||||
|
|
||||||
|
networks:
|
||||||
|
archiv-net:
|
||||||
|
driver: bridge
|
||||||
|
name: ${COMPOSE_NETWORK_NAME:-archiv-net}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
minio-data:
|
||||||
|
ocr-models:
|
||||||
|
ocr-cache:
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: archiv
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: archiv
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U archiv -d archiv"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio:
|
||||||
|
# Pinned MinIO release for reproducible deploys. Bumped manually until
|
||||||
|
# Renovate is bootstrapped for these production images (see follow-up issue).
|
||||||
|
image: minio/minio:RELEASE.2025-02-28T09-55-16Z
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: archiv
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Idempotent bucket bootstrap + service-account creation.
|
||||||
|
# Runs once per `docker compose up` and exits 0. The entrypoint is
|
||||||
|
# extracted to infra/minio/bootstrap.sh so the (non-trivial) idempotent
|
||||||
|
# logic is readable, reviewable, and unit-testable as a script rather
|
||||||
|
# than YAML-escaped shell.
|
||||||
|
create-buckets:
|
||||||
|
# Custom image bakes bootstrap.sh in at build time. A bind-mount fails on
|
||||||
|
# the Docker-out-of-Docker production runner because the host daemon
|
||||||
|
# resolves the relative path against the host filesystem, not the
|
||||||
|
# runner container's CWD. See #506 + infra/minio/Dockerfile.
|
||||||
|
build:
|
||||||
|
context: ./infra/minio
|
||||||
|
# Declare one-shot intent so `docker compose up -d --wait` treats
|
||||||
|
# exited(0) as success rather than "not running, fail". Pair with
|
||||||
|
# backend's `service_completed_successfully` dependency below. See #510.
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
environment:
|
||||||
|
MINIO_PASSWORD: ${MINIO_PASSWORD}
|
||||||
|
MINIO_APP_PASSWORD: ${MINIO_APP_PASSWORD}
|
||||||
|
|
||||||
|
# Dev-only mail catcher; gated behind the staging profile so production
|
||||||
|
# never starts it. Staging workflow runs with `--profile staging`.
|
||||||
|
mailpit:
|
||||||
|
# Pinned for reproducibility; bumped manually until Renovate is bootstrapped.
|
||||||
|
image: axllent/mailpit:v1.29.7
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["staging"]
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
# TCP-port open check via BusyBox `nc`. The previous wget-based probe
|
||||||
|
# introduced a non-obvious binary dependency on the mailpit image; a
|
||||||
|
# future tag that ships without wget would silently disable the
|
||||||
|
# healthcheck. `nc` is part of BusyBox in the upstream image.
|
||||||
|
test: ["CMD-SHELL", "nc -z localhost 8025 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# --- OCR: Volume bootstrap ---
|
||||||
|
# Ensures correct ownership and directory structure on ocr-cache / ocr-models
|
||||||
|
# before ocr-service starts. Handles pre-existing volumes (including those
|
||||||
|
# created before the non-root ocr user was introduced in commit 1aca4c4a)
|
||||||
|
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
|
||||||
|
ocr-volume-init:
|
||||||
|
image: alpine:3.21
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
|
||||||
|
volumes:
|
||||||
|
- ocr-models:/app/models
|
||||||
|
- ocr-cache:/app/cache
|
||||||
|
networks: []
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
ocr-service:
|
||||||
|
build:
|
||||||
|
context: ./ocr-service
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
# Surya OCR loads ~5GB of transformer models at startup; first request
|
||||||
|
# triggers a further ~1GB Kraken model download into ocr-cache.
|
||||||
|
# CX42+ (16 GB RAM) honours the default. On a CX32 (8 GB) override with
|
||||||
|
# OCR_MEM_LIMIT=6g (slower first-request, fits the host).
|
||||||
|
mem_limit: ${OCR_MEM_LIMIT:-12g}
|
||||||
|
memswap_limit: ${OCR_MEM_LIMIT:-12g}
|
||||||
|
volumes:
|
||||||
|
- ocr-models:/app/models
|
||||||
|
- ocr-cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||||
|
environment:
|
||||||
|
HF_HOME: /app/cache
|
||||||
|
XDG_CACHE_HOME: /app/cache
|
||||||
|
TORCH_HOME: /app/models/torch
|
||||||
|
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
|
||||||
|
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
|
||||||
|
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
|
||||||
|
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
||||||
|
TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
|
||||||
|
OCR_CONFIDENCE_THRESHOLD: "0.3"
|
||||||
|
OCR_CONFIDENCE_THRESHOLD_KURRENT: "0.5"
|
||||||
|
# SSRF allowlist pinned explicitly to the internal MinIO hostname.
|
||||||
|
# In prod the OCR service only fetches PDFs from MinIO over the
|
||||||
|
# docker network; localhost/127.0.0.1 are dev-only sources and
|
||||||
|
# must NOT be reachable here. Do not widen to `*`.
|
||||||
|
ALLOWED_PDF_HOSTS: "minio"
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 120s
|
||||||
|
depends_on:
|
||||||
|
ocr-volume-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
|
||||||
|
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: familienarchiv/backend:${TAG:-nightly}
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
ocr-service:
|
||||||
|
condition: service_healthy
|
||||||
|
# Gate startup on the bucket bootstrap. Without this, backend
|
||||||
|
# starts in parallel with create-buckets and may race the policy
|
||||||
|
# bind. Also tells compose's `up -d --wait` that create-buckets
|
||||||
|
# is a one-shot that must complete successfully. See #510.
|
||||||
|
create-buckets:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
# Bound to localhost only — Caddy fronts external traffic.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PORT_BACKEND}:8080"
|
||||||
|
# Host path holding the ODS spreadsheet + PDFs for the mass-import endpoint.
|
||||||
|
# Read-only; MassImportService only reads (Files.list / Files.walk on /import).
|
||||||
|
# Required — no default — so staging and prod cannot accidentally share an
|
||||||
|
# import source. CI workflows pin this per-env (see .gitea/workflows/).
|
||||||
|
volumes:
|
||||||
|
- ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the mass-import payload (ODS + PDFs). See docs/DEPLOYMENT.md.}:/import:ro
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
|
||||||
|
SPRING_DATASOURCE_USERNAME: archiv
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
# Application uses the bucket-scoped service account, not MinIO root.
|
||||||
|
S3_ENDPOINT: http://minio:9000
|
||||||
|
S3_ACCESS_KEY: archiv-app
|
||||||
|
S3_SECRET_KEY: ${MINIO_APP_PASSWORD}
|
||||||
|
S3_BUCKET_NAME: familienarchiv
|
||||||
|
S3_REGION: us-east-1
|
||||||
|
# No SPRING_PROFILES_ACTIVE — base application.yaml is production-ready
|
||||||
|
# (Swagger disabled, show-sql off, open-in-view false).
|
||||||
|
APP_BASE_URL: https://${APP_DOMAIN}
|
||||||
|
APP_ADMIN_USERNAME: ${APP_ADMIN_USERNAME}
|
||||||
|
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD}
|
||||||
|
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||||
|
APP_OCR_TRAINING_TOKEN: ${OCR_TRAINING_TOKEN}
|
||||||
|
MAIL_HOST: ${MAIL_HOST}
|
||||||
|
MAIL_PORT: ${MAIL_PORT:-587}
|
||||||
|
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||||
|
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||||
|
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud}
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true}
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true}
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
|
||||||
|
OTEL_LOGS_EXPORTER: none
|
||||||
|
OTEL_METRICS_EXPORTER: none
|
||||||
|
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||||
|
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: familienarchiv/frontend:${TAG:-nightly}
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: production
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${PORT_FRONTEND}:3000"
|
||||||
|
environment:
|
||||||
|
# SSR fetches go inside the docker network; clients hit https://${APP_DOMAIN}
|
||||||
|
API_INTERNAL_URL: http://backend:8080
|
||||||
|
ORIGIN: https://${APP_DOMAIN}
|
||||||
|
# Enforce upload size limit in the adapter-node layer (fixes GHSA-2crg-3p73-43xp bypass).
|
||||||
|
# Must be ≤ client_max_body_size in the Caddy reverse proxy to avoid 413 mismatches.
|
||||||
|
BODY_SIZE_LIMIT: 50M
|
||||||
|
networks:
|
||||||
|
- archiv-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/login >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${PORT_DB}:5432"
|
- "${PORT_DB}:5432"
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "${PORT_MINIO_API}:9000" # API Port
|
- "${PORT_MINIO_API}:9000" # API Port
|
||||||
- "${PORT_MINIO_CONSOLE}:9001" # Web-Oberfläche
|
- "${PORT_MINIO_CONSOLE}:9001" # Web-Oberfläche
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
exit 0;
|
exit 0;
|
||||||
"
|
"
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
|
|
||||||
# --- Mail catcher: Mailpit (dev only) ---
|
# --- Mail catcher: Mailpit (dev only) ---
|
||||||
# Catches all outgoing emails and displays them in a web UI.
|
# Catches all outgoing emails and displays them in a web UI.
|
||||||
@@ -69,7 +69,24 @@ services:
|
|||||||
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
|
- "${PORT_MAILPIT_UI:-8025}:8025" # Web UI
|
||||||
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
|
- "${PORT_MAILPIT_SMTP:-1025}:1025" # SMTP
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
|
|
||||||
|
# --- OCR: Volume bootstrap ---
|
||||||
|
# Ensures correct ownership and directory structure on ocr_cache / ocr_models
|
||||||
|
# before ocr-service starts. Handles pre-existing volumes (including those
|
||||||
|
# created before the non-root ocr user was introduced in commit 1aca4c4a)
|
||||||
|
# and guarantees /app/cache/.tmp exists for TMPDIR staging. See ADR-021.
|
||||||
|
ocr-volume-init:
|
||||||
|
image: alpine:3.21
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- "chown -R 1000:1000 /app/cache /app/models && mkdir -p /app/cache/.tmp && chown 1000:1000 /app/cache/.tmp"
|
||||||
|
volumes:
|
||||||
|
- ocr_models:/app/models
|
||||||
|
- ocr_cache:/app/cache
|
||||||
|
networks: []
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
# --- OCR: Python microservice (Surya + Kraken) ---
|
# --- OCR: Python microservice (Surya + Kraken) ---
|
||||||
# Single-node only: OCR training reloads the model in-process after each run.
|
# Single-node only: OCR training reloads the model in-process after each run.
|
||||||
@@ -87,8 +104,14 @@ services:
|
|||||||
memswap_limit: 12g
|
memswap_limit: 12g
|
||||||
volumes:
|
volumes:
|
||||||
- ocr_models:/app/models
|
- ocr_models:/app/models
|
||||||
- ocr_cache:/root/.cache # Hugging Face / ketos model download cache — prevents re-downloads on container recreate
|
- ocr_cache:/app/cache # HuggingFace / ketos cache — prevents re-downloads on recreate (HF_HOME)
|
||||||
environment:
|
environment:
|
||||||
|
HF_HOME: /app/cache
|
||||||
|
XDG_CACHE_HOME: /app/cache
|
||||||
|
TORCH_HOME: /app/models/torch
|
||||||
|
TMPDIR: /app/cache/.tmp # Stage GB-scale Surya model downloads on SSD, not the 512 MB RAM tmpfs.
|
||||||
|
# /tmp keeps its small DoS cap; training ZIPs still unpack under /tmp
|
||||||
|
# but ZIP Slip protection (_validate_zip_entry) is unchanged. See ADR-021.
|
||||||
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
KRAKEN_MODEL_PATH: /app/models/german_kurrent.mlmodel
|
||||||
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||||
OCR_CONFIDENCE_THRESHOLD: "0.3"
|
OCR_CONFIDENCE_THRESHOLD: "0.3"
|
||||||
@@ -99,13 +122,24 @@ services:
|
|||||||
OCR_CLAHE_TILE_SIZE: "8" # CLAHE tile grid size (NxN tiles per page)
|
OCR_CLAHE_TILE_SIZE: "8" # CLAHE tile grid size (NxN tiles per page)
|
||||||
OCR_MAX_CACHED_MODELS: "2" # LRU cache; each model ~500 MB, so 2 = ~1 GB resident
|
OCR_MAX_CACHED_MODELS: "2" # LRU cache; each model ~500 MB, so 2 = ~1 GB resident
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 120s
|
start_period: 120s
|
||||||
|
depends_on:
|
||||||
|
ocr-volume-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=512m # training-ZIP unzip + transient PDF buffers only (small, RAM-friendly).
|
||||||
|
# GB-scale model downloads go to TMPDIR=/app/cache/.tmp instead. See ADR-021.
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
# --- Backend: Spring Boot ---
|
# --- Backend: Spring Boot ---
|
||||||
backend:
|
backend:
|
||||||
@@ -147,10 +181,22 @@ services:
|
|||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||||
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||||
|
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
||||||
|
# Tempo is defined in docker-compose.observability.yml (future issue).
|
||||||
|
# OTLP failures are non-fatal — backend starts cleanly without the observability stack.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||||
|
# 10% sampling in this compose (dev + staging) — override locally to 1.0 if needed
|
||||||
|
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.1"
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_BACKEND}:8080"
|
- "${PORT_BACKEND}:8080"
|
||||||
|
# Management port — Prometheus scrapes /actuator/prometheus from inside archiv-net.
|
||||||
|
# Not exposed to the host; Docker service-name DNS (backend:8081) is sufficient.
|
||||||
|
expose:
|
||||||
|
- "8081"
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -163,6 +209,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: development # Dockerfile is multi-stage; default would be the production stage
|
||||||
container_name: archive-frontend
|
container_name: archive-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -181,13 +228,16 @@ services:
|
|||||||
API_INTERNAL_URL: http://backend:8080
|
API_INTERNAL_URL: http://backend:8080
|
||||||
# Vite dev proxy forwards /api from browser to the backend container
|
# Vite dev proxy forwards /api from browser to the backend container
|
||||||
API_PROXY_TARGET: http://backend:8080
|
API_PROXY_TARGET: http://backend:8080
|
||||||
|
# Upload size limit for adapter-node (production target). Not enforced by Vite dev server
|
||||||
|
# but kept here to match docker-compose.prod.yml and prevent config drift.
|
||||||
|
BODY_SIZE_LIMIT: 50M
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_FRONTEND}:5173"
|
- "${PORT_FRONTEND}:5173"
|
||||||
networks:
|
networks:
|
||||||
- archive-net
|
- archiv-net
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
archive-net:
|
archiv-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
|||||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
|
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||||
@@ -117,7 +117,7 @@ Controllers never call repositories directly. Services never reach into another
|
|||||||
### Permission system
|
### Permission system
|
||||||
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
|
||||||
|
|
||||||
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
|
Sessions use a Spring Session JDBC-backed cookie (`fa_session`, `httpOnly`, `SameSite=strict`, maxAge=86400 s). CSRF protection uses the double-submit cookie pattern: Spring Security sets an `XSRF-TOKEN` cookie (readable by JS); SvelteKit's `handleFetch` injects the value as `X-XSRF-TOKEN` on every mutating request; a missing or mismatched token returns `403 CSRF_TOKEN_MISSING`. See [ADR-022](adr/022-csrf-session-revocation-rate-limiting.md) and [docs/security-guide.md](security-guide.md) for the full security reference.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user